Repository: Canop/broot Branch: main Commit: 5293d3231198 Files: 290 Total size: 1.4 MB Directory structure: gitextract_ip4l8ksy/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── bacon.toml ├── benches/ │ ├── composite.rs │ ├── fuzzy.rs │ ├── path_normalization.rs │ ├── shared/ │ │ ├── mod.rs │ │ └── names.rs │ └── toks.rs ├── build-all-targets.sh ├── build.rs ├── build.sh ├── features.md ├── man/ │ └── page ├── release.sh ├── resources/ │ ├── default-conf/ │ │ ├── conf.hjson │ │ ├── skins/ │ │ │ ├── catppuccin-macchiato.hjson │ │ │ ├── catppuccin-mocha.hjson │ │ │ ├── dark-blue.hjson │ │ │ ├── dark-gruvbox.hjson │ │ │ ├── dark-orange.hjson │ │ │ ├── native-16.hjson │ │ │ ├── solarized-dark.hjson │ │ │ ├── solarized-light.hjson │ │ │ ├── tokyo-night.hjson │ │ │ └── white.hjson │ │ └── verbs.hjson │ ├── icons/ │ │ ├── nerdfont/ │ │ │ ├── README.md │ │ │ └── data/ │ │ │ ├── double_extension_to_icon_name_map.rs │ │ │ ├── extension_to_icon_name_map.rs │ │ │ ├── file_name_to_icon_name_map.rs │ │ │ └── icon_name_to_icon_code_point_map.rs │ │ └── vscode/ │ │ └── data/ │ │ ├── README │ │ ├── double_extension_to_icon_name_map.rs │ │ ├── extension_to_icon_name_map.rs │ │ ├── file_name_to_icon_name_map.rs │ │ └── icon_name_to_icon_code_point_map.rs │ └── syntect/ │ └── README.md ├── rustfmt.toml ├── src/ │ ├── app/ │ │ ├── app.rs │ │ ├── app_context.rs │ │ ├── app_panels.rs │ │ ├── app_state.rs │ │ ├── cmd_context.rs │ │ ├── cmd_result.rs │ │ ├── display_context.rs │ │ ├── mod.rs │ │ ├── mode.rs │ │ ├── panel.rs │ │ ├── panel_id.rs │ │ ├── panel_purpose.rs │ │ ├── panel_reference.rs │ │ ├── panel_state.rs │ │ ├── sel_info.rs │ │ ├── selection.rs │ │ ├── standard_status.rs │ │ ├── state_type.rs │ │ └── status.rs │ ├── browser/ │ │ ├── browser_state.rs │ │ └── mod.rs │ ├── cli/ │ │ ├── args.rs │ │ ├── install_launch_args.rs │ │ └── mod.rs │ ├── command/ │ │ ├── command.rs │ │ ├── completion.rs │ │ ├── mod.rs │ │ ├── panel_input.rs │ │ ├── parts.rs │ │ ├── scroll.rs │ │ ├── sel.rs │ │ ├── sequence.rs │ │ └── trigger_type.rs │ ├── conf/ │ │ ├── conf.rs │ │ ├── default.rs │ │ ├── default_flags.rs │ │ ├── file_size.rs │ │ ├── format.rs │ │ ├── import.rs │ │ ├── mod.rs │ │ ├── special_handling_conf.rs │ │ └── verb_conf.rs │ ├── content_search/ │ │ ├── content_match.rs │ │ ├── content_search_result.rs │ │ ├── mod.rs │ │ └── needle.rs │ ├── content_type/ │ │ ├── extensions.rs │ │ ├── magic_numbers.rs │ │ └── mod.rs │ ├── display/ │ │ ├── areas.rs │ │ ├── cell_size.rs │ │ ├── col.rs │ │ ├── displayable_tree.rs │ │ ├── flags_display.rs │ │ ├── git_status_display.rs │ │ ├── layout_instructions.rs │ │ ├── luma.rs │ │ ├── matched_string.rs │ │ ├── mod.rs │ │ ├── num_format.rs │ │ ├── permissions.rs │ │ ├── screen.rs │ │ └── status_line.rs │ ├── errors.rs │ ├── file_sum/ │ │ ├── mod.rs │ │ └── sum_computation.rs │ ├── filesystems/ │ │ ├── filesystems_state.rs │ │ ├── mod.rs │ │ ├── mount_list.rs │ │ └── mount_space_display.rs │ ├── flag/ │ │ └── mod.rs │ ├── git/ │ │ ├── ignore.rs │ │ ├── mod.rs │ │ ├── status.rs │ │ └── status_computer.rs │ ├── help/ │ │ ├── help_content.rs │ │ ├── help_features.rs │ │ ├── help_search_modes.rs │ │ ├── help_state.rs │ │ ├── help_verbs.rs │ │ └── mod.rs │ ├── hex/ │ │ ├── byte.rs │ │ ├── hex_view.rs │ │ └── mod.rs │ ├── icon/ │ │ ├── font.rs │ │ ├── icon_plugin.rs │ │ └── mod.rs │ ├── image/ │ │ ├── double_line.rs │ │ ├── image_view.rs │ │ ├── mod.rs │ │ ├── source_image.rs │ │ ├── svg.rs │ │ └── zune_compat.rs │ ├── keys.rs │ ├── kitty/ │ │ ├── detect_support.rs │ │ ├── image_renderer.rs │ │ ├── mod.rs │ │ └── terminal_esc.rs │ ├── launchable.rs │ ├── lib.rs │ ├── main.rs │ ├── net/ │ │ ├── client.rs │ │ ├── message.rs │ │ ├── mod.rs │ │ └── server.rs │ ├── path/ │ │ ├── anchor.rs │ │ ├── closest.rs │ │ ├── common.rs │ │ ├── from.rs │ │ ├── mod.rs │ │ ├── normalize.rs │ │ └── special_path.rs │ ├── pattern/ │ │ ├── candidate.rs │ │ ├── composite_pattern.rs │ │ ├── content_pattern.rs │ │ ├── content_regex_pattern.rs │ │ ├── exact_pattern.rs │ │ ├── fuzzy_pattern.rs │ │ ├── input_pattern.rs │ │ ├── mod.rs │ │ ├── name_match.rs │ │ ├── operator.rs │ │ ├── pattern.rs │ │ ├── pattern_object.rs │ │ ├── pattern_parts.rs │ │ ├── pos.rs │ │ ├── regex_pattern.rs │ │ ├── search_mode.rs │ │ └── tok_pattern.rs │ ├── permissions/ │ │ ├── mod.rs │ │ └── permissions_unix.rs │ ├── preview/ │ │ ├── dir_view.rs │ │ ├── mod.rs │ │ ├── preview.rs │ │ ├── preview_state.rs │ │ ├── preview_transformer.rs │ │ └── zero_len_file_view.rs │ ├── print.rs │ ├── shell_install/ │ │ ├── bash.rs │ │ ├── fish.rs │ │ ├── mod.rs │ │ ├── nushell.rs │ │ ├── powershell.rs │ │ ├── state.rs │ │ └── util.rs │ ├── skin/ │ │ ├── app_skin.rs │ │ ├── cli_mad_skin.rs │ │ ├── ext_colors.rs │ │ ├── help_mad_skin.rs │ │ ├── mod.rs │ │ ├── panel_skin.rs │ │ ├── purpose_mad_skin.rs │ │ ├── skin_entry.rs │ │ ├── status_mad_skin.rs │ │ └── style_map.rs │ ├── stage/ │ │ ├── filtered_stage.rs │ │ ├── mod.rs │ │ ├── stage.rs │ │ ├── stage_state.rs │ │ └── stage_sum.rs │ ├── syntactic/ │ │ ├── mod.rs │ │ ├── syntax_theme.rs │ │ ├── syntaxer.rs │ │ └── text_view.rs │ ├── task_sync.rs │ ├── terminal.rs │ ├── trash/ │ │ ├── mod.rs │ │ ├── trash_sort.rs │ │ ├── trash_state.rs │ │ └── trash_state_cols.rs │ ├── tree/ │ │ ├── mod.rs │ │ ├── sort.rs │ │ ├── tree.rs │ │ ├── tree_line.rs │ │ ├── tree_line_type.rs │ │ └── tree_options.rs │ ├── tree_build/ │ │ ├── bid.rs │ │ ├── bline.rs │ │ ├── build_report.rs │ │ ├── builder.rs │ │ └── mod.rs │ ├── tty/ │ │ ├── mod.rs │ │ ├── tline.rs │ │ ├── tline_builder.rs │ │ ├── trange.rs │ │ ├── tstring.rs │ │ └── tty_view.rs │ ├── verb/ │ │ ├── coarity.rs │ │ ├── exec_pattern.rs │ │ ├── execution_builder.rs │ │ ├── external_execution.rs │ │ ├── external_execution_mode.rs │ │ ├── file_type_condition.rs │ │ ├── internal.rs │ │ ├── internal_execution.rs │ │ ├── internal_focus.rs │ │ ├── internal_path.rs │ │ ├── internal_select.rs │ │ ├── invocation_parser.rs │ │ ├── mod.rs │ │ ├── sequence_execution.rs │ │ ├── verb.rs │ │ ├── verb_arg_def.rs │ │ ├── verb_description.rs │ │ ├── verb_execution.rs │ │ ├── verb_invocation.rs │ │ ├── verb_store.rs │ │ └── write.rs │ └── watcher.rs ├── target.sh ├── tests/ │ └── search_strings.rs ├── version.sh └── website/ ├── .gitignore ├── README.md ├── ddoc.hjson └── src/ ├── README.md ├── common-problems.md ├── community.md ├── conf_file.md ├── conf_verbs.md ├── css/ │ ├── site.css │ └── tab-langs.css ├── export.md ├── file-operations.md ├── help.md ├── icons.md ├── index.md ├── input.md ├── install-br.md ├── install.md ├── js/ │ ├── broot.js │ └── tab-langs.js ├── launch.md ├── modal.md ├── navigation.md ├── panels.md ├── remote.md ├── skins.md ├── staging-area.md ├── trash.md ├── tree_view.md ├── tricks.md └── verbs.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ github: [Canop] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: 'bug' assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: 'enhancement' assignees: '' --- ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: [ "main" ] pull_request: branches: [ "main" ] env: CARGO_TERM_COLOR: always jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build run: cargo build --verbose - name: Run tests run: cargo test --verbose ================================================ FILE: .gitignore ================================================ .bacon-locations .ignore /*-deploy.sh /d.sh /.config /bench.sh /broot_*.zip /build /compile.sh /fix-win-toolchain.sh /glassbench_*.db /press /releases /screens /target /termux-deploy.sh /trav /website/site deploy.sh dev.log .DS_Store /zigbuild /.intentionally-empty-file.o ================================================ FILE: CHANGELOG.md ================================================ ### v1.56.1 - 2026-03-20 - fix a typo in a verb in default conf ### v1.56.0 - 2026-03-20 - `impacted_panel` verb argument, allows the effect of a verb to be on another panel (eg to scroll the preview panel without removing the focus from the tree) - Fix #1119 - `focus_panel_left` and `focus_panel_right` internals - Fix #1115 #### Major Feature: merge staged files to issue a single command When a verb argument has a `space-separated` or `comma-separated` flag, a single external command is run even when the selection is multiple - Fix #465 The default `verbs.json` file has an example of a `zip` verb building an archive from all staged files. ### v1.55.0 - 2026-02-09 - activate Kitty Graphics Protocol to display Hi-Res images in iTerm2 - Thanks @kidonng - Tokyo Night skin ( https://github.com/Canop/broot/blob/main/resources/default-conf/skins/tokyo-night.hjson ) - Thanks @hb-hello - matches related to several name patterns joined with and/or in a composite pattern are merged instead of having just the first one shown - Thanks @Tomaz-Vieira - nushell integration: switch $nu.temp-path to $nu.temp-dir - #1116 - Thanks @stevenxxiu ### v1.54.0 - 2025-12-03 - fix crash on rendering B&W images with Kitty image protocol - don't match directories when a composite pattern has a content pattern, even negated (eg `/js$/&!c/;`: it's clear the user wants to match js *files* not containing a semicolon) ### v1.53.0 - 2025-11-08 - fix some cases of the verb not removed from the input on execution (with a risk of accidental double execution) - add the `:filesystems` (short `:fs`) verb and state on windows (it was already present on linux and mac). - improve the generation of preview pattern from a file tree pattern (i.e. going from `/java$/&c/test` to `/test` on opening a matching file in preview). With this change broot avoids filtering the preview when it shouldn't (eg when you searched `/java$/|c/test`) - See #1097 - display files whose name isn't valid UTF-8 (they were previously ignored) - android executable is back to the official binary archive ### v1.52.0 - 2025-11-01 - `auto_open_staging_area` preference - Fix #1090 - search content of file target of symlink - Fix #1081 - fix nushell script (swapped logic for --listen and --listen-auto) - Thanks @cderwin - return non-zero exit code on error - Thanks @Sambhram1 ### v1.51.0 - 2025-10-05 - improved image rendering (both speed by using the zune-image library, and quality with bilinear interpolation) - fix compilation broken by 1.50.0 on Android- thanks @dead10ck - `--listen-auto` listens for commands on a random linux socket - Fix #1064 - when auto-completing, `back-tab` cycles in reverse order - Fix #1071 ### v1.50.0 - 2025-09-25 - big text files now only partially loaded for initial display, remaining being done in background - Fix #1052 - better support of kitty image protocol over tmux, ssh or unknown terminals, with `kitty_graphics_display` option and `$TMUX_NEST_COUNT` env variable - see PR #1034 - Thanks @stevenxxiu - "trash" compilation feature removed: trash related features are built depending on the platform - build chain revised. Future official releases should include a Mac binary - fix crash on double unstage of last entry in stage panel - fix #1057 - fallback to transparent background for text preview when the skin specifies nothing - Thanks @letmeiiiin ### v1.49.1 - 2025-09-15 - watching made much more efficient (some deep changes won't lead to an automatic refresh which only impacts dir size) - the name given with `--listen` is now provided to verb as the `{server-name}` verb argument ### v1.49.0 - 2025-09-13 - `:toggle_watch` internal, with `:watch` shortcut, bound by default to `alt-w`. When watching is active, the tree is refreshed whenever any directory/file, even deep, is changed - Fix #730 - fallback to a transparent background for images in image preview instead of a specific color - Fix #1040 - Thanks @letmeiiiin - fix --server socket written at a non writable location on Android/termux - Fix #1045 ### v1.48.0 - 2025-08-29 - Support for the 'Cmd' modifier in key shortcuts (the key is called 'Command', 'Super', 'Apple', 'Windows', depending on systems and users) - "filesystem" features have been made available for Mac: - the `:fs` screen, listing filesystems - filesystem free space & total space displayed when size computations are requested - device id displayed with `:toggle_device_id` (shortcut: "dev") - Fix `.config/git/ignore` not being loaded on Mac - Fix #1032 - Thanks @9999years ### v1.47.0 - 2025-06-26 - text files with control chars were previously previewed as binary. They're now displayed as text with some '�' when needed - Fix #977 - files with ANSI escape codes (such as the one you would obtain with `dysk --color yes > ansi.txt` can now be previewed with `:preview_tty` - Fix #1019 - first line of the tree is cropped (right aligned) when it doesn't fit ### v1.46.5 - 2025-05-30 - fix `:focus some/path` called in a command sequence always opening new panel - Fix #1014 ### v1.46.4 - 2025-05-14 - support for keys F13 to F24 (if your system supports it) - fix `:focus` with argument given in configuration going up one level when root is selected - Fix #1009 - fix `--max-depth` ignored when in `default_flags` - Fix #1013 ### v1.46.3 - 2025-04-24 - fix broot waiting for events on internals like `:quit` - Fix #1006 ### v1.46.2 - 2025-04-21 - fix broken nushell script (`--max-depth` again) - Thanks @sandyspiers & @amitkot ### v1.46.1 - 2025-04-20 - fix nushell script broken by new `--max-depth` argument - Thanks @lizclipse ### v1.46.0 - 2025-04-16 - `:set_max_depth ` and `:unset_max_depth` - Fix #843 - Thanks @mcky - clear cache when files are deleted in staging area - Fix #999 - recompute preview transform when source file changed since last preview ### v1.45.1 - 2025-03-25 - Fix compilation failing without `--locked` - Fix #995 ### v1.45.0 - 2025-03-17 - Fix total search impossible to redo after refresh - Fix #986 - With `refresh_after: false`, a verb configuration can request that the tree isn't refreshed after its execution - Fix #987 ### v1.44.7 - 2025-02-12 - fix bad regex match position - Fix #979 - update resvg dependency to 0.44 - Thanks @NoisyCoil - on `--server`, remove the existing socket if it already exists - Thanks @VasilisManol ### v1.44.6 - 2025-01-12 -fix .ignore files ignored when not in a git repository - Fix #970 -update git2 dependency to 0.20 - Fix #974 ### v1.44.5 - 2025-01-02 - no real change (just reverting a crate name to ease some packaging) ### v1.44.4 - 2025-01-01 - fix panic in preview on syntax coloring (when a sublime syntax isn't compatible with the regex engine) - Fix #967 ### v1.44.3 - 2024-12-26 - removed default bindings on left and right keys. You may add them back by adding this to your verbs.hjson: ```Hjson { key: "left", internal: "back" } { key: "right", internal: "open_stay" } ``` - rustc minimal version changed from 1.76 to 1.79, which allows better performing image rendering - remove dependency to onig, to allow compatibility with gcc 15 - Fix #956 ### v1.44.2 - 2024-10-22 - temp files created for kitty now erased on quitting or when too many of them have been written - no longer panics when launched with BROOT_LOG=debug but the broot.log file can't be created - Fix #951 - fix user and group names displayed as "????" when coming from openldap - Fix #953 ### v1.44.1 - 2024-10-16 - fix wrong position of IMEs (input method editors) popup - thanks @xubaiwang - See #948 - improve querying the terminal for capabilities (prevent some escape chars from leaking) ### v1.44.0 - 2024-09-07 - `:focus_staging_area_no_open` internal, focus the staging area if it's already open, does nothing in other case - Fix #926 - fix some composite patterns with several operators and no parenthesis ### v1.43.0 - 2024-08-30 - 'Size' and 'Deletion date' columns in trash screen. This screen now supports the `:toggle_date`, `:toggle_size`, `:sort_by_date`, and `:sort_by_size` internals. - new `:show` internal make the provided path visible and selected, adding lines to the tree if necessary, does nothing if the provided path is not a descendant of the current tree root (this part may change depending on feedback) - Fix #936 ### v1.42.0 - 2024-08-18 - support of `.ignore` files with the same syntax than `.gitignore`. They have priority over `.gitignore` so that a personal `.ignore` file can override a shared `.gitignore` - See https://dystroy.org/broot/tree_view/#hidden-ignored-files - Fix #613 - `:toggle_ignore` internal, identical to `:toggle_git_ignore`, but with a clearer name so should be preferred - the `panels` verb filter now works in most contexts (it was previously only checked on key events) - many dependencies updated ### v1.41.1 - 2024-08-04 - allow compilation with rustc 1.76 - Fix #925 ### v1.41.0 - 2024-08-04 #### Major Feature: :search_again ctrl-s now triggers `:search_again` which either - brings back the last used search pattern, when no filtering pattern is active - does a "total search" if a filtering pattern is active and the search wasn't complete #### Major Feature: internals changing panel widths * `set_panel_width`, taking as parameter the index of the panel and the desired width * `move_panel_divider`, taking as parameter the index of the divider and the desired change `ctrl-<` is bound by default to `:move_panel_divider 0 -1` `ctrl->` is bound by default to `:move_panel_divider 0 1` See http://dystroy.org/broot/panels/#resize-panels #### Minor Changes: - when git file infos are shown, and git ignored files aren't hidden, those files are flagged with a 'I' - Fix #916 - Remove .bak extension from content search exclusion list - Fix #915 - Update nerdfont and vscode icons - Thanks @jpaju - `{initial-root}` verb argument ### v1.40.0 - 2024-07-16 #### Major Feature: preview transformers You can now define preview transformers to be applied before preview. They allow for example previewing PDF or Office files, or beautifying JSON files. Edit the `preview_transformers` array in your conf.hjson file. See https://dystroy.org/broot/conf_file/#preview #### Fixes - fix search on root - Fix #904 - fix some verb cycling problems - Fix #902 ### v1.39.2 - 2024-07-08 - fix UNC paths being displayed on Windows (regression at 1.39.1) - Fix #812 (again) ### v1.39.1 - 2024-07-05 - fix high-resolution (kitty protocole) image broken in release mode - Fix #885 - canonicalize paths when focusing them (mostly useful when following links) - a few minor internal optimizations ### v1.39.0 - 2024-05-31 - `:open_trash` shows the content of the trash. Other new internals & verbs: `:delete_trashed_file`, `:restore_trashed_file`, `:purge_trash` - Fix #855 - it's now possible to remove a default keybinding by defining a verb with no execution - Fix #632 - fix build on Android - thanks @dead10ck ### v1.38.0 - 2024-05-04 - `-{flags}` verb lets you change the state the same way you do it at start, eg `:-sd` to show sizes and dates - calling `:focus` on the tree root now goes up the tree (experimental) ### v1.37.0 - 2024-04-28 - optionally display lines surrounding a matching line in preview, with `lines_before_match_in_preview` and `lines_after_match_in_preview` - Fix #756 - filtered preview: jump between matches with `:next_match` (default: `tab`) and `:previous_match` (default `shift-tab`) - display setuid, setgid and sticky bits in permission - Fix #863, Thanks @Jisu-Woniu ### v1.36.1 - 2024-03-11 - fix ANSI code leaking to the input on start on Mac - Fix #854 ### v1.36.0 - 2024-03-01 - releases at github should be more `cargo binstall` friendly - Thanks @FrancescElies - improved `--help` - new `:stage_all_directories` internal - Fix #844 - `:print_tree` is one line shorter, so as to let the original shell command visible without scroll - fix and document the "kitty-csi-check" optional feature which can be enabled at compilation ### v1.35.0 - 2024-03-01 - Nerdfont icon theme - Fix #333 - Thanks @JonasLeonhard, @cho-m, @texastoland, @asdf8dfafjk and others ### v1.34.0 - 2024-02-24 - new `--verb-output` launch argument, dedicated to the new `:clear_output` and `:write_output` internals - Fix #825 - verb sequences (based on `cmd`) can take arguments from the verb invocation - don't fail launch in case of bad verb configuration, more helpful error message in such case - faster kitty image rendering by default - Fix #789 - `{file-git-relative}` verb argument - Thanks @VasilisManol - modify nushell function import: `use` instead of `source` - Thanks @texastoland and @FrancescElies - fix some resizing and flickering problems on Windows (appeared with 1.33.0) - Fix #840 - write `installed` flag file on `--install` - Fix #837 ### v1.33.1 - 2024-02-03 - fix the release's version ### v1.33.0 - 2024-02-03 - on terminals supporting the kitty keyboard protocol, you can now define and use key combinations like `space-n`, `ctrl-alt-a-b`, `shift-space-c`, `ctrl-enter`, etc. - new syntax for special paths - Fix #687, #669 ### v1.32.0 - 2024-01-02 - with "modal" enabled, `initial_mode` setting lets you choose whether to start in `input` mode or `command` mode (default) - Fix #708 ### v1.31.0 - 2023-12-30 - keep broot's work dir synchronized with the root of the current panel. Can be disabled in conf with `update_work_dir: false` - Fix #813 - fix `:trash` internal not working on staged files ### v1.30.2 - 2023-12-23 - don't canonicalize paths on windows on new panels - Fix #809 ### v1.30.1 - 2023-12-03 - nushell script: replace the deprecated `def-env` with `def --env` - Thanks @melMass ### v1.30.0 - 2023-12-03 - `:trash` internal - I'd like feedback on this one - Fix #799 - solve symlinks on `:panel_right` to display the dest path and the dest filesystem - Fix #804 - `:panel_right` on a directory now removes the filter - more '~' expansion in verb arguments ### v1.29.0 - 2023-11-22 - `terminal_title` option in configuration - Fix #794 - `:toggle_tree` internal and `--tree` and `--no-tree` launch flags (experimental, feedback welcome) - Fix #670 - Thanks @eldad - `{git-name}` verb argument ### v1.28.1 - 2023-11-13 - fix a regression in handling of rooted gitignore patterns - Fix #795 ### v1.28.0 - 2023-11-12 - left and right keys bound to verbs can be used when the input isn't empty, if they would have no effect to the input - default_flags now accept long parameters, including --cmd - Fix #790 - gitignore: fix relative patterns with several tokens - Fix #782 ### v1.27.0 - 2023-10-29 - the `apply_to` verb filter accepts new values: `text_file` and `binary_file`. Broot users editing files in their terminal (vi, emacs, etc.) should configure broot to open their text editor on `enter`: see https://dystroy.org/broot/tricks/#change-standard-file-opening - small breaking change: `:stage_all_files` now stages also symlinks - Fix #606 - new `{git-root}` verb argument - Fix 760 - Thanks @9999years - fix a freeze on windows when launching a search with `-c` - Thanks @3tilley - fix automatic preview pattern not escaping spaces and colons - Fix #778 ### v1.26.1 - 2023-09-30 - improved status line ### v1.26.0 - 2023-09-27 - when given a path to a file at launch, broot now selects it in the tree and opens it in preview - Fix #729 - allow rebinding of the 'tab' and 'esc' keys with the `:next_match` and `:escape` internals - Fix #740 - fix fuzzy patterns not case insensitive on some characters - Fix #746 ### v1.25.2 - 2023-09-20 - optional BROOT_CONFIG_DIR env var - the site now shows all env variables: https://dystroy.org/broot/launch/#environment-variables - `--only-folders` now longer allows symlinks to non directories - Fix #742 ### v1.25.1 - 2023-09-03 - fix shift-char in input extending the selection - Fix #733 ### v1.25.0 - 2023-08-19 - allow unescaped '::' in pattern position, experimental (might be removed) - allow hexa color notation in skins (eg `#fb0` or `#FFD700`) ### v1.24.2 - 2023-07-18 - fix a case of br script installation failing on Windows/Powershell ### v1.24.1 - 2023-07-16 - slightly better `--help` ### v1.24.0 - 2023-07-16 - installer for the powershell br script on windows - Thanks @felixkroemer - new `--help`, more compact - allow extra spaces before the verb - updated man page, now distributed in releases as /man/broot.1 ### v1.23.0 - 2023-06-16 - prettier, faster SVG rendering - reorganize default conf files, with a "skins" subfolder ### v1.22.1 - 2023-05-23 - allow dir computations in /run/media - Fix #704 - Thanks @jinliu - fix included solarized-dark.hjson skin file ### v1.22.0 - 2023-05-18 - define disk space availability colors in skin - Fix #705 - left elision of path when path/name doesn't fit - Fix #700 ### v1.21.3 - 2023-05-02 - `switch_terminal` verb parameter - Thanks @stevenxxiu - on Windows, when using `-c`, clear events after delay - Fix #699 ### v1.21.2 - 2023-03-30 - update dependencies because of some yanked ones ### v1.21.1 - 2023-03-23 - resolve `~` in special paths - Fix #685 - better clipboard support on MacOS - Thanks @bryan824 ### v1.21.0 - 2023-03-17 - better nushell integration (no need to quote arguments anymore, fix path extension broken by new version of nushell) - Thanks @stevenxxiu - don't show modal-only keys in help page when modal mode isn't enabled ### v1.20.2 - 2023-02-19 - fix debug statement printed in some cases (mostly on Windows) - Fix #672 ### v1.20.1 - 2023-02-08 - fix status line not always displaying the hint of the input's verb - Fix #665 ### v1.20.0 - 2023-02-03 - unless overridden, `/proc` is now `no-enter`, which solves freezes when searching on `/` in some system - See #639 - SVG files now rendered as images in the preview panel - new version of the nushell function. You should be prompted for an update - Fix #656 - Thanks @FrancescElies and @mediumrarez - `no-hide` special paths - Thanks @Avlllo - preview can now be opened on directories, showing their first level - Fix #405 - better determine whether the terminal is white or dark in some (probably rare) cases - See https://github.com/Canop/terminal-light/issues/2 ### v1.19.0 - 2023-01-03 - Nushell support - Fix #375 - Thanks @FrancescElies, @mediumrarez, and issue contributors ### v1.18.0 - 2022-12-21 - Hjson configuration file can now omit outside braces (it's "braceless Hjson"), making it much cleaner - allow opening the help screen with just the `?` key on Windows (as for other systems) - fix a crash in some cases of input being cleaned with a selection - Fix #643 ### v1.17.1 - 2022-12-15 - Windows specific implementation of :cpp ### v1.17.0 - 2022-12-09 - max file size for content search now configurable (default is now 10MB) - Fix #626 - file summing now avoids /proc and /run - default configuration sets /media as not entered by default (can be commented out, of course) ### v1.16.2 - 2022-11-04 - you can restrict the panels in which verbs apply with the verb configuration `panels` parameter - fix rm on Windows behaving "recursively" (it was `cmd /c del /Q /S {file}`) - Fix #627 ### v1.16.1 - 2022-10-13 - fix ctrl-left not usable anymore in filtered preview to remove filtering ### v1.16.0 - 2022-10-07 - status messages now displayed on toggling (for example showing hidden files) - upgrade terminal-light to 1.0.1 for better recognition of background color on high precision color terminals - in default configuration, ctrl-left never opens a panel to the left, as I think this was most often unwanted (one too many hit on cltr-left). It's possible to get the old behavior by binding ctrl-left to `:panel_left` instead of the new `:panel_left_no_open` internal. - New escaping rules let you skip many `\`, especially when building regexes - See new rules at https://dystroy.org/broot/input/#escaping - Fix #592 ### v1.15.0 - 2022-09-24 - with `show_matching_characters_on_path_searches: false`, it's possible to show only file names even when searching paths - Fix #490 - `--sort-by-type-dirs-first` and `--sort-by-type-dirs-last` - Fix #602 - modal: in input mode, uppercase letters don't trigger verbs anymore - Fix #604 - fix `:line_down_no_cycle` which was cycling - Fix #603 - selecting lines up or down with the mouse wheel now wraps in both direction (ie going up when your on top brings you to the bottom, and vice-versa) - `:select` internal, which can be used to select a visible file when given a path as argument. Experimental ### v1.14.3 - 2022-09-12 - fix crash with token searches - Fix #504 - Thanks @FedericoStra ### v1.14.2 - 2022-07-11 - Terminal background luma determination now works on all tested unixes, including MacOS - Fix #575 - Allow `:focus` based verbs to take a pattern - Fix #389 ### v1.14.1 - 2022-07-06 Due to a technical problem, background color based skin selection is disabled on non linux systems. ### v1.14.0 - 2022-07-05 #### Major Feature: imports A configuration file can now import one or several other ones. An import can have a condition on the terminal's background color, which makes it possible to import either a dark or a light theme depending on the current terminal settings. You're also encouraged to split your configuration in several files, as is now done for the default configuration. ### Minor changes - fix `--cmd` not working (it was accidentally renamed in `--commands`, `-c` was still working) - Fix #570 ### v1.13.3 - 2022-06-19 - fix `default_flags` in conf not working anymore - Fix #566 ### v1.13.2 - 2022-06-18 - advice to hit alt-i and|or alt-h when no file is visible - Fix #556 - examples on search modes in help screen - Fix #559 - list of syntactic themes in default conf - the --file-export-path launch argument which was deprecated since broot 1.6 has been removed (redirect the output of broot instead) - better built-in verbs for Windows - Thanks @Spacelord-XaN - take the .git/info/exclude file into account for ignoring - Thanks @refi64 Note: The released archive doesn't include an Android build - see https://github.com/Canop/broot/issues/565 ### v1.13.1 - 2022-05-30 - fix alt-enter failing to cd to directory ### v1.13.0 - 2022-05-29 - close the staging area when it's emptied with a verb (e.g. on `:rm`) - format files counts with thousands separator - Fix #549 - try verbs in order allowing some with filters before one without - Fix #552 ### v1.12.0 - 2022-05-05 - `:stage_all_files` internal, adding to the staging area all the files verifying the current pattern. Mapped by default to ctrl-a ### v1.11.1 - 2022-04-04 - fix broot not being usable while an image is being opened by hitting enter on linux - Fix #530 ### v1.11.0 - 2022-04-02 - sorting by type, with 3 new internals: `:sort_by_type_dirs_first`, `:sort_by_type_dirs_last`, and `:sort_by_type`. The last one lets you toggle between no sort, sorting by type with directories first, and sorting by type with directories last. - Fix #467 ### v1.10.0 - 2022-03-29 - verb filtering on file extension - Fix #508 - don't quit on tiny terminals - Fix #511 - fix the `capture_mouse` config item which was described in documentation but not usable (the non documented `disable_mouse_capture` argument was working and is kept for compatibility) ### v1.9.4 - 2022-03-07 - don't query size of remote filesystems anymore. This fixes some 10 seconds hangs in some cases (e.g. filesystem screen) when a remote filesystem is unreachable ### v1.9.3 - 2022-02-15 - keep same line visible in preview when resizing - `:previous_dir` and `:next_dir` internals - Fix #502 ### v1.9.2 - 2022-01-23 - instead of crashing on syntect panic in code preview, fall back to unstyled text - Fix #485 - fix files in worktree missing from git statuses - Fix #428 ### v1.9.1 - 2022-01-07 - fix a few problems of speed, flickering and uncleaned background with high resolution image preview ### v1.9.0 - 2022-01-06 - total search (launched with ctrl-s) shows all matches - This is experimental and might be reversed, opinions welcome - kitty graphics protocol used for high definition image rendering on recent enough versions of WezTerm - Fix #473 - fix syntaxic preview of Python files broken by comments - Fix #477 - home key bound to :input_go_to_start, end key bound to :input_go_to_end - Fix #475 ### v1.8.1 - 2021-12-29 - fix regex pattern automatically built from content pattern when going from a tree search to a file preview isn't escaped - Fix #472 ### v1.8.0 - 2021-12-26 - alt-i bound to toggle_git_ignore - alt-h bound to toggle_hidden - text previews switches to hexa when there are not printable chars (eg escape sequences) ### v1.7.5 - 2021-12-16 - Make the "clipboard" feature non default again, as it proves to make compilation harder on some platform. I still distribute executables with this feature and you can still try the compilation with `cargo install broot --features "clipboard"` ### v1.7.4 - 2021-12-01 - Fix 1 or 2 characters of the right ASCII column in hex view sometimes lost ### v1.7.3 - 2021-11-19 - Fix rendering artefacts on Windows, like a duplicate input line ### v1.7.2 - 2021-11-18 - include more syntaxes for preview of code files (using the list from the bat project) - Fix #464 ### v1.7.1 - 2021-11-07 - fix clipboard filled with dummy value on launch on X11 ### v1.7.0 - 2021-10-30 - "clipboard" feature now default (can still be removed at compilation with `--no-default-features`) - fix clipboard features not working on some recent linux distributions - you can now select part of the input with shift arrows or by dragging the mouse cursor - new internals: input_selection_cut and input_selection_copy (not bound by default) ### v1.6.6 - 2021-10-22 - make it possible to rebind left and right arrow keys without breaking usage in input - Fix #438 ### v1.6.5 - 2021-10-01 - improve decision on whether to trim root - Fix #434 - better make the tree's selected line visible ### v1.6.4 - 2021-10-01 - better scrolling behaviors - Fix #419 - fix special-path::Enter for symlinks - Fix #448 ### v1.6.3 - 2021-08-02 - hjson: fix bad parsing on tab before colon - now checks all args of externals are set, doesn't use the raw {arg} ### v1.6.2 - 2021-07-31 - broot reads now both the TERM and TERMINAL env variables to try determine whether the terminal is Kitty - using `:toggle_device_id`, you can display the device id of files (unix only) - fix a few problems with filesystems analysis by upgrading lfs-core to 0.4.2 - Fix #420 - a few minor rendering improvements ### v1.6.1 - 2021-06-23 - fix compilation on freeBSD - fix `:filesystems` view not listing disks whose mount point has a space character - fix panic on searching `cr/.*` if a file starts with an empty line - Fix #406 - fix preview of linux pseudo-files - identify "RAM" and "encrypted" disks in `:filesystems` view ### v1.6.0 - 2021-06-16 - `{root}` argument (current tree root) can be used in verb patterns - Fix #395 - `working_dir` verb attribute - Fix #396 - client-server mode fixed, no longer feature-gated (but still only available on unix like systems) - broot tries to keep same selection on option changes - `:tree_up` and `:tree_down` internals, mapped to ctrl-up and ctrl-down - Fix #399 - better handling of auto color mode: two separate behaviors: for app running and for export when leaving - Fix #397 - remove the deprecated `--no-style` launch argument (use `--color no` instead) - deprecate the `--out` argument (redirecting the output is the recommended solution) - fix a few minor bugs ### v1.5.1 - 2021-06-03 - fixed a few problems with the `:del_word_right` internal ### v1.5.0 - 2021-06-02 - new `auto_exec` verb property: a non-auto_exec verb isn't executed directly on a keyboard shortcut but fills the input so that it may be edited before execution on enter key - add support for backtab key (by default it's bound to :previous_match) - `:rename` built-in verb, best used with its keyboard shortcut F2 - new standard verb arguments: `{file-stem}`, `{file-extension}`, and `{file-dot-extension}`, - new `:toggle_second_tree` internal - Fix #388 - total size of staging area computed and displayed if sizes displayed elsewhere - new `file_sum_threads_count` conf property to define the number of threads used for file summing (size, count, last modified). The goal is to more easily search what's the best value depending on the cpu, OS and disk type/speed - `:input_clear` internal - Fix #24 ### v1.4.0 - 2021-05-11 - the default (non prefixed) search is now "path fuzzy" instead of "name fuzzy". You can still change the default mode and mode bindings in the config. This was done after a survey in chat. - new "unordered tokens" search type: `t/ab,cd` searches for tokens "ab" and "cd" in any order and case insensitive in the subpath, matches for example `src/dcd/Bab.rs` - Fix #378 - fix search modes configuration removing all default mappings - Fix #383 - conf / quit_on_last_cancel to allow quitting with esc when there's nothing to cancel - Fix #380 - new `parent` skin entry for the part of the sub-path before the file name (visible when you search on subpath) - when a content search has been done, opening a file with a compatible command (like the standard `:edit`) opens on the first line with a match ### v1.3.1 - 2021-04-30 - fix `:previous_match` not jumping over indirect matches - Fix #377 - fix typing a prefixed pattern then emptying it while keeping the prefix doesn't remove filtering - Fix #379 - fix shifted matching chars highlighting with regex patterns when showing icons - Fix #376 ### v1.3.0 - 2021-04-28 #### Minor changes: - modal mode: revert to command mode on command execution - Fix #372 - modal mode: when in command mode, '/' only enters input mode and is never appended to the input - better handle failing external programs when not leaving broot #### Major feature: staging area You may add files to the staging area then apply a command on all of them. This new feature is described [here](https://dystroy.org/broot/staging-area). Several verbs have been added. Type "stag" in help to see them and their keyboard shortcuts. ### v1.2.10 - 2021-04-03 - fix shift based key shortcuts - Fix #363 - check there's another panel before executing verbs with other-panel argument - Fix #366 ### v1.2.9 - 2021-03-18 - fix panic on `:input_del_word_left` - Fix #361 - remove diacritics and normalize unicode from input on fuzzy search (an unnormalized string with unwanted diacritics most often happen when you paste a string in the input) ### v1.2.8 - 2021-03-11 - it's possible to define several key shortcuts for a verb, using the "keys" property - improvements of fuzzy matching ### v1.2.7 - 2021-02-28 - don't ask again for installation if no sourcing file has been found ### v1.2.6 - 2021-02-27 - clipboard features (copy and paste verbs) now work on Android/Termux (needs the Termux API to be installed) - fix a compilation problem on non gnu windows - Thanks @Stargateur - obey '--color no' even in standard application mode. In that case, automatically enable selection marks or you wouldn't know what line is selected ### v1.2.5 - 2021-02-25 - fix style characters being written in `--no-style` mode - Fix #346 - replace `--no-style` with `--color` taking `yes`, `no` or `auto`, with detection of output being piped in `auto` mode (default). `--no-style` is still usable but it's not documented anymore - Fix #347 - fix wrong version number written in log file - Fix #349 - by default the number of panels is now limited to 2 (can be changed in conf with `max_panels_count`). The goal is to improve the global ergonomics for the most common (universal?) use case - Fix #345 ### v1.2.4 - 2021-02-14 - :line_down_no_cycle and :line_up_nocycle. They may be mapped instead of :line_up and :line_down when you don't want to cycle (ie arrive on top when you go down past the end of the tree/list) - Fix #344 - fix selected line number rendering in text preview ### v1.2.3 - 2021-02-06 - special paths in "no-enter" or "hide" aren't counted when summing sizes or dates. It's a compromise: it makes all sums a little slower, especially if you have a lot of special paths or complex ones, but it allows skipping over the very slow disks and thus makes some cases much faster - Fix #331 - br fish shell function uses shell completion of broot - tree height in `:pt` now applies even when there are more root items (thus truncating the tree) - Fix #341 - fix the F5 and F6 shortcuts (copy and move between panels) in the default configuration ### v1.2.1 - 2021-01-27 - allow dashes instead of underscores in conf property names. This fixes a regression as "special-paths", "ext-colors" and "search-modes" were defined with a dash up to version 1.0.7. Now both spellings are OK - Fix #330 - fix some problems with paths containing spaces (regression since 1.1.11)- Fix #329 ### v1.2.0 - 2021-01-14 - experimental "modal mode" (or "vim mode") in broot. See https://dystroy.org/broot/vim_mode/ - fix mouse staying captured during external app execution - Fix #325 ### v1.1.11 - 2021-01-07 - fix handling of rules starting with '/' in the global gitignore - Fix #321 - alt-c now mapped to the new :copy_line verb which, when in tree, puts the selected path in the clipboard and, when in text preview, puts the selected text line in the clipboard - Fix #322 - it's possible to define verb execution patterns as arrays instead of simple strings, to avoid having to escape quotes - Fix #319 ### v1.1.10 - 2020-12-24 broot now accepts both TOML and Hjson files for configuration. Default is Hjson. I explain the change [here](https://dystroy.org/blog/hjson-in-broot/) ### v1.0.9 - 2020-12-19 - fix handling on quotes in configured verbs - Fix #316 ### v1.0.8 - 2020-12-01 - when sizes are displayed (eg on `br -s`), show size of root line and root filesystem info - modified size cache management makes some size computations faster - sizes (and dates and counts) are progressively displayed ### v1.0.7 - 2020-11-27 * :previous_same_depth and :next_same_depth internals * in kitty terminal, image preview is high definition ### v1.0.6 - 2020-11-19 * optional icons, thanks to @asdf8dfafjk (@fiAtcBr on Miaou) - See https://dystroy.org/broot/icons * dev.log renamed into broot.log * `:line_up` and `:line_down` accept an optional count as argument - Fix #301 ### v1.0.5 - 2020-11-05 * in case of IO error when previewing a file, display the error instead of quitting * fix regression related to display of texts with characters taking several columns * preview now supports opening system files with size 0 (eg /proc "files") ### v1.0.4 - 2020-10-22 * don't use absolute paths for built-in verbs * fix freeze on circular symlink chains * `:filesystems` (alias `:fs`) display all mounted filesystems in a filtrable view. You can enter to browse at the mount point (unix only for now) * `:toggle_root_fs` (alias `:rfs`) toogles showing information on the filesystem of the current directory * filesystem information (mainly size and usage) related to the current filesystem displayed in whale-spotting mode ### v1.0.3 - 2020-10-07 * change the syntax of cols_order in conf * fix left key moving the cursor to start of input (instead of just one char left) ### v1.0.2 - 2020-10-04 * `cr/` patterns search on file content with regular expressions * search modes and their prefixes listed in help ### v1.0.1 - 2020-09-30 * don't apply .gitignore files (including the global one) when not in a git repository - Fix #274 * the "clipboard" optional feature adds: * the `:copy_path` verb which copies the selected path to the clipboard (mapped to alt-c) * the `:input_paste` verb which inserts the clipboard content in the input (mapped to ctrl-v) * it's now possible to define verbs executing sequences of commands - Fix #277 * fix opening of link of link - Fix #280 * broot is now compatible with Android, you can use it on Termux for example * help page lists all optional features enabled at compilation * list of verbs in help page is searchable ### v1.0.0 - 2020-09-01 - nothing new, which is better when you want to call your software stable ### v0.20.3 - 2020-08-23 - fix a few problems with tabulation rendering - fix a few cases of files being called "huge" while they're only very big ### v0.20.2 - 2020-08-18 - fix esc key not removing the filter in text preview ### v0.20.1 - 2020-08-18 - completion of the "client-server" feature - the tree tries to keep the selection when you remove a filter using the esc key - :focus now has a shortcut for when a file is selected too: ctrl-f - show_selection_mark preference in config (mostly for cases the background isn't clear enough) - **breaking change:** The working directory of external processes launched by broot isn't set anymore by default. If you want it to be changed, add `set_working_dir = true` to the verb definition. ### v0.20.0 - 2020-08-16 - it's now possible to launch a terminal as sub process from broot (and be back to broot on exit) - the selected directory is now the working dir for subprocess launched from broot - images are previewed as such - :preview_binary, :preview_text, and :preview_image verbs allow the choice of previewing mode - fix a possible panic in previewed files on displaying fuzzy pattern matches ### v0.19.4 - 2020-07-31 - don't install the br shell function when --outcmd is set or $BR_INSTALL is "no" - Fix #265 - more relevant status hints - Fix #261 ### v0.19.3 - 2020-07-27 - refined search in preview interaction (see blog https://dystroy.org/blog/broot-c-search/) ### v0.19.2 - 2020-07-26 - "client-server" feature (see client-server.md) - preview's pattern is kept when changing file - selected line in preview, interesting when removing the pattern (to see what's around a match) - faster availability of huge files in preview - search in preview now interrupted by key events (just like the trees) - a content search in a tree is propagated as a regex in a preview on :panel_right (ctrl-right) - syntax theme choice in conf.toml - {line} in a verb execution pattern refers to the line number ### v0.19.1 - 2020-07-17 Force trimming root when searching (trimming root when not searching is no longer the default) ### v0.19.0 - 2020-07-16 #### Major feature: the preview panel Hit ctrl-right when a file is selected and you get the preview. ### v0.18.6 - 2020-07-10 - `[ext-colors]` section in config - a few minor fixes and changes ### v0.18.5 - 2020-07-05 - git status takes into account overloading of enter and alt-enter - a few minor fixes and changes ### v0.18.4 - 2020-07-02 - `--git-status` launch option - fix rendering on windows ### v0.18.3 - 2020-06-30 Faster rendering (0.18.2 made it slower on some terminals) ### v0.18.2 - 2020-06-29 Remove flickering ### v0.18.1 - 2020-06-28 Column order is now configurable - Fix #127 ### v0.18.0 - 2020-06-26 #### Major change: Recursive last modified date computation The date of directories is now the modification date of the last modified inner file, whatever its depth. This is computed in the background and doesn't slow your navigation. #### Major change: Sort mode Size can now be displayed out of sort mode, which concerns either size or dates. There are new launch arguments: * `--sort-by-count` : sort by number of files in directories * `--sort-by-date` : sort by dates, taking content into account (make it easy to find deep recent files) * `--sort-by-size` : sort by size * `--whale-spotting` or `-w` : "whale spotting" mode (sort by size and show all files) The `-s` launch argument now works similarly to -d or -p : it doesn't activate a sort mode but activates showing the sizes. `-s` has been replaced with `-w`. Similarly new verbs have been defined: * `:toggle_counts`, with shortcut `counts` shows the number of files in directories * `:toggle_sizes`, with shortcut `sizes` shows the sizes of files and directories * `:sort_by_count` has for shortcut `sc` * `:sort_by_date` has for shortcut `sd` * `:sort_by_size` has `ss` as shortcut * `:no_sort` removes the current sort mode, if any ### v0.17.0 - 2020-06-21 #### Major feature: keep broot open behind terminal editors If you now open vi or emacs from broot with `leave_broot = false` you should be back in broot after you quit the editor - Fix #34 - Fix #144 - Fix #158 #### Minor changes: - it's possible to define input edition shortcuts - Fix #235 - MacOS: config directory for new install is ~/.config/broot - Fix #103 ### v0.16.0 - 2020-06-20 #### Major feature: composite patterns It's now possible to use logical operators on patterns. For example: * `!/txt$/` : files whose name doesn't end in "txt" * `carg|c/carg` : files whose name or content has "carg" * `(json|xml)&c/test` : files containing "test" and whose name fuzzily contains either "json" or "xml" The document contains other examples and precisions. ### v0.15.1 - 2020-06-12 - fix some problems related to relative paths in built in cp and mv ### v0.15.0 - 2020-06-12 #### Major feature: new input syntax - Breaking Change New search modes (see https://dystroy.org/broot/input/#the-filtering-pattern) : - fuzzy or regex on sub-paths (the path starting from the displayed root) - search in file content - it's possible to configure how search modes are selected in config - search pattern characters can be escaped with a '\' #### Minor changes: - tab goes to next direct match when there's no verb in input - Fix #234 - `:open_stay_filter` to be used if you want to keep the pattern when you navigate - Fix #240 - mouse capture can be disabled with `capture_mouse = false` - Fix #238 - several small fixes ### v0.14.2 - 2020-06-01 - `apply_to` verb property - fix #237 ### v0.14.1 - 2020-05-29 - fix uppercase letters ignored in input field ### v0.14.0 - 2020-05-29 #### Major feature: `:focus` verb This verb can be called, and parameterized, with a path as argument, which makes it possible to have a shortcut to a specific location. As a result, the specific `:focus_user_home` and `:focus_root` verbs have been removed (`:focus ~` works on all OS). #### Major feature: panels! There are three major ways to open a new panel: - by using ctrl-left or ctrl-right, which can also be used to navigate between panels - when a verb is edited, by using ctrl-p, which opens a panel which on closure will fill the argument - by using any verb with a bang. For example `:focus! ~` or `:!help` When you have two panels, you may use some new verbs like :copy_to_panel which copies the selection to the selected location in the other panel. Many new verbs and functions are related to panels but broot can still be used exactly as before without using panels. #### Major feature: autocompletion Using the Tab key you can complete verbs or paths #### Major feature: special paths Some paths can be handled in a specific way. Fix #205 and #208 You can for example decide that some slow disk shouldn't be entered automatically #### Minor changes: - date/time format configurable - Fix #229 - esc doesn't quit broot anymore (by popular demand) It's probably a good idea to remove your existing conf.toml file so that broot creates a brand new one with suggestions of shortcuts. ### v0.13.6 - 2020-04-08 - ignore diacritics in searches - Fix #216 ### v0.13.5 - 2020-03-28 - right key open directory, left key gets back (when input is empty) - Fix #179 - replace ~ in path arguments with user home dir - Fix #211 - use $XDG_CONFIG_HOME/git/ignore when the normal core.excludesFile git setting is missing - Fix #212 - add a man page to archive - Fix #165 ### v0.13.4 - 2020-03-13 - support for an arg made of an optional group - Fix #210 ### v0.13.3 - 2020-02-27 - fix a compilation problem related to dependency (termimad) version ### v0.13.2 - 2020-02-16 - fix -i and -I launch arguments being ignored (fix #202) ### v0.13.1 - 2020-02-08 - fix background not always removed when skin requires no background (Fix #194) ### v0.13.0 - 2020-02-05 #### Major change: git related features - `:show_git_file_info` compute git repo statistics and file statuses. Statistics are computed in background and cached. - `:git_diff` verb launching `git diff {file}` - `:git_status` filter files to show only the ones which are relevant for `git status` (warning: slow on big repositories) #### Major change: rewamped launch flags Several new launch flags have been added, mostly doing the opposite of previous ones (eg `-S` negates `-s`) and a new entry in the conf.toml lets you define default flags (which can be overridden by the ones you pass on the command line). Do `br --help` to view the complete list of flags. #### Minor changes: - on refresh or after command, if the previously selected path can't be selected (missing file, probably) then the previous index will be kept if possible - alt-enter can be rebinded (users should not do that without binding `:cd`, though) ### v0.12.2 - 2020-01-29 - fix Ctrl-J being interpreted as Enter (fix #177) ### v0.12.1 - 2020-01-25 - fix panic on some inputs starting with a `/` (Fix #175) - TAB key now jumps to direct matches only - `--conf` arg to launch broot with specific config file(s) (fix #141) ### v0.12.0 - 2020-01-19 - **breaking change:** commands given with `--cmd` must be separated (default separator is `;`) - fix some cases of terminal let in a bad state on errors (thanks Nathan West) - bring some changes to the fish shell function and its installation (PR #128) - consider path `$ZDOTDIR/.zshrc` for zsh shell function sourcing (fix #90) - don't use .gitignore files of parent repositories - change default value of the toggle_trim_root to false (fix #106 but might be reverted) - `:print_relative_path` verb (fix #169, thanks Roshan George) - `:chmod` verb ### v0.11.9 - 2020-01-15 - fix a case of bad selection after search followed by interrupted search (#147) - `--set-install-state` can be used in tests or manual installs to set the installation state - Raspberry now a default target available in installation page - fix a regression: `br -s` not finishing computing size until receiving an event - display the real size of sparse files (fix #102) ### v0.11.8 - 2020-01-12 - set different skins for the r, w and x parts of the mode (permission) - compatibility with freeBSD - generate shell completion scripts on build (deep into the target directory) - `--print-shell-function` launch argument to print the shell functions to stdout ### v0.11.7 - 2020-01-11 - fix cancelled verbs possibly executed (fix #104) (major dangerous bug) ### v0.11.6 - 2020-01-10 - backspace was previously bound to :back if not consumed by input. This is removed - fix unsignificative event interpreted as previous event repetition - fix wrong background applied on sizes in tree display - allow env vars used in verb execution to contain parameters (fix #114) - allow the use of arrow keys as triggers for verbs (fix #121) - fix scroll adjustment when using the arrow keys (when there's a scrollbar) (fix #112) ### v0.11.5 - 2020-01-10 - keep same path selected when lines are reordered (such as when directory sizes are computed - changed the skin used before installation so that it works better on white backgrounds ### v0.11.4 - 2020-01-09 - make :open_stay and :open_leave work in help screen (applying on configuration file) - Mac/fish: use ~/.config/fish even on systems where the config home is usually different - Mac/bash: add .bash_profile to the list of possible sourcing files - define ctrl-c as a new way to quit ### v0.11.3 - 2020-01-09 - fix the 'n' answer being ignored when user is asked authorization ### v0.11.2 - 2019-12-30 - fix alt-enter not recognized on some computers ### v0.11.0 - 2019-12-21 New major feature: the `:total_search` verb, normally triggered with *ctrl-s*: done after a search it repeats it but looks at **all** the children, even if it's long and there were a lot of matches ### v0.10.5 - 2019-12-20 - should not panic anymore when opening arbitrary files on server - allow more keys for verbs. For example you can use `enter` (this one won't apply on directories but only on files) - display all possible verb completions in status - don't query the terminal size after start: use the new Resize event of Crossterm ### v0.10.4 - 2019-12-16 * fuzzy search performance improvement * verb invocation now optional so that a verb can be defined to just introduce a keyboard shortcut * owner and group separately skinned * screen redrawn on resize (but tree not recomputed, you may want to do F5 to have the best sized tree) * changes in br shell function storage and sourcing from fish, bash, and zsh. Fixes #39 and #53. Note that broot will ask you again to install the br function ### v0.10.3 - 2019-11-27 * fix panic on doing `:rm` on the last child of current root * refactor help page generation using Termimad templates * clear help background when terminal was resized between redraws ### v0.10.2 - 2019-11-15 * colored status line * better handling of errors when opening files externally * spinner replaced with an explicit text * `:parent` no longer keeps the filter (this was too confusing) * new `:up` command, focusing the parent of the current tree root * `$PAGER` used in default config. Fix #20 * default conf links to the white background skin published on web site * new "default" entry in skin, to define a global background replacing the terminal's one ### v0.10.1 - 2019-11-04 * incorporate crossterm 0.13.2 to fix a regression in vi launch (see https://github.com/Canop/broot/issues/73) ### v0.10.0 - 2019-11-03 * moved to the crossterm 0.13 and termimad 0.7.1 * broot runs on stderr, * broot can run in a subshell Those changes allow tricks like `my_unix_command "$(broot)"` when you do `:pp` to print the path on stdout from broot ### v0.9.6 - 2019-09-20 * smarter cut of the status line when it doesn't fit the console's width * fix mouse click on the status line crashing broot * prevent the best match from being hidden inside "unlisted" matches ### v0.9.5 - 2019-09-15 * keyboard keys & shortcuts can be defined for more actions, all built-in verbs documented in website * paths built from verb arguments are now normalized ### v0.9.4 - 2019-09-13 New internal verbs like :focus_root, :focus_user_home, :refresh, :select_first You can define triggering keys for verbs. For example you can add those mappings: [[verbs]] invocation = "root" key = "F9" execution = ":focus_root" [[verbs]] invocation = "home" key = "ctrl-H" execution = ":focus_user_home" [[verbs]] invocation = "top" key = "F6" execution = ":select_first" [[verbs]] invocation = "bottom" key = "F7" execution = ":select_last" Then, when doing Ctrl-H, you would go to you user home (`~` when on linux) and F7 would select the last line of the tree. A few more keys are defined as default, like F1 for `:help` and F5 for `:refresh`. ### v0.9.3 - 2019-08-02 Launching broot with `--sizes` now sets a set of features enabling fast "whale spotting" navigation ### v0.9.2 - 2019-07-31 Fix non consistent builds due to lack of precise versioning in crossterm subcrate versioning ### v0.9.1 - 2019-07-29 #### Major change * A new syntax allows specifying verbs which can work on relative paths or absolute paths alike. For example the old definition of `cp` was invocation = "cp {newpath}" execution = "/bin/cp -r {file} {parent}{newpath}" and it's now invocation = "cp {newpath}" execution = "/bin/cp -r {file} {newpath:path-from-parent}" The :path-from-parent formatting means the token will be interpreted as a path, and if it's not starting with a / will be prefixed by the parent path. It's possible to also use `{subpath:path-from-directory}` where directory is parent only if the selected file isn't a directory itself. #### Minor changes - shift-tab selects the previous match - mouse wheel support (selection in tree, scroll in help) - the input field handles left/right arrow keys, home/end, click, and delete ### v0.9.0 - 2019-07-19 #### Major change The logic behind opening has changed to allow easier opening of files in non terminal applications without closing broot. **Old behavior:** - in case of enter or double-click - on a directory: open that directory, staying in broot - on a file: open the file, quitting broot - in case of alt-enter - on a directory: cd to that directory, quitting broot - on a file: cd to that file's parent directory, quitting broot **New behavior:** - in case of enter or double-click - on a directory: open that directory, staying in broot - on a file: open that file in default editor, not closing broot - in case of alt-enter - on a directory: cd to that directory, quitting broot - on a file: open that file in default editor, quitting broot #### Minor change - Hitting `?` more directly opens the help screen, even when executing a verb ### v0.8.6 - 2019-07-03 - Hitting enter when first line is selected, or clicking it, goes up to the parent directory - detect and color executable files on windows - new toggle to display dates of files (last modification) - a few small improvements ### v0.8.5 - 2019-06-20 - minor cosmetic changes (this version was mostly released to ensure consistency with termimad's crate) ### v0.8.4 - 2019-06-17 - apply verbs on link files, not on their targets (rm some_link was dangerous) ### v0.8.3 - 2019-06-16 - mouse support: click to select, double-click to open ### v0.8.2 - 2019-06-15 - fix wrong result of scrolling when help text fits the screen ### v0.8.1 - 2019-06-10 - change default skin to only use highly compatible colors - allow ANSI colors in skin configuration ### v0.8.0 - 2019-06-07 Half broot has been rewritten to allow Windows compatibility. Termion has been replaced with crossterm. ### v0.7.5 - 2019-04-03 - try to give arguments to verbs executed with --cmd - Hitting no longer quits when root is selected (many users found it confusing) ### v0.7.4 - 2019-03-25 - fix verbs crashing broot in / - fix user displayed in place of user with :perm ### v0.7.3 - 2019-03-22 - :print_tree outputs the tree. See [documentation](https://dystroy.org/broot/documentation/usage/#export-a-tree) for examples of use - F5 refreshes the tree ### v0.7.2 - 2019-03-15 - env variables usable in verb execution patterns, which makes it possible to use `$EDITOR` in default conf.toml - ctrl-u and ctrl-d are now alternatives to page-up and page-down - better error messages regarding faulty configurations - more precise errors in case of invalid regexes - use the OS specific file opener instead of xdg-open (concretly it means `open` is now used on MacOS) Thanks Ophir LOJKINE for his contributions in this release ### v0.7.1 - 2019-03-08 - fix a few problems with the count of "unlisted" files ### v0.7.0 - 2019-03-07 ##### Major changes - verbs can now accept complex arguments. This allows functions like mkdir, mv, cp, etc. and your own rich commands - custom verbs can be executed without leaving broot (if defined with `leave_broot=false`) ##### Minor changes - Ctrl-Q shortcut to leave broot - fix a case of incorrect count of "unlisted" files ### v0.6.3 - 2019-02-23 - `br` installer for the fish shell - faster directory size computation (using a pool of threads) - fix alt-enter failing to cd when the path had spaces - executable files rendered with a different color ### v0.6.2 - 2019-02-18 - all colors can be configured in conf.toml ### v0.6.1 - 2019-02-14 - complete verbs handling in help screen - faster regex search - fix missing version in `broot -V` ### v0.6.0 - 2019-02-12 ##### Major changes - broot now installs the **br** shell function itself *(for bash and zsh, help welcome for other shells)* - new verb `:toggle_trim_root` allows to keep all root children - verbs can refer to `{directory}` which is the parent dir when a simple file is selected - user configured verbs can be launched from parent shell too (like is done for `cd {directory}`) ##### Minor changes - allow page up and page down on help screen - fuzzy pattern: increase score of match starting after word separator - better handle errors on a few cases of non suitable root (like passing an invalid path) - clearer status error on `:cd`. Mentions `` in help - add a scrollbar on help screen ### v0.5.2 - 2019-02-04 - More responsive on slow disks - fix a link to documentation in autogenerated conf ### v0.5.1 - 2019-02-03 - alt-enter now executes `:cd` ### v0.5.0 - 2019-01-30 - patterns can be regexes (add a slash before or after the pattern) - configuration parsing more robust - no need to put all verbs in config: builtins are accessible even without being in config - no need to type the entire verb shortcut: if only one is possible it's proposed - verbs with {file} usable in help state: they apply to the configuration file - clear in app error message when calling :cd and not using the br shell function - bring back jemalloc (it's faster for broot) - more precise display of file/dir sizes ### 0.4.7 - 2019-01-21 - fix some cases of panic on broot quitting - new `--cmd` program argument allows passing a sequence of commands to be immediately executed (see [updated documentation](https://github.com/Canop/broot/blob/master/documentation.md#passing-commands-as-program-argument)) - better handling of symlink (display type of target, show invalid links, allow verbs on target) - compiled with rustc 1.32 which brings about 4% improvements in perfs compared to 1.31 ### v0.4.6 - 2019-01-12 - fix configured verbs not correctly handling paths with spaces - fix `:q` not instantly quitting broot when computing size - hit enter on tree root correctly quits broot ### v0.4.5 - 2019-01-11 - Faster search, mainly ### v0.4.3 - 2019-01-08 - Faster search and directory size computation. ### v0.4.2 - 2019-01-07 - more complete search if time allows - search pattern kept after verb execution ### v0.4.1 - 2019-01-07 - first public release ================================================ FILE: CONTRIBUTING.md ================================================ Before you start, unless you're fixing a typo or proposing a trivial change, please - discuss the need and technical design first, either in an issue or on [miaou](https://miaou.dystroy.org/3768) - keep it simple and focused - don't touch more files or lines than necessary - apply the standard formatting of the project - check tests And remember: there's no problem in asking when not sure, even if somebody else may have asked before, we're humans. More info at https://dystroy.org/blog/contributing/ ================================================ FILE: Cargo.toml ================================================ [package] name = "broot" version = "1.56.1" authors = ["dystroy "] repository = "https://github.com/Canop/broot" homepage = "https://dystroy.org/broot" documentation = "https://dystroy.org/broot" description = "File browser and launcher" edition = "2021" keywords = ["cli", "fuzzy", "tree", "search", "file"] license = "MIT" categories = ["command-line-utilities"] readme = "README.md" build = "build.rs" rust-version = "1.83" exclude = ["website", "broot*.zip"] [features] default = [] clipboard = ["terminal-clipboard"] kitty-csi-check = ["xterm-query"] [dependencies] base64 = "0.22" bet = "1.1" char_reader = "0.1" chrono = "0.4" clap = { version = "4.4", features = ["derive", "cargo"] } clap-help = "1.4" cli-log = "2.1" crokey = "1.3" custom_error = "1.6" deser-hjson = "2.2.3" directories = "4.0" file-size = "1.0.3" flate2 = "1.0" flex-grow = "0.1" git2 = { version = "0.20", default-features = false } # waiting for a good pure-rust alternative glob = "0.3" id-arena = "2.2.1" image = "=0.25.6" # later versions ask for rust 1.85+ zune-core = "0.4.12" zune-image = "0.4.15" include_dir = "0.7" lazy-regex = "3.5" libc = "0.2" lru = "0.16.3" memmap2 = "0.9" notify = "8.0" once_cell = "1.18" # waiting for https://github.com/rust-lang/rust/issues/109736 opener = "0.8" pathdiff = "0.2" phf = { version = "0.13", features = ["macros"] } rand = "0.8" rayon = "1.9" resvg = "0.45" rustc-hash = "2" secular = { version = "1.0", features = ["normalization", "bmp"] } serde = { version = "1.0", features = ["derive"] } smallvec = "1.15" # version 2 is still alpha splitty = "1.0.2" strict = "0.2" syntect = { package = "syntect-no-panic", version = "6.0", default-features = false, features = ["default-fancy"] } # see https://github.com/Canop/broot/pull/968 tempfile = "3.2" termimad = "0.34" terminal-clipboard = { version = "0.4.1", optional = true } terminal-light = "1.8" toml = "0.9" umask = "2.1.0" unicode-width = "0.2" vte = "0.15" which = "=4.4.0" # following versions ask for a a more recent rustc xterm-query = { version = "0.5", optional = true } [dev-dependencies] glassbench = "0.4.4" [target.'cfg(any(target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android")))))'.dependencies] trash = "=5.2.2" # later versions ask for rust 1.85+ [target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies] lfs-core = "0.17.0" [target.'cfg(target_family = "unix")'.dependencies] uzers = "0.12" [target.'cfg(target_os = "windows")'.dependencies] is_executable = "1.0.1" [build-dependencies] clap = { version = "4.4", features = ["derive", "cargo"] } clap_complete = "4.4" clap_mangen = "0.2.12" [profile.dev] debug = false [profile.release] debug = false lto = "fat" codegen-units = 1 # this removes a few hundred bytes from the final exec size strip = "symbols" [[bench]] name = "fuzzy" harness = false [[bench]] name = "toks" harness = false [[bench]] name = "composite" harness = false [[bench]] name = "path_normalization" harness = false [patch.crates-io] # bet = { path = "../bet" } # clap-help = { path = "../clap-help" } # coolor = { path = "../coolor" } # crokey = { path = "../crokey" } # crossterm = { path = "../crossterm-rs/crossterm" } # csv2svg = { path = "../csv2svg" } # deser-hjson = { path = "../deser-hjson" } # glassbench = { path = "../glassbench" } # lazy-regex = { path = "../lazy-regex" } # lfs-core = { path = "../lfs-core" } # minimad = { path = "../minimad" } # secular = { path = "../secular", features=["normalization"] } # syntect-no-panic = { path = "../syntect" } # terminal-clipboard = { path = "../terminal-clipboard" } # umask = { path = "../umask" } # cli-log = { path = "../cli-log" } # lazy-regex-proc_macros = { path = "../lazy-regex/src/proc_macros" } # strict = { path = "../strict" } # termimad = { path = "../termimad" } # terminal-light = { path = "../terminal-light" } # xterm-query = { path = "../xterm-query" } [package.metadata.binstall] pkg-url = "{ repo }/releases/download/v{ version }/{ name }_{ version }{ archive-suffix }" bin-dir = "{ target }/{ bin }{ binary-ext }" pkg-fmt = "zip" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Canop Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ## Broot [![Tests][s3]][l3] [![MIT][s2]][l2] [![Latest Version][s1]][l1] [![Chat on Miaou][s4]][l4] [![Packaging status][srep]][lrep] [s1]: https://img.shields.io/crates/v/broot.svg [l1]: https://crates.io/crates/broot [s2]: https://img.shields.io/badge/license-MIT-blue.svg [l2]: LICENSE [s3]: https://github.com/Canop/broot/actions/workflows/tests.yml/badge.svg [l3]: https://github.com/Canop/broot/actions/workflows/tests.yml [s4]: https://miaou.dystroy.org/static/shields/room.svg [l4]: https://miaou.dystroy.org/3490?broot [srep]: https://repology.org/badge/tiny-repos/broot.svg [lrep]: https://repology.org/project/broot/versions Broot is a better way to navigate directories, find files, and launch commands. ![cows](website/src/img/20241027-cows.png) [**Complete Documentation**](https://dystroy.org/broot/) - [**Installation Instructions**](https://dystroy.org/broot/install/) - [**Contributing or Getting Help**](https://dystroy.org/blog/contributing/) ## Get an overview of a directory, even a big one Hit `br -s` ![overview](website/src/img/20230930-overview.png) Notice the *unlisted*? That's what makes it usable, where the old `tree` command would produce pages of output. `.gitignore` files are properly dealt with to put unwanted files out of your way. As you sometimes want to see gitignored files, or hidden ones, you'll soon get used to the alti and alth shortcuts to toggle those visibilities. (you can ignore them though, see [documentation](https://dystroy.org/broot/navigation/#toggles)). ## Find a directory, then `cd` to it type a few letters ![cd](website/src/img/20230930-cd.png) Hit altenter and you're back to the terminal in the desired location. This way, you can navigate to a directory with the minimum amount of keystrokes, even if you don't exactly remember where it is. Broot is fast and doesn't block (any keystroke interrupts the current search to start the next one). Most useful keys for this: * the letters of what you're looking for * enter on the root line to go up to the parent (staying in broot) * enter to focus a directory (staying in broot) * esc to get back to the previous state or clear your search * and may be used to move the selection * altenter to get back to the shell, having `cd` to the selected directory * alth to toggle showing hidden files (the ones whose name starts with a dot) * alti to toggle showing gitignored files * `:q` if you just want to quit (you can use ctrlq if you prefer) ## Never lose track of file hierarchy while you search ![search](website/src/img/20230930-gccran.png) Broot tries to select the most relevant file. You can still go from one match to another one using tab or arrow keys. You may also search with a regular expression. To do this, add a `/` before the pattern. And you have [other types of searches](input/#the-filtering-pattern), for example, searching on file content (start with `c/`): ![content search](website/src/img/20230930-content-memm.png) You may also apply logical operators or combine patterns, for example, searching `test` in all files except JSON ones could be `!/json$/&c/test` and searching `carg` both in file names and file contents would be `carg|c/carg`. Once the file you want is selected, you can * hit enter (or double-click) to open it in your system's default program * hit altenter to open it in your system's default program and close broot * hit ctrl to preview it (and then a second time to go inside the preview) * type a verb. For example, `:e` opens the file in your preferred editor (which may be a terminal one) [blog: a broot content search workflow](https://dystroy.org/blog/broot-c-search/) ## Manipulate your files Most often, when not using broot, you move your files in the blind. You do a few `ls` before, then your manipulation, and maybe you check after. You can instead do it without losing the view of the file hierarchy. ![mv](website/src/img/20230930-mv.png) Move, copy, rm, mkdir, are built in, and you can add your own shortcuts. Here's chmod: ![chmod](website/src/img/20230930-chmod.png) ## Manage files with panels When a directory is selected, do ctrl and you open another panel (you may open other ones, or navigate between them, with ctrl and ctrl). ![custom colors tree](website/src/img/20230930-colored-panels.png) (yes, colors are fully customizable) You can, for example, copy or move elements between panels: ![cpp](website/src/img/20230930-cpp.png) If you like, you may do it Norton Commander style by binding `:copy_to_panel` to F5 and `:move_to_panel` to F6. ## Preview files Hit ctrl when a file is selected, and the preview panel appears. ![preview](website/src/img/20230930-preview.png) ![preview](website/src/img/20230930-preview-image.png) The preview panel stays synchronized with the selection in the tree panels. Broot displays images in high resolution when the terminal supports Kitty's graphics protocol (compatible terminals: [Kitty](https://sw.kovidgoyal.net/kitty/index.html), [WezTerm](https://wezfurlong.org/wezterm/)): ![kitty preview](website/src/img/20201127-kitty-preview.png) ## Apply a standard or personal command to a file ![size](website/src/img/20230930-edit.png) Just find the file you want to edit with a few keystrokes, type `:e`, then enter. You can add verbs or configure the existing ones; see [documentation](https://dystroy.org/broot/conf_file/#verbs-shortcuts-and-keys). And you can add shortcuts, for example, a ctrl sequence or a function key ## Apply commands on several files Add files to the [staging area](staging-area), then execute any command on all of them. ![staging mv](website/src/img/20230930-staging-mv.png) ## Replace `ls` (and its clones): If you want to display *sizes*, *dates*, and *permissions*, do `br -sdp` which gets you this: ![replace ls](website/src/img/20240501-sdp.png) You may also toggle options with a few keystrokes while inside broot. For example, you could have typed this `-sdp` while in broot. Or hit alth and you see hidden files. ## Sort, see what takes space: You may sort by launching broot with `--sort-by-size` or `--sort-by-date`. Or you may, inside broot, type a space, then `sd`, and enter and you toggled the `:sort_by_date` mode. When sorting, the whole content of the directories is taken into account. So if you want to find on Monday morning the most recently modified files, launch `br --sort-by-date ~`. If you start broot with the `--whale-spotting` option (or its shortcut `-w`), you get a mode tailored to "whale spotting" navigation, making it easy to determine what files or folders take space. Sizes, dates, and file counts are computed in the background; you don't have to wait for them when you navigate. ![size](website/src/img/20230930-whale-spotting.png) And you keep all broot tools, like filtering or the ability to delete or open files and directories. If you hit `:fs`, you can check the usage of all filesystems, so that you focus on cleaning the full ones. ![fs](website/src/img/20230930-fs.png) ## Check git statuses: Use `:gf` to display the statuses of files (what are the new ones, the modified ones, etc.), the current branch name and the change statistics. ![size](website/src/img/20230930-git.png) And if you want to see *only* the files which would be displayed by the `git status` command, do `:gs`. From there it's easy to edit, or diff, selected files. ![gg](website/src/img/20230930-gg.png) From there, it's easy to edit, diff, or revert selected files. [blog: use broot and meld to diff before commit](https://dystroy.org/blog/gg/) ## Further Reading See **[Broot's web site](https://dystroy.org/broot)** for instructions regarding installation and usage. ================================================ FILE: bacon.toml ================================================ # This is a configuration file for the bacon tool # More info at https://github.com/Canop/bacon default_job = "check" env.CARGO_TERM_COLOR = "always" [jobs] [jobs.check-all] command = ["cargo", "check", "--all-targets"] need_stdout = false watch = ["tests", "benches", "examples"] [jobs.bacon-ls] command = [ "cargo", "check", "--message-format", "json-diagnostic-rendered-ansi" ] analyzer = "cargo_json" need_stdout = true [exports.cargo-json-spans] auto = true exporter = "analyzer" line_format = "{diagnostic.level}:{span.file_name}:{span.line_start}:{span.line_end}:{diagnostic.message}" path = "bacon-analyzzzer.json" [jobs.check] command = [ "cargo", "check", "--features", "clipboard kitty-csi-check", ] need_stdout = false watch = ["benches"] [jobs.android] command = [ "cargo", "ndk", "check", "-t", "x86_64", "--features", "clipboard", ] need_stdout = false [jobs.miri] command = ["cargo", "+nightly", "miri", "run"] need_stdout = true [jobs.windows] command = [ "cross", "build", "--target", "x86_64-pc-windows-gnu", "--features", "clipboard" ] [jobs.netbsd] command = [ "cross", "build", "--target", "x86_64-unknown-netbsd", "--release", "--features", "clipboard" ] [jobs.light] command = ["cargo", "check"] need_stdout = false [jobs.clippy] command = [ "cargo", "clippy", "--", "-W", "clippy::explicit_iter_loop", "-A", "clippy::bool_to_int_with_if", "-A", "clippy::collapsible_else_if", "-A", "clippy::collapsible_if", "-A", "clippy::derive_partial_eq_without_eq", "-A", "clippy::if_same_then_else", "-A", "clippy::len_without_is_empty", "-A", "clippy::manual_clamp", "-A", "clippy::manual_range_contains", "-A", "clippy::manual_unwrap_or", "-A", "clippy::match_like_matches_macro", "-A", "clippy::module_inception", "-A", "clippy::needless_bool", "-A", "clippy::needless_range_loop", "-A", "clippy::neg_multiply", "-A", "clippy::uninlined_format_args", "-A", "clippy::unnecessary_map_or", "-A", "clippy::vec_init_then_push", ] need_stdout = false [jobs.pedantic] command = [ "cargo", "clippy", "--", "-W", "clippy::pedantic", "-A", "clippy::bool_to_int_with_if", "-A", "clippy::map_unwrap_or", "-A", "clippy::match_like_matches_macro", "-A", "clippy::missing_errors_doc", "-A", "clippy::module_inception", "-A", "clippy::similar_names", "-A", "clippy::struct_excessive_bools", "-A", "clippy::too_many_lines", "-A", "clippy::unnecessary_debug_formatting", "-A", "clippy::unreadable_literal", "-A", "clippy::wildcard_imports", "-A", "clippy::vec_init_then_push", "-A", "clippy::single_match_else", ] need_stdout = false [jobs.test] command = ["cargo", "test"] need_stdout = true [keybindings] a = "job:check-all" i = "job:initial" c = "job:clippy" d = "job:doc-open" t = "job:test" r = "job:check-all" ================================================ FILE: benches/composite.rs ================================================ mod shared; use { broot::{ command::CommandParts, pattern::*, }, glassbench::*, }; // this file benches composite patterns on file names so don't // use file content sub patterns here static PATTERNS: &[&str] = &[ "réveil", "r&!e", "(!e&!b)|c", ]; fn bench_score_of_composite(gb: &mut Bench) { let search_modes = SearchModeMap::default(); for pattern in PATTERNS { let name = format!("Composite({:?})::score_of", &pattern); gb.task(name, |b| { let parts = CommandParts::from(pattern.to_string()); let cp = Pattern::new(&parts.pattern, &search_modes, 10*1024*1024).unwrap(); b.iter(|| { for name in shared::NAMES { pretend_used(cp.score_of_string(name)); } }); }); } } glassbench!( "Composite Patterns", bench_score_of_composite, ); ================================================ FILE: benches/fuzzy.rs ================================================ mod shared; use { broot::pattern::FuzzyPattern, glassbench::*, }; static PATTERNS: &[&str] = &["réveil", "AB", "e", "brt", "brootz"]; fn bench_score_of_fuzzy(gb: &mut Bench) { for pattern in PATTERNS { let task_name = format!("Fuzzy({pattern:?})::score_of"); gb.task(task_name, |b| { let fp = FuzzyPattern::from(pattern); b.iter(|| { for name in shared::NAMES { pretend_used(fp.score_of(name)); } }); }); } } glassbench!( "Fuzzy Patterns", bench_score_of_fuzzy, ); ================================================ FILE: benches/path_normalization.rs ================================================ use { broot::path, glassbench::*, }; static PATHS: &[&str] = &[ "/abc/test/../thing.png", "/abc/def/../../thing.png", "/home/dys/test", "/home/dys", "/home/dys/", "/home/dys/..", "/home/dys/../", "/..", "../test", "/home/dys/../../../test", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/", "/", "π/2", ]; fn bench_normalization(gb: &mut Bench) { gb.task("normalize_path", |b| { b.iter(|| { for path in PATHS { pretend_used(path::normalize_path(path)); } }); }); } glassbench!( "Path Normalization", bench_normalization, ); ================================================ FILE: benches/shared/mod.rs ================================================ mod names; pub use names::*; ================================================ FILE: benches/shared/names.rs ================================================ pub static NAMES: &[&str] = &[ " brr ooT", "Réveillon", "dys", "test", " tetsesstteststt ", "a rbrroot", "Ab", "test again", "des réveils", "pi", "a quite longer name", "compliqué - 这个大象有多重", "brrooT", "1", "another name.jpeg", "aaaaaab", "a ab abba aab", "abcdrtodota", "palimpsestes désordonnés", "a", "π", "normal.dot", "ùmeé9$njfbaù rz&é", "FactoryFactoryFactoryFactory.java", "leftPad.js", "Cargo.toml", "Cargo.lock", "main.rs", ".gitignore", "lib.rs", " un réveil", "aaaaaaaaaaaaaaaaabbbbbbb", "BABABC B AB", "réveils", "paem", "poëme", "mjrzemrjzm mrjz mrzr rb root", "&cq", "..a", "~~~~~", "ba", "bar", "bar ro ot", "& aé &a é", "mùrz*jfzùenfzeùrjmùe", "krz", "q", "mjrfzm e", "dystroy.org", "www", "termimad", "minimad", "regex", "lazy_regex", "jaquerie", "Tillon", "Tellini", "Garo", "Portequoi", "Terdi", "Ploplo", "le dragon", "l'ours", "la tortue géante", "le chamois", "dystroy", "bra ernre rjrz a e3 broorar/ e/ smallvec/memmap;r b oot4 Z", "un petit peu n'importe quoi", "dans", "cette", "liste", "Broot", " broot", " broot ", "b-root", "biroute", "Miaou", "meow", "et", "surtout", "La Grande Roulette", "this list is", "very obviously", "tailored at stressing", "the engine", "and the reader", "C++", "javascript", "SQL", "C#", "Haskell", "Lisp", "Pascal", "and", "Fortran", "are just missing from this codebase", "denys", "seguret", "is", "the", "author", "bro o o o o o o o o o o o o ot", "bro o o o o o o o o o o o o otz", "br bro boo broot brootz", "b b bb bb ca e 1234 oooot", "Bo br BBBroo OOOOOt", "kir ba lrbvr b rbaz broot", "nrel ora hr rbooo t roo jrzz 7 tz", "not matching anything, is it ?", "ae/r/re /reee/ea", "era", "lrlb rre o", "rjre nr", ]; ================================================ FILE: benches/toks.rs ================================================ mod shared; use { broot::pattern::TokPattern, glassbench::*, }; static PATTERNS: &[&str] = &["a", "réveil", "bro,c", "e,jenc,arec,ehro", "broot"]; fn bench_score_of_toks(gb: &mut Bench) { for pattern in PATTERNS { let task_name = format!("TokPattern({pattern:?})::score_of"); gb.task(task_name, |b| { let fp = TokPattern::new(pattern); b.iter(|| { for name in shared::NAMES { pretend_used(fp.score_of(name)); } }); }); } } glassbench!( "Tokens Patterns", bench_score_of_toks, ); ================================================ FILE: build-all-targets.sh ================================================ #WARNING: This script is NOT meant for normal installation, it's dedicated # to the compilation of all supported targets. # This is a long process and it involves specialized toolchains. # For usual compilation do # cargo build --release # or read all possible installation solutions on # https://dystroy.org/broot/install H1="\n\e[30;104;1m\e[2K\n\e[A" # style first header H2="\n\e[30;104m\e[1K\n\e[A" # style second header EH="\e[00m\n\e[2K" # end header NAME=broot version=$(./version.sh) echo -e "${H1}Compilation of all targets for $NAME $version${EH}" # Clean previous build rm -rf build mkdir build echo " build cleaned" # Build versions for other platforms using cargo cross cross_build() { export RUSTFLAGS="" target_name="$1" target="$2" features="$3" echo -e "${H2}Compiling the $target_name version (target=$target, features='$features')${EH}" cargo clean --quiet if [[ -n $features ]] then cross build --quiet --target "$target" --release --features "$features" else cross build --quiet --target "$target" --release fi mkdir "build/$target" if [[ $target_name == 'Windows' ]] then exec="$NAME.exe" else exec="$NAME" fi cp "target/$target/release/$exec" "build/$target/" echo " Done" } cross_build "x86-64 GLIBC" "x86_64-unknown-linux-gnu" "clipboard" cross_build "NetBSD/amd64" "x86_64-unknown-netbsd" "" cross_build "MUSL" "x86_64-unknown-linux-musl" "" cross_build "ARM 32" "armv7-unknown-linux-gnueabihf" "" cross_build "ARM 32 MUSL" "armv7-unknown-linux-musleabi" "" cross_build "ARM 64" "aarch64-unknown-linux-gnu" "" cross_build "ARM 64 MUSL" "aarch64-unknown-linux-musl" "" cross_build "Windows" "x86_64-pc-windows-gnu" "clipboard" # use zig to build a version for GLIBC 2.28 # cargo zigbuild must be installed before target="x86_64-unknown-linux-gnu" glibc_version="2.28" zig_target="$target.$glibc_version" echo -e "${H2}Compiling for $target with GLIBC $glibc_version version${EH}" cargo zigbuild --release --target "$zig_target" folder="$target-glibc$glibc_version" mkdir "build/$folder" cp "target/$target/release/$NAME" "build/$folder/" echo " Done" # use zig with docker to build a Mac version target="aarch64-apple-darwin" echo -e "${H2}Compiling for $target ${EH}" docker run \ --rm -it -v $(pwd):/io -w /io ghcr.io/rust-cross/cargo-zigbuild \ cargo zigbuild --release --target "$target" --target-dir "zigbuild" mkdir "build/$target" cp "zigbuild/$target/release/$NAME" "build/$target/" echo " Done" # use cargo-ndk to build an android version # cargo-ndk and the NDK must first be installed and ANDROID_NDK_HOME point to the NDK ndk_target="x86_64" target="${ndk_target}-linux-android" echo -e "${H2}Compiling for $target ${EH}" cargo ndk build -t $ndk_target --features clipboard --release mkdir "build/$target" cp "target/$target/release/$NAME" "build/$target/" echo " Done" # build the local version target=$(./target.sh) echo -e "${H2}Compiling the local target - $target${EH}" cargo clean cargo build --release --features "clipboard" mkdir "build/$target/" cp "target/release/$NAME" "build/$target/" echo " Done" # Find, and copy the completion scripts # (they are re built as part of the normal compilation by build.rs) echo -e "${H2}Copying completion scripts${EH}" mkdir build/completion cp "$(broot -c 'rp/release\/build\/broot-[^\/]+\/out\/broot.bash;:parent;:pp' target)/"* build/completion echo " Done" # copy the default conf echo -e "${H2}copying default configuration${EH}" cp -r resources/default-conf build echo " Done" # add the resource (the icons font) echo -e "${H2}copying vscode-icon font${EH}" mkdir build/resources cp resources/icons/vscode/vscode.ttf build/resources echo "the font file comes from https://github.com/vscode-icons/vscode-icons/ and is licensed as MIT" > build/resources/README.md echo " Done" # add a summary of content echo ' This archive contains pre-compiled binaries. For more information, or if you prefer to compile yourself, see https://dystroy.org/broot/install ' > build/install.md echo -e "${H1}FINISHED${EH}" ================================================ FILE: build.rs ================================================ //! This file is executed during broot compilation. //! It builds shell completion scripts and the man page //! //! Note: to see the eprintln messages, run cargo with //! cargo -vv build --release use { clap::CommandFactory, clap_complete::{Generator, Shell}, std::{env, ffi::OsStr}, }; include!("src/cli/args.rs"); /// The man page built by clap-mangen is too rough to be used as is. It's only /// used as part of a manual process to update the one in /man/page /// so this generation is usually not needed pub const BUILD_MAN_PAGE: bool = false; fn write_completions_file>( generator: G, out_dir: P, ) { let mut args = Args::command(); for name in &["broot", "br"] { clap_complete::generate_to(generator, &mut args, (*name).to_string(), &out_dir) .expect("clap complete generation failed"); } } /// write the shell completion scripts which will be added to /// the release archive fn build_completion_scripts() { let out_dir = env::var_os("OUT_DIR").expect("out dir not set"); write_completions_file(Shell::Bash, &out_dir); write_completions_file(Shell::Elvish, &out_dir); write_completions_file(Shell::Fish, &out_dir); write_completions_file(Shell::PowerShell, &out_dir); write_completions_file(Shell::Zsh, &out_dir); eprintln!("completion scripts generated in {out_dir:?}"); } /// generate the man page from the Clap configuration fn build_man_page() -> std::io::Result<()> { let out_dir = env::var_os("OUT_DIR").expect("out dir not set"); let out_dir = PathBuf::from(out_dir); let cmd = Args::command(); let man = clap_mangen::Man::new(cmd); let mut buffer = Vec::::default(); man.render(&mut buffer)?; let file_path = out_dir.join("broot.1"); std::fs::write(&file_path, buffer)?; eprintln!("map page generated in {file_path:?}"); Ok(()) } fn main() -> std::io::Result<()> { build_completion_scripts(); if BUILD_MAN_PAGE { build_man_page()?; } Ok(()) } ================================================ FILE: build.sh ================================================ # This script compiles broot for the local system # # After compilation, broot can be found in target/release # # If you're not a developer but just want to install broot to use it, # you'll probably prefer one of the options listed at # https://dystroy.org/broot/install # # Depending on your system, it's possible one of the 'features' # won't compile for you. You may remove them (see features.md) # # The line below can be safely executed on systems which don't # support sh scripts. cargo build --release --features "clipboard" ================================================ FILE: features.md ================================================ This page defines the optional features which may be applied on compilation: * clipboard * trash * kitty-csi-check Feature gating is usually temporary: they may be removed when a technical problem is solved, when a feature becomes "mainstream", or when it's dropped because no user mentioned using it. ## The "clipboard" feature This feature allows the `:copy_path` verb which copies the currently selected path into the clipboard, as well as copy-pasting from,to,within the input. Limits: - the feature doesn't compile right now on some platforms (for example Raspberry) - on some platforms the content leaves the clipboard when you quit broot (so you must paste while broot is still running) ## The "trash" feature This feature enables commands for managing the system Trash. They are `:open_trash`, `:delete_trashed_file`, `:restore_trashed_file`, `:purge_trash`. ## The "kitty-csi-check" feature The Kitty graphics protocol allows displaying images in high resolution in broot. Most terminals don't support it, so support must be verified. Doing this with CSI escape sequences is a solution, but it involve delays and should only be enabled when this support can't be determined with [environment variables](https://dystroy.org/broot/launch/#environment-variables). Enabling this feature is thus not recommended unless you use a terminal you know support this protocol and isn't recognized by broot. If this happen, please tell me so that we can update one of the fast checks. ================================================ FILE: man/page ================================================ .\" Manpage for broot .\" Some items starting with a # are replaced on build .TH broot 1 "#date" "#version" "broot manpage" .SH NAME broot \- Tree view, file manager, configurable launcher .SH SYNOPSIS .B broot [\fIflags\fR] [\fIoptions\fR] [path] .br .B br [\fIflags\fR] [\fIoptions\fR] [path] .SH DESCRIPTION \fBbroot\fR lets you explore file hierarchies with a tree-like view, manipulate files, launch actions, and define your own shortcuts. .PP \fBbroot\fR is best launched as \fBbr\fR: this shell function gives you access to more commands, especially \fIcd\fR. The \fBbr\fR shell function is interactively installed on first \fBbroot\fR launch. .PP Flags and options can be classically passed on launch but also written in the configuration file. Each flag has a counter-flag so that you can cancel at command line a flag which has been set in the configuration file. .SH FLAGS FLAGS .TP \fB\-d\fR, \fB\-\-dates\fR Show the last modified date of files and directories .TP \fB\-D\fR, \fB\-\-no\-dates\fR Don\*(Aqt show the last modified date .TP \fB\-f\fR, \fB\-\-only\-folders\fR Only show folders .TP \fB\-F\fR, \fB\-\-no\-only\-folders\fR Show folders and files alike .TP \fB\-\-show\-root\-fs\fR Show filesystem info on top .TP \fB\-g\fR, \fB\-\-show\-git\-info\fR Show git statuses on files and stats on repo .TP \fB\-G\fR, \fB\-\-no\-show\-git\-info\fR Don\*(Aqt show git statuses on files and stats on repo .TP \fB\-\-git\-status\fR Only show files having an interesting git status, including hidden ones .TP \fB\-\-help\fR Print help information .TP \fB\-h\fR, \fB\-\-hidden\fR Show hidden files .TP \fB\-H\fR, \fB\-\-no\-hidden\fR Don\*(Aqt show hidden files .TP \fB\-i\fR, \fB\-\-git\-ignored\fR Show git ignored files .TP \fB\-I\fR, \fB\-\-no\-git\-ignored\fR Don\*(Aqt show git ignored files .TP \fB\-p\fR, \fB\-\-permissions\fR Show permissions .TP \fB\-P\fR, \fB\-\-no\-permissions\fR Don\*(Aqt show permissions .TP \fB\-s\fR, \fB\-\-sizes\fR Show the size of files and directories .TP \fB\-S\fR, \fB\-\-no\-sizes\fR Don\*(Aqt show sizes .TP \fB\-\-sort\-by\-count\fR Sort by count (only show one level of the tree) .TP \fB\-\-sort\-by\-date\fR Sort by date (only show one level of the tree) .TP \fB\-\-sort\-by\-size\fR Sort by size (only show one level of the tree) .TP \fB\-\-sort\-by\-type\fR Same as sort\-by\-type\-dirs\-first .TP \fB\-\-sort\-by\-type\-dirs\-first\fR Sort by type, directories first (only show one level of the tree) .TP \fB\-\-sort\-by\-type\-dirs\-last\fR Sort by type, directories last (only show one level of the tree) .TP \fB\-w\fR, \fB\-\-whale\-spotting\fR Sort by size, show ignored and hidden files .TP \fB\-\-no\-sort\fR Don\*(Aqt sort .TP \fB\-t\fR, \fB\-\-trim\-root\fR Trim the root too and don\*(Aqt show a scrollbar .TP \fB\-T\fR, \fB\-\-no\-trim\-root\fR Don\*(Aqt trim the root level, show a scrollbar .TP \fB\-\-outcmd\fR=\fIOUTCMD\fR Where to write the produced cmd (if any) .TP \fB\-c\fR, \fB\-\-cmd\fR=\fICMD\fR Semicolon separated commands to execute .TP \fB\-\-color\fR=\fICOLOR\fR [default: auto] Whether to have styles and colors (default is usually OK) .br .br [\fIpossible values: \fRauto, yes, no] .TP \fB\-\-conf\fR=\fICONF\fR Semicolon separated paths to specific config files .TP \fB\-\-height\fR=\fIHEIGHT\fR Height (if you don\*(Aqt want to fill the screen or for file export) .TP \fB\-\-install\fR Install or reinstall the br shell function .TP \fB\-\-set\-install\-state\fR=\fISET_INSTALL_STATE\fR Where to write the produced cmd (if any) .br .br [\fIpossible values: \fRundefined, refused, installed] .TP \fB\-\-print\-shell\-function\fR=\fIPRINT_SHELL_FUNCTION\fR Print to stdout the br function for a given shell .TP \fB\-\-listen\fR=\fILISTEN\fR A socket to listen to for commands .TP \fB\-\-get\-root\fR Ask for the current root of the remote broot .TP \fB\-\-write\-default\-conf\fR=\fIWRITE_DEFAULT_CONF\fR Write default conf files in given directory .TP \fB\-\-send\fR=\fISEND\fR A socket that broot sends commands to before quitting .TP \fB\-V\fR, \fB\-\-version\fR Print version .TP .SH BUGS .PP .B broot is known to be slow on most \fIWindows\fR installations. .PP On unix and mac platforms, most problems you may encounter are related to some terminals or terminal multiplexers which either intercepts some standard TTY instructions or break buffering or size querying. The list of shortcuts you can define in the config file is thus dependent of your system. .SH AUTHOR .B broot is free and open-source and is written by \fIdenys.seguret@gmail.com\fR. The source code and documentation are available at https://dystroy.org/broot ================================================ FILE: release.sh ================================================ # build a new release of broot # This isn't used for normal compilation (see https://dystroy.org/broot for instruction) # but for building the official releases version=$(./version.sh) echo "Building release $version" # make the build directory and compile for all targets ./build-all-targets.sh # add the readme and changelog in the build directory echo "This is broot. More info and installation instructions on https://dystroy.org/broot" > build/README.md cp CHANGELOG.md build # add the man page and fix its date and version cp man/page build/broot.1 sed -i "s/#version/$version/g" build/broot.1 sed -i "s/#date/$(date +'%Y\/%m\/%d')/g" build/broot.1 # publish version number echo "$version" > build/version # prepare the release archive rm broot_*.zip cd build zip -r "../broot_$version.zip" * cd - # copy it to releases folder mkdir -p releases/broot_${version} cp "broot_$version.zip" releases/broot_${version} ================================================ FILE: resources/default-conf/conf.hjson ================================================ ############################################################### # This configuration file lets you # - define new commands # - change the shortcut or triggering keys of built-in verbs # - change the colors # - set default values for flags # - set special behaviors on specific paths # - and more... # # Configuration documentation is available at # https://dystroy.org/broot # # This file's format is Hjson ( https://hjson.github.io/ ). Some # properties are commented out. To enable them, remove the `#`. # ############################################################### ############################################################### # Default flags # You can set up flags you want broot to start with by # default, for example `default_flags="-ihp"` if you usually want # to see hidden and gitignored files and the permissions (then # if you don't want the hidden files at a specific launch, # you can launch broot with `br -H`). # A popular flag is the `g` one which displays git related info. # # default_flags: ############################################################### # Terminal's title # If you want the terminal's title to be updated when you change # directory, set a terminal_title pattern by uncommenting one of # the examples below and tuning it to your taste. # # terminal_title: "[broot] {git-name}" # terminal_title: "{file} 🐄" # terminal_title: "-= {file-name} =-" # reset_terminal_title_on_exit: false ############################################################### # Date/Time format # If you want to change the format for date/time, uncomment the # following line and change it according to # https://docs.rs/chrono/0.4.11/chrono/format/strftime/index.html # # date_time_format: %Y/%m/%d %R ############################################################### # uncomment to activate modal mode # # (you really should read https://dystroy.org/broot/modal/ # before as it may not suit everybody even among vim users) # # You may start either in 'command' mode, or in 'input' mode # # modal: true # initial_mode: command ############################################################### # Whether to mark the selected line with a triangle # show_selection_mark: true ############################################################### # Column order # cols_order, if specified, must be a permutation of the following # array. You should keep the name column at the end as it has a # variable length. # # cols_order: [ # mark # git # size # permission # date # count # branch # name # ] ############################################################### # True Colors # If this parameter isn't set, broot tries to automatically # determine whether true colors (24 bits) are available. # As this process is unreliable, you may uncomment this setting # and set it to false or true if you notice the colors in # previewed images are too off. # # true_colors: false ############################################################### # Icons # If you want to display icons in broot, uncomment this line # (see https://dystroy.org/broot/icons for installation and # troubleshooting) # # icon_theme: vscode ############################################################### # Special paths # If some paths must be handled specially, uncomment (and change # this section as per the examples) # Setting "list":"never" on a dir prevents broot from looking at its # children when searching, unless the dir is the selected root. # Setting "sum":"never" on a dir prevents broot from looking at its # children when computing the total size and count of files. # Setting "show":"always" makes a file visible even if its name # starts with a dot. # Setting "list":"always" may be useful on a link to a directory # (they're otherwise not entered by broot unless selected) # special_paths: { "/media" : { list: "never" sum: "never" } "~/.config": { "show": "always" } "trav": { show: always list: "always", sum: "never" } # "~/useless": { "show": "never" } # "~/my-link-I-want-to-explore": { "list": "always" } } ############################################################### # Quit on last cancel # You can usually cancel the last state change on escape. # If you want the escape key to quit broot when there's nothing # to cancel (for example when you just opened broot), uncomment # this parameter # # quit_on_last_cancel: true ############################################################### # Search modes # # broot allows many search modes. # A search mode is defined by # - the way to search: 'fuzzy', 'exact', 'regex', or 'tokens'. # - where to search: file 'name', 'path', or file 'content' # A search pattern may for example be "fuzzy path" (default), # "regex content" or "exact path". # # The search mode is selected from its prefix. For example, if # you type "abc", the default mode is "fuzzy path". If you type # "/abc", the mode is "regex path". If you type "rn/abc", the mode # is "regex name". # # This mapping may be modified. You may want to dedicate the # empty prefix (the one which doesn't need a '/') to the # search mode you use most often. The example below makes it # easy to search on name rather than on the subpath. # # More information on # https://dystroy.org/broot/input/#the-filtering-pattern # # search_modes: { # : fuzzy name # /: regex name # } ############################################################### # File Extension Colors # # uncomment and modify the next section if you want to color # file name depending on their extension # # ext_colors: { # png: rgb(255, 128, 75) # rs: yellow # } ############################################################### # Max file size for content search # # Bigger files are ignored when searching their content. You # can specify this size either in ISO units (eg 5GB) or in # the old binary units (eg 44Kib) content_search_max_file_size: 10MB ############################################################### # Max Panels Count # # Change this if you sometimes want to have more than 2 panels # open # max_panels_count: 2 ############################################################### # Update work dir # # By default, broot process' work dir is kept in sync with the # current's panel root. If you want to keep it unchanged, # uncomment this setting # # update_work_dir: false ############################################################### # Kitty Keyboard extension # # If you want to use advanced keyboard shortcuts in Kitty # compatible terminals (Kitty, Wezterm), set this to true. # # This makes it possible to use shortcuts like 'space-n', # 'ctrl-alt-a-b', 'shift-space', etc. # enable_kitty_keyboard: false ############################################################### # lines around matching line in filtered preview # # When searching the content of a file, you can have either # only the matching lines displayed, or some of the surrounding # ones too. # lines_before_match_in_preview: 1 lines_after_match_in_preview: 1 ############################################################### # transformations before preview # # It's possible to define transformations to apply to some files # before calling one of the default preview renderers in broot. # Below are three examples that you may uncomment and adapt: # preview_transformers: [ // # Use mutool to render any PDF file as an image // # In this example we use placeholders for the input and output files // { // input_extensions: [ "pdf" ] // case doesn't matter // output_extension: png // mode: image // command: [ "mutool", "draw", "-w", "1000", "-o", "{output-path}", "{input-path}" ] // } // # Use LibreOffice to render Office files as images // # In this example, {output-dir} is used to specify where LibreOffice must write the result // { // input_extensions: [ "xls", "xlsx", "doc", "docx", "ppt", "pptx", "ods", "odt", "odp" ] // output_extension: png // mode: image // command: [ // "libreoffice", "--headless", // "--convert-to", "png", // "--outdir", "{output-dir}", // "{input-path}" // ] // } // # Use jq to beautify JSON // # In this example, the command refers to neither the input nor the output, // # so broot pipes them to the stdin and stdout of the jq process // { // input_extensions: [ "json" ] // output_extension: json // mode: text // command: [ "jq" ] // } // # Use zmore to preview compressed text files // { // input_extensions: [ "gz", "z", "tgz" ] // case doesn't matter // output_extension: txt // mode: text // command: [ "zmore" ] // } ] ############################################################### # Imports # # While it's possible to have all configuration in one file, # it's more convenient to split it in several ones. # Importing also allows to set a condition on the terminal's # color, which makes it possible to have a different skin # chosen when your terminal has a light background and when # it has a light one. imports: [ # Verbs are better configured in verbs.hjson. But you # can also add another files for your personal verbs verbs.hjson # This file contains the skin to use when the terminal # is dark (or when this couldn't be determined) { luma: [ dark unknown ] # (un)comment to choose your preferred skin file: skins/dark-blue.hjson //file: skins/catppuccin-macchiato.hjson //file: skins/catppuccin-mocha.hjson //file: skins/dark-gruvbox.hjson //file: skins/dark-orange.hjson //file: skins/solarized-dark.hjson } # This skin is imported when your terminal is light { luma: light # (un)comment to choose your preferred skin //file: skins/solarized-light.hjson file: skins/white.hjson } ] ================================================ FILE: resources/default-conf/skins/catppuccin-macchiato.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # This skin uses RGB values so won't work for some # terminals. # # Created by Majixed # Based on the catppuccin-mocha theme by A. Taha Baki # # Doc at https://dystroy.org/broot/skins/ ############################################################### skin: { input: rgb(202, 211, 245) none # fg:none bg:$surface2 selected_line: none rgb(91, 96, 120) # fg:$text bg:none default: rgb(202, 211, 245) none # fg:$overlay0 bg:none tree: rgb(110, 115, 141) none # fg:$sapphire bg:none parent: rgb(125, 196, 228) none file: none none # ### PERMISSIONS # perm__: rgb(184, 192, 224) none # $peach perm_r: rgb(245, 169, 127) none # $maroon perm_w: rgb(238, 153, 160) none # $green perm_x: rgb(166, 218, 149) none # $teal owner: rgb(139, 213, 202) none # $sky group: rgb(145, 215, 227) none # ### DATE # # $subtext1 dates: rgb(184, 192, 224) none # ### DIRECTORY # # $lavender directory: rgb(183, 189, 248) none Bold # $green exe: rgb(166, 218, 149) none # $yellow link: rgb(238, 212, 159) none # $subtext0 pruning: rgb(165, 173, 203) none Italic # ### PREVIEW # # fg:$text bg:$mantle preview_title: rgb(202, 211, 245) rgb(30, 32, 48) # fg:$text bg:$mantle preview: rgb(202, 211, 245) rgb(30, 32, 48) # fg:$overlay0 preview_line_number: rgb(110, 115, 141) none # fg:$overlay0 preview_separator: rgb(110, 115, 141) none # ### MATCH # char_match: rgb(238, 212, 159) rgb(73, 77, 100) Bold Italic content_match: rgb(238, 212, 159) rgb(73, 77, 100) Bold Italic preview_match: rgb(238, 212, 159) rgb(73, 77, 100) Bold Italic # children count # fg:$yellow bg:none count: rgb(238, 212, 159) none sparse: rgb(237, 135, 150) none content_extract: rgb(237, 135, 150) none Italic # ### GIT # git_branch: rgb(245, 169, 127) none git_insertions: rgb(245, 169, 127) none git_deletions: rgb(245, 169, 127) none git_status_current: rgb(245, 169, 127) none git_status_modified: rgb(245, 169, 127) none git_status_new: rgb(245, 169, 127) none Bold git_status_ignored: rgb(245, 169, 127) none git_status_conflicted: rgb(245, 169, 127) none git_status_other: rgb(245, 169, 127) none staging_area_title: rgb(245, 169, 127) none # ### FLAG # flag_label: rgb(237, 135, 150) none flag_value: rgb(237, 135, 150) none Bold # ### STATUS # # fg:none #bg:$mantle status_normal: none rgb(30, 32, 48) # fg:$red bg:$mantle status_italic: rgb(237, 135, 150) rgb(30, 32, 48) Italic # fg:$maroon bg:$mantle status_bold: rgb(238, 153, 160) rgb(30, 32, 48) Bold # fg:$maroon bg:$mantle status_ellipsis: rgb(238, 153, 160) rgb(30, 32, 48) Bold # fg:$text bg:$red status_error: rgb(202, 211, 245) rgb(237, 135, 150) # fg:$maroon bg:$mantle status_job: rgb(238, 153, 160) rgb(40, 38, 37) # fg:$maroon bg:$mantle status_code: rgb(238, 153, 160) rgb(30, 32, 48) Italic # fg:$maroon bg:$mantle mode_command_mark: rgb(238, 153, 160) rgb(30, 32, 48) Bold # ### HELP # # fg:$text help_paragraph: rgb(202, 211, 245) none # fg:$red help_headers: rgb(237, 135, 150) none Bold # fg:$peach help_bold: rgb(245, 169, 127) none Bold # fg:$yellow help_italic: rgb(238, 212, 159) none Italic # fg:green bg:$surface0 help_code: rgb(166, 218, 149) rgb(54, 58, 79) # fg:$overlay0 help_table_border: rgb(110, 115, 141) none # ### HEX # # fg:$text hex_null: rgb(202, 211, 245) none # fg:$peach hex_ascii_graphic: rgb(245, 169, 127) none # fg:$green hex_ascii_whitespace: rgb(166, 218, 149) none # fg: teal hex_ascii_other: rgb(139, 213, 202) none # fg: red hex_non_ascii: rgb(237, 135, 150) none # fg:$text bg:$red file_error: rgb(243, 60, 44) none # ### PURPOSE # purpose_normal: none none purpose_italic: rgb(169, 90, 127) none Italic purpose_bold: rgb(169, 90, 127) none Bold purpose_ellipsis: none none # ### SCROLLBAR # # fg:$surface0 scrollbar_track: rgb(54, 58, 79) none # fg:$surface1 scrollbar_thumb: rgb(91, 96, 120) none # ### GOODTOBAD # good_to_bad_0: rgb(166, 218, 149) none good_to_bad_1: rgb(139, 213, 202) none good_to_bad_2: rgb(145, 215, 227) none good_to_bad_3: rgb(125, 196, 228) none good_to_bad_4: rgb(138, 173, 244) none good_to_bad_5: rgb(183, 189, 248) none good_to_bad_6: rgb(198, 160, 246) none good_to_bad_7: rgb(245, 169, 127) none good_to_bad_8: rgb(238, 153, 160) none good_to_bad_9: rgb(237, 135, 150) none } ================================================ FILE: resources/default-conf/skins/catppuccin-mocha.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # This skin uses RGB values so won't work for some # terminals. # # Created by A. Taha Baki # Based on the built-in gruvbox theme. # # Doc at https://dystroy.org/broot/skins/ ############################################################### skin: { input: rgb(205, 214, 244) none # fg:none bg:$surface2 selected_line: none rgb(88, 91, 112) # fg:$text bg:none default: rgb(205, 214, 244) none # fg:$overlay0 bg:none tree: rgb(108, 112, 134) none # fg:$sapphire bg:none parent: rgb(116, 199, 236) none file: none none # ### PERMISSIONS # perm__: rgb(186, 194, 222) none # $peach perm_r: rgb(250, 179, 135) none # $maroon perm_w: rgb(235, 160, 172) none # $green perm_x: rgb(166, 227, 161) none # $teal owner: rgb(148, 226, 213) none # $sky group: rgb(137, 220, 235) none # ### DATE # # $subtext1 dates: rgb(186, 194, 222) none # ### DIRECTORY # # $lavender directory: rgb(180, 190, 254) none Bold # $green exe: rgb(166, 227, 161) none # $yellow link: rgb(249, 226, 175) none # $subtext0 pruning: rgb(166, 173, 200) none Italic # ### PREVIEW # # fg:$text bg:$mantle preview_title: rgb(205, 214, 244) rgb(24, 24, 37) # fg:$text bg:$mantle preview: rgb(205, 214, 244) rgb(24, 24, 37) # fg:$overlay0 preview_line_number: rgb(108, 112, 134) none # fg:$overlay0 preview_separator: rgb(108, 112, 134) none # ### MATCH # char_match: rgb(249, 226, 175) rgb(69, 71, 90) Bold Italic content_match: rgb(249, 226, 175) rgb(69, 71, 90) Bold Italic preview_match: rgb(249, 226, 175) rgb(69, 71, 90) Bold Italic # children count # fg:$yellow bg:none count: rgb(249, 226, 175) none sparse: rgb(243, 139, 168) none content_extract: rgb(243, 139, 168) none Italic # ### GIT # git_branch: rgb(250, 179, 135) none git_insertions: rgb(250, 179, 135) none git_deletions: rgb(250, 179, 135) none git_status_current: rgb(250, 179, 135) none git_status_modified: rgb(250, 179, 135) none git_status_new: rgb(250, 179, 135) none Bold git_status_ignored: rgb(250, 179, 135) none git_status_conflicted: rgb(250, 179, 135) none git_status_other: rgb(250, 179, 135) none staging_area_title: rgb(250, 179, 135) none # ### FLAG # flag_label: rgb(243, 139, 168) none flag_value: rgb(243, 139, 168) none Bold # ### STATUS # # fg:none #bg:$mantle status_normal: none rgb(24, 24, 37) # fg:$red bg:$mantle status_italic: rgb(243, 139, 168) rgb(24, 24, 37) Italic # fg:$maroon bg:$mantle status_bold: rgb(235, 160, 172) rgb(24, 24, 37) Bold # fg:$maroon bg:$mantle status_ellipsis: rgb(235, 160, 172) rgb(24, 24, 37) Bold # fg:$text bg:$red status_error: rgb(205, 214, 244) rgb(243, 139, 168) # fg:$maroon bg:$mantle status_job: rgb(235, 160, 172) rgb(40, 38, 37) # fg:$maroon bg:$mantle status_code: rgb(235, 160, 172) rgb(24, 24, 37) Italic # fg:$maroon bg:$mantle mode_command_mark: rgb(235, 160, 172) rgb(24, 24, 37) Bold # ### HELP # # fg:$text help_paragraph: rgb(205, 214, 244) none # fg:$red help_headers: rgb(243, 139, 168) none Bold # fg:$peach help_bold: rgb(250, 179, 135) none Bold # fg:$yellow help_italic: rgb(249, 226, 175) none Italic # fg:green bg:$surface0 help_code: rgb(166, 227, 161) rgb(49, 50, 68) # fg:$overlay0 help_table_border: rgb(108, 112, 134) none # ### HEX # # fg:$text hex_null: rgb(205, 214, 244) none # fg:$peach hex_ascii_graphic: rgb(250, 179, 135) none # fg:$green hex_ascii_whitespace: rgb(166, 227, 161) none # fg: teal hex_ascii_other: rgb(148, 226, 213) none # fg: red hex_non_ascii: rgb(243, 139, 168) none # fg:$text bg:$red file_error: rgb(251, 73, 52) none # ### PURPOSE # purpose_normal: none none purpose_italic: rgb(177, 98, 134) none Italic purpose_bold: rgb(177, 98, 134) none Bold purpose_ellipsis: none none # ### SCROLLBAR # # fg:$surface0 scrollbar_track: rgb(49, 50, 68) none # fg:$surface1 scrollbar_thumb: rgb(88, 91, 112) none # ### GOODTOBAD # good_to_bad_0: rgb(166, 227, 161) none good_to_bad_1: rgb(148, 226, 213) none good_to_bad_2: rgb(137, 220, 235) none good_to_bad_3: rgb(116, 199, 236) none good_to_bad_4: rgb(137, 180, 250) none good_to_bad_5: rgb(180, 190, 254) none good_to_bad_6: rgb(203, 166, 247) none good_to_bad_7: rgb(250, 179, 135) none good_to_bad_8: rgb(235, 160, 172) none good_to_bad_9: rgb(243, 139, 168) none } ================================================ FILE: resources/default-conf/skins/dark-blue.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # # To create your own skin, copy this file, change the entries # and import your skin file from the main conf file (look # for "imports") # # Doc at https://dystroy.org/broot/skins/ ############################################################### ############################################################### # Skin # If you want to change the colors of broot, # uncomment the following block and start messing # with the various values. # A skin entry value is made of two parts separated with a '/': # The first one is the skin for the active panel. # The second one, optional, is the skin for non active panels. # You may find explanations and other skins on # https://dystroy.org/broot/skins ############################################################### skin: { default: gray(22) none / gray(20) none tree: gray(8) None / gray(4) None parent: gray(18) None / gray(13) None file: gray(22) None / gray(15) None directory: ansi(110) None bold / ansi(110) None exe: Cyan None link: Magenta None pruning: gray(12) None Italic perm__: gray(5) None perm_r: ansi(94) None perm_w: ansi(132) None perm_x: ansi(65) None owner: ansi(138) None group: ansi(131) None count: ansi(138) gray(4) dates: ansi(66) None sparse: ansi(214) None content_extract: ansi(29) None content_match: ansi(34) None device_id_major: ansi(138) None device_id_sep: ansi(102) None device_id_minor: ansi(138) None git_branch: ansi(178) None git_insertions: ansi(28) None git_deletions: ansi(160) None git_status_current: gray(5) None git_status_modified: ansi(28) None git_status_new: ansi(94) None bold git_status_ignored: gray(17) None git_status_conflicted: ansi(88) None git_status_other: ansi(88) None selected_line: None gray(6) / None gray(4) char_match: Green None file_error: Red None flag_label: gray(15) gray(2) flag_value: ansi(178) gray(2) bold input: White gray(2) / gray(15) None status_error: gray(22) ansi(124) status_job: ansi(220) gray(5) status_normal: gray(20) gray(4) / gray(2) gray(2) status_italic: ansi(178) gray(4) / gray(2) gray(2) status_bold: ansi(178) gray(4) bold / gray(2) gray(2) status_code: ansi(229) gray(4) / gray(2) gray(2) status_ellipsis: gray(19) gray(1) / gray(2) gray(2) purpose_normal: gray(20) gray(2) purpose_italic: ansi(178) gray(2) purpose_bold: ansi(178) gray(2) bold purpose_ellipsis: gray(20) gray(2) scrollbar_track: gray(7) None / gray(4) None scrollbar_thumb: gray(22) None / gray(14) None help_paragraph: gray(20) None help_bold: ansi(178) None bold help_italic: ansi(229) None help_code: gray(21) gray(3) help_headers: ansi(178) None help_table_border: ansi(239) None preview: gray(20) gray(1) / gray(18) gray(2) preview_title: gray(23) gray(2) / gray(21) gray(2) preview_line_number: gray(12) gray(3) preview_separator: gray(5) None preview_match: None ansi(29) hex_null: gray(8) None hex_ascii_graphic: gray(18) None hex_ascii_whitespace: ansi(143) None hex_ascii_other: ansi(215) None hex_non_ascii: ansi(167) None staging_area_title: gray(22) gray(2) / gray(20) gray(3) mode_command_mark: gray(5) ansi(204) bold good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ############################################################### # Syntax Theme # # If you want to choose the theme used for preview, uncomment # one of the following lines: # # syntax_theme: GitHub # syntax_theme: SolarizedDark # syntax_theme: SolarizedLight syntax_theme: MochaDark # syntax_theme: OceanDark # syntax_theme: OceanLight ================================================ FILE: resources/default-conf/skins/dark-gruvbox.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # This skin uses RGB values so won't work for some # terminals. # # # (initially contributed by @basbebe) # # Doc at https://dystroy.org/broot/skins/ ############################################################### skin: { default: rgb(235, 219, 178) none / rgb(189, 174, 147) none tree: rgb(70, 70, 80) None / rgb(60, 60, 60) None parent: rgb(235, 219, 178) none / rgb(189, 174, 147) none Italic file: None None / None None Italic directory: rgb(131, 165, 152) None Bold / rgb(131, 165, 152) None exe: rgb(184, 187, 38) None link: rgb(104, 157, 106) None pruning: rgb(124, 111, 100) None Italic perm__: None None perm_r: rgb(215, 153, 33) None perm_w: rgb(204, 36, 29) None perm_x: rgb(152, 151, 26) None owner: rgb(215, 153, 33) None Bold group: rgb(215, 153, 33) None count: rgb(69, 133, 136) rgb(50, 48, 47) dates: rgb(168, 153, 132) None sparse: rgb(250, 189,47) None content_extract: ansi(29) None Italic content_match: ansi(34) None Bold git_branch: rgb(251, 241, 199) None git_insertions: rgb(152, 151, 26) None git_deletions: rgb(190, 15, 23) None git_status_current: rgb(60, 56, 54) None git_status_modified: rgb(152, 151, 26) None git_status_new: rgb(104, 187, 38) None Bold git_status_ignored: rgb(213, 196, 161) None git_status_conflicted: rgb(204, 36, 29) None git_status_other: rgb(204, 36, 29) None selected_line: None rgb(60, 56, 54) / None rgb(50, 48, 47) char_match: rgb(250, 189, 47) None file_error: rgb(251, 73, 52) None flag_label: rgb(189, 174, 147) None flag_value: rgb(211, 134, 155) None Bold input: rgb(251, 241, 199) None / rgb(189, 174, 147) None Italic status_error: rgb(213, 196, 161) rgb(204, 36, 29) status_job: rgb(250, 189, 47) rgb(60, 56, 54) status_normal: None rgb(40, 38, 37) / None None status_italic: rgb(211, 134, 155) rgb(40, 38, 37) Italic / None None status_bold: rgb(211, 134, 155) rgb(40, 38, 37) Bold / None None status_code: rgb(251, 241, 199) rgb(40, 38, 37) / None None status_ellipsis: rgb(251, 241, 199) rgb(40, 38, 37) Bold / None None purpose_normal: None None purpose_italic: rgb(177, 98, 134) None Italic purpose_bold: rgb(177, 98, 134) None Bold purpose_ellipsis: None None scrollbar_track: rgb(80, 73, 69) None / rgb(50, 48, 47) None scrollbar_thumb: rgb(213, 196, 161) None / rgb(102, 92, 84) None help_paragraph: None None help_bold: rgb(214, 93, 14) None Bold help_italic: rgb(211, 134, 155) None Italic help_code: rgb(142, 192, 124) rgb(50, 48, 47) help_headers: rgb(254, 128, 25) None Bold help_table_border: rgb(80, 73, 69) None preview_title: rgb(235, 219, 178) rgb(40, 40, 40) / rgb(189, 174, 147) rgb(40, 40, 40) preview: rgb(235, 219, 178) None / rgb(235, 219, 178) None preview_line_number: rgb(124, 111, 100) rgb(40, 40, 40) / rgb(124, 111, 100) None preview_separator: rgb(70, 70, 80) None / rgb(60, 60, 60) None preview_match: None ansi(29) Bold hex_null: rgb(189, 174, 147) None hex_ascii_graphic: rgb(213, 196, 161) None hex_ascii_whitespace: rgb(152, 151, 26) None hex_ascii_other: rgb(254, 128, 25) None hex_non_ascii: rgb(214, 93, 14) None staging_area_title: rgb(235, 219, 178) rgb(40, 40, 40) / rgb(189, 174, 147) rgb(40, 40, 40) mode_command_mark: gray(5) ansi(204) Bold good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ================================================ FILE: resources/default-conf/skins/dark-orange.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # # To create your own skin, copy this file, change the entries # and import your skin file from the main conf file (look # for "imports") # # Doc at https://dystroy.org/broot/skins/ ############################################################### ############################################################### # Skin # If you want to change the colors of broot, # uncomment the following bloc and start messing # with the various values. # A skin entry value is made of two parts separated with a '/': # The first one is the skin for the active panel. # The second one, optional, is the skin for non active panels. # You may find explanations and other skins on # https://dystroy.org/broot/skins ############################################################### skin: { default: none none / gray(20) none tree: ansi(94) None / gray(3) None parent: gray(18) None / gray(13) None file: gray(20) None / gray(15) None directory: ansi(208) None Bold / ansi(172) None bold exe: Cyan None link: Magenta None pruning: gray(12) None Italic perm__: gray(5) None perm_r: ansi(94) None perm_w: ansi(132) None perm_x: ansi(65) None owner: ansi(138) None group: ansi(131) None count: ansi(136) gray(3) dates: ansi(66) None sparse: ansi(214) None content_extract: ansi(29) None content_match: ansi(34) None git_branch: ansi(229) None git_insertions: ansi(28) None git_deletions: ansi(160) None git_status_current: gray(5) None git_status_modified: ansi(28) None git_status_new: ansi(94) None Bold git_status_ignored: gray(17) None git_status_conflicted: ansi(88) None git_status_other: ansi(88) None selected_line: None gray(5) / None gray(4) char_match: Yellow None file_error: Red None flag_label: gray(15) None flag_value: ansi(208) None Bold input: White None / gray(15) gray(2) status_error: gray(22) ansi(124) status_job: ansi(220) gray(5) status_normal: gray(20) gray(3) / gray(2) gray(2) status_italic: ansi(208) gray(3) / gray(2) gray(2) status_bold: ansi(208) gray(3) Bold / gray(2) gray(2) status_code: ansi(229) gray(3) / gray(2) gray(2) status_ellipsis: gray(19) gray(1) / gray(2) gray(2) purpose_normal: gray(20) gray(2) purpose_italic: ansi(178) gray(2) purpose_bold: ansi(178) gray(2) Bold purpose_ellipsis: gray(20) gray(2) scrollbar_track: gray(7) None / gray(4) None scrollbar_thumb: gray(22) None / gray(14) None help_paragraph: gray(20) None help_bold: ansi(208) None Bold help_italic: ansi(166) None help_code: gray(21) gray(3) help_headers: ansi(208) None help_table_border: ansi(239) None preview: gray(20) gray(1) / gray(18) gray(2) preview_line_number: gray(12) gray(3) preview_separator: ansi(94) None / gray(3) None preview_match: None ansi(29) hex_null: gray(11) None hex_ascii_graphic: gray(18) None hex_ascii_whitespace: ansi(143) None hex_ascii_other: ansi(215) None hex_non_ascii: ansi(167) None good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ############################################################### # Syntax Theme # # If you want to choose the theme used for preview, uncomment # one of the following lines: # # syntax_theme: GitHub syntax_theme: SolarizedDark # syntax_theme: SolarizedLight # syntax_theme: MochaDark # syntax_theme: OceanDark # syntax_theme: OceanLight ================================================ FILE: resources/default-conf/skins/native-16.hjson ================================================ ############################################################### # 16 ANSI color theme. Colors in this theme are restricted from # ANSI color 0 - 15. This will allow the theme to adapt to your # terminal emulator's theme. Note that, for now, the preview # mode does not yet support this theme because of syntect not # having a 16 ansi color theme. # # More info at https://jeffkreeftmeijer.com/vim-16-color/ # Doc at https://dystroy.org/broot/skins/ ############################################################### skin: { directory: ansi(12) file: ansi(7) pruning: ansi(8) none italic selected_line: none ansi(0) tree: ansi(8) # Search char_match: ansi(3) none underlined parent: ansi(4) none bold # File properties exe: ansi(2) link: ansi(13) sparse: ansi(12) # Prompt input: ansi(6) # Status bar status_bold: ansi(7) ansi(8) bold status_code: ansi(10) ansi(8) status_ellipsis: ansi(7) ansi(8) status_error: ansi(7) ansi(8) status_italic: ansi(7) ansi(8) italic status_job: ansi(7) ansi(8) status_normal: ansi(7) ansi(8) # Flag status flag_label: ansi(6) flag_value: ansi(14) none bold # Background default: none none # Scrollbar scrollbar_track: ansi(0) scrollbar_thumb: ansi(3) # Git git_branch: ansi(13) git_deletions: ansi(1) git_insertions: ansi(2) git_status_conflicted: ansi(1) git_status_current: ansi(6) git_status_ignored: ansi(8) git_status_modified: ansi(3) git_status_new: ansi(2) none bold git_status_other: ansi(5) # Staging area staging_area_title: ansi(3) # Documentation help_bold: ansi(7) none bold help_code: ansi(4) help_headers: ansi(3) help_italic: ansi(7) none italic help_paragraph: ansi(7) help_table_border: ansi(8) # Device column device_id_major: ansi(5) device_id_minor: ansi(5) device_id_sep: ansi(5) # Counts column count: ansi(13) # Dates column dates: ansi(6) # Permissions column group: ansi(3) owner: ansi(3) perm__: ansi(8) perm_r: ansi(3) perm_w: ansi(1) perm_x: ansi(2) # Hex preview hex_null: ansi(8) hex_ascii_graphic: ansi(2) hex_ascii_whitespace: ansi(3) hex_ascii_other: ansi(4) hex_non_ascii: ansi(5) # Preview # preview: none # preview_line_number: none # preview_match: none # preview_title: none # Used for displaying errors file_error: ansi(1) # Content searches content_extract: ansi(7) content_match: ansi(3) none underlined # Used in status line purpose_bold: ansi(0) ansi(7) bold purpose_ellipsis: ansi(0) purpose_italic: ansi(0) ansi(7) italic purpose_normal: ansi(0) # Modal indicator mode_command_mark: ansi(7) ansi(4) # File system occupation good_to_bad_0: ansi(2) good_to_bad_1: ansi(2) good_to_bad_2: ansi(2) good_to_bad_3: ansi(2) good_to_bad_4: ansi(2) good_to_bad_5: ansi(1) good_to_bad_6: ansi(1) good_to_bad_7: ansi(1) good_to_bad_8: ansi(1) good_to_bad_9: ansi(1) } ================================================ FILE: resources/default-conf/skins/solarized-dark.hjson ================================================ // contributed by [@danieltrautmann](https://github.com/danieltrautmann) // // // The Solarized Dark skin uses RGB values, so it might not work well with some // terminals // // Doc at https://dystroy.org/broot/skins/ skin: { default: "rgb(131, 148, 150) rgb(0, 43, 54) / rgb(131, 148, 150) rgb(7, 54, 66)" // base0 base03 / base01 base02 tree: "rgb(88, 110, 117) none" // base01 default file: "none none" // default default directory: "rgb(38, 139, 210) none bold" // blue default bold exe: "rgb(211, 1, 2) none" // red default link: "rgb(211, 54, 130) none" // magenta default pruning: "rgb(88, 110, 117) none italic" // base01 default italic perm__: "rgb(88, 110, 117) none" // base01 default perm_r: "none none" // default default perm_w: "none none" // default default perm_x: "none none" // default default owner: "rgb(88, 110, 117) none" // base01 default group: "rgb(88, 110, 117) none" // base01 default sparse: "none none" // default default git_branch: "rgb(147, 161, 161) none" // base1 default git_insertions: "rgb(133, 153, 0) none" // green default git_deletions: "rgb(211, 1, 2) none" // red default git_status_current: "none none" // default default git_status_modified: "rgb(181, 137, 0) none" // yellow default git_status_new: "rgb(133, 153, 0) none" // green default git_status_ignored: "rgb(88, 110, 117) none" // base01 default git_status_conflicted: "rgb(211, 1, 2) none" // red default git_status_other: "rgb(211, 1, 2) none" // red default selected_line: "none rgb(7, 54, 66)" // default base02 char_match: "rgb(133, 153, 0) none underlined" // green default underlined file_error: "rgb(203, 75, 22) none italic" // orange default italic flag_label: "none none" // default default flag_value: "rgb(181, 137, 0) none bold" // yellow default bold input: "none none" // default default status_error: "rgb(203, 75, 22) rgb(7, 54, 66)" // orange base02 status_job: "rgb(108, 113, 196) rgb(7, 54, 66) bold" // violet base02 bold status_normal: "none rgb(7, 54, 66)" // default base02 status_italic: "rgb(181, 137, 0) rgb(7, 54, 66)" // yellow base02 status_bold: "rgb(147, 161, 161) rgb(7, 54, 66) bold" // base1 base02 bold status_code: "rgb(108, 113, 196) rgb(7, 54, 66)" // violet base02 status_ellipsis: "none rgb(7, 54, 66)" // default base02 scrollbar_track: "rgb(7, 54, 66) none" // base02 default scrollbar_thumb: "none none" // default default help_paragraph: "none none" // default default help_bold: "rgb(147, 161, 161) none bold" // base1 default bold help_italic: "rgb(147, 161, 161) none italic" // base1 default italic help_code: "rgb(147, 161, 161) rgb(7, 54, 66)" // base1 base02 help_headers: "rgb(181, 137, 0) none" // yellow default help_table_border: "none none" // default default preview_title: "gray(20) rgb(0, 43, 54)" staging_area_title: "gray(22) rgb(0, 43, 54)" good_to_bad_0: "ansi(28)" // green good_to_bad_1: "ansi(29)" good_to_bad_2: "ansi(29)" good_to_bad_3: "ansi(29)" good_to_bad_4: "ansi(29)" good_to_bad_5: "ansi(100)" good_to_bad_6: "ansi(136)" good_to_bad_7: "ansi(172)" good_to_bad_8: "ansi(166)" good_to_bad_9: "ansi(196)" // red } ================================================ FILE: resources/default-conf/skins/solarized-light.hjson ================================================ // contributed by [@danieltrautmann](https://github.com/danieltrautmann) // // // The Solarized Light skin uses RGB values, so it might not work well with some // terminals // // If you prefer to keep the background the same as your terminal background, change // the "default" entry to // default: "none none / rgb(147, 161, 161) none" // // Doc at https://dystroy.org/broot/skins/ skin: { // base00 default / base1 base2 default: "rgb(101, 123, 131) none / rgb(147, 161, 161) none" // base1 default tree: "rgb(147, 161, 161) none" // default default file: "none none" // blue default bold directory: "rgb(38, 139, 210) none bold" // red default exe: "rgb(211, 1, 2) none" // magenta default link: "rgb(211, 54, 130) none" // base1 default italic pruning: "rgb(147, 161, 161) none italic" // base1 default perm__: "rgb(147, 161, 161) none" // default default perm_r: "none none" // default default perm_w: "none none" // default default perm_x: "none none" // base1 default owner: "rgb(147, 161, 161) none" // base1 default group: "rgb(147, 161, 161) none" // default default sparse: "none none" // base01 default git_branch: "rgb(88, 110, 117) none" // green default git_insertions: "rgb(133, 153, 0) none" // red default git_deletions: "rgb(211, 1, 2) none" // default default git_status_current: "none none" // yellow default git_status_modified: "rgb(181, 137, 0) none" // green default git_status_new: "rgb(133, 153, 0) none" // base1 default git_status_ignored: "rgb(147, 161, 161) none" // red default git_status_conflicted: "rgb(211, 1, 2) none" // red default git_status_other: "rgb(211, 1, 2) none" // default base2 selected_line: "none rgb(238, 232, 213)" // green default underlined char_match: "rgb(133, 153, 0) none underlined" // orange default italic file_error: "rgb(203, 75, 22) none italic" // default default flag_label: "none none" // yellow default bold flag_value: "rgb(181, 137, 0) none bold" // default default input: "none none" // orange base2 status_error: "rgb(203, 75, 22) rgb(238, 232, 213)" // violet base2 bold status_job: "rgb(108, 113, 196) rgb(238, 232, 213) bold" // default base2 status_normal: "none rgb(238, 232, 213)" // yellow base2 status_italic: "rgb(181, 137, 0) rgb(238, 232, 213)" // base01 base2 bold status_bold: "rgb(88, 110, 117) rgb(238, 232, 213) bold" // violet base2 status_code: "rgb(108, 113, 196) rgb(238, 232, 213)" // default base2 status_ellipsis: "none rgb(238, 232, 213)" // base2 default scrollbar_track: "rgb(238, 232, 213) none" // default default scrollbar_thumb: "none none" // default default help_paragraph: "none none" // base01 default bold help_bold: "rgb(88, 110, 117) none bold" // base01 default italic help_italic: "rgb(88, 110, 117) none italic" // base01 base2 help_code: "rgb(88, 110, 117) rgb(238, 232, 213)" // yellow default help_headers: "rgb(181, 137, 0) none" // default default help_table_border: "none none" preview_title: "rgb(147, 161, 161) rgb(238, 232, 213)" preview: "rgb(101, 123, 131) rgb(253, 246, 227) / rgb(147, 161, 161) rgb(238, 232, 213)" preview_line_number: "rgb(147, 161, 161) rgb(238, 232, 213)" preview_separator: "rgb(147, 161, 161) rgb(238, 232, 213)" preview_match: "None ansi(29)" staging_area_title: "gray(22) rgb(253, 246, 227)" good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ================================================ FILE: resources/default-conf/skins/tokyo-night.hjson ================================================ ############################################################### # A skin for a terminal with a dark background # This skin uses RGB values so won't work for some # terminals. # # Created by hb-hello # Based on the tokyo-night theme made by wixdaq - https://wixdaq.github.io/Tokyo-Night-Website/ # # Doc at https://dystroy.org/broot/skins/ ############################################################### skin: { default: rgb(169, 177, 214) None / rgb(192, 202, 245) rgb(32, 35, 48) tree: rgb(54, 59, 84) None / rgb(43, 43, 59) None parent: rgb(192, 202, 245) None / rgb(192, 202, 245) rgb(32, 35, 48) Italic file: rgb(120, 124, 153) None / rgb(169, 177, 214) None Italic directory: rgb(122, 162, 247) None Bold / rgb(97, 131, 187) None exe: rgb(158, 206, 106) None link: rgb(187, 154, 247) None pruning: rgb(81, 89, 125) None Italic # permissions perm__: rgb(120, 124, 153) None perm_r: rgb(65, 166, 181) None perm_w: rgb(115, 218, 202) None perm_x: rgb(187, 154, 247) None owner: rgb(97, 131, 187) None Bold group: rgb(97, 131, 187) None count: rgb(65, 166, 181) rgb(16, 16, 20) dates: rgb(120, 124, 153) None sparse: rgb(224, 175, 104) None content_extract: rgb(115, 218, 202) None Italic content_match: rgb(122, 162, 247) None Bold # git git_branch: rgb(157, 124, 216) None git_insertions: rgb(158, 206, 106) None git_deletions: rgb(247, 118, 142) None git_status_current: rgb(120, 124, 153) None git_status_modified: rgb(224, 175, 104) None git_status_new: rgb(115, 218, 202) None Bold git_status_ignored: rgb(81, 86, 112) None git_status_conflicted: rgb(219, 75, 75) None git_status_other: rgb(187, 97, 107) None # highlighting selected_line: None rgb(32, 35, 48) / None rgb(30, 32, 46) char_match: rgb(224, 175, 104) None file_error: rgb(224, 175, 104) None flag_label: rgb(120, 124, 153) None flag_value: rgb(187, 154, 247) None Bold input: rgb(169, 177, 214) rgb(20, 20, 27) / rgb(192, 202, 245) rgb(20, 20, 27) Italic #status bar status_error: rgb(192, 202, 245) rgb(219, 75, 75) status_job: rgb(22, 22, 30) rgb(224, 175, 104) status_normal: rgb(120, 124, 153) rgb(22, 22, 30) / None None status_italic: rgb(187, 154, 247) rgb(22, 22, 30) Italic / None None status_bold: rgb(187, 154, 247) rgb(22, 22, 30) Bold / None None status_code: rgb(192, 202, 245) rgb(22, 22, 30) / None None status_ellipsis: rgb(192, 202, 245) rgb(22, 22, 30) Bold / None None purpose_normal: None None purpose_italic: rgb(97, 131, 187) None Italic purpose_bold: rgb(97, 131, 187) None Bold purpose_ellipsis: None None # scrollbar scrollbar_track: rgb(54, 59, 84) None / rgb(16, 16, 20) None scrollbar_thumb: rgb(169, 177, 214) None / rgb(120, 124, 153) None #documentation help_paragraph: None None help_bold: rgb(255, 158, 100) None Bold help_italic: rgb(187, 154, 247) None Italic help_code: rgb(158, 206, 106) rgb(16, 16, 20) help_headers: rgb(122, 162, 247) None Bold help_table_border: rgb(43, 43, 59) None preview_title: rgb(192, 202, 245) rgb(16, 16, 20) / rgb(169, 177, 214) rgb(16, 16, 20) preview: rgb(169, 177, 214) None / rgb(169, 177, 214) None preview_line_number: rgb(81, 89, 125) rgb(16, 16, 20) / rgb(81, 89, 125) None preview_separator: rgb(54, 59, 84) None / rgb(43, 43, 59) None preview_match: None rgb(224, 175, 104) Bold hex_null: rgb(120, 124, 153) None hex_ascii_graphic: rgb(169, 177, 214) None hex_ascii_whitespace: rgb(81, 89, 125) None hex_ascii_other: rgb(255, 158, 100) None hex_non_ascii: rgb(219, 75, 75) None staging_area_title: rgb(192, 202, 245) rgb(16, 16, 20) / rgb(169, 177, 214) rgb(16, 16, 20) mode_command_mark: rgb(22, 22, 30) rgb(187, 154, 247) Bold # file system occupation good_to_bad_0: rgb(158, 206, 106) good_to_bad_1: rgb(158, 206, 106) good_to_bad_2: rgb(158, 206, 106) good_to_bad_3: rgb(158, 206, 106) good_to_bad_4: rgb(255, 158, 100) good_to_bad_5: rgb(255, 158, 100) good_to_bad_6: rgb(255, 158, 100) good_to_bad_7: rgb(255, 158, 100) good_to_bad_8: rgb(255, 158, 100) good_to_bad_9: rgb(255, 158, 100) } ================================================ FILE: resources/default-conf/skins/white.hjson ================================================ ############################################################### # A skin for a terminal with a white background # # To create your own skin, copy this file, change the entries # and import your skin file from the main conf file (look # for "imports") # # Doc at https://dystroy.org/broot/skins/ ############################################################### syntax_theme: base16-ocean.light skin: { default: gray(1) None tree: gray(7) None / gray(18) None file: gray(3) None / gray(8) None directory: ansi(25) None Bold / ansi(25) None exe: ansi(130) None link: Magenta None pruning: gray(12) None Italic perm__: gray(5) None perm_r: ansi(94) None perm_w: ansi(132) None perm_x: ansi(65) None owner: ansi(138) None group: ansi(131) None dates: ansi(66) None sparse: ansi(214) None git_branch: ansi(229) None git_insertions: ansi(28) None git_deletions: ansi(160) None git_status_current: gray(5) None git_status_modified: ansi(28) None git_status_new: ansi(94) None Bold git_status_ignored: gray(17) None git_status_conflicted: ansi(88) None git_status_other: ansi(88) None selected_line: None gray(19) / None gray(21) char_match: ansi(22) None file_error: Red None flag_label: gray(9) None flag_value: ansi(166) None Bold input: gray(1) None / gray(4) gray(20) status_error: gray(22) ansi(124) status_normal: gray(2) gray(20) status_job: ansi(220) gray(5) status_italic: ansi(166) gray(20) status_bold: ansi(166) gray(20) status_code: ansi(17) gray(20) status_ellipsis: gray(19) gray(15) purpose_normal: gray(20) gray(2) purpose_italic: ansi(178) gray(2) purpose_bold: ansi(178) gray(2) Bold purpose_ellipsis: gray(20) gray(2) scrollbar_track: gray(20) none scrollbar_thumb: ansi(238) none help_paragraph: gray(2) none help_bold: ansi(202) none bold help_italic: ansi(202) none italic help_code: gray(5) gray(22) help_headers: ansi(202) none help_table_border: ansi(239) None preview_title: gray(3) None / gray(5) None preview: gray(5) gray(23) / gray(7) gray(23) preview_line_number: gray(6) gray(20) preview_separator: gray(7) None / gray(18) None preview_match: None ansi(29) Underlined hex_null: gray(15) None hex_ascii_graphic: gray(2) None hex_ascii_whitespace: ansi(143) None hex_ascii_other: ansi(215) None hex_non_ascii: ansi(167) None staging_area_title: gray(8) None / gray(13) None mode_command_mark: gray(15) ansi(204) Bold good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ================================================ FILE: resources/default-conf/verbs.hjson ================================================ ############################################################### # This file contains the verb definitions for broot # # Some verbs here are examples and not enabled by default: you # need to uncomment them if you want to use them. # # Documentation at https://dystroy.org/broot/verbs/ ############################################################### verbs: [ # You should customize this standard opening of text files. # If you edit text files in your terminal (vi, emacs, helix, eg.), then # you'll find it convenient to change the 'key' from 'ctrl-e' to 'enter'. # # If $EDITOR isn't set on your computer, you should either set it using # something similar to # export EDITOR=/usr/local/bin/nvim # or just replace it with your editor of choice in the 'execution' # pattern. # If your editor is able to open a file on a specific line, use {line} # so that you may jump directly at the right line from a preview or # a content search. # Examples depending on your favourite editor: # external: "nvim +{line} {file:space-separated}" # external: "helix {file}:{line}" { invocation: edit shortcut: e key: ctrl-e apply_to: text_file external: "$EDITOR {file:space-separated}" leave_broot: false } # Example 1: launching `tail -n` on the selected file (leaving broot) # { # name: tail_lines # invocation: tl {lines_count} # external: "tail -f -n {lines_count} {file}" # } # Example 2: creating a new file without leaving broot # { # name: touch # invocation: touch {new_file} # external: "touch {directory}/{new_file}" # leave_broot: false # } # Zip selected file(s) into a new archive named .zip # This can be called on the staging area after having staged several # files or directories { invocation: "zip {name}" external: [ "zip" "-r" "{name:path-from-directory}.zip" "{file:space-separated}" ] leave_broot: false working_dir: "{root}" } # A convenient shortcut to create a new text file in # the current directory or below { invocation: create {subpath} execution: "$EDITOR {directory}/{subpath}" leave_broot: false } { invocation: git_diff shortcut: gd leave_broot: false execution: "git difftool -y {file}" } # On ctrl-b, propose the creation of a copy of the selection. # While this might occasionally be useful, this verb is mostly here # as an example to demonstrate rare standard groups like {file-stem} # and {file-dot-extension} and the auto_exec verb property which # allows verbs to stay unexecuted until you hit enter { invocation: "backup {version}" key: ctrl-b leave_broot: false auto_exec: false execution: "cp -r {file} {parent}/{file-stem}-{version}{file-dot-extension}" } # By default, `rm` does the system rm, and completely removes # the file. If you prefer to have the file moved to the system # trash, you may use the ':trash' internal with the verb below: # { # invocation: "rm" # internal: "trash" # leave_broot: false # } # This verb lets you launch a terminal on ctrl-T # (on exit you'll be back in broot) { invocation: terminal key: ctrl-t execution: "$SHELL" set_working_dir: true leave_broot: false } # Scroll the rightest panel on alt-pagedown and alt-pageup, # while keeping the focus on your current panel { key: alt-pagedown internal: page_down impacted_panel: right } { key: alt-pageup internal: page_up impacted_panel: right } # Here's an example of a verb needing the shell capabilities. # It copies all children of the currently selected directory # to a destination you type. # It uses a star, which needs the shell for expansion. That's # why such verb must have the `from_shell: true` parameter. # { # invocation: "cpa {dest}" # external: "cp -r {directory}/* {dest}" # from_shell: true # } # Here's an example of a shortcut bringing you to your home directory # { # invocation: home # key: ctrl-home # execution: ":focus ~" # } # Here's going to the work-dir root of the current git repository # { # invocation: gtr # execution: ":focus {git-root}" # } # A popular set of shortcuts for going up and down: # # { # key: ctrl-k # execution: ":line_up" # } # { # key: ctrl-j # execution: ":line_down" # } # { # key: ctrl-u # execution: ":page_up" # } # { # key: ctrl-d # execution: ":page_down" # } # If you develop using git, you might like to often switch # to the git status filter: # { # key: alt-g # execution: ":toggle_git_status" # } # You can reproduce the bindings of Norton Commander # on copying or moving to the other panel: # { # key: F5 # external: "cp -r {file} {other-panel-directory}" # leave_broot: false # } # { # key: F6 # external: "mv {file} {other-panel-directory}" # leave_broot: false # } ] ================================================ FILE: resources/icons/nerdfont/README.md ================================================ # Nerd Fonts Icons ## Requirements [Nerd Fonts](https://github.com/ryanoasis/nerd-fonts) installed either through a patched font or available as a fallback font. ## Configuration In broot config file, set ``` icon_theme: nerdfont ``` ## Limitations These icons are limited by availability of symbols in Nerd Fonts, so this feature can only support a subset of filetypes available in `vscode` theme. ## Editing the Icon for a File: If you want to find an icon for a file: go to https://www.nerdfonts.com/cheat-sheet and search for: - a icon name like "file", which should return the multiple file icon results. Pick one you like and copy the icon code "ea7b". Copy it into the corresponding mapping prefixed with "0x" in ./data/*.rs. ( "default_file", 0xf15b ), //  - a icon code like "0xf15b" without the "0x" prefix. This should return the corresponding "" icon. ## Tips on editing these files in vi 1. Open ./icon_name_to_icon_code_point_map.rs then in the same session, switch to file you want to edit use C-n and C-y in edit mode 2. This plugin currently searches for lowercase, make everything so 3. Remember to run :Tabularize over ')' and ','. The tabular Plugin 4. :'<,'>!sort 5. `cargo run` in debug mode should figure out some problems. ================================================ FILE: resources/icons/nerdfont/data/double_extension_to_icon_name_map.rs ================================================ // SEE ./README on how to edit this file [ ( "adapter.js" , "file_type_nest_adapter_js" ), ( "adapter.ts" , "file_type_nest_adapter_ts" ), ( "component.dart" , "file_type_ng_component_dart" ), ( "component.js" , "file_type_ng_component_js2" ), ( "component.ts" , "file_type_ng_component_ts2" ), ( "container.dart" , "file_type_ng_smart_component_dart" ), ( "container.js" , "file_type_ng_smart_component_js2" ), ( "container.ts" , "file_type_ng_smart_component_ts2" ), ( "controller.js" , "file_type_nest_controller_js" ), ( "controller.ts" , "file_type_nest_controller_ts" ), ( "css.map" , "file_type_cssmap" ), ( "d.ts" , "file_type_typescriptdef_official" ), ( "decorator.js" , "file_type_nest_decorator_js" ), ( "decorator.ts" , "file_type_nest_decorator_ts" ), ( "directive.dart" , "file_type_ng_directive_dart" ), ( "directive.js" , "file_type_ng_directive_js2" ), ( "directive.ts" , "file_type_ng_directive_ts2" ), ( "eslintrc.js" , "file_type_eslint" ), ( "filter.js" , "file_type_nest_filter_js" ), ( "filter.ts" , "file_type_nest_filter_ts" ), ( "gateway.js" , "file_type_nest_gateway_js" ), ( "gateway.ts" , "file_type_nest_gateway_ts" ), ( "gradle.kts" , "file_type_gradle" ), ( "guard.js" , "file_type_nest_guard_js" ), ( "guard.ts" , "file_type_nest_guard_ts" ), ( "interceptor.dart" , "file_type_ng_interceptor_dart" ), ( "interceptor.js" , "file_type_nest_interceptor_js" ), ( "interceptor.ts" , "file_type_nest_interceptor_ts" ), ( "js.flow" , "file_type_flow" ), ( "js.map" , "file_type_jsmap" ), ( "js.snap" , "file_type_jest_snapshot" ), ( "middleware.js" , "file_type_nest_middleware_js" ), ( "middleware.ts" , "file_type_nest_middleware_ts" ), ( "module.js" , "file_type_nest_module_js" ), ( "module.ts" , "file_type_nest_module_ts" ), ( "page.dart" , "file_type_ng_smart_component_dart" ), ( "page.js" , "file_type_ng_smart_component_js2" ), ( "page.ts" , "file_type_ng_smart_component_ts2" ), ( "pipe.dart" , "file_type_ng_pipe_dart" ), ( "pipe.js" , "file_type_nest_pipe_js" ), ( "pipe.ts" , "file_type_nest_pipe_ts" ), ( "routing.dart" , "file_type_ng_routing_dart" ), ( "routing.js" , "file_type_ng_routing_js2" ), ( "routing.ts" , "file_type_ng_routing_ts2" ), ( "service.dart" , "file_type_ng_service_dart" ), ( "service.js" , "file_type_nest_service_js" ), ( "service.ts" , "file_type_nest_service_ts" ), ( "tar.gz" , "file_type_zip" ), ( "tar.xz" , "file_type_zip" ), ( "tar.zst" , "file_type_zip" ), ] ================================================ FILE: resources/icons/nerdfont/data/extension_to_icon_name_map.rs ================================================ // SEE ./README on how to edit this file [ ( "3g2" , "file_type_video" ), ( "3gp" , "file_type_video" ), ( "7z" , "file_type_zip2" ), ( "P" , "file_type_prolog" ), ( "a" , "file_type_binary" ), ( "aac" , "file_type_audio" ), ( "accda" , "file_type_access2" ), ( "accdb" , "file_type_access" ), ( "accdc" , "file_type_access2" ), ( "accde" , "file_type_access2" ), ( "accdp" , "file_type_access2" ), ( "accdr" , "file_type_access2" ), ( "accdt" , "file_type_access2" ), ( "accdu" , "file_type_access2" ), ( "act" , "file_type_audio" ), ( "ade" , "file_type_access2" ), ( "adp" , "file_type_access2" ), ( "afdesign" , "file_type_affinitydesigner" ), ( "affinitydesigner" , "file_type_affinitydesigner" ), ( "affinityphoto" , "file_type_affinityphoto" ), ( "affinitypublisher" , "file_type_affinitypublisher" ), ( "afphoto" , "file_type_affinityphoto" ), ( "afpub" , "file_type_affinitypublisher" ), ( "ai" , "file_type_ai2" ), ( "aiff" , "file_type_audio" ), ( "amr" , "file_type_audio" ), ( "amv" , "file_type_video" ), ( "ape" , "file_type_audio" ), ( "app" , "file_type_binary" ), ( "ascx" , "file_type_aspx" ), ( "asf" , "file_type_video" ), ( "aspx" , "file_type_aspx" ), ( "au" , "file_type_audio" ), ( "avi" , "file_type_video" ), ( "avif" , "file_type_avif" ), ( "awk" , "file_type_awk" ), ( "babelignore" , "file_type_babel2" ), ( "babelrc" , "file_type_babel2" ), ( "bazel" , "file_type_bazel" ), ( "bazelrc" , "file_type_bazel" ), ( "bb" , "file_type_blitzbasic" ), ( "bcmx" , "file_type_outlook" ), ( "bin" , "file_type_binary" ), ( "bithoundrc" , "file_type_bithound" ), ( "bmp" , "file_type_image" ), ( "boringignore" , "file_type_darcs" ), ( "bowerrc" , "file_type_bower" ), ( "browserslistrc" , "file_type_browserslist" ), ( "buckconfig" , "file_type_buckbuild" ), ( "bz" , "file_type_zip2" ), ( "bz2" , "file_type_zip2" ), ( "bzip2" , "file_type_zip2" ), ( "bzrignore" , "file_type_bazaar" ), ( "c" , "file_type_c" ), ( "cake" , "file_type_cake" ), ( "cargo-lock" , "file_type_rust" ), ( "cc" , "file_type_cpp" ), ( "cer" , "file_type_cert" ), ( "cfignore" , "file_type_cloudfoundry" ), ( "cjm" , "file_type_clojure" ), ( "cl" , "file_type_opencl" ), ( "class" , "file_type_class" ), ( "cljc" , "file_type_clojure" ), ( "cljs" , "file_type_clojurescript" ), ( "cma" , "file_type_binary" ), ( "cmi" , "file_type_binary" ), ( "cmo" , "file_type_binary" ), ( "cmx" , "file_type_binary" ), ( "cmxa" , "file_type_binary" ), ( "coffeelintignore" , "file_type_coffeelint" ), ( "condarc" , "file_type_conda" ), ( "cpp" , "file_type_cpp" ), ( "crec" , "file_type_lync" ), ( "crl" , "file_type_cert" ), ( "crt" , "file_type_cert" ), ( "cs" , "file_type_csharp" ), ( "csproj" , "file_type_csproj" ), ( "csr" , "file_type_cert" ), ( "css" , "file_type_css" ), ( "csslintrc" , "file_type_csslint" ), ( "csv" , "file_type_csv" ), ( "csx" , "file_type_csharp2" ), ( "cvsignore" , "file_type_cvs" ), ( "db" , "file_type_db" ), ( "db3" , "file_type_sqlite" ), ( "dct" , "file_type_audio" ), ( "der" , "file_type_cert" ), ( "dio" , "file_type_drawio" ), ( "divx" , "file_type_video" ), ( "djt" , "file_type_django" ), ( "dll" , "file_type_binary" ), ( "doc" , "file_type_word2" ), ( "docm" , "file_type_word2" ), ( "docx" , "file_type_word2" ), ( "doczrc" , "file_type_docz" ), ( "dojorc" , "file_type_dojo" ), ( "dot" , "file_type_word2" ), ( "dotm" , "file_type_word2" ), ( "dotx" , "file_type_word2" ), ( "drawio" , "file_type_drawio" ), ( "dss" , "file_type_audio" ), ( "dta" , "file_type_stata" ), ( "dvc" , "file_type_dvc" ), ( "dvf" , "file_type_audio" ), ( "eco" , "file_type_docpad" ), ( "editorconfig" , "file_type_editorconfig" ), ( "ejs" , "file_type_ejs" ), ( "el" , "file_type_emacs" ), ( "elc" , "file_type_emacs" ), ( "elm" , "file_type_elm" ), ( "ember-cli" , "file_type_ember" ), ( "enc" , "file_type_license" ), ( "ensime" , "file_type_ensime" ), ( "env" , "file_type_env" ), ( "eot" , "file_type_font" ), ( "eps" , "file_type_eps" ), ( "eskip" , "file_type_skipper" ), ( "eslintcache" , "file_type_eslint2" ), ( "eslintignore" , "file_type_eslint2" ), ( "eslintrc" , "file_type_eslint2" ), ( "exe" , "file_type_binary" ), ( "exp" , "file_type_tcl" ), ( "f4a" , "file_type_video" ), ( "f4b" , "file_type_video" ), ( "f4p" , "file_type_video" ), ( "f4v" , "file_type_video" ), ( "fbx" , "file_type_fbx" ), ( "fnl" , "file_type_fennel" ), ( "fig" , "file_type_matlab" ), ( "firebaserc" , "file_type_firebase" ), ( "fish" , "file_type_shell" ), ( "fla" , "file_type_fla" ), ( "flac" , "file_type_audio" ), ( "flooignore" , "file_type_floobits" ), ( "flowconfig" , "file_type_flow" ), ( "flutter-plugins" , "file_type_flutter" ), ( "flv" , "file_type_video" ), ( "fods" , "file_type_excel2" ), ( "fossaignore" , "file_type_fossa" ), ( "fs" , "file_type_fsharp" ), ( "fsproj" , "file_type_fsproj" ), ( "gemfile" , "file_type_bundler" ), ( "gif" , "file_type_image" ), ( "gitattributes" , "file_type_git" ), ( "gitconfig" , "file_type_git" ), ( "gitignore" , "file_type_git" ), ( "gitkeep" , "file_type_git" ), ( "gitmodules" , "file_type_git" ), ( "gmx" , "file_type_gamemaker" ), ( "go" , "file_type_go" ), ( "gqlconfig" , "file_type_graphql" ), ( "gradle" , "file_type_gradle2" ), ( "graphqlconfig" , "file_type_graphql_config" ), ( "gsm" , "file_type_audio" ), ( "gvimrc" , "file_type_vim" ), ( "gz" , "file_type_zip2" ), ( "h" , "file_type_cheader" ), ( "hgignore" , "file_type_mercurial" ), ( "hl" , "file_type_binary" ), ( "hpp" , "file_type_cppheader" ), ( "hs" , "file_type_haskell" ), ( "html" , "file_type_html" ), ( "htmlhintrc" , "file_type_htmlhint" ), ( "huskyrc" , "file_type_husky" ), ( "hxp" , "file_type_lime" ), ( "hxproj" , "file_type_haxedevelop" ), ( "ibc" , "file_type_idrisbin" ), ( "ico" , "file_type_image" ), ( "idr" , "file_type_idris" ), ( "ignore-glob" , "file_type_fossil" ), ( "iklax" , "file_type_audio" ), ( "ilk" , "file_type_binary" ), ( "inc" , "file_type_inc" ), ( "include" , "file_type_inc" ), ( "infopathxml" , "file_type_infopath" ), ( "ino" , "file_type_arduino" ), ( "ipkg" , "file_type_idrispkg" ), ( "ipynb" , "file_type_jupyter" ), ( "iuml" , "file_type_plantuml" ), ( "ivs" , "file_type_audio" ), ( "jade-lintrc" , "file_type_pug" ), ( "jar" , "file_type_jar" ), ( "java" , "file_type_java" ), ( "jestrc" , "file_type_jest" ), ( "jpeg" , "file_type_image" ), ( "jpg" , "file_type_image" ), ( "jpmignore" , "file_type_jpm" ), ( "js" , "file_type_js" ), ( "jsbeautify" , "file_type_jsbeautify" ), ( "jsbeautifyrc" , "file_type_jsbeautify" ), ( "jshintignore" , "file_type_jshint" ), ( "jshintrc" , "file_type_jshint" ), ( "json" , "file_type_json_official" ), ( "json-ld" , "file_type_jsonld" ), ( "json5" , "file_type_json5" ), ( "jsonld" , "file_type_jsonld" ), ( "jsp" , "file_type_jsp" ), ( "jss" , "file_type_jss" ), ( "jl" , "file_type_julia" ), ( "kdl" , "file_type_config" ), ( "key" , "file_type_key" ), ( "kit" , "file_type_codekit" ), ( "kiteignore" , "file_type_kite" ), ( "kt" , "file_type_kotlin" ), ( "laccdb" , "file_type_access2" ), ( "ldb" , "file_type_access2" ), ( "lib" , "file_type_binary" ), ( "licence" , "file_type_license" ), ( "lidr" , "file_type_idris" ), ( "lintstagedrc" , "file_type_lintstagedrc" ), ( "liquid" , "file_type_liquid" ), ( "lnk" , "file_type_lnk" ), ( "lock" , "emoji_type_lock" ), ( "log" , "file_type_log" ), ( "ls" , "file_type_livescript" ), ( "lua" , "file_type_lua" ), ( "lucee" , "file_type_cf2" ), ( "m2v" , "file_type_video" ), ( "m4a" , "file_type_audio" ), ( "m4b" , "file_type_audio" ), ( "m4p" , "file_type_audio" ), ( "m4v" , "file_type_video" ), ( "mailmap" , "file_type_git" ), ( "mam" , "file_type_access2" ), ( "manifest" , "file_type_manifest" ), ( "map" , "file_type_map" ), ( "maq" , "file_type_access2" ), ( "markdown" , "file_type_markdown" ), ( "master" , "file_type_layout" ), ( "md" , "file_type_markdown" ), ( "mdb" , "file_type_access2" ), ( "mdown" , "file_type_markdown" ), ( "mdw" , "file_type_access2" ), ( "mdx" , "file_type_mdx" ), ( "merlin" , "file_type_ocaml" ), ( "metadata" , "file_type_flutter" ), ( "mex" , "file_type_matlab" ), ( "mexn" , "file_type_matlab" ), ( "mexrs6" , "file_type_matlab" ), ( "mk3d" , "file_type_video" ), ( "mkv" , "file_type_video" ), ( "mmf" , "file_type_audio" ), ( "mn" , "file_type_matlab" ), ( "mo" , "file_type_poedit" ), ( "modernizr" , "file_type_modernizr" ), ( "mogg" , "file_type_audio" ), ( "mov" , "file_type_video" ), ( "mp2" , "file_type_video" ), ( "mp3" , "file_type_audio" ), ( "mp4" , "file_type_video" ), ( "mpc" , "file_type_audio" ), ( "mpe" , "file_type_video" ), ( "mpeg" , "file_type_video" ), ( "mpeg2" , "file_type_video" ), ( "mpg" , "file_type_video" ), ( "mpv" , "file_type_video" ), ( "msg" , "file_type_outlook" ), ( "mst" , "file_type_mustache" ), ( "msv" , "file_type_audio" ), ( "mtn-ignore" , "file_type_monotone" ), ( "mum" , "file_type_matlab" ), ( "mustache" , "file_type_mustache" ), ( "mx" , "file_type_matlab" ), ( "mx3" , "file_type_matlab" ), ( "mysql" , "file_type_mysql" ), ( "n" , "file_type_binary" ), ( "ndll" , "file_type_binary" ), ( "nix" , "file_type_nix" ), ( "njs" , "file_type_nunjucks" ), ( "njsproj" , "file_type_njsproj" ), ( "node-version" , "file_type_node2" ), ( "nowignore" , "file_type_zeit" ), ( "npmignore" , "file_type_npm" ), ( "npmrc" , "file_type_npm" ), ( "npy" , "file_type_numpy" ), ( "npz" , "file_type_numpy" ), ( "nsriignore" , "file_type_nsri" ), ( "nsrirc" , "file_type_nsri" ), ( "nsv" , "file_type_video" ), ( "nu" , "file_type_nushell" ), ( "nunj" , "file_type_nunjucks" ), ( "nupkg" , "file_type_nuget" ), ( "nuspec" , "file_type_nuget" ), ( "nvmrc" , "file_type_node2" ), ( "nycrc" , "file_type_nyc" ), ( "o" , "file_type_binary" ), ( "obj" , "file_type_binary" ), ( "ocrec" , "file_type_lync" ), ( "ods" , "file_type_excel2" ), ( "oft" , "file_type_outlook" ), ( "oga" , "file_type_audio" ), ( "ogg" , "file_type_audio" ), ( "ogv" , "file_type_video" ), ( "one" , "file_type_onenote" ), ( "onepkg" , "file_type_onenote" ), ( "onetoc" , "file_type_onenote" ), ( "onetoc2" , "file_type_onenote" ), ( "opencl" , "file_type_opencl" ), ( "opus" , "file_type_audio" ), ( "org" , "file_type_org" ), ( "otf" , "file_type_font" ), ( "otm" , "file_type_outlook" ), ( "ovpn" , "file_type_ovpn" ), ( "p12" , "file_type_cert" ), ( "p4ignore" , "file_type_helix" ), ( "p7b" , "file_type_cert" ), ( "p7r" , "file_type_cert" ), ( "pa" , "file_type_powerpoint2" ), ( "packages" , "file_type_flutter_package" ), ( "patch" , "file_type_patch" ), ( "pcd" , "file_type_pcl" ), ( "pck" , "file_type_plsql_package" ), ( "pdb" , "file_type_binary" ), ( "pde" , "file_type_arduino" ), ( "pdf" , "file_type_pdf2" ), ( "pem" , "file_type_key" ), ( "pex" , "file_type_xml" ), ( "pfa" , "file_type_font" ), ( "pfb" , "file_type_font" ), ( "pfx" , "file_type_cert" ), ( "pgsql" , "file_type_pgsql" ), ( "phar" , "file_type_php3" ), ( "php" , "file_type_php" ), ( "php1" , "file_type_php3" ), ( "php2" , "file_type_php3" ), ( "php3" , "file_type_php3" ), ( "php4" , "file_type_php3" ), ( "php5" , "file_type_php3" ), ( "php6" , "file_type_php3" ), ( "php_cs" , "file_type_phpcsfixer" ), ( "phps" , "file_type_php3" ), ( "phpsa" , "file_type_php3" ), ( "phpt" , "file_type_php3" ), ( "phpunit" , "file_type_phpunit" ), ( "phtml" , "file_type_php3" ), ( "pipfile" , "file_type_pip" ), ( "pkb" , "file_type_plsql_package_body" ), ( "pkg" , "file_type_package" ), ( "pkh" , "file_type_plsql_package_header" ), ( "pks" , "file_type_plsql_package_spec" ), ( "plantuml" , "file_type_plantuml" ), ( "plist" , "file_type_config" ), ( "png" , "file_type_image" ), ( "po" , "file_type_poedit" ), ( "policyfile" , "file_type_chef" ), ( "postcssrc" , "file_type_postcssconfig" ), ( "pot" , "file_type_powerpoint2" ), ( "potm" , "file_type_powerpoint2" ), ( "potx" , "file_type_powerpoint2" ), ( "ppa" , "file_type_powerpoint2" ), ( "ppam" , "file_type_powerpoint2" ), ( "pps" , "file_type_powerpoint2" ), ( "ppsm" , "file_type_powerpoint2" ), ( "ppsx" , "file_type_powerpoint2" ), ( "ppt" , "file_type_powerpoint2" ), ( "pptm" , "file_type_powerpoint2" ), ( "pptx" , "file_type_powerpoint2" ), ( "prettierignore" , "file_type_prettier" ), ( "prettierrc" , "file_type_prettier" ), ( "prisma" , "file_type_prisma" ), ( "pro" , "file_type_prolog" ), ( "procfile" , "file_type_procfile" ), ( "psd" , "file_type_photoshop2" ), ( "psd1" , "file_type_powershell_psd2" ), ( "psm1" , "file_type_powershell_psm2" ), ( "psmdcp" , "file_type_nuget" ), ( "pst" , "file_type_outlook" ), ( "pu" , "file_type_plantuml" ), ( "pub" , "file_type_publisher" ), ( "pug-lintrc" , "file_type_pug" ), ( "puml" , "file_type_plantuml" ), ( "puz" , "file_type_publisher" ), ( "py" , "file_type_python" ), ( "pyc" , "file_type_binary" ), ( "pyd" , "file_type_binary" ), ( "pyo" , "file_type_binary" ), ( "pyup" , "file_type_pyup" ), ( "q" , "file_type_q" ), ( "qbs" , "file_type_qbs" ), ( "qmldir" , "file_type_qmldir" ), ( "qt" , "file_type_video" ), ( "qvd" , "file_type_qlikview" ), ( "qvw" , "file_type_qlikview" ), ( "r" , "file_type_r" ), ( "ra" , "file_type_audio" ), ( "rake" , "file_type_rake" ), ( "rakefile" , "file_type_rake" ), ( "rar" , "file_type_zip2" ), ( "raw" , "file_type_audio" ), ( "rb" , "file_type_ruby" ), ( "re" , "file_type_reason" ), ( "reg" , "file_type_registry" ), ( "rego" , "file_type_rego" ), ( "rehypeignore" , "file_type_rehype" ), ( "rehyperc" , "file_type_rehype" ), ( "remarkignore" , "file_type_remark" ), ( "remarkrc" , "file_type_remark" ), ( "renovaterc" , "file_type_renovate" ), ( "retextignore" , "file_type_retext" ), ( "retextrc" , "file_type_retext" ), ( "rm" , "file_type_video" ), ( "rmvb" , "file_type_video" ), ( "rproj" , "file_type_rproj" ), ( "rs" , "file_type_rust" ), ( "rspec" , "file_type_rspec" ), ( "rt" , "file_type_reacttemplate" ), ( "rust-toolchain" , "file_type_rust_toolchain" ), ( "rwd" , "file_type_matlab" ), ( "sailsrc" , "file_type_sails" ), ( "sass" , "file_type_sass" ), ( "sbt" , "file_type_sbt" ), ( "scala" , "file_type_scala" ), ( "scpt" , "file_type_binary" ), ( "scptd" , "file_type_binary" ), ( "scssm" , "file_type_scss" ), ( "sentryclirc" , "file_type_sentry" ), ( "sequelizerc" , "file_type_sequelize" ), ( "sfd" , "file_type_font" ), ( "sh" , "file_type_shell" ), ( "sig" , "file_type_onenote" ), ( "sketch" , "file_type_sketch" ), ( "slddc" , "file_type_matlab" ), ( "sldm" , "file_type_powerpoint2" ), ( "sldx" , "file_type_powerpoint2" ), ( "sln" , "file_type_sln2" ), ( "sls" , "file_type_saltstack" ), ( "slx" , "file_type_matlab" ), ( "smv" , "file_type_matlab" ), ( "snyk" , "file_type_snyk" ), ( "so" , "file_type_binary" ), ( "solidarity" , "file_type_solidarity" ), ( "spe" , "file_type_spacengine" ), ( "sql" , "file_type_sql" ), ( "sqlite" , "file_type_sqlite" ), ( "sqlite3" , "file_type_sqlite" ), ( "src" , "file_type_cert" ), ( "sss" , "file_type_sss" ), ( "sst" , "file_type_cert" ), ( "stl" , "file_type_cert" ), ( "storyboard" , "file_type_storyboard" ), ( "stylelintcache" , "file_type_stylelint" ), ( "stylelintignore" , "file_type_stylelint" ), ( "stylelintrc" , "file_type_stylelint" ), ( "svg" , "file_type_svg" ), ( "svi" , "file_type_video" ), ( "svnignore" , "file_type_subversion" ), ( "swc" , "file_type_flash" ), ( "swf" , "file_type_flash" ), ( "swift" , "file_type_swift" ), ( "tar" , "file_type_zip2" ), ( "tcl" , "file_type_tcl" ), ( "texi" , "file_type_tex" ), ( "tf" , "file_type_terraform" ), ( "tfignore" , "file_type_tfs" ), ( "tfstate" , "file_type_terraform" ), ( "tgz" , "file_type_zip2" ), ( "tiff" , "file_type_image" ), ( "tikz" , "file_type_tex" ), ( "tlg" , "file_type_log" ), ( "tmlanguage" , "file_type_xml" ), ( "todo" , "file_type_todo" ), ( "toml" , "file_type_toml" ), ( "ts" , "file_type_typescript" ), ( "tst" , "file_type_test" ), ( "tt2" , "file_type_tt" ), ( "tta" , "file_type_audio" ), ( "ttf" , "file_type_font" ), ( "txt" , "file_type_text" ), ( "unibeautifyrc" , "file_type_unibeautify" ), ( "unity" , "file_type_shaderlab" ), ( "vagrantfile" , "file_type_vagrant" ), ( "vala" , "file_type_vala" ), ( "vapi" , "file_type_vapi" ), ( "vash" , "file_type_vash" ), ( "vbhtml" , "file_type_vbhtml" ), ( "vbproj" , "file_type_vbproj" ), ( "vcxproj" , "file_type_vcxproj" ), ( "vercelignore" , "file_type_zeit" ), ( "vimrc" , "file_type_vim" ), ( "vob" , "file_type_video" ), ( "vox" , "file_type_audio" ), ( "vscodeignore" , "file_type_vscode-insiders" ), ( "vsix" , "file_type_vsix" ), ( "vsixmanifest" , "file_type_vsixmanifest" ), ( "vuerc" , "file_type_vueconfig" ), ( "wasm" , "file_type_wasm" ), ( "watchmanconfig" , "file_type_watchmanconfig" ), ( "wav" , "file_type_audio" ), ( "webm" , "file_type_video" ), ( "webp" , "file_type_webp" ), ( "wll" , "file_type_word2" ), ( "wma" , "file_type_audio" ), ( "wmv" , "file_type_video" ), ( "woff" , "file_type_font" ), ( "woff2" , "file_type_font" ), ( "wxml" , "file_type_wxml" ), ( "wxss" , "file_type_wxss" ), ( "xcodeproj" , "file_type_xcode" ), ( "xfl" , "file_type_xfl" ), ( "xib" , "file_type_xib" ), ( "xlf" , "file_type_xliff" ), ( "xliff" , "file_type_xliff" ), ( "xls" , "file_type_excel2" ), ( "xlsm" , "file_type_excel2" ), ( "xlsx" , "file_type_excel2" ), ( "xsf" , "file_type_infopath" ), ( "xsn" , "file_type_infopath" ), ( "xtp2" , "file_type_infopath" ), ( "xvc" , "file_type_matlab" ), ( "xz" , "file_type_zip2" ), ( "yaml" , "file_type_yaml" ), ( "yamllint" , "file_type_yamllint" ), ( "yarn-integrity" , "file_type_yarn" ), ( "yarnclean" , "file_type_yarn" ), ( "yarnignore" , "file_type_yarn" ), ( "yarnrc" , "file_type_yarn" ), ( "yaspellerrc" , "file_type_yandex" ), ( "yml" , "file_type_yaml" ), ( "yy" , "file_type_gamemaker2" ), ( "yyp" , "file_type_gamemaker2" ), ( "zip" , "file_type_zip2" ), ( "zipx" , "file_type_zip2" ), ( "zst" , "file_type_zip2" ), // SEE ./README on how to edit this file ] ================================================ FILE: resources/icons/nerdfont/data/file_name_to_icon_name_map.rs ================================================ [ ( ".scalafix.conf" , "file_type_config" ), ( ".scalafmt.conf" , "file_type_config" ), ( "LICENCE" , "file_type_license" ), ( "LICENSE" , "file_type_license" ), ( "VERSION" , "file_type_version" ), ( "licence" , "file_type_license" ), ( "license" , "file_type_license" ), ( "readme" , "file_type_text" ), ( "todo" , "file_type_todo" ), ( "version" , "file_type_version" ), ( "angular-cli.json" , "file_type_angular" ), ( "angular.json" , "file_type_angular" ), ( "api-extractor-base.json" , "file_type_api_extractor" ), ( "api-extractor.json" , "file_type_api_extractor" ), ( "app-routing.module.dart" , "file_type_ng_routing_dart" ), ( "app-routing.module.js" , "file_type_ng_routing_js2" ), ( "app-routing.module.ts" , "file_type_ng_routing_ts2" ), ( "app.config.js" , "file_type_expo" ), ( "app.config.json" , "file_type_expo" ), ( "app.config.json5" , "file_type_expo" ), ( "app.json" , "file_type_expo" ), ( "appveyor.yml" , "file_type_appveyor" ), ( "aurelia.json" , "file_type_aurelia" ), ( "azure-pipelines.yml" , "file_type_azurepipelines" ), ( "bazel.rc" , "file_type_bazel" ), ( "berksfile" , "file_type_chef" ), ( "berksfile.lock" , "file_type_chef" ), ( "bitbucket-pipelines.yml" , "file_type_bitbucketpipeline" ), ( "bower.json" , "file_type_bower" ), ( "browserslist" , "file_type_browserslist" ), ( "build.ninja" , "file_type_ninja" ), ( "build.properties" , "file_type_config" ), ( "cargo.lock" , "file_type_cargo" ), ( "cargo.toml" , "file_type_cargo" ), ( "checkstyle.json" , "file_type_haxecheckstyle" ), ( "chefignore" , "file_type_chef" ), ( "circle.yml" , "file_type_circleci" ), ( "codacy.yaml" , "file_type_codacy" ), ( "codacy.yml" , "file_type_codacy" ), ( "codeclimate.yml" , "file_type_codeclimate" ), ( "codecov.yml" , "file_type_codecov" ), ( "coffeelint.json" , "file_type_coffeelint" ), ( "commitlint.config.js" , "file_type_commitlint" ), ( "composer.json" , "file_type_composer" ), ( "composer.lock" , "file_type_composer" ), ( "conanfile.py" , "file_type_conan" ), ( "conanfile.txt" , "file_type_conan" ), ( "config.codekit" , "file_type_codekit" ), ( "config.codekit2" , "file_type_codekit" ), ( "config.codekit3" , "file_type_codekit" ), ( "coveralls.yml" , "file_type_coveralls" ), ( "crowdin.yml" , "file_type_crowdin" ), ( "csscomb.json" , "file_type_csscomb" ), ( "dependabot.yml" , "file_type_dependabot" ), ( "dependencies.yml" , "file_type_dependencies" ), ( "devcontainer.json" , "file_type_devcontainer" ), ( "docker-compose.test.yml" , "file_type_dockertest2" ), ( "drone.yml" , "file_type_drone" ), ( "drone.yml.sig" , "file_type_drone" ), ( "ejs.t" , "file_type_hygen" ), ( "elm-package.json" , "file_type_elm2" ), ( "emakefile" , "file_type_erlang2" ), ( "emakerfile" , "file_type_erlang2" ), ( "eslint.config.js" , "file_type_eslint" ), ( "eslint.config.cjs" , "file_type_eslint" ), ( "eslint.config.mjs" , "file_type_eslint" ), ( "favicon.ico" , "file_type_favicon" ), ( "firebase.json" , "file_type_firebasehosting" ), ( "firestore.indexes.json" , "file_type_firestore" ), ( "firestore.rules" , "file_type_firestore" ), ( "format.ps1xml" , "file_type_powershell_format" ), ( "fuse.js" , "file_type_fusebox" ), ( "gemfile.lock" , "file_type_bundler" ), ( "gitlab-ci.yml" , "file_type_gitlab" ), ( "glide.yml" , "file_type_glide" ), ( "go.mod" , "file_type_go_package" ), ( "go.sum" , "file_type_go_package" ), ( "greenkeeper.json" , "file_type_greenkeeper" ), ( "guard.dart" , "file_type_ng_guard_dart" ), ( "haxelib.json" , "file_type_haxe" ), ( "husky.config.js" , "file_type_husky" ), ( "include.xml" , "file_type_lime" ), ( "integrity.json" , "file_type_nsri-integrity" ), ( "ionic.config.json" , "file_type_ionic" ), ( "ionic.project" , "file_type_ionic" ), ( "jade-lint.json" , "file_type_pug" ), ( "jakefile" , "file_type_jake" ), ( "jakefile.js" , "file_type_jake" ), ( "jasmine.json" , "file_type_jasmine" ), ( "jbuilder" , "file_type_jbuilder" ), ( "jest.config.json" , "file_type_jest" ), ( "jest.json" , "file_type_jest" ), ( "jestrc.js" , "file_type_jest" ), ( "jestrc.json" , "file_type_jest" ), ( "jsconfig.json" , "file_type_jsconfig" ), ( "jscpd.json" , "file_type_jscpd" ), ( "jsx.snap" , "file_type_jest_snapshot" ), ( "kitchen.yml" , "file_type_kitchenci" ), ( "layout.htm" , "file_type_layout" ), ( "layout.html" , "file_type_layout" ), ( "lerna.json" , "file_type_lerna" ), ( "lint-staged.config.js" , "file_type_lintstagedrc" ), ( "makefile" , "file_type_makefile" ), ( "manifest.bak" , "file_type_manifest_bak" ), ( "manifest.skip" , "file_type_manifest_skip" ), ( "markdownlint.json" , "file_type_markdownlint" ), ( "marko.js" , "file_type_markojs" ), ( "maven.config" , "file_type_maven" ), ( "mocha.opts" , "file_type_mocha" ), ( "module.dart" , "file_type_ng_module_dart" ), ( "nest-cli.json" , "file_type_nestjs" ), ( "nestconfig.json" , "file_type_nestjs" ), ( "netlify.toml" , "file_type_netlify" ), ( "next.config.js" , "file_type_next" ), ( "ng-tailwind.js" , "file_type_ng_tailwind" ), ( "nginx.conf" , "file_type_nginx" ), ( "nodemon.json" , "file_type_nodemon" ), ( "now.json" , "file_type_zeit" ), ( "npm-shrinkwrap.json" , "file_type_npm" ), ( "nsri.config.js" , "file_type_nsri" ), ( "nycrc.json" , "file_type_nyc" ), ( "package-lock.json" , "file_type_npm" ), ( "package.json" , "file_type_npm" ), ( "package.pins" , "file_type_swift" ), ( "php_cs.dist" , "file_type_phpcsfixer" ), ( "phpunit.xml" , "file_type_phpunit" ), ( "phpunit.xml.dist" , "file_type_phpunit" ), ( "phraseapp.yml" , "file_type_phraseapp" ), ( "pipfile.lock" , "file_type_pip" ), ( "platformio.ini" , "file_type_platformio" ), ( "pnpm-lock.yaml" , "file_type_pnpm" ), ( "pnpm-workspace.yaml" , "file_type_pnpm" ), ( "pnpmfile.js" , "file_type_pnpm" ), ( "postcss.config.js" , "file_type_postcssconfig" ), ( "postcssrc.js" , "file_type_postcssconfig" ), ( "postcssrc.json" , "file_type_postcssconfig" ), ( "postcssrc.yml" , "file_type_postcssconfig" ), ( "pre-commit-config.yaml" , "file_type_precommit" ), ( "pubspec.lock" , "file_type_flutter_package" ), ( "pubspec.yaml" , "file_type_flutter_package" ), ( "pug-lintrc.js" , "file_type_pug" ), ( "pug-lintrc.json" , "file_type_pug" ), ( "pyup.yml" , "file_type_pyup" ), ( "quasar.conf.js" , "file_type_quasar" ), ( "robots.txt" , "file_type_robots" ), ( "rubocop.yml" , "file_type_rubocop" ), ( "rubocop_todo.yml" , "file_type_rubocop" ), ( "serverless.yml" , "file_type_serverless" ), ( "snapcraft.yaml" , "file_type_snapcraft" ), ( "solidarity.json" , "file_type_solidarity" ), ( "stylish-haskell.yaml" , "file_type_stylish_haskell" ), ( "svelte.config.js" , "file_type_svelte" ), ( "symfony.lock" , "file_type_symfony" ), ( "tailwind.config.cjs" , "file_type_tailwind" ), ( "tailwind.config.js" , "file_type_tailwind" ), ( "testcaferc.json" , "file_type_testcafe" ), ( "tox.ini" , "file_type_tox" ), ( "travis.yml" , "file_type_travis" ), ( "ts.snap" , "file_type_jest_snapshot" ), ( "tslint.json" , "file_type_tslint" ), ( "tslint.yaml" , "file_type_tslint" ), ( "tslint.yml" , "file_type_tslint" ), ( "tsx.snap" , "file_type_jest_snapshot" ), ( "types.ps1xml" , "file_type_powershell_types" ), ( "unibeautify.config.js" , "file_type_unibeautify" ), ( "vercel.json" , "file_type_zeit" ), ( "vsts-ci.yml" , "file_type_azurepipelines" ), ( "vue.config.js" , "file_type_vueconfig" ), ( "wercker.yml" , "file_type_wercker" ), ( "wpml-config.xml" , "file_type_wpml" ), ( "yarn-metadata.json" , "file_type_yarn" ), ( "yarn.lock" , "file_type_yarn" ), ( "yaspeller.json" , "file_type_yandex" ), ( "yo-rc.json" , "file_type_yeoman" ), ] ================================================ FILE: resources/icons/nerdfont/data/icon_name_to_icon_code_point_map.rs ================================================ // if you want to find an icon for a file: go to https://www.nerdfonts.com/cheat-sheet and search // for f07b in case of "default_file". That should return:  [ ( "default_file", 0xf15b ), //  ( "default_folder", 0xf07b ), //  ( "default_folder_opened", 0xf07c ), //  ( "default_root_folder", 0xea83 ), //  ( "default_root_folder_opened", 0xf115 ), //  ( "emoji_type_link", 0xf0c1 ), //  ( "emoji_type_lock", 0xf023 ), //  ( "file_type_access", 0xf0221 ), // 󰈡 ( "file_type_access2", 0xf1aa1 ), // 󱪡 ( "file_type_actionscript", 0xeaff ), //  ( "file_type_actionscript2", 0xeaff ), //  ( "file_type_ada", 0xf15b ), ( "file_type_advpl", 0xf15b ), ( "file_type_affectscript", 0xf0477 ), // 󰑷 ( "file_type_affinitydesigner", 0xf1c5 ), //  ( "file_type_affinityphoto", 0xf1c5 ), //  ( "file_type_affinitypublisher", 0xf1c5 ), //  ( "file_type_ai", 0xe669 ), //  ( "file_type_ai2", 0xe7b4 ), //  ( "file_type_al", 0xf061d ), // 󰘝 ( "file_type_angular", 0xe753 ), //  ( "file_type_ansible", 0xf109a ), // 󱂚 ( "file_type_antlr", 0xf1119 ), // 󱄙 ( "file_type_anyscript", 0xf0477 ), // 󰑷 ( "file_type_apache", 0xf048b ), // 󰒋 ( "file_type_apex", 0xf17b ), //  ( "file_type_api_extractor", 0xf109b ), // 󱂛 ( "file_type_apib", 0xf109b ), // 󱂛 ( "file_type_apib2", 0xf109b ), // 󱂛 ( "file_type_apl", 0xe638 ), //  ( "file_type_applescript", 0xe711 ), //  ( "file_type_appveyor", 0xeacf ), //  ( "file_type_arduino", 0xf043f ), // 󰐿 ( "file_type_asciidoc", 0xf09ee ), // 󰧮 ( "file_type_asp", 0xf0aae ), // 󰪮 ( "file_type_aspx", 0xf0aae ), // 󰪮 ( "file_type_assembly", 0xe637 ), //  ( "file_type_ats", 0xf107c ), // 󱁼 ( "file_type_audio", 0xf0223 ), // 󰈣 ( "file_type_aurelia", 0xf022a ), // 󰈪 ( "file_type_autohotkey", 0xf107b ), // 󰷊 ( "file_type_autoit", 0xf107b ), // 󰷊 ( "file_type_avif", 0xf021f ), // 󰈟 ( "file_type_avro", 0xf09ee ), // 󰧮 ( "file_type_awk", 0xf09ee ), // 󰧮 ( "file_type_aws", 0xf102a ), // 󱀪 ( "file_type_azure", 0xf102a ), // 󱀪 ( "file_type_azurepipelines", 0xf102a ), // 󱀪 ( "file_type_babel", 0xf0a25 ), // 󰨥 ( "file_type_babel2", 0xf0a25 ), // 󰨥 ( "file_type_ballerina", 0xf15ca ), // 󱗊 ( "file_type_bat", 0xf0b5f ), // 󰭟 ( "file_type_bats", 0xf0b5f ), // 󰭟 ( "file_type_bazaar", 0xf0d6 ), //  ( "file_type_bazel", 0xe63a ), //  ( "file_type_befunge", 0xf04cc ), // 󰓌 ( "file_type_biml", 0xe73e ), //  ( "file_type_binary", 0xeae8 ), //  ( "file_type_bitbucketpipeline", 0xe703 ), //  ( "file_type_bithound", 0xf0a44 ), // 󰩄 ( "file_type_blade", 0xf0e61 ), // 󰹡 ( "file_type_blitzbasic", 0xf0e7 ), //  ( "file_type_bolt", 0xf0e7 ), //  ( "file_type_bosque", 0xf021b ), // 󰈛 ( "file_type_bower", 0xe74d ), //  ( "file_type_bower2", 0xe74d ), //  ( "file_type_browserslist", 0xf488 ), //  ( "file_type_buckbuild", 0xf1415 ), // 󱐕 ( "file_type_bundler", 0xf107c ), // 󱁼 ( "file_type_c", 0xe649 ), //  ( "file_type_c2", 0xf107c ), // 󱁼 ( "file_type_c3", 0xf107c ), // 󱁼 ( "file_type_c_al", 0xf107c ), // 󱁼 ( "file_type_cabal", 0xe777 ), //  ( "file_type_caddy", 0xf107c ), // 󱁼 ( "file_type_cake", 0xe63d ), //  ( "file_type_cakephp", 0xe63d ), //  ( "file_type_capacitor", 0xf107c ), // 󱁼 ( "file_type_cargo", 0xe68b ), //  ( "file_type_cddl", 0xf107c ), // 󱁼 ( "file_type_cert", 0xf1186 ), // 󱆆 ( "file_type_ceylon", 0xf107c ), // 󱁼 ( "file_type_cf", 0xf107c ), // 󱁼 ( "file_type_cf2", 0xf107c ), // 󱁼 ( "file_type_cfc", 0xf107c ), // 󱁼 ( "file_type_cfc2", 0xf107c ), // 󱁼 ( "file_type_cfm", 0xf107c ), // 󱁼 ( "file_type_cfm2", 0xf107c ), // 󱁼 ( "file_type_cheader", 0xf0273 ), // 󰉳 ( "file_type_chef", 0xf107c ), // 󱁼 ( "file_type_chef_cookbook", 0xf107c), // 󱁼 ( "file_type_circleci", 0xf107c ), // 󱁼 ( "file_type_class", 0xeb5b ), //  ( "file_type_clojure", 0xe642 ), //  ( "file_type_clojurescript", 0xe768 ), //  ( "file_type_cloudfoundry", 0xf0217 ), // 󰈗 ( "file_type_cmake", 0xe673 ), //  ( "file_type_cobol", 0xf107c ), // 󱁼 ( "file_type_codacy", 0xf017c ), // 󱁼 ( "file_type_codeclimate", 0xf017c ), // 󱁼 ( "file_type_codecov", 0xf017c ), // 󱁼 ( "file_type_codekit", 0xeae9 ), //  ( "file_type_coffeelint", 0xf06ca ), // 󰛊 ( "file_type_coffeescript", 0xe751 ), //  ( "file_type_commitlint", 0xe729 ), //  ( "file_type_compass", 0xebd5 ), //  ( "file_type_composer", 0xe783 ), //  ( "file_type_conan", 0xf107c ), // 󱁼 ( "file_type_conda", 0xf107c ), // 󱁼 ( "file_type_config", 0xe615 ), //  ( "file_type_confluence", 0xf0303 ), // 󰌃 ( "file_type_coveralls", 0xf107c ), // 󱁼 ( "file_type_cpp", 0xe646 ), //  ( "file_type_cpp2", 0xe61d ), //  ( "file_type_cpp3", 0xf0672 ), // 󰙲 ( "file_type_cppheader", 0xf0273 ), // 󰉳 ( "file_type_crowdin", 0xf1975 ), // 󱥵 ( "file_type_crystal", 0xe62f ), //  ( "file_type_csharp", 0xe648 ), //  ( "file_type_csharp2", 0xe648 ), //  ( "file_type_csproj", 0xeb51 ), //  ( "file_type_css", 0xe749), //  ( "file_type_csscomb", 0xe614 ), //  ( "file_type_csslint", 0xe614 ), //  ( "file_type_cssmap", 0xe614 ), //  ( "file_type_csv", 0xe64a ), //  ( "file_type_cucumber", 0xf017c ), // 󱁼 ( "file_type_cuda", 0xe64b ), //  ( "file_type_cvs", 0xf017c ), // 󱁼 ( "file_type_cypress", 0xf0c35 ), // 󰰵 ( "file_type_cython", 0xe73c ), //  ( "file_type_dal", 0xf15b ), ( "file_type_darcs", 0xf15b ), ( "file_type_dartlang", 0xe798 ), //  ( "file_type_db", 0xe64d ), //  ( "file_type_delphi", 0xf15b ), ( "file_type_dependabot", 0xf0573 ), // 󰕳 ( "file_type_dependencies", 0xf0573 ), // 󰕳 ( "file_type_devcontainer", 0xf15b ), ( "file_type_diff", 0xf055a ), // 󰕚 ( "file_type_django", 0xe71d ), //  ( "file_type_dlang", 0xe7af ), //  ( "file_type_docker", 0xf308 ), //  ( "file_type_docker2", 0xe7b0 ), //  ( "file_type_dockertest", 0xe650 ), //  ( "file_type_dockertest2", 0xf0868 ), // 󰡨 ( "file_type_docpad", 0xf1a9a ), // 󱪚 ( "file_type_docz", 0xf0dc9 ), // 󰷉 ( "file_type_dojo", 0xe71c ), //  ( "file_type_dotjs", 0xf444 ), //  ( "file_type_doxygen", 0xf15b ), ( "file_type_drawio", 0xf0f49 ), // 󰽉 ( "file_type_drone", 0xf01e2 ), // 󰇢 ( "file_type_drools", 0xf15b ), ( "file_type_dustjs", 0xe35d ), //  ( "file_type_dvc", 0xf15b ), ( "file_type_dylan", 0xf15b ), ( "file_type_edge", 0xf01e9 ), // 󰇩 ( "file_type_edge2", 0xf282 ), //  ( "file_type_editorconfig", 0xe652 ), //  ( "file_type_eex", 0xf15b ), ( "file_type_ejs", 0xe618 ), //  ( "file_type_elastic", 0xea6d ), //  ( "file_type_elasticbeanstalk", 0xe26a ), //  ( "file_type_elixir", 0xe62d ), //  ( "file_type_elm", 0xe62c ), //  ( "file_type_elm2", 0xe62c ), //  ( "file_type_emacs", 0xe632 ), //  ( "file_type_ember", 0xe71b ), //  ( "file_type_ensime", 0xf15b ), ( "file_type_env", 0xe615 ), //  ( "file_type_eps", 0xf10e0 ), // 󱃠 ( "file_type_erb", 0xf15b ), ( "file_type_erlang", 0xe7b1 ), //  ( "file_type_erlang2", 0xe7b1 ), //  ( "file_type_eslint", 0xf0c7a ), // 󰱺 ( "file_type_eslint2", 0xf0c7a ), // 󰱺 ( "file_type_excel", 0xf021b ), // 󰈛 ( "file_type_excel2", 0xf021b ), // 󰈛 ( "file_type_expo", 0xe27c ), //  ( "file_type_falcon", 0xf15b ), ( "file_type_favicon", 0xe623 ), //  ( "file_type_fbx", 0xf15b ), ( "file_type_fennel", 0xe6af ), //  ( "file_type_firebase", 0xf0967 ), // 󰥧 ( "file_type_firebasehosting", 0xe787 ), //  ( "file_type_firestore", 0xe657 ), //  ( "file_type_fla", 0xf15b ), ( "file_type_flash", 0xf0e7 ), //  ( "file_type_floobits", 0xf15b ), ( "file_type_flow", 0xf15b ), ( "file_type_flutter", 0xf15b ), ( "file_type_flutter_package", 0xf487 ), //  ( "file_type_font", 0xf031 ), //  ( "file_type_fortran", 0xf121a ), // 󱈚 ( "file_type_fossa", 0xf15b ), ( "file_type_fossil", 0xf15b ), ( "file_type_freemarker", 0xf15b ), ( "file_type_fsharp", 0xe65a ), //  ( "file_type_fsharp2", 0xe65a ), //  ( "file_type_fsproj", 0xe65a ), //  ( "file_type_fthtml", 0xf0f6d ), // 󰽭 ( "file_type_fusebox", 0xf15b ), ( "file_type_galen", 0xf15b ), ( "file_type_galen2", 0xf15b ), ( "file_type_gamemaker", 0xf0eb7 ), // 󰺷 ( "file_type_gamemaker2", 0xf1393 ), // 󱎓 ( "file_type_gamemaker81", 0xf11b ), //  ( "file_type_gatsby", 0xf0e43 ), // 󰹃 ( "file_type_gcode", 0xf15b ), ( "file_type_genstat", 0xf15b ), ( "file_type_git", 0xe702 ), //  ( "file_type_git2", 0xe702 ), //  ( "file_type_gitlab", 0xf296 ), //  ( "file_type_glide", 0xf2a5 ), //  ( "file_type_glsl", 0xf0dcb ), // 󰷋 ( "file_type_glyphs", 0xf15b ), ( "file_type_gnuplot", 0xe779 ), //  ( "file_type_go", 0xe626 ), //  ( "file_type_go_aqua", 0xf15b ), ( "file_type_go_black", 0xf15b ), ( "file_type_go_fuchsia", 0xe626 ), //  ( "file_type_go_gopher", 0xe626 ), //  ( "file_type_go_lightblue", 0xe626 ), //  ( "file_type_go_package", 0xe626 ), //  ( "file_type_go_white", 0xe626 ), //  ( "file_type_go_yellow", 0xe626 ), //  ( "file_type_godot", 0xe65f ), //  ( "file_type_gradle", 0xe660 ), //  ( "file_type_gradle2", 0xe660 ), //  ( "file_type_graphql", 0xf0877 ), // 󰡷 ( "file_type_graphql_config", 0xf0877 ), // 󰡷 ( "file_type_graphviz", 0xeb03 ), //  ( "file_type_greenkeeper", 0xf15b ), ( "file_type_gridsome", 0xf15b ), ( "file_type_groovy", 0xe775 ), //  ( "file_type_groovy2", 0xe775 ), //  ( "file_type_grunt", 0xe74c ), //  ( "file_type_gulp", 0xe763 ), //  ( "file_type_haml", 0xe664 ), //  ( "file_type_handlebars", 0xe60e ), //  ( "file_type_handlebars2", 0xe60e ), //  ( "file_type_harbour", 0xf15b ), ( "file_type_haskell", 0xe777 ), //  ( "file_type_haskell2", 0xe777 ), //  ( "file_type_haxe", 0xe666 ), //  ( "file_type_haxecheckstyle", 0xe666 ), //  ( "file_type_haxedevelop", 0xe666 ), //  ( "file_type_helix", 0xf15b ), ( "file_type_helm", 0xf15b ), ( "file_type_hjson", 0xe60b ), //  ( "file_type_hlsl", 0xf0dcb ), // 󰷋 ( "file_type_homeassistant", 0xf07d0 ), // 󰟐 ( "file_type_host", 0xe0a2 ), //  ( "file_type_htaccess", 0xe615 ), //  ( "file_type_html", 0xe736 ), //  ( "file_type_htmlhint", 0xe736 ), //  ( "file_type_http", 0xf15b ), ( "file_type_hunspell", 0xf15b ), ( "file_type_husky", 0xf107c ), ( "file_type_hy", 0xf15b ), ( "file_type_hygen", 0xf15b ), ( "file_type_icl", 0xf15b ), ( "file_type_idris", 0xf15b ), ( "file_type_idrisbin", 0xf15b ), ( "file_type_idrispkg", 0xf15b ), ( "file_type_image", 0xf021f ), // 󰈟 ( "file_type_imba", 0xf15b ), ( "file_type_inc", 0xf15b ), ( "file_type_infopath", 0xf15b ), ( "file_type_informix", 0xf15b ), ( "file_type_ini", 0xf15b ), ( "file_type_ink", 0xf15b ), ( "file_type_innosetup", 0xf15b ), ( "file_type_io", 0xf15b ), ( "file_type_iodine", 0xf15b ), ( "file_type_ionic", 0xf15b ), ( "file_type_jake", 0xf15b ), ( "file_type_janet", 0xf15b ), ( "file_type_jar", 0xe256 ), //  ( "file_type_jasmine", 0xf15b ), ( "file_type_java", 0xe256 ), //  ( "file_type_jbuilder", 0xe256 ), //  ( "file_type_jekyll", 0xe70d ), //  ( "file_type_jenkins", 0xe767 ), //  ( "file_type_jest", 0xf0668 ), // 󰙨 ( "file_type_jest_snapshot", 0xf0668 ), // 󰙨 ( "file_type_jinja", 0xe66f ), //  ( "file_type_jpm", 0xf15b ), ( "file_type_js", 0xf031e ), //  ( "file_type_js_official", 0xf031e ), //  ( "file_type_jsbeautify", 0xf031e ), //  ( "file_type_jsconfig", 0xf031e ), //  ( "file_type_jscpd", 0xf031e ), //  ( "file_type_jshint", 0xf031e ), //  ( "file_type_jsmap", 0xf031e ), //  ( "file_type_json", 0xe60b ), //  ( "file_type_json2", 0xe60b ), //  ( "file_type_json5", 0xe60b ), //  ( "file_type_json_official", 0xe60b ), //  ( "file_type_jsonld", 0xf0626 ), // 󰘦 ( "file_type_jsonnet", 0xf0626 ), // 󰘦 ( "file_type_jsp", 0xf15b ), ( "file_type_jss", 0xf15b ), ( "file_type_julia", 0xe624 ), //  ( "file_type_julia2", 0xe624 ), //  ( "file_type_jupyter", 0x10018B ), ( "file_type_karma", 0xf15b ), ( "file_type_key", 0xf1184 ), // 󱆄 ( "file_type_kitchenci", 0xf15b ), ( "file_type_kite", 0xf15b ), ( "file_type_kivy", 0xf15b ), ( "file_type_kos", 0xf15b ), ( "file_type_kotlin", 0xe634 ), //  ( "file_type_kusto", 0xf15b ), ( "file_type_latino", 0xf15b ), ( "file_type_layout", 0xf15b ), ( "file_type_lerna", 0xf15b ), ( "file_type_less", 0xe758 ), //  ( "file_type_lex", 0xf15b ), ( "file_type_license", 0xe60a ), //  ( "file_type_light_actionscript2", 0xf0477 ), // 󰑷 ( "file_type_light_ada", 0xf15b ), ( "file_type_light_apl", 0xf15b ), ( "file_type_light_babel", 0xf0a25 ), // 󰨥 ( "file_type_light_babel2", 0xf0a25 ), // 󰨥 ( "file_type_light_cabal", 0xf15b ), ( "file_type_light_circleci", 0xf15b ), ( "file_type_light_cloudfoundry", 0xf15b ), ( "file_type_light_codacy", 0xf15b ), ( "file_type_light_codeclimate", 0xf15b ), ( "file_type_light_config", 0xf107c ), // 󱁼 ( "file_type_light_crystal", 0xe62f ), //  ( "file_type_light_db", 0xe64d ), //  ( "file_type_light_docpad", 0xf1a9a ), // 󱪚 ( "file_type_light_drone", 0xf01e2 ), // 󰇢 ( "file_type_light_expo", 0xe27c ), //  ( "file_type_light_firebasehosting", 0xe787 ), //  ( "file_type_light_fla", 0xf15b ), ( "file_type_light_font", 0xf031 ), //  ( "file_type_light_gamemaker2", 0xf1393 ), // 󱎓 ( "file_type_light_gradle", 0xe738 ), //  ( "file_type_light_hjson", 0xe60b ), //  ( "file_type_light_ini", 0xf15b ), ( "file_type_light_io", 0xf15b ), ( "file_type_light_js", 0xf031e ), //  ( "file_type_light_jsconfig", 0xf031e ), //  ( "file_type_light_jsmap", 0xf031e ), //  ( "file_type_light_json", 0xe60b ), //  ( "file_type_light_json5", 0xe60b ), //  ( "file_type_light_jsonld", 0xf0626 ), // 󰘦 ( "file_type_light_kite", 0xf15b ), ( "file_type_light_lerna", 0xf15b ), ( "file_type_light_mdx", 0xe73e ), //  ( "file_type_light_mlang", 0xf15b ), ( "file_type_light_mustache", 0xe228 ), //  ( "file_type_light_next", 0xf15b ), ( "file_type_light_nim", 0xe677 ), //  ( "file_type_light_openHAB", 0xf15b ), ( "file_type_light_pcl", 0xf15b ), ( "file_type_light_pnpm", 0xe71e ), //  ( "file_type_light_prettier", 0xf15b ), ( "file_type_light_prisma", 0xe684 ), //  ( "file_type_light_purescript", 0xe630 ), //  ( "file_type_light_razzle", 0xf15b ), ( "file_type_light_rehype", 0xf15b ), ( "file_type_light_remark", 0xf15b ), ( "file_type_light_retext", 0xf15b ), ( "file_type_light_rubocop", 0xf15b ), ( "file_type_light_shaderlab", 0xf15b ), // 󰷋 ( "file_type_light_solidity", 0xf15a ), //  ( "file_type_light_stylelint", 0xe695 ), //  ( "file_type_light_stylus", 0xe759 ), //  ( "file_type_light_symfony", 0xe756 ), //  ( "file_type_light_systemd", 0xf15b ), ( "file_type_light_systemverilog", 0xf15b ), ( "file_type_light_testcafe", 0xf15b ), ( "file_type_light_testjs", 0xf0668 ), // 󰙨 ( "file_type_light_tex", 0xe69b ), //  ( "file_type_light_todo", 0xe69c ), //  ( "file_type_light_toml", 0xf0626 ), // 󰘦 ( "file_type_light_unibeautify", 0xf15b ), ( "file_type_light_vash", 0xf15b ), ( "file_type_light_vsix", 0xf15b ), ( "file_type_light_vsixmanifest", 0xf15b ), ( "file_type_light_xfl", 0xf15b ), ( "file_type_light_yaml", 0xf0626 ), // 󰘦 ( "file_type_light_zeit", 0xf15b ), ( "file_type_lighthouse", 0xf15b ), ( "file_type_lime", 0xf15b ), ( "file_type_lintstagedrc", 0xf15b ), ( "file_type_liquid", 0xf15b ), ( "file_type_lisp", 0xf15b ), ( "file_type_livescript", 0xf15b ), ( "file_type_lnk", 0xf15b ), ( "file_type_locale", 0xf1ab ), //  ( "file_type_log", 0xf1085 ), // 󱂅 ( "file_type_lolcode", 0xf15b ), ( "file_type_lsl", 0xf15b ), ( "file_type_lua", 0xe620 ), //  ( "file_type_lync", 0xf15b ), ( "file_type_makefile", 0xe673 ), //  ( "file_type_manifest", 0xf15b ), ( "file_type_manifest_bak", 0xf15b ), ( "file_type_manifest_skip", 0xf15b ), ( "file_type_map", 0xf15b ), ( "file_type_mariadb", 0xe64d ), //  ( "file_type_markdown", 0xf48a ), //  ( "file_type_markdownlint", 0xf48a ), //  ( "file_type_marko", 0xf15b ), ( "file_type_markojs", 0xf15b ), ( "file_type_matlab", 0xf15b ), ( "file_type_maven", 0xe674 ), //  ( "file_type_maxscript", 0xf15b ), ( "file_type_maya", 0xf15b ), ( "file_type_mdx", 0xe73e ), //  ( "file_type_mediawiki", 0xf15b ), ( "file_type_mercurial", 0xf15b ), ( "file_type_meson", 0xf15b ), ( "file_type_meteor", 0xf15b ), ( "file_type_mjml", 0xf15b ), ( "file_type_mlang", 0xf15b ), ( "file_type_mocha", 0xf15b ), ( "file_type_modernizr", 0xe720 ), //  ( "file_type_mojolicious", 0xf15b ), ( "file_type_moleculer", 0xf15b ), ( "file_type_mongo", 0xe7a4 ), //  ( "file_type_monotone", 0xf15b ), ( "file_type_mson", 0xf15b ), ( "file_type_mustache", 0xe228 ), //  ( "file_type_mysql", 0xe704 ), //  ( "file_type_nearly", 0xf15b ), ( "file_type_nest_adapter_js", 0xf031e ), ( "file_type_nest_adapter_ts", 0xf031e ), ( "file_type_nest_controller_js", 0xf031e ), ( "file_type_nest_controller_ts", 0xf031e ), ( "file_type_nest_decorator_js", 0xf031e ), ( "file_type_nest_decorator_ts", 0xf031e ), ( "file_type_nest_filter_js", 0xf031e ), ( "file_type_nest_filter_ts", 0xf031e ), ( "file_type_nest_gateway_js", 0xf031e ), ( "file_type_nest_gateway_ts", 0xf031e ), ( "file_type_nest_guard_js", 0xf031e ), ( "file_type_nest_guard_ts", 0xf031e ), ( "file_type_nest_interceptor_js", 0xf031e ), ( "file_type_nest_interceptor_ts", 0xf031e ), ( "file_type_nest_middleware_js", 0xf031e ), ( "file_type_nest_middleware_ts", 0xf031e ), ( "file_type_nest_module_js", 0xf031e ), ( "file_type_nest_module_ts", 0xf031e ), ( "file_type_nest_pipe_js", 0xf031e ), ( "file_type_nest_pipe_ts", 0xf031e ), ( "file_type_nest_service_js", 0xf031e ), ( "file_type_nest_service_ts", 0xf031e ), ( "file_type_nestjs", 0xf031e ), ( "file_type_netlify", 0xf15b ), ( "file_type_next", 0xf15b ), ( "file_type_ng_component_css", 0xe749 ), //  ( "file_type_ng_component_dart", 0xe64c ), //  ( "file_type_ng_component_html", 0xe736 ), //  ( "file_type_ng_component_js", 0xe781 ), //  ( "file_type_ng_component_js2", 0xe781 ), //  ( "file_type_ng_component_less", 0xe758 ), //  ( "file_type_ng_component_sass", 0xe74b ), //  ( "file_type_ng_component_scss", 0xe749 ), //  ( "file_type_ng_component_ts", 0xf06e6 ), // 󰛦 ( "file_type_ng_component_ts2", 0xf06e6 ), // 󰛦 ( "file_type_ng_controller_js", 0xe781 ), // .. ( "file_type_ng_controller_ts", 0xf06e6 ), ( "file_type_ng_directive_dart", 0xe64c ), ( "file_type_ng_directive_js", 0xe781 ), ( "file_type_ng_directive_js2", 0xe781 ), ( "file_type_ng_directive_ts", 0xf06e6 ), ( "file_type_ng_directive_ts2", 0xf06e6 ), ( "file_type_ng_guard_dart", 0xe64c ), ( "file_type_ng_guard_js", 0xe781 ), ( "file_type_ng_guard_ts", 0xf06e6 ), // .. ( "file_type_ng_interceptor_dart", 0xe64c ), ( "file_type_ng_interceptor_js", 0xe781 ), ( "file_type_ng_interceptor_ts", 0xf06e6 ), ( "file_type_ng_module_dart", 0xe64c ), ( "file_type_ng_module_js", 0xe781 ), ( "file_type_ng_module_js2", 0xe781 ), ( "file_type_ng_module_ts", 0xf06e6 ), ( "file_type_ng_module_ts2", 0xf06e6 ), ( "file_type_ng_pipe_dart", 0xe64c ), ( "file_type_ng_pipe_js", 0xe781 ), ( "file_type_ng_pipe_js2", 0xe781 ), ( "file_type_ng_pipe_ts", 0xf06e6 ), ( "file_type_ng_pipe_ts2", 0xf06e6 ), ( "file_type_ng_routing_dart", 0xe64c ), ( "file_type_ng_routing_js", 0xe781 ), ( "file_type_ng_routing_js2", 0xe781 ), ( "file_type_ng_routing_ts", 0xf06e6 ), ( "file_type_ng_routing_ts2", 0xe781 ), ( "file_type_ng_service_dart", 0xe64c ), ( "file_type_ng_service_js", 0xe781 ), ( "file_type_ng_service_js2", 0xe781 ), ( "file_type_ng_service_ts", 0xf06e6 ), ( "file_type_ng_service_ts2", 0xf06e6 ), ( "file_type_ng_smart_component_dart", 0xe64c ), ( "file_type_ng_smart_component_js", 0xe781 ), ( "file_type_ng_smart_component_js2", 0xe781 ), ( "file_type_ng_smart_component_ts", 0xf06e6 ), ( "file_type_ng_smart_component_ts2", 0xf06e6 ), ( "file_type_ng_tailwind", 0xf13ff ), // 󱏿 ( "file_type_nginx", 0xe776 ), //  ( "file_type_nim", 0xe677 ), //  ( "file_type_nimble", 0xf15b ), ( "file_type_ninja", 0xf0774 ), // 󰝴 ( "file_type_nix", 0xf1105 ), // 󱄅 ( "file_type_njsproj", 0xf15b ), ( "file_type_node", 0xf0399 ), // 󰎙 ( "file_type_node2", 0xf0399 ), // 󰎙 ( "file_type_nodemon", 0xf0399 ), // 󰎙 ( "file_type_npm", 0xe71e ), //  ( "file_type_nsi", 0xf15b ), ( "file_type_nsri", 0xf15b ), ( "file_type_nsri-integrity", 0xf15b ), ( "file_type_nuget", 0xf15b ), ( "file_type_numpy", 0xf15b ), ( "file_type_nunjucks", 0xe679 ), //  ( "file_type_nushell", 0xf07c6 ), // 󰟆 ( "file_type_nuxt", 0xf1106 ), // 󱄆 ( "file_type_nyc", 0xf15b ), ( "file_type_objectivec", 0xf0bf3 ), // 󰯳 ( "file_type_objectivecpp", 0xf0bf3 ), // 󰯳 ( "file_type_ocaml", 0xe67a ), //  ( "file_type_onenote", 0xf15b ), ( "file_type_openHAB", 0xf15b ), ( "file_type_opencl", 0xf15b ), ( "file_type_org", 0xe633 ), //  ( "file_type_outlook", 0xf0d22 ), // 󰴢 ( "file_type_ovpn", 0xf15b ), // ( "file_type_package", 0xeb29 ), //  ( "file_type_paket", 0xf03d7 ), // 󰏗 ( "file_type_patch", 0xf15b ), // ( "file_type_pcl", 0xf15b ), // ( "file_type_pddl", 0xf15b), ( "file_type_pddl_happenings", 0xf1b5 ), // ( "file_type_pddl_plan", 0xf15b ), ( "file_type_pdf", 0xeaeb ), //  ( "file_type_pdf2", 0xe67d ), //  ( "file_type_perl", 0xe769 ), //  ( "file_type_perl2", 0xe769 ), ( "file_type_perl6", 0xe769 ), ( "file_type_pgsql", 0xe76e ), //  ( "file_type_photoshop", 0xe7b8 ), //  ( "file_type_photoshop2", 0xe7b8 ), ( "file_type_php", 0xe73d ), //  ( "file_type_php2", 0xe608 ), //  ( "file_type_php3", 0xf031f ), // 󰌟 ( "file_type_phpcsfixer", 0xe73d ), ( "file_type_phpunit", 0xe73d ), ( "file_type_phraseapp", 0xf15b ), ( "file_type_pine", 0xf15b ), ( "file_type_pip", 0xe73c ), //  ( "file_type_plantuml", 0xf15b ), ( "file_type_platformio", 0xf15b ), ( "file_type_plsql", 0xf15b ), ( "file_type_plsql_package", 0xf15b ), ( "file_type_plsql_package_body", 0xf15b ), ( "file_type_plsql_package_header", 0xf15b ), ( "file_type_plsql_package_spec", 0xf15b ), ( "file_type_pnpm", 0xe71e ), //  ( "file_type_poedit", 0xf15b ), ( "file_type_polymer", 0xf0421 ), // 󰐡 ( "file_type_pony", 0xf15b ), ( "file_type_postcss", 0xf13c ), //  ( "file_type_postcssconfig", 0xf13c ), ( "file_type_powerpoint", 0xf0227 ), // 󰈧 ( "file_type_powerpoint2", 0xf0227 ), ( "file_type_powershell", 0xf0a0a ), ( "file_type_powershell2", 0xf0a0a ), ( "file_type_powershell_format", 0xf0a0a ), ( "file_type_powershell_psd", 0xf0a0a ), ( "file_type_powershell_psd2", 0xf0a0a ), ( "file_type_powershell_psm", 0xf0a0a ), ( "file_type_powershell_psm2", 0xf0a0a ), ( "file_type_powershell_types", 0xf0a0a ), // 󰨊 ( "file_type_precommit", 0xe729 ), //  ( "file_type_prettier", 0xf15b ), ( "file_type_prisma", 0xe684 ), //  ( "file_type_processinglang", 0xf15b ), ( "file_type_procfile", 0xf15b ), ( "file_type_progress", 0xf15b ), ( "file_type_prolog", 0xf15b ), ( "file_type_prometheus", 0xf15b ), ( "file_type_protobuf", 0xf15b ), ( "file_type_protractor", 0xf15b ), ( "file_type_publisher", 0xf15b ), ( "file_type_pug", 0xf15b ), ( "file_type_puppet", 0xe631 ), //  ( "file_type_purescript", 0xe630 ), //  ( "file_type_pyret", 0xf15b ), ( "file_type_python", 0xe73c ), //  ( "file_type_pyup", 0xe73c ), ( "file_type_q", 0xf15b ), ( "file_type_qbs", 0xf15b ), ( "file_type_qlikview", 0xf15b ), ( "file_type_qml", 0xf15b ), ( "file_type_qmldir", 0xf15b ), ( "file_type_qsharp", 0xf15b ), ( "file_type_quasar", 0xf15b ), ( "file_type_r", 0xf0b19 ), // 󰬙 ( "file_type_racket", 0xf15b ), ( "file_type_rails", 0xe604 ), //  ( "file_type_rake", 0xf15b ), ( "file_type_raml", 0xf0626 ), // 󰘦 ( "file_type_razor", 0xf1997 ), // 󱦗 ( "file_type_razzle", 0xf15b ), ( "file_type_reactjs", 0xe7ba ), //  ( "file_type_reacttemplate", 0xe7ba), //  ( "file_type_reactts", 0xe7ba ), //  ( "file_type_reason", 0xe687 ), //  ( "file_type_red", 0xf15b ), ( "file_type_registry", 0xf15b ), ( "file_type_rego", 0xf15b ), ( "file_type_rehype", 0xf15b ), ( "file_type_remark", 0xf15b ), ( "file_type_renovate", 0xf15b), ( "file_type_rescript", 0xf15b ), ( "file_type_rest", 0xf15b), ( "file_type_retext", 0xf15b), ( "file_type_rexx", 0xf15b ), ( "file_type_riot", 0xf15b ), ( "file_type_rmd", 0xf15b ), ( "file_type_robotframework", 0xf15b), ( "file_type_robots", 0xf06a9 ), // 󰚩 ( "file_type_rollup", 0xe689 ), //  ( "file_type_rproj", 0xf15b ), ( "file_type_rspec", 0xf15b ), ( "file_type_rubocop", 0xf15b ), ( "file_type_ruby", 0xf0d2d ), // 󰴭 ( "file_type_rust", 0xe7a8 ), //  ( "file_type_rust_toolchain", 0xe7a8 ), ( "file_type_sails", 0xf15b ), ( "file_type_saltstack", 0xf15b ), ( "file_type_san", 0xf15b ), ( "file_type_sas", 0xe74b ), ( "file_type_sass", 0xe74b ), //  ( "file_type_sbt", 0xe68d ), //  ( "file_type_scala", 0xe737 ), //  ( "file_type_scilab", 0xf15b ), ( "file_type_script", 0xf06e6 ), ( "file_type_scss", 0xf13c ), ( "file_type_scss2", 0xf13c ), ( "file_type_sdlang", 0xf15b), ( "file_type_sentry", 0xf15b ), ( "file_type_sequelize", 0xf15b ), ( "file_type_serverless", 0xf15b ), ( "file_type_shaderlab", 0xf15b ), ( "file_type_shell", 0xe691 ), //  ( "file_type_silverstripe", 0xf15b ), ( "file_type_sketch", 0xf15b ), ( "file_type_skipper", 0xf15b ), ( "file_type_slang", 0xf15b ), ( "file_type_slice", 0xf15b ), ( "file_type_slim", 0xf15b ), ( "file_type_sln", 0xf15b ), ( "file_type_sln2", 0xf15b ), ( "file_type_smarty", 0xf15b ), ( "file_type_snapcraft", 0xf15b ), ( "file_type_snort", 0xf15b ), ( "file_type_snyk", 0xf15b ), ( "file_type_solidarity", 0xf15b ), ( "file_type_solidity", 0xf15b ), ( "file_type_source", 0xf15b ), ( "file_type_spacengine", 0xf15b ), ( "file_type_sqf", 0xf15b ), ( "file_type_sql", 0xf0b86 ), // 󰮆 ( "file_type_sqlite", 0xe7c4 ), //  ( "file_type_squirrel", 0xf15b ), ( "file_type_sss", 0xf15b ), ( "file_type_stan", 0xf15b ), ( "file_type_stata", 0xf15b ), ( "file_type_stencil", 0xf15b ), ( "file_type_storyboard", 0xf15b ), ( "file_type_storybook", 0xf15b ), ( "file_type_stylable", 0xf15b ), ( "file_type_style", 0xf15b ), ( "file_type_styled", 0xf15b ), ( "file_type_stylelint", 0xf15b ), ( "file_type_stylish_haskell", 0xf15b ), ( "file_type_stylus", 0xf15b ), ( "file_type_subversion", 0xf15b ), ( "file_type_svelte", 0xe697 ), //  ( "file_type_svg", 0xe698 ), //  ( "file_type_swagger", 0xf15b ), ( "file_type_swift", 0xe755 ), //  ( "file_type_swig", 0xf15b ), ( "file_type_symfony", 0xe756 ), //  ( "file_type_systemd", 0xf15b ), ( "file_type_systemverilog", 0xf15b ), ( "file_type_t4tt", 0xf15b ), ( "file_type_tailwind", 0xf13ff ), // 󱏿 ( "file_type_tcl", 0xf15b ), ( "file_type_tera", 0xf15b ), ( "file_type_terraform", 0xf1062 ), // 󱁢 ( "file_type_test", 0xf0668 ), ( "file_type_testcafe", 0xf15b ), ( "file_type_testjs", 0xf0668 ), // 󰙨 ( "file_type_testts", 0xf0668 ), ( "file_type_tex", 0xe69b ), //  ( "file_type_text", 0xf09a8 ), // 󰦨 ( "file_type_textile", 0xf15b ), ( "file_type_tfs", 0xf15b ), ( "file_type_todo", 0xe69c ), //  ( "file_type_toml", 0xf0626 ), // 󰘦 ( "file_type_tox", 0xf15b ), ( "file_type_travis", 0xe77e ), //  ( "file_type_tsconfig", 0xe628 ), //  ( "file_type_tslint", 0xe628 ), //  ( "file_type_tt", 0xf15b ), ( "file_type_ttcn", 0xf15b ), ( "file_type_twig", 0xe61c ), //  ( "file_type_typescript", 0xf06e6 ), // 󰛦 ( "file_type_typescript_official", 0xf06e6 ), // 󰛦 ( "file_type_typescriptdef", 0xf06e6 ), // 󰛦 ( "file_type_typescriptdef_official", 0xf06e6 ), // 󰛦 ( "file_type_typo3", 0xe772 ), //  ( "file_type_unibeautify", 0xf15b ), ( "file_type_vagrant", 0xf15b ), ( "file_type_vala", 0xe69e ), //  ( "file_type_vapi", 0xf15b ), ( "file_type_vash", 0xf15b ), ( "file_type_vb", 0xf15b ), ( "file_type_vba", 0xf15b ), ( "file_type_vbhtml", 0xf15b ), ( "file_type_vbproj", 0xf15b ), ( "file_type_vcxproj", 0xf15b ), ( "file_type_velocity", 0xf15b ), ( "file_type_verilog", 0xf15b ), ( "file_type_version", 0xeb78 ), //  ( "file_type_vhdl", 0xf15b ), ( "file_type_video", 0xf022b ), // 󰈫 ( "file_type_view", 0xf15b ), ( "file_type_vim", 0xe62b ), //  ( "file_type_vlang", 0xe6ac ), //  ( "file_type_volt", 0xf15b ), ( "file_type_vscode", 0xe70c ), //  ( "file_type_vscode-insiders", 0xe70c ), //  ( "file_type_vscode2", 0xe70c ), //  ( "file_type_vscode3", 0xe70c ), //  ( "file_type_vsix", 0xf15b ), ( "file_type_vsixmanifest", 0xf15b ), ( "file_type_vue", 0xe6a0), //  ( "file_type_vueconfig", 0xe6a0 ), //  ( "file_type_wallaby", 0xf15b ), ( "file_type_wasm", 0xe6a1 ), //  ( "file_type_watchmanconfig", 0xf15b ), ( "file_type_webp", 0xf021f ), // 󰈟 ( "file_type_webpack", 0xf072b ), // 󰜫 ( "file_type_wenyan", 0xf15b ), ( "file_type_wercker", 0xf15b ), ( "file_type_wolfram", 0xf0a9a ), // 󰪚 ( "file_type_word", 0xe6a5 ), //  ( "file_type_word2", 0xe6a5 ), //  ( "file_type_wpml", 0xf15b ), ( "file_type_wurst", 0xf15b ), ( "file_type_wxml", 0xf15b ), ( "file_type_wxss", 0xf15b ), ( "file_type_xcode", 0xf15b ), ( "file_type_xfl", 0xf15b ), ( "file_type_xib", 0xf15b ), ( "file_type_xliff", 0xf15b ), ( "file_type_xmake", 0xf15b ), ( "file_type_xml", 0xf05c0 ), // 󰗀 ( "file_type_xquery", 0xf15b ), ( "file_type_xsl", 0xf15b ), ( "file_type_yacc", 0xf15b ), ( "file_type_yaml", 0xf0626 ), // 󰘦 ( "file_type_yamllint", 0xf0626 ), // 󰘦 ( "file_type_yandex", 0xf15b ), ( "file_type_yang", 0xf15b ), ( "file_type_yarn", 0xe6a7 ), //  ( "file_type_yeoman", 0xf15b ), ( "file_type_zeit", 0xf15b ), ( "file_type_zig", 0xe6a9 ), //  ( "file_type_zip", 0xf1c6 ), //  ( "file_type_zip2", 0xf1c6 ),//  ] ================================================ FILE: resources/icons/vscode/data/README ================================================ Tips on editing these files in vi 1. Open ./icon_name_to_icon_code_point_map.rs then in the same session, switch to file you want to edit use C-n and C-y in edit mode 2. This plugin currently searches for lowercase, make everything so 3. Remember to run :Tabularize over ')' and ',' 4. :'<,'>!sort 5. `cargo run` in debug mode should figure out if some problems. ================================================ FILE: resources/icons/vscode/data/double_extension_to_icon_name_map.rs ================================================ // SEE ./README on how to edit this file [ ( "gradle.kts" , "file_type_gradle" ), ( "tar.gz" , "file_type_zip" ), ( "tar.xz" , "file_type_zip" ), ( "tar.zst" , "file_type_zip" ), ] ================================================ FILE: resources/icons/vscode/data/extension_to_icon_name_map.rs ================================================ // SEE ./README on how to edit this file [ ( "3g2" , "file_type_video" ) , ( "3gp" , "file_type_video" ) , ( "7z" , "file_type_zip2" ) , ( "aac" , "file_type_audio" ) , ( "accda" , "file_type_access2" ) , ( "accdb" , "file_type_access2" ) , ( "accdc" , "file_type_access2" ) , ( "accde" , "file_type_access2" ) , ( "accdp" , "file_type_access2" ) , ( "accdr" , "file_type_access2" ) , ( "accdt" , "file_type_access2" ) , ( "accdu" , "file_type_access2" ) , ( "act" , "file_type_audio" ) , ( "adapter.js" , "file_type_nest_adapter_js" ) , ( "adapter.ts" , "file_type_nest_adapter_ts" ) , ( "ade" , "file_type_access2" ) , ( "adp" , "file_type_access2" ) , ( "afdesign" , "file_type_affinitydesigner" ) , ( "affinitydesigner" , "file_type_affinitydesigner" ) , ( "affinityphoto" , "file_type_affinityphoto" ) , ( "affinitypublisher" , "file_type_affinitypublisher" ) , ( "a" , "file_type_binary" ) , ( "afphoto" , "file_type_affinityphoto" ) , ( "afpub" , "file_type_affinitypublisher" ) , ( "aiff" , "file_type_audio" ) , ( "ai" , "file_type_ai2" ) , ( "amr" , "file_type_audio" ) , ( "amv" , "file_type_video" ) , ( "angular-cli.json" , "file_type_angular" ) , ( "angular.json" , "file_type_angular" ) , ( "ape" , "file_type_audio" ) , ( "api-extractor-base.json" , "file_type_api_extractor" ) , ( "api-extractor.json" , "file_type_api_extractor" ) , ( "app.config.js" , "file_type_expo" ) , ( "app.config.json5" , "file_type_expo" ) , ( "app.config.json" , "file_type_expo" ) , ( "app" , "file_type_binary" ) , ( "app.json" , "file_type_expo" ) , ( "app-routing.module.dart" , "file_type_ng_routing_dart" ) , ( "app-routing.module.js" , "file_type_ng_routing_js2" ) , ( "app-routing.module.ts" , "file_type_ng_routing_ts2" ) , ( "appveyor.yml" , "file_type_appveyor" ) , ( "ascx" , "file_type_aspx" ) , ( "asf" , "file_type_video" ) , ( "aspx" , "file_type_aspx" ) , ( "au" , "file_type_audio" ) , ( "aurelia.json" , "file_type_aurelia" ) , ( "avif" , "file_type_avif" ) , ( "avi" , "file_type_video" ) , ( "awk" , "file_type_awk" ) , ( "azure-pipelines.yml" , "file_type_azurepipelines" ) , ( "babelignore" , "file_type_babel2" ) , ( "babelrc" , "file_type_babel2" ) , ( "bazel.bazelrc" , "file_type_bazel" ) , ( "bazel.rc" , "file_type_bazel" ) , ( "bazelrc" , "file_type_bazel" ) , ( "bb" , "file_type_blitzbasic" ) , ( "bcmx" , "file_type_outlook" ) , ( "berksfile" , "file_type_chef" ) , ( "berksfile.lock" , "file_type_chef" ) , ( "bat" , "file_type_bat" ) , ( "bin" , "file_type_binary" ) , ( "bitbucket-pipelines.yml" , "file_type_bitbucketpipeline" ) , ( "bithoundrc" , "file_type_bithound" ) , ( "bmp" , "file_type_image" ) , ( "boringignore" , "file_type_darcs" ) , ( "bower.json" , "file_type_bower" ) , ( "bowerrc" , "file_type_bower" ) , ( "browserslist" , "file_type_browserslist" ) , ( "browserslistrc" , "file_type_browserslist" ) , ( "buckconfig" , "file_type_buckbuild" ) , ( "BUILD.bazel" , "file_type_bazel" ) , ( "build.ninja" , "file_type_ninja" ) , ( "bz2" , "file_type_zip2" ) , ( "bz" , "file_type_zip2" ) , ( "bzip2" , "file_type_zip2" ) , ( "bzrignore" , "file_type_bazaar" ) , ( "cake" , "file_type_cake" ) , ( "cargo.lock" , "file_type_cargo" ) , ( "cargo.toml" , "file_type_cargo" ) , ( "cer" , "file_type_cert" ) , ( "cfignore" , "file_type_cloudfoundry" ) , ( "c" , "file_type_c" ) , ( "checkstyle.json" , "file_type_haxecheckstyle" ) , ( "chefignore" , "file_type_chef" ) , ( "circle.yml" , "file_type_circleci" ) , ( "cjm" , "file_type_clojure" ) , ( "class" , "file_type_class" ) , ( "cl" , "file_type_opencl" ) , ( "cljc" , "file_type_clojure" ) , ( "cljs" , "file_type_clojurescript" ) , ( "cma" , "file_type_binary" ) , ( "cmi" , "file_type_binary" ) , ( "cmo" , "file_type_binary" ) , ( "cmxa" , "file_type_binary" ) , ( "cmx" , "file_type_binary" ) , ( "codacy.yaml" , "file_type_codacy" ) , ( "codacy.yml" , "file_type_codacy" ) , ( "codeclimate.yml" , "file_type_codeclimate" ) , ( "codecov.yml" , "file_type_codecov" ) , ( "coffeelintignore" , "file_type_coffeelint" ) , ( "coffeelint.json" , "file_type_coffeelint" ) , ( "commitlint.config.js" , "file_type_commitlint" ) , ( "component.dart" , "file_type_ng_component_dart" ) , ( "component.js" , "file_type_ng_component_js2" ) , ( "component.ts" , "file_type_ng_component_ts2" ) , ( "composer.json" , "file_type_composer" ) , ( "composer.lock" , "file_type_composer" ) , ( "conanfile.py" , "file_type_conan" ) , ( "conanfile.txt" , "file_type_conan" ) , ( "condarc" , "file_type_conda" ) , ( "config.codekit2" , "file_type_codekit" ) , ( "config.codekit3" , "file_type_codekit" ) , ( "config.codekit" , "file_type_codekit" ) , ( "container.dart" , "file_type_ng_smart_component_dart" ) , ( "container.js" , "file_type_ng_smart_component_js2" ) , ( "container.ts" , "file_type_ng_smart_component_ts2" ) , ( "controller.js" , "file_type_nest_controller_js" ) , ( "controller.ts" , "file_type_nest_controller_ts" ) , ( "coveralls.yml" , "file_type_coveralls" ) , ( "crec" , "file_type_lync" ) , ( "crl" , "file_type_cert" ) , ( "crowdin.yml" , "file_type_crowdin" ) , ( "crt" , "file_type_cert" ) , ( "csproj" , "file_type_csproj" ) , ( "csr" , "file_type_cert" ) , ( "csscomb.json" , "file_type_csscomb" ) , ( "csslintrc" , "file_type_csslint" ) , ( "css.map" , "file_type_cssmap" ) , ( "csv" , "file_type_text" ) , ( "csx" , "file_type_csharp2" ) , ( "cvsignore" , "file_type_cvs" ) , ( "db3" , "file_type_sqlite" ) , ( "db" , "file_type_db" ) , ( "dct" , "file_type_audio" ) , ( "decorator.js" , "file_type_nest_decorator_js" ) , ( "decorator.ts" , "file_type_nest_decorator_ts" ) , ( "dependabot.yml" , "file_type_dependabot" ) , ( "dependencies.yml" , "file_type_dependencies" ) , ( "der" , "file_type_cert" ) , ( "devcontainer.json" , "file_type_devcontainer" ) , ( "dio" , "file_type_drawio" ) , ( "directive.dart" , "file_type_ng_directive_dart" ) , ( "directive.js" , "file_type_ng_directive_js2" ) , ( "directive.ts" , "file_type_ng_directive_ts2" ) , ( "divx" , "file_type_video" ) , ( "djt" , "file_type_django" ) , ( "dll" , "file_type_binary" ) , ( "doc" , "file_type_word2" ) , ( "docker-compose.test.yml" , "file_type_dockertest2" ) , ( "docm" , "file_type_word2" ) , ( "docx" , "file_type_word2" ) , ( "doczrc" , "file_type_docz" ) , ( "dojorc" , "file_type_dojo" ) , ( "dot" , "file_type_word2" ) , ( "dotm" , "file_type_word2" ) , ( "dotx" , "file_type_word2" ) , ( "drawio" , "file_type_drawio" ) , ( "drone.yml" , "file_type_drone" ) , ( "drone.yml.sig" , "file_type_drone" ) , ( "dss" , "file_type_audio" ) , ( "dta" , "file_type_stata" ) , ( "d.ts" , "file_type_typescriptdef_official" ) , ( "dvc" , "file_type_dvc" ) , ( "dvf" , "file_type_audio" ) , ( "eco" , "file_type_docpad" ) , ( "editorconfig" , "file_type_editorconfig" ) , ( "ejs" , "file_type_ejs" ) , ( "ejs.t" , "file_type_hygen" ) , ( "elc" , "file_type_emacs" ) , ( "elm" , "file_type_elm" ) , ( "el" , "file_type_emacs" ) , ( "elm-package.json" , "file_type_elm2" ) , ( "emakefile" , "file_type_erlang2" ) , ( "emakerfile" , "file_type_erlang2" ) , ( "ember-cli" , "file_type_ember" ) , ( "enc" , "file_type_license" ) , ( "ensime" , "file_type_ensime" ) , ( "eot" , "file_type_font" ) , ( "eps" , "file_type_eps" ) , ( "eskip" , "file_type_skipper" ) , ( "eslintcache" , "file_type_eslint2" ) , ( "eslintignore" , "file_type_eslint2" ) , ( "eslintrc" , "file_type_eslint2" ) , ( "exe" , "file_type_binary" ) , ( "exp" , "file_type_tcl" ) , ( "f4a" , "file_type_video" ) , ( "f4b" , "file_type_video" ) , ( "f4p" , "file_type_video" ) , ( "f4v" , "file_type_video" ) , ( "favicon.ico" , "file_type_favicon" ) , ( "fbx" , "file_type_fbx" ) , ( "fig" , "file_type_matlab" ) , ( "filter.js" , "file_type_nest_filter_js" ) , ( "filter.ts" , "file_type_nest_filter_ts" ) , ( "firebase.json" , "file_type_firebasehosting" ) , ( "firebaserc" , "file_type_firebase" ) , ( "firestore.indexes.json" , "file_type_firestore" ) , ( "firestore.rules" , "file_type_firestore" ) , ( "fish" , "file_type_shell" ) , ( "flac" , "file_type_audio" ) , ( "fla" , "file_type_fla" ) , ( "flooignore" , "file_type_floobits" ) , ( "flowconfig" , "file_type_flow" ) , ( "flutter-plugins" , "file_type_flutter" ) , ( "flv" , "file_type_video" ) , ( "fods" , "file_type_excel2" ) , ( "format.ps1xml" , "file_type_powershell_format" ) , ( "fossaignore" , "file_type_fossa" ) , ( "fs" , "file_type_fsharp" ) , ( "fsproj" , "file_type_fsproj" ) , ( "fuse.js" , "file_type_fusebox" ) , ( "gateway.js" , "file_type_nest_gateway_js" ) , ( "gateway.ts" , "file_type_nest_gateway_ts" ) , ( "gemfile" , "file_type_bundler" ) , ( "gemfile.lock" , "file_type_bundler" ) , ( "gif" , "file_type_image" ) , ( "gitattributes" , "file_type_git" ) , ( "gitconfig" , "file_type_git" ) , ( "gitignore" , "file_type_git" ) , ( "gitkeep" , "file_type_git" ) , ( "gitlab-ci.yml" , "file_type_gitlab" ) , ( "gitmodules" , "file_type_git" ) , ( "glide.yml" , "file_type_glide" ) , ( "gmx" , "file_type_gamemaker" ) , ( "go.mod" , "file_type_go_package" ) , ( "go.sum" , "file_type_go_package" ) , ( "gqlconfig" , "file_type_graphql" ) , ( "gradle" , "file_type_gradle2" ) , ( "graphqlconfig" , "file_type_graphql_config" ) , ( "greenkeeper.json" , "file_type_greenkeeper" ) , ( "gsm" , "file_type_audio" ) , ( "guard.dart" , "file_type_ng_guard_dart" ) , ( "guard.js" , "file_type_nest_guard_js" ) , ( "guard.ts" , "file_type_nest_guard_ts" ) , ( "gvimrc" , "file_type_vim" ) , ( "gz" , "file_type_zip2" ) , ( "haxelib.json" , "file_type_haxe" ) , ( "h" , "file_type_cheader" ) , ( "hgignore" , "file_type_mercurial" ) , ( "hl" , "file_type_binary" ) , ( "hpp" , "file_type_cppheader" ) , ( "hs" , "file_type_haskell" ) , ( "html" , "file_type_html" ) , ( "htmlhintrc" , "file_type_htmlhint" ) , ( "husky.config.js" , "file_type_husky" ) , ( "huskyrc" , "file_type_husky" ) , ( "hxp" , "file_type_lime" ) , ( "hxproj" , "file_type_haxedevelop" ) , ( "ibc" , "file_type_idrisbin" ) , ( "ico" , "file_type_image" ) , ( "idr" , "file_type_idris" ) , ( "ignore-glob" , "file_type_fossil" ) , ( "iklax" , "file_type_audio" ) , ( "ilk" , "file_type_binary" ) , ( "inc" , "file_type_inc" ) , ( "include" , "file_type_inc" ) , ( "include.xml" , "file_type_lime" ) , ( "infopathxml" , "file_type_infopath" ) , ( "ino" , "file_type_arduino" ) , ( "integrity.json" , "file_type_nsri-integrity" ) , ( "interceptor.dart" , "file_type_ng_interceptor_dart" ) , ( "interceptor.js" , "file_type_nest_interceptor_js" ) , ( "interceptor.ts" , "file_type_nest_interceptor_ts" ) , ( "ionic.config.json" , "file_type_ionic" ) , ( "ionic.project" , "file_type_ionic" ) , ( "ipkg" , "file_type_idrispkg" ) , ( "ipynb" , "file_type_jupyter" ) , ( "iuml" , "file_type_plantuml" ) , ( "ivs" , "file_type_audio" ) , ( "jade-lint.json" , "file_type_pug" ) , ( "jade-lintrc" , "file_type_pug" ) , ( "jakefile" , "file_type_jake" ) , ( "jakefile.js" , "file_type_jake" ) , ( "jar" , "file_type_jar" ) , ( "jasmine.json" , "file_type_jasmine" ) , ( "java" , "file_type_java" ) , ( "jbuilder" , "file_type_jbuilder" ) , ( "jest.config.json" , "file_type_jest" ) , ( "jest.json" , "file_type_jest" ) , ( "jestrc" , "file_type_jest" ) , ( "jestrc.js" , "file_type_jest" ) , ( "jestrc.json" , "file_type_jest" ) , ( "jpeg" , "file_type_image" ) , ( "jpg" , "file_type_image" ) , ( "jpmignore" , "file_type_jpm" ) , ( "jsbeautify" , "file_type_jsbeautify" ) , ( "jsbeautifyrc" , "file_type_jsbeautify" ) , ( "jsconfig.json" , "file_type_jsconfig" ) , ( "jscpd.json" , "file_type_jscpd" ) , ( "js.flow" , "file_type_flow" ) , ( "jshintignore" , "file_type_jshint" ) , ( "jshintrc" , "file_type_jshint" ) , ( "js.map" , "file_type_jsmap" ) , ( "json5" , "file_type_json5" ) , ( "json" , "file_type_json_official" ) , ( "json-ld" , "file_type_jsonld" ) , ( "jsonld" , "file_type_jsonld" ) , ( "js" , "file_type_js" ) , ( "jsp" , "file_type_jsp" ) , ( "jss" , "file_type_jss" ) , ( "js.snap" , "file_type_jest_snapshot" ) , ( "jsx.snap" , "file_type_jest_snapshot" ) , ( "kdl" , "file_type_config" ) , ( "key" , "file_type_key" ) , ( "kitchen.yml" , "file_type_kitchenci" ) , ( "kiteignore" , "file_type_kite" ) , ( "kit" , "file_type_codekit" ) , ( "laccdb" , "file_type_access2" ) , ( "layout.htm" , "file_type_layout" ) , ( "layout.html" , "file_type_layout" ) , ( "ldb" , "file_type_access2" ) , ( "lerna.json" , "file_type_lerna" ) , ( "lhs" , "file_type_haskell" ) , ( "lib" , "file_type_binary" ) , ( "licence" , "file_type_license" ) , ( "license" , "file_type_license" ) , ( "lidr" , "file_type_idris" ) , ( "lint-staged.config.js" , "file_type_lintstagedrc" ) , ( "lintstagedrc" , "file_type_lintstagedrc" ) , ( "liquid" , "file_type_liquid" ) , ( "lnk" , "file_type_lnk" ) , ( "lock" , "emoji_type_lock" ) , ( "log" , "file_type_log" ) , ( "ls" , "file_type_livescript" ) , ( "lucee" , "file_type_cf2" ) , ( "m2v" , "file_type_video" ) , ( "m4a" , "file_type_audio" ) , ( "m4b" , "file_type_audio" ) , ( "m4p" , "file_type_audio" ) , ( "m4v" , "file_type_video" ) , ( "mailmap" , "file_type_git" ) , ( "makefile" , "file_type_makefile" ) , ( "mam" , "file_type_access2" ) , ( "manifest.bak" , "file_type_manifest_bak" ) , ( "manifest" , "file_type_manifest" ) , ( "manifest.skip" , "file_type_manifest_skip" ) , ( "map" , "file_type_map" ) , ( "maq" , "file_type_access2" ) , ( "markdown" , "file_type_markdown" ) , ( "markdownlint.json" , "file_type_markdownlint" ) , ( "marko.js" , "file_type_markojs" ) , ( "master" , "file_type_layout" ) , ( "maven.config" , "file_type_maven" ) , ( "mdb" , "file_type_access2" ) , ( "md" , "file_type_markdown" ) , ( "mdown" , "file_type_markdown" ) , ( "mdw" , "file_type_access2" ) , ( "mdx" , "file_type_mdx" ) , ( "merlin" , "file_type_ocaml" ) , ( "metadata" , "file_type_flutter" ) , ( "mex" , "file_type_matlab" ) , ( "mexn" , "file_type_matlab" ) , ( "mexrs6" , "file_type_matlab" ) , ( "middleware.js" , "file_type_nest_middleware_js" ) , ( "middleware.ts" , "file_type_nest_middleware_ts" ) , ( "mk3d" , "file_type_video" ) , ( "mkv" , "file_type_video" ) , ( "mmf" , "file_type_audio" ) , ( "mn" , "file_type_matlab" ) , ( "mocha.opts" , "file_type_mocha" ) , ( "modernizr" , "file_type_modernizr" ) , ( "module.dart" , "file_type_ng_module_dart" ) , ( "module.js" , "file_type_nest_module_js" ) , ( "module.ts" , "file_type_nest_module_ts" ) , ( "mo" , "file_type_poedit" ) , ( "mogg" , "file_type_audio" ) , ( "mov" , "file_type_video" ) , ( "mp2" , "file_type_video" ) , ( "mp3" , "file_type_audio" ) , ( "mp4" , "file_type_video" ) , ( "mpc" , "file_type_audio" ) , ( "mpe" , "file_type_video" ) , ( "mpeg2" , "file_type_video" ) , ( "mpeg" , "file_type_video" ) , ( "mpg" , "file_type_video" ) , ( "mpv" , "file_type_video" ) , ( "msg" , "file_type_outlook" ) , ( "mst" , "file_type_mustache" ) , ( "msv" , "file_type_audio" ) , ( "mtn-ignore" , "file_type_monotone" ) , ( "mum" , "file_type_matlab" ) , ( "mustache" , "file_type_mustache" ) , ( "mx3" , "file_type_matlab" ) , ( "mx" , "file_type_matlab" ) , ( "ndll" , "file_type_binary" ) , ( "nest-cli.json" , "file_type_nestjs" ) , ( "nestconfig.json" , "file_type_nestjs" ) , ( "netlify.toml" , "file_type_netlify" ) , ( "next.config.js" , "file_type_next" ) , ( "n" , "file_type_binary" ) , ( "nginx.conf" , "file_type_nginx" ) , ( "ng-tailwind.js" , "file_type_ng_tailwind" ) , ( "nix" , "file_type_nix" ) , ( "njs" , "file_type_nunjucks" ) , ( "njsproj" , "file_type_njsproj" ) , ( "nodemon.json" , "file_type_nodemon" ) , ( "node-version" , "file_type_node2" ) , ( "nowignore" , "file_type_zeit" ) , ( "now.json" , "file_type_zeit" ) , ( "npmignore" , "file_type_npm" ) , ( "npmrc" , "file_type_npm" ) , ( "npm-shrinkwrap.json" , "file_type_npm" ) , ( "npy" , "file_type_numpy" ) , ( "npz" , "file_type_numpy" ) , ( "nsri.config.js" , "file_type_nsri" ) , ( "nsriignore" , "file_type_nsri" ) , ( "nsrirc" , "file_type_nsri" ) , ( "nsv" , "file_type_video" ) , ( "nu" , "file_type_nushell" ) , ( "nunj" , "file_type_nunjucks" ) , ( "nupkg" , "file_type_nuget" ) , ( "nuspec" , "file_type_nuget" ) , ( "nvmrc" , "file_type_node2" ) , ( "nycrc" , "file_type_nyc" ) , ( "nycrc.json" , "file_type_nyc" ) , ( "obj" , "file_type_binary" ) , ( "ocrec" , "file_type_lync" ) , ( "ods" , "file_type_excel2" ) , ( "o" , "file_type_binary" ) , ( "oft" , "file_type_outlook" ) , ( "oga" , "file_type_audio" ) , ( "ogg" , "file_type_audio" ) , ( "ogv" , "file_type_video" ) , ( "one" , "file_type_onenote" ) , ( "onepkg" , "file_type_onenote" ) , ( "onetoc2" , "file_type_onenote" ) , ( "onetoc" , "file_type_onenote" ) , ( "opencl" , "file_type_opencl" ) , ( "opus" , "file_type_audio" ) , ( "org" , "file_type_org" ) , ( "otf" , "file_type_font" ) , ( "otm" , "file_type_outlook" ) , ( "ovpn" , "file_type_ovpn" ) , ( "p12" , "file_type_cert" ) , ( "p4ignore" , "file_type_helix" ) , ( "p7b" , "file_type_cert" ) , ( "p7r" , "file_type_cert" ) , ( "package.json" , "file_type_npm" ) , ( "package-lock.json" , "file_type_npm" ) , ( "package.pins" , "file_type_swift" ) , ( "packages" , "file_type_flutter_package" ) , ( "pa" , "file_type_powerpoint2" ) , ( "page.dart" , "file_type_ng_smart_component_dart" ) , ( "page.js" , "file_type_ng_smart_component_js2" ) , ( "page.ts" , "file_type_ng_smart_component_ts2" ) , ( "patch" , "file_type_patch" ) , ( "pcd" , "file_type_pcl" ) , ( "pck" , "file_type_plsql_package" ) , ( "pdb" , "file_type_binary" ) , ( "pde" , "file_type_arduino" ) , ( "pdf" , "file_type_pdf2" ) , ( "pem" , "file_type_key" ) , ( "pex" , "file_type_xml" ) , ( "pfa" , "file_type_font" ) , ( "pfb" , "file_type_font" ) , ( "P" , "file_type_prolog" ) , ( "pfx" , "file_type_cert" ) , ( "phar" , "file_type_php3" ) , ( "php" , "file_type_php3" ) , ( "php1" , "file_type_php3" ) , ( "php2" , "file_type_php3" ) , ( "php3" , "file_type_php3" ) , ( "php4" , "file_type_php3" ) , ( "php5" , "file_type_php3" ) , ( "php6" , "file_type_php3" ) , ( "php_cs.dist" , "file_type_phpcsfixer" ) , ( "php_cs" , "file_type_phpcsfixer" ) , ( "phpsa" , "file_type_php3" ) , ( "phps" , "file_type_php3" ) , ( "phpt" , "file_type_php3" ) , ( "phpunit" , "file_type_phpunit" ) , ( "phpunit.xml.dist" , "file_type_phpunit" ) , ( "phpunit.xml" , "file_type_phpunit" ) , ( "phraseapp.yml" , "file_type_phraseapp" ) , ( "phtml" , "file_type_php3" ) , ( "pipe.dart" , "file_type_ng_pipe_dart" ) , ( "pipe.js" , "file_type_nest_pipe_js" ) , ( "pipe.ts" , "file_type_nest_pipe_ts" ) , ( "pipfile" , "file_type_pip" ) , ( "pipfile.lock" , "file_type_pip" ) , ( "pkb" , "file_type_plsql_package_body" ) , ( "pkg" , "file_type_package" ) , ( "pkh" , "file_type_plsql_package_header" ) , ( "pks" , "file_type_plsql_package_spec" ) , ( "plantuml" , "file_type_plantuml" ) , ( "platformio.ini" , "file_type_platformio" ) , ( "plist" , "file_type_config" ) , ( "png" , "file_type_image" ) , ( "pnpmfile.js" , "file_type_pnpm" ) , ( "pnpm-lock.yaml" , "file_type_pnpm" ) , ( "pnpm-workspace.yaml" , "file_type_pnpm" ) , ( "po" , "file_type_poedit" ) , ( "policyfile" , "file_type_chef" ) , ( "postcss.config.js" , "file_type_postcssconfig" ) , ( "postcssrc" , "file_type_postcssconfig" ) , ( "postcssrc.js" , "file_type_postcssconfig" ) , ( "postcssrc.json" , "file_type_postcssconfig" ) , ( "postcssrc.yml" , "file_type_postcssconfig" ) , ( "pot" , "file_type_powerpoint2" ) , ( "potm" , "file_type_powerpoint2" ) , ( "potx" , "file_type_powerpoint2" ) , ( "ppa" , "file_type_powerpoint2" ) , ( "ppam" , "file_type_powerpoint2" ) , ( "pps" , "file_type_powerpoint2" ) , ( "ppsm" , "file_type_powerpoint2" ) , ( "ppsx" , "file_type_powerpoint2" ) , ( "ppt" , "file_type_powerpoint2" ) , ( "pptm" , "file_type_powerpoint2" ) , ( "pptx" , "file_type_powerpoint2" ) , ( "pre-commit-config.yaml" , "file_type_precommit" ) , ( "prettierignore" , "file_type_prettier" ) , ( "prettierrc" , "file_type_prettier" ) , ( "prisma" , "file_type_prisma" ) , ( "pro" , "file_type_prolog" ) , ( "procfile" , "file_type_procfile" ) , ( "properties" , "file_type_config" ) , ( "psd" , "file_type_photoshop2" ) , ( "psd1" , "file_type_powershell_psd2" ) , ( "psm1" , "file_type_powershell_psm2" ) , ( "psmdcp" , "file_type_nuget" ) , ( "pst" , "file_type_outlook" ) , ( "pu" , "file_type_plantuml" ) , ( "pub" , "file_type_publisher" ) , ( "pubspec.lock" , "file_type_flutter_package" ) , ( "pubspec.yaml" , "file_type_flutter_package" ) , ( "pug-lintrc" , "file_type_pug" ) , ( "pug-lintrc.js" , "file_type_pug" ) , ( "pug-lintrc.json" , "file_type_pug" ) , ( "puml" , "file_type_plantuml" ) , ( "puz" , "file_type_publisher" ) , ( "pyc" , "file_type_binary" ) , ( "pyd" , "file_type_binary" ) , ( "py" , "file_type_python" ) , ( "pyo" , "file_type_binary" ) , ( "pyup" , "file_type_pyup" ) , ( "pyup.yml" , "file_type_pyup" ) , ( "qbs" , "file_type_qbs" ) , ( "q" , "file_type_q" ) , ( "qmldir" , "file_type_qmldir" ) , ( "qt" , "file_type_video" ) , ( "quasar.conf.js" , "file_type_quasar" ) , ( "qvd" , "file_type_qlikview" ) , ( "qvw" , "file_type_qlikview" ) , ( "ra" , "file_type_audio" ) , ( "rakefile" , "file_type_rake" ) , ( "rake" , "file_type_rake" ) , ( "rar" , "file_type_zip2" ) , ( "raw" , "file_type_audio" ) , ( "re" , "file_type_reason" ) , ( "reg" , "file_type_registry" ) , ( "rego" , "file_type_rego" ) , ( "rehypeignore" , "file_type_rehype" ) , ( "rehyperc" , "file_type_rehype" ) , ( "remarkignore" , "file_type_remark" ) , ( "remarkrc" , "file_type_remark" ) , ( "renovaterc" , "file_type_renovate" ) , ( "retextignore" , "file_type_retext" ) , ( "retextrc" , "file_type_retext" ) , ( "rm" , "file_type_video" ) , ( "rmvb" , "file_type_video" ) , ( "robots.txt" , "file_type_robots" ) , ( "routing.dart" , "file_type_ng_routing_dart" ) , ( "routing.js" , "file_type_ng_routing_js2" ) , ( "routing.ts" , "file_type_ng_routing_ts2" ) , ( "rproj" , "file_type_rproj" ) , ( "rs" , "file_type_rust" ) , ( "rspec" , "file_type_rspec" ) , ( "rt" , "file_type_reacttemplate" ) , ( "rubocop_todo.yml" , "file_type_rubocop" ) , ( "rubocop.yml" , "file_type_rubocop" ) , ( "rust-toolchain" , "file_type_rust_toolchain" ) , ( "rwd" , "file_type_matlab" ) , ( "sailsrc" , "file_type_sails" ) , ( "sass" , "file_type_sass" ) , ( "sbt" , "file_type_sbt" ) , ( "scala" , "file_type_scala" ) , ( "scpt" , "file_type_binary" ) , ( "scptd" , "file_type_binary" ) , ( "scssm" , "file_type_scss" ) , ( "sentryclirc" , "file_type_sentry" ) , ( "sequelizerc" , "file_type_sequelize" ) , ( "serverless.yml" , "file_type_serverless" ) , ( "service.dart" , "file_type_ng_service_dart" ) , ( "service.js" , "file_type_nest_service_js" ) , ( "service.ts" , "file_type_nest_service_ts" ) , ( "sfd" , "file_type_font" ) , ( "sh" , "file_type_shell" ) , ( "sig" , "file_type_onenote" ) , ( "sketch" , "file_type_sketch" ) , ( "slddc" , "file_type_matlab" ) , ( "sldm" , "file_type_powerpoint2" ) , ( "sldx" , "file_type_powerpoint2" ) , ( "sln" , "file_type_sln2" ) , ( "sls" , "file_type_saltstack" ) , ( "slx" , "file_type_matlab" ) , ( "smv" , "file_type_matlab" ) , ( "snapcraft.yaml" , "file_type_snapcraft" ) , ( "snyk" , "file_type_snyk" ) , ( "so" , "file_type_binary" ) , ( "solidarity" , "file_type_solidarity" ) , ( "solidarity.json" , "file_type_solidarity" ) , ( "spe" , "file_type_spacengine" ) , ( "sqlite3" , "file_type_sqlite" ) , ( "sqlite" , "file_type_sqlite" ) , ( "sql" , "file_type_sql" ) , ( "src" , "file_type_cert" ) , ( "sss" , "file_type_sss" ) , ( "sst" , "file_type_cert" ) , ( "stl" , "file_type_cert" ) , ( "storyboard" , "file_type_storyboard" ) , ( "stylelintcache" , "file_type_stylelint" ) , ( "stylelintignore" , "file_type_stylelint" ) , ( "stylelintrc" , "file_type_stylelint" ) , ( "jenkinsfile" , "file_type_jenkins" ) , ( "stylish-haskell.yaml" , "file_type_stylish_haskell" ) , ( "svg" , "file_type_svg" ) , ( "svi" , "file_type_video" ) , ( "svnignore" , "file_type_subversion" ) , ( "swc" , "file_type_flash" ) , ( "swf" , "file_type_flash" ) , ( "symfony.lock" , "file_type_symfony" ) , ( "tar" , "file_type_zip2" ) , ( "tcl" , "file_type_tcl" ) , ( "testcaferc.json" , "file_type_testcafe" ) , ( "texi" , "file_type_tex" ) , ( "tf" , "file_type_terraform" ) , ( "tfignore" , "file_type_tfs" ) , ( "tfstate" , "file_type_terraform" ) , ( "tgz" , "file_type_zip2" ) , ( "tiff" , "file_type_image" ) , ( "tikz" , "file_type_tex" ) , ( "tlg" , "file_type_log" ) , ( "tmlanguage" , "file_type_xml" ) , ( "todo" , "file_type_todo" ) , ( "toml" , "file_type_toml" ) , ( "tox.ini" , "file_type_tox" ) , ( "travis.yml" , "file_type_travis" ) , ( "tslint.json" , "file_type_tslint" ) , ( "tslint.yaml" , "file_type_tslint" ) , ( "tslint.yml" , "file_type_tslint" ) , ( "ts.snap" , "file_type_jest_snapshot" ) , ( "tst" , "file_type_test" ) , ( "tsx" , "file_type_typescript" ) , ( "tsx.snap" , "file_type_jest_snapshot" ) , ( "tt2" , "file_type_tt" ) , ( "tta" , "file_type_audio" ) , ( "ttf" , "file_type_font" ) , ( "txt" , "file_type_text" ) , ( "types.ps1xml" , "file_type_powershell_types" ) , ( "unibeautify.config.js" , "file_type_unibeautify" ) , ( "unibeautifyrc" , "file_type_unibeautify" ) , ( "unity" , "file_type_shaderlab" ) , ( "vagrantfile" , "file_type_vagrant" ) , ( "vala" , "file_type_vala" ) , ( "vapi" , "file_type_vapi" ) , ( "vash" , "file_type_vash" ) , ( "vbhtml" , "file_type_vbhtml" ) , ( "vbproj" , "file_type_vbproj" ) , ( "vcxproj" , "file_type_vcxproj" ) , ( "vercelignore" , "file_type_zeit" ) , ( "vercel.json" , "file_type_zeit" ) , ( "vimrc" , "file_type_vim" ) , ( "vob" , "file_type_video" ) , ( "vox" , "file_type_audio" ) , ( "vscodeignore" , "file_type_vscode-insiders" ) , ( "vsix" , "file_type_vsix" ) , ( "vsixmanifest" , "file_type_vsixmanifest" ) , ( "vsts-ci.yml" , "file_type_azurepipelines" ) , ( "vue.config.js" , "file_type_vueconfig" ) , ( "vuerc" , "file_type_vueconfig" ) , ( "wasm" , "file_type_wasm" ) , ( "watchmanconfig" , "file_type_watchmanconfig" ) , ( "wav" , "file_type_audio" ) , ( "webm" , "file_type_video" ) , ( "webp" , "file_type_webp" ) , ( "wercker.yml" , "file_type_wercker" ) , ( "wll" , "file_type_word2" ) , ( "wma" , "file_type_audio" ) , ( "wmv" , "file_type_video" ) , ( "woff2" , "file_type_font" ) , ( "woff" , "file_type_font" ) , ( "wpml-config.xml" , "file_type_wpml" ) , ( "wxml" , "file_type_wxml" ) , ( "wxss" , "file_type_wxss" ) , ( "xcodeproj" , "file_type_xcode" ) , ( "xfl" , "file_type_xfl" ) , ( "xib" , "file_type_xib" ) , ( "xlf" , "file_type_xliff" ) , ( "xliff" , "file_type_xliff" ) , ( "xls" , "file_type_excel2" ) , ( "xlsm" , "file_type_excel2" ) , ( "xlsx" , "file_type_excel2" ) , ( "xml" , "file_type_xml" ) , ( "xsf" , "file_type_infopath" ) , ( "xsn" , "file_type_infopath" ) , ( "xtp2" , "file_type_infopath" ) , ( "xvc" , "file_type_matlab" ) , ( "xz" , "file_type_zip2" ) , ( "yaml" , "file_type_yaml" ) , ( "yamllint" , "file_type_yamllint" ) , ( "yarnclean" , "file_type_yarn" ) , ( "yarnignore" , "file_type_yarn" ) , ( "yarn-integrity" , "file_type_yarn" ) , ( "yarn.lock" , "file_type_yarn" ) , ( "yarn-metadata.json" , "file_type_yarn" ) , ( "yarnrc" , "file_type_yarn" ) , ( "yaspeller.json" , "file_type_yandex" ) , ( "yaspellerrc" , "file_type_yandex" ) , ( "yml" , "file_type_yaml" ) , ( "yo-rc.json" , "file_type_yeoman" ) , ( "yy" , "file_type_gamemaker2" ) , ( "yyp" , "file_type_gamemaker2" ) , ( "zip" , "file_type_zip2" ) , ( "zipx" , "file_type_zip2" ) , ( "zst" , "file_type_zip2" ) , ] ================================================ FILE: resources/icons/vscode/data/file_name_to_icon_name_map.rs ================================================ [ ( ".scalafix.conf" , "file_type_config" ), ( ".scalafmt.conf" , "file_type_config" ), ( "build.properties" , "file_type_config" ), ( "eslint.config.cjs" , "file_type_eslint" ), ( "eslint.config.js" , "file_type_eslint" ), ( "eslint.config.mjs" , "file_type_eslint" ), ( "license" , "file_type_license"), ( "package-lock.json" , "file_type_npm" ), ( "package.json" , "file_type_npm" ), ( "readme" , "file_type_text" ), ( "todo" , "file_type_todo" ), ( "jenkinsfile" , "file_type_jenkins"), ] ================================================ FILE: resources/icons/vscode/data/icon_name_to_icon_code_point_map.rs ================================================ [ ( "emoji_type_lock", 0x1F512 ), ( "emoji_type_link", 0x1F517 ), ( "default_file", 0x100000 ), ( "default_folder_opened", 0x100001 ), ( "default_folder", 0x100002 ), ( "default_root_folder_opened", 0x100003 ), ( "default_root_folder", 0x100004 ), ( "file_type_access2", 0x100064 ), ( "file_type_access", 0x100065 ), ( "file_type_actionscript2", 0x100066 ), ( "file_type_actionscript", 0x100067 ), ( "file_type_ada", 0x100068 ), ( "file_type_advpl", 0x100069 ), ( "file_type_affectscript", 0x10006A ), ( "file_type_affinitydesigner", 0x10006B ), ( "file_type_affinityphoto", 0x10006C ), ( "file_type_affinitypublisher", 0x10006D ), ( "file_type_ai2", 0x10006E ), ( "file_type_ai", 0x10006F ), ( "file_type_al", 0x100070 ), ( "file_type_angular", 0x100071 ), ( "file_type_ansible", 0x100072 ), ( "file_type_antlr", 0x100073 ), ( "file_type_anyscript", 0x100074 ), ( "file_type_apache", 0x100075 ), ( "file_type_apex", 0x100076 ), ( "file_type_apib2", 0x100077 ), ( "file_type_apib", 0x100078 ), ( "file_type_api_extractor", 0x100079 ), ( "file_type_apl", 0x10007A ), ( "file_type_applescript", 0x10007B ), ( "file_type_appveyor", 0x10007C ), ( "file_type_arduino", 0x10007D ), ( "file_type_asciidoc", 0x10007E ), ( "file_type_asp", 0x10007F ), ( "file_type_aspx", 0x100080 ), ( "file_type_assembly", 0x100081 ), ( "file_type_ats", 0x100082 ), ( "file_type_audio", 0x100083 ), ( "file_type_aurelia", 0x100084 ), ( "file_type_autohotkey", 0x100085 ), ( "file_type_autoit", 0x100086 ), ( "file_type_avif", 0x100087 ), ( "file_type_avro", 0x100088 ), ( "file_type_awk", 0x100089 ), ( "file_type_aws", 0x10008A ), ( "file_type_azurepipelines", 0x10008B ), ( "file_type_azure", 0x10008C ), ( "file_type_babel2", 0x10008D ), ( "file_type_babel", 0x10008E ), ( "file_type_ballerina", 0x10008F ), ( "file_type_bats", 0x100090 ), ( "file_type_bat", 0x100091 ), ( "file_type_bazaar", 0x100092 ), ( "file_type_bazel", 0x100093 ), ( "file_type_befunge", 0x100094 ), ( "file_type_biml", 0x100095 ), ( "file_type_binary", 0x100096 ), ( "file_type_bitbucketpipeline", 0x100097 ), ( "file_type_bithound", 0x100098 ), ( "file_type_blade", 0x100099 ), ( "file_type_blitzbasic", 0x10009A ), ( "file_type_bolt", 0x10009B ), ( "file_type_bosque", 0x10009C ), ( "file_type_bower2", 0x10009D ), ( "file_type_bower", 0x10009E ), ( "file_type_browserslist", 0x10009F ), ( "file_type_buckbuild", 0x1000A0 ), ( "file_type_bundler", 0x1000A1 ), ( "file_type_c2", 0x1000A2 ), ( "file_type_c3", 0x1000A3 ), ( "file_type_cabal", 0x1000A4 ), ( "file_type_caddy", 0x1000A5 ), ( "file_type_cakephp", 0x1000A6 ), ( "file_type_cake", 0x1000A7 ), ( "file_type_c_al", 0x1000A8 ), ( "file_type_capacitor", 0x1000A9 ), ( "file_type_cargo", 0x1000AA ), ( "file_type_cddl", 0x1000AB ), ( "file_type_cert", 0x1000AC ), ( "file_type_ceylon", 0x1000AD ), ( "file_type_cf2", 0x1000AE ), ( "file_type_cfc2", 0x1000AF ), ( "file_type_cfc", 0x1000B0 ), ( "file_type_cfm2", 0x1000B1 ), ( "file_type_cfm", 0x1000B2 ), ( "file_type_cf", 0x1000B3 ), ( "file_type_cheader", 0x1000B4 ), ( "file_type_chef_cookbook", 0x1000B5 ), ( "file_type_chef", 0x1000B6 ), ( "file_type_circleci", 0x1000B7 ), ( "file_type_class", 0x1000B8 ), ( "file_type_clojurescript", 0x1000B9 ), ( "file_type_clojure", 0x1000BA ), ( "file_type_cloudfoundry", 0x1000BB ), ( "file_type_cmake", 0x1000BC ), ( "file_type_cobol", 0x1000BD ), ( "file_type_codacy", 0x1000BE ), ( "file_type_codeclimate", 0x1000BF ), ( "file_type_codecov", 0x1000C0 ), ( "file_type_codekit", 0x1000C1 ), ( "file_type_coffeelint", 0x1000C2 ), ( "file_type_coffeescript", 0x1000C3 ), ( "file_type_commitlint", 0x1000C4 ), ( "file_type_compass", 0x1000C5 ), ( "file_type_composer", 0x1000C6 ), ( "file_type_conan", 0x1000C7 ), ( "file_type_conda", 0x1000C8 ), ( "file_type_config", 0x1000C9 ), ( "file_type_confluence", 0x1000CA ), ( "file_type_coveralls", 0x1000CB ), ( "file_type_cpp2", 0x1000CC ), ( "file_type_cpp3", 0x1000CD ), ( "file_type_cppheader", 0x1000CE ), ( "file_type_cpp", 0x1000CF ), ( "file_type_crowdin", 0x1000D0 ), ( "file_type_crystal", 0x1000D1 ), ( "file_type_csharp2", 0x1000D2 ), ( "file_type_csharp", 0x1000D3 ), ( "file_type_csproj", 0x1000D4 ), ( "file_type_csscomb", 0x1000D5 ), ( "file_type_csslint", 0x1000D6 ), ( "file_type_cssmap", 0x1000D7 ), ( "file_type_css", 0x1000D8 ), ( "file_type_c", 0x1000D9 ), ( "file_type_cucumber", 0x1000DA ), ( "file_type_cuda", 0x1000DB ), ( "file_type_cvs", 0x1000DC ), ( "file_type_cypress", 0x1000DD ), ( "file_type_cython", 0x1000DE ), ( "file_type_dal", 0x1000DF ), ( "file_type_darcs", 0x1000E0 ), ( "file_type_dartlang", 0x1000E1 ), ( "file_type_db", 0x1000E2 ), ( "file_type_delphi", 0x1000E3 ), ( "file_type_dependabot", 0x1000E4 ), ( "file_type_dependencies", 0x1000E5 ), ( "file_type_devcontainer", 0x1000E6 ), ( "file_type_diff", 0x1000E7 ), ( "file_type_django", 0x1000E8 ), ( "file_type_dlang", 0x1000E9 ), ( "file_type_docker2", 0x1000EA ), ( "file_type_docker", 0x1000EB ), ( "file_type_dockertest2", 0x1000EC ), ( "file_type_dockertest", 0x1000ED ), ( "file_type_docpad", 0x1000EE ), ( "file_type_docz", 0x1000EF ), ( "file_type_dojo", 0x1000F0 ), ( "file_type_dotjs", 0x1000F1 ), ( "file_type_doxygen", 0x1000F2 ), ( "file_type_drawio", 0x1000F3 ), ( "file_type_drone", 0x1000F4 ), ( "file_type_drools", 0x1000F5 ), ( "file_type_dustjs", 0x1000F6 ), ( "file_type_dvc", 0x1000F7 ), ( "file_type_dylan", 0x1000F8 ), ( "file_type_edge2", 0x1000F9 ), ( "file_type_edge", 0x1000FA ), ( "file_type_editorconfig", 0x1000FB ), ( "file_type_eex", 0x1000FC ), ( "file_type_ejs", 0x1000FD ), ( "file_type_elasticbeanstalk", 0x1000FE ), ( "file_type_elastic", 0x1000FF ), ( "file_type_elixir", 0x100100 ), ( "file_type_elm2", 0x100101 ), ( "file_type_elm", 0x100102 ), ( "file_type_emacs", 0x100103 ), ( "file_type_ember", 0x100104 ), ( "file_type_ensime", 0x100105 ), ( "file_type_eps", 0x100106 ), ( "file_type_erb", 0x100107 ), ( "file_type_erlang2", 0x100108 ), ( "file_type_erlang", 0x100109 ), ( "file_type_eslint2", 0x10010A ), ( "file_type_eslint", 0x10010B ), ( "file_type_excel2", 0x10010C ), ( "file_type_excel", 0x10010D ), ( "file_type_expo", 0x10010E ), ( "file_type_falcon", 0x10010F ), ( "file_type_favicon", 0x100110 ), ( "file_type_fbx", 0x100111 ), ( "file_type_firebasehosting", 0x100112 ), ( "file_type_firebase", 0x100113 ), ( "file_type_firestore", 0x100114 ), ( "file_type_flash", 0x100115 ), ( "file_type_fla", 0x100116 ), ( "file_type_floobits", 0x100117 ), ( "file_type_flow", 0x100118 ), ( "file_type_flutter_package", 0x100119 ), ( "file_type_flutter", 0x10011A ), ( "file_type_font", 0x10011B ), ( "file_type_fortran", 0x10011C ), ( "file_type_fossa", 0x10011D ), ( "file_type_fossil", 0x10011E ), ( "file_type_freemarker", 0x10011F ), ( "file_type_fsharp2", 0x100120 ), ( "file_type_fsharp", 0x100121 ), ( "file_type_fsproj", 0x100122 ), ( "file_type_fthtml", 0x100123 ), ( "file_type_fusebox", 0x100124 ), ( "file_type_galen2", 0x100125 ), ( "file_type_galen", 0x100126 ), ( "file_type_gamemaker2", 0x100127 ), ( "file_type_gamemaker81", 0x100128 ), ( "file_type_gamemaker", 0x100129 ), ( "file_type_gatsby", 0x10012A ), ( "file_type_gcode", 0x10012B ), ( "file_type_genstat", 0x10012C ), ( "file_type_git2", 0x10012D ), ( "file_type_gitlab", 0x10012E ), ( "file_type_git", 0x10012F ), ( "file_type_glide", 0x100130 ), ( "file_type_glsl", 0x100131 ), ( "file_type_glyphs", 0x100132 ), ( "file_type_gnuplot", 0x100133 ), ( "file_type_go_aqua", 0x100134 ), ( "file_type_go_black", 0x100135 ), ( "file_type_godot", 0x100136 ), ( "file_type_go_fuchsia", 0x100137 ), ( "file_type_go_gopher", 0x100138 ), ( "file_type_go_lightblue", 0x100139 ), ( "file_type_go_package", 0x10013A ), ( "file_type_go", 0x10013B ), ( "file_type_go_white", 0x10013C ), ( "file_type_go_yellow", 0x10013D ), ( "file_type_gradle2", 0x10013E ), ( "file_type_gradle", 0x10013F ), ( "file_type_graphql_config", 0x100140 ), ( "file_type_graphql", 0x100141 ), ( "file_type_graphviz", 0x100142 ), ( "file_type_greenkeeper", 0x100143 ), ( "file_type_gridsome", 0x100144 ), ( "file_type_groovy2", 0x100145 ), ( "file_type_groovy", 0x100146 ), ( "file_type_grunt", 0x100147 ), ( "file_type_gulp", 0x100148 ), ( "file_type_haml", 0x100149 ), ( "file_type_handlebars2", 0x10014A ), ( "file_type_handlebars", 0x10014B ), ( "file_type_harbour", 0x10014C ), ( "file_type_haskell2", 0x10014D ), ( "file_type_haskell", 0x10014E ), ( "file_type_haxecheckstyle", 0x10014F ), ( "file_type_haxedevelop", 0x100150 ), ( "file_type_haxe", 0x100151 ), ( "file_type_helix", 0x100152 ), ( "file_type_helm", 0x100153 ), ( "file_type_hjson", 0x100154 ), ( "file_type_hlsl", 0x100155 ), ( "file_type_homeassistant", 0x100156 ), ( "file_type_host", 0x100157 ), ( "file_type_htmlhint", 0x100158 ), ( "file_type_html", 0x100159 ), ( "file_type_http", 0x10015A ), ( "file_type_hunspell", 0x10015B ), ( "file_type_husky", 0x10015C ), ( "file_type_hygen", 0x10015D ), ( "file_type_hy", 0x10015E ), ( "file_type_icl", 0x10015F ), ( "file_type_idrisbin", 0x100160 ), ( "file_type_idrispkg", 0x100161 ), ( "file_type_idris", 0x100162 ), ( "file_type_image", 0x100163 ), ( "file_type_imba", 0x100164 ), ( "file_type_inc", 0x100165 ), ( "file_type_infopath", 0x100166 ), ( "file_type_informix", 0x100167 ), ( "file_type_ini", 0x100168 ), ( "file_type_ink", 0x100169 ), ( "file_type_innosetup", 0x10016A ), ( "file_type_iodine", 0x10016B ), ( "file_type_ionic", 0x10016C ), ( "file_type_io", 0x10016D ), ( "file_type_jake", 0x10016E ), ( "file_type_janet", 0x10016F ), ( "file_type_jar", 0x100170 ), ( "file_type_jasmine", 0x100171 ), ( "file_type_java", 0x100172 ), ( "file_type_jbuilder", 0x100173 ), ( "file_type_jekyll", 0x100174 ), ( "file_type_jenkins", 0x100175 ), ( "file_type_jest_snapshot", 0x100176 ), ( "file_type_jest", 0x100177 ), ( "file_type_jinja", 0x100178 ), ( "file_type_jpm", 0x100179 ), ( "file_type_jsbeautify", 0x10017A ), ( "file_type_jsconfig", 0x10017B ), ( "file_type_jscpd", 0x10017C ), ( "file_type_jshint", 0x10017D ), ( "file_type_jsmap", 0x10017E ), ( "file_type_js_official", 0x10017F ), ( "file_type_json2", 0x100180 ), ( "file_type_json5", 0x100181 ), ( "file_type_jsonld", 0x100182 ), ( "file_type_jsonnet", 0x100183 ), ( "file_type_json_official", 0x100184 ), ( "file_type_json", 0x100185 ), ( "file_type_jsp", 0x100186 ), ( "file_type_jss", 0x100187 ), ( "file_type_js", 0x100188 ), ( "file_type_julia2", 0x100189 ), ( "#file_type_julia", 0x10018A ), ( "file_type_jupyter", 0x10018B ), ( "file_type_karma", 0x10018C ), ( "file_type_key", 0x10018D ), ( "file_type_kitchenci", 0x10018E ), ( "file_type_kite", 0x10018F ), ( "file_type_kivy", 0x100190 ), ( "file_type_kos", 0x100191 ), ( "file_type_kotlin", 0x100192 ), ( "file_type_kusto", 0x100193 ), ( "file_type_latino", 0x100194 ), ( "file_type_layout", 0x100195 ), ( "file_type_lerna", 0x100196 ), ( "file_type_less", 0x100197 ), ( "file_type_lex", 0x100198 ), ( "file_type_license", 0x100199 ), ( "file_type_light_actionscript2", 0x10019A ), ( "file_type_light_ada", 0x10019B ), ( "file_type_light_apl", 0x10019C ), ( "file_type_light_babel2", 0x10019D ), ( "file_type_light_babel", 0x10019E ), ( "file_type_light_cabal", 0x10019F ), ( "file_type_light_circleci", 0x1001A0 ), ( "file_type_light_cloudfoundry", 0x1001A1 ), ( "file_type_light_codacy", 0x1001A2 ), ( "file_type_light_codeclimate", 0x1001A3 ), ( "file_type_light_config", 0x1001A4 ), ( "file_type_light_crystal", 0x1001A5 ), ( "file_type_light_db", 0x1001A6 ), ( "file_type_light_docpad", 0x1001A7 ), ( "file_type_light_drone", 0x1001A8 ), ( "file_type_light_expo", 0x1001A9 ), ( "file_type_light_firebasehosting", 0x1001AA ), ( "file_type_light_fla", 0x1001AB ), ( "file_type_light_font", 0x1001AC ), ( "file_type_light_gamemaker2", 0x1001AD ), ( "file_type_light_gradle", 0x1001AE ), ( "file_type_light_hjson", 0x1001AF ), ( "file_type_lighthouse", 0x1001B0 ), ( "file_type_light_ini", 0x1001B1 ), ( "file_type_light_io", 0x1001B2 ), ( "file_type_light_jsconfig", 0x1001B3 ), ( "file_type_light_jsmap", 0x1001B4 ), ( "file_type_light_json5", 0x1001B5 ), ( "file_type_light_jsonld", 0x1001B6 ), ( "file_type_light_json", 0x1001B7 ), ( "file_type_light_js", 0x1001B8 ), ( "file_type_light_kite", 0x1001B9 ), ( "file_type_light_lerna", 0x1001BA ), ( "file_type_light_mdx", 0x1001BB ), ( "file_type_light_mlang", 0x1001BC ), ( "file_type_light_mustache", 0x1001BD ), ( "file_type_light_next", 0x1001BE ), ( "file_type_light_nim", 0x1001BF ), ( "file_type_light_openHAB", 0x1001C0 ), ( "file_type_light_pcl", 0x1001C1 ), ( "file_type_light_pnpm", 0x1001C2 ), ( "file_type_light_prettier", 0x1001C3 ), ( "file_type_light_prisma", 0x1001C4 ), ( "file_type_light_purescript", 0x1001C5 ), ( "file_type_light_razzle", 0x1001C6 ), ( "file_type_light_rehype", 0x1001C7 ), ( "file_type_light_remark", 0x1001C8 ), ( "file_type_light_retext", 0x1001C9 ), ( "file_type_light_rubocop", 0x1001CA ), ( "file_type_light_shaderlab", 0x1001CB ), ( "file_type_light_solidity", 0x1001CC ), ( "file_type_light_stylelint", 0x1001CD ), ( "file_type_light_stylus", 0x1001CE ), ( "file_type_light_symfony", 0x1001CF ), ( "file_type_light_systemd", 0x1001D0 ), ( "file_type_light_systemverilog", 0x1001D1 ), ( "file_type_light_testcafe", 0x1001D2 ), ( "file_type_light_testjs", 0x1001D3 ), ( "file_type_light_tex", 0x1001D4 ), ( "file_type_light_todo", 0x1001D5 ), ( "file_type_light_toml", 0x1001D6 ), ( "file_type_light_unibeautify", 0x1001D7 ), ( "file_type_light_vash", 0x1001D8 ), ( "file_type_light_vsixmanifest", 0x1001D9 ), ( "file_type_light_vsix", 0x1001DA ), ( "file_type_light_xfl", 0x1001DB ), ( "file_type_light_yaml", 0x1001DC ), ( "file_type_light_zeit", 0x1001DD ), ( "file_type_lime", 0x1001DE ), ( "file_type_lintstagedrc", 0x1001DF ), ( "file_type_liquid", 0x1001E0 ), ( "file_type_lisp", 0x1001E1 ), ( "file_type_livescript", 0x1001E2 ), ( "file_type_lnk", 0x1001E3 ), ( "file_type_locale", 0x1001E4 ), ( "file_type_log", 0x1001E5 ), ( "file_type_lolcode", 0x1001E6 ), ( "file_type_lsl", 0x1001E7 ), ( "file_type_lua", 0x1001E8 ), ( "file_type_lync", 0x1001E9 ), ( "file_type_makefile", 0x1001EA ), ( "file_type_manifest_bak", 0x1001EB ), ( "file_type_manifest_skip", 0x1001EC ), ( "file_type_manifest", 0x1001ED ), ( "file_type_map", 0x1001EE ), ( "file_type_mariadb", 0x1001EF ), ( "file_type_markdownlint", 0x1001F0 ), ( "file_type_markdown", 0x1001F1 ), ( "file_type_markojs", 0x1001F2 ), ( "file_type_marko", 0x1001F3 ), ( "file_type_matlab", 0x1001F4 ), ( "file_type_maven", 0x1001F5 ), ( "file_type_maxscript", 0x1001F6 ), ( "file_type_maya", 0x1001F7 ), ( "file_type_mdx", 0x1001F8 ), ( "file_type_mediawiki", 0x1001F9 ), ( "file_type_mercurial", 0x1001FA ), ( "file_type_meson", 0x1001FB ), ( "file_type_meteor", 0x1001FC ), ( "file_type_mjml", 0x1001FD ), ( "file_type_mlang", 0x1001FE ), ( "file_type_mocha", 0x1001FF ), ( "file_type_modernizr", 0x100200 ), ( "file_type_mojolicious", 0x100201 ), ( "file_type_moleculer", 0x100202 ), ( "file_type_mongo", 0x100203 ), ( "file_type_monotone", 0x100204 ), ( "file_type_mson", 0x100205 ), ( "file_type_mustache", 0x100206 ), ( "file_type_mysql", 0x100207 ), ( "file_type_nearly", 0x100208 ), ( "file_type_nest_adapter_js", 0x100209 ), ( "file_type_nest_adapter_ts", 0x10020A ), ( "file_type_nest_controller_js", 0x10020B ), ( "file_type_nest_controller_ts", 0x10020C ), ( "file_type_nest_decorator_js", 0x10020D ), ( "file_type_nest_decorator_ts", 0x10020E ), ( "file_type_nest_filter_js", 0x10020F ), ( "file_type_nest_filter_ts", 0x100210 ), ( "file_type_nest_gateway_js", 0x100211 ), ( "file_type_nest_gateway_ts", 0x100212 ), ( "file_type_nest_guard_js", 0x100213 ), ( "file_type_nest_guard_ts", 0x100214 ), ( "file_type_nest_interceptor_js", 0x100215 ), ( "file_type_nest_interceptor_ts", 0x100216 ), ( "file_type_nestjs", 0x100217 ), ( "file_type_nest_middleware_js", 0x100218 ), ( "file_type_nest_middleware_ts", 0x100219 ), ( "file_type_nest_module_js", 0x10021A ), ( "file_type_nest_module_ts", 0x10021B ), ( "file_type_nest_pipe_js", 0x10021C ), ( "file_type_nest_pipe_ts", 0x10021D ), ( "file_type_nest_service_js", 0x10021E ), ( "file_type_nest_service_ts", 0x10021F ), ( "file_type_netlify", 0x100220 ), ( "file_type_next", 0x100221 ), ( "file_type_ng_component_css", 0x100222 ), ( "file_type_ng_component_dart", 0x100223 ), ( "file_type_ng_component_html", 0x100224 ), ( "file_type_ng_component_js2", 0x100225 ), ( "file_type_ng_component_js", 0x100226 ), ( "file_type_ng_component_less", 0x100227 ), ( "file_type_ng_component_sass", 0x100228 ), ( "file_type_ng_component_scss", 0x100229 ), ( "file_type_ng_component_ts2", 0x10022A ), ( "file_type_ng_component_ts", 0x10022B ), ( "file_type_ng_controller_js", 0x10022C ), ( "file_type_ng_controller_ts", 0x10022D ), ( "file_type_ng_directive_dart", 0x10022E ), ( "file_type_ng_directive_js2", 0x10022F ), ( "file_type_ng_directive_js", 0x100230 ), ( "file_type_ng_directive_ts2", 0x100231 ), ( "file_type_ng_directive_ts", 0x100232 ), ( "file_type_ng_guard_dart", 0x100233 ), ( "file_type_ng_guard_js", 0x100234 ), ( "file_type_ng_guard_ts", 0x100235 ), ( "file_type_ng_interceptor_dart", 0x100236 ), ( "file_type_ng_interceptor_js", 0x100237 ), ( "file_type_ng_interceptor_ts", 0x100238 ), ( "file_type_nginx", 0x100239 ), ( "file_type_ng_module_dart", 0x10023A ), ( "file_type_ng_module_js2", 0x10023B ), ( "file_type_ng_module_js", 0x10023C ), ( "file_type_ng_module_ts2", 0x10023D ), ( "file_type_ng_module_ts", 0x10023E ), ( "file_type_ng_pipe_dart", 0x10023F ), ( "file_type_ng_pipe_js2", 0x100240 ), ( "file_type_ng_pipe_js", 0x100241 ), ( "file_type_ng_pipe_ts2", 0x100242 ), ( "file_type_ng_pipe_ts", 0x100243 ), ( "file_type_ng_routing_dart", 0x100244 ), ( "file_type_ng_routing_js2", 0x100245 ), ( "file_type_ng_routing_js", 0x100246 ), ( "file_type_ng_routing_ts2", 0x100247 ), ( "file_type_ng_routing_ts", 0x100248 ), ( "file_type_ng_service_dart", 0x100249 ), ( "file_type_ng_service_js2", 0x10024A ), ( "file_type_ng_service_js", 0x10024B ), ( "file_type_ng_service_ts2", 0x10024C ), ( "file_type_ng_service_ts", 0x10024D ), ( "file_type_ng_smart_component_dart", 0x10024E ), ( "file_type_ng_smart_component_js2", 0x10024F ), ( "file_type_ng_smart_component_js", 0x100250 ), ( "file_type_ng_smart_component_ts2", 0x100251 ), ( "file_type_ng_smart_component_ts", 0x100252 ), ( "file_type_ng_tailwind", 0x100253 ), ( "file_type_nimble", 0x100254 ), ( "file_type_nim", 0x100255 ), ( "file_type_ninja", 0x100256 ), ( "file_type_nix", 0x100257 ), ( "file_type_njsproj", 0x100258 ), ( "file_type_node2", 0x100259 ), ( "file_type_nodemon", 0x10025A ), ( "file_type_node", 0x10025B ), ( "file_type_npm", 0x10025C ), ( "file_type_nsi", 0x10025D ), ( "file_type_nsri-integrity", 0x10025E ), ( "file_type_nsri", 0x10025F ), ( "file_type_nuget", 0x100260 ), ( "file_type_numpy", 0x100261 ), ( "file_type_nunjucks", 0x100262 ), ( "file_type_nushell", 0xf07c6 ), ( "file_type_nuxt", 0x100263 ), ( "file_type_nyc", 0x100264 ), ( "file_type_objectivecpp", 0x100265 ), ( "file_type_objectivec", 0x100266 ), ( "file_type_ocaml", 0x100267 ), ( "file_type_onenote", 0x100268 ), ( "file_type_opencl", 0x100269 ), ( "file_type_openHAB", 0x10026A ), ( "file_type_org", 0x10026B ), ( "file_type_outlook", 0x10026C ), ( "file_type_ovpn", 0x10026D ), ( "file_type_package", 0x10026E ), ( "file_type_paket", 0x10026F ), ( "file_type_patch", 0x100270 ), ( "file_type_pcl", 0x100271 ), ( "file_type_pddl_happenings", 0x100272 ), ( "file_type_pddl_plan", 0x100273 ), ( "file_type_pddl", 0x100274 ), ( "file_type_pdf2", 0x100275 ), ( "file_type_pdf", 0x100276 ), ( "file_type_perl2", 0x100277 ), ( "file_type_perl6", 0x100278 ), ( "file_type_perl", 0x100279 ), ( "file_type_pgsql", 0x10027A ), ( "file_type_photoshop2", 0x10027B ), ( "file_type_photoshop", 0x10027C ), ( "file_type_php2", 0x10027D ), ( "file_type_php3", 0x10027E ), ( "file_type_phpcsfixer", 0x10027F ), ( "file_type_php", 0x100280 ), ( "file_type_phpunit", 0x100281 ), ( "file_type_phraseapp", 0x100282 ), ( "file_type_pine", 0x100283 ), ( "file_type_pip", 0x100284 ), ( "file_type_plantuml", 0x100285 ), ( "file_type_platformio", 0x100286 ), ( "file_type_plsql_package_body", 0x100287 ), ( "file_type_plsql_package_header", 0x100288 ), ( "file_type_plsql_package_spec", 0x100289 ), ( "file_type_plsql_package", 0x10028A ), ( "file_type_plsql", 0x10028B ), ( "file_type_pnpm", 0x10028C ), ( "file_type_poedit", 0x10028D ), ( "file_type_polymer", 0x10028E ), ( "file_type_pony", 0x10028F ), ( "file_type_postcssconfig", 0x100290 ), ( "file_type_postcss", 0x100291 ), ( "file_type_powerpoint2", 0x100292 ), ( "file_type_powerpoint", 0x100293 ), ( "file_type_powershell2", 0x100294 ), ( "file_type_powershell_format", 0x100295 ), ( "file_type_powershell_psd2", 0x100296 ), ( "file_type_powershell_psd", 0x100297 ), ( "file_type_powershell_psm2", 0x100298 ), ( "file_type_powershell_psm", 0x100299 ), ( "file_type_powershell", 0x10029A ), ( "file_type_powershell_types", 0x10029B ), ( "file_type_precommit", 0x10029C ), ( "file_type_prettier", 0x10029D ), ( "file_type_prisma", 0x10029E ), ( "file_type_processinglang", 0x10029F ), ( "file_type_procfile", 0x1002A0 ), ( "file_type_progress", 0x1002A1 ), ( "file_type_prolog", 0x1002A2 ), ( "file_type_prometheus", 0x1002A3 ), ( "file_type_protobuf", 0x1002A4 ), ( "file_type_protractor", 0x1002A5 ), ( "file_type_publisher", 0x1002A6 ), ( "file_type_pug", 0x1002A7 ), ( "file_type_puppet", 0x1002A8 ), ( "file_type_purescript", 0x1002A9 ), ( "file_type_pyret", 0x1002AA ), ( "file_type_python", 0x1002AB ), ( "file_type_pyup", 0x1002AC ), ( "file_type_qbs", 0x1002AD ), ( "file_type_qlikview", 0x1002AE ), ( "file_type_qmldir", 0x1002AF ), ( "file_type_qml", 0x1002B0 ), ( "file_type_qsharp", 0x1002B1 ), ( "file_type_q", 0x1002B2 ), ( "file_type_quasar", 0x1002B3 ), ( "file_type_racket", 0x1002B4 ), ( "file_type_rails", 0x1002B5 ), ( "file_type_rake", 0x1002B6 ), ( "file_type_raml", 0x1002B7 ), ( "file_type_razor", 0x1002B8 ), ( "file_type_razzle", 0x1002B9 ), ( "file_type_reactjs", 0x1002BA ), ( "file_type_reacttemplate", 0x1002BB ), ( "file_type_reactts", 0x1002BC ), ( "file_type_reason", 0x1002BD ), ( "file_type_red", 0x1002BE ), ( "file_type_registry", 0x1002BF ), ( "file_type_rego", 0x1002C0 ), ( "file_type_rehype", 0x1002C1 ), ( "file_type_remark", 0x1002C2 ), ( "file_type_renovate", 0x1002C3 ), ( "file_type_rescript", 0x1002C4 ), ( "file_type_rest", 0x1002C5 ), ( "file_type_retext", 0x1002C6 ), ( "file_type_rexx", 0x1002C7 ), ( "file_type_riot", 0x1002C8 ), ( "file_type_rmd", 0x1002C9 ), ( "file_type_robotframework", 0x1002CA ), ( "file_type_robots", 0x1002CB ), ( "file_type_rollup", 0x1002CC ), ( "file_type_rproj", 0x1002CD ), ( "file_type_rspec", 0x1002CE ), ( "file_type_r", 0x1002CF ), ( "file_type_rubocop", 0x1002D0 ), ( "file_type_ruby", 0x1002D1 ), ( "file_type_rust", 0x1002D2 ), ( "file_type_rust_toolchain", 0x1002D3 ), ( "file_type_sails", 0x1002D4 ), ( "file_type_saltstack", 0x1002D5 ), ( "file_type_san", 0x1002D6 ), ( "file_type_sass", 0x1002D7 ), ( "file_type_sas", 0x1002D8 ), ( "file_type_sbt", 0x1002D9 ), ( "file_type_scala", 0x1002DA ), ( "file_type_scilab", 0x1002DB ), ( "file_type_script", 0x1002DC ), ( "file_type_scss2", 0x1002DD ), ( "file_type_scss", 0x1002DE ), ( "file_type_sdlang", 0x1002DF ), ( "file_type_sentry", 0x1002E0 ), ( "file_type_sequelize", 0x1002E1 ), ( "file_type_serverless", 0x1002E2 ), ( "file_type_shaderlab", 0x1002E3 ), ( "file_type_shell", 0x1002E4 ), ( "file_type_silverstripe", 0x1002E5 ), ( "file_type_sketch", 0x1002E6 ), ( "file_type_skipper", 0x1002E7 ), ( "file_type_slang", 0x1002E8 ), ( "file_type_slice", 0x1002E9 ), ( "file_type_slim", 0x1002EA ), ( "file_type_sln2", 0x1002EB ), ( "file_type_sln", 0x1002EC ), ( "file_type_smarty", 0x1002ED ), ( "file_type_snapcraft", 0x1002EE ), ( "file_type_snort", 0x1002EF ), ( "file_type_snyk", 0x1002F0 ), ( "file_type_solidarity", 0x1002F1 ), ( "file_type_solidity", 0x1002F2 ), ( "file_type_source", 0x1002F3 ), ( "file_type_spacengine", 0x1002F4 ), ( "file_type_sqf", 0x1002F5 ), ( "file_type_sqlite", 0x1002F6 ), ( "file_type_sql", 0x1002F7 ), ( "file_type_squirrel", 0x1002F8 ), ( "file_type_sss", 0x1002F9 ), ( "file_type_stan", 0x1002FA ), ( "file_type_stata", 0x1002FB ), ( "file_type_stencil", 0x1002FC ), ( "file_type_storyboard", 0x1002FD ), ( "file_type_storybook", 0x1002FE ), ( "file_type_stylable", 0x1002FF ), ( "file_type_styled", 0x100300 ), ( "file_type_stylelint", 0x100301 ), ( "file_type_style", 0x100302 ), ( "file_type_stylish_haskell", 0x100303 ), ( "file_type_stylus", 0x100304 ), ( "file_type_subversion", 0x100305 ), ( "file_type_svelte", 0x100306 ), ( "file_type_svg", 0x100307 ), ( "file_type_swagger", 0x100308 ), ( "file_type_swift", 0x100309 ), ( "file_type_swig", 0x10030A ), ( "file_type_symfony", 0x10030B ), ( "file_type_systemd", 0x10030C ), ( "file_type_systemverilog", 0x10030D ), ( "file_type_t4tt", 0x10030E ), ( "file_type_tailwind", 0x10030F ), ( "file_type_tcl", 0x100310 ), ( "file_type_tera", 0x100311 ), ( "file_type_terraform", 0x100312 ), ( "file_type_testcafe", 0x100313 ), ( "file_type_testjs", 0x100314 ), ( "file_type_test", 0x100315 ), ( "file_type_testts", 0x100316 ), ( "file_type_tex", 0x100317 ), ( "file_type_textile", 0x100318 ), ( "file_type_text", 0x100319 ), ( "file_type_tfs", 0x10031A ), ( "file_type_todo", 0x10031B ), ( "file_type_toml", 0x10031C ), ( "file_type_tox", 0x10031D ), ( "file_type_travis", 0x10031E ), ( "file_type_tsconfig", 0x10031F ), ( "file_type_tslint", 0x100320 ), ( "file_type_ttcn", 0x100321 ), ( "file_type_tt", 0x100322 ), ( "file_type_twig", 0x100323 ), ( "file_type_typescriptdef_official", 0x100324 ), ( "file_type_typescriptdef", 0x100325 ), ( "file_type_typescript_official", 0x100326 ), ( "file_type_typescript", 0x100327 ), ( "file_type_typo3", 0x100328 ), ( "file_type_unibeautify", 0x100329 ), ( "file_type_vagrant", 0x10032A ), ( "file_type_vala", 0x10032B ), ( "file_type_vapi", 0x10032C ), ( "file_type_vash", 0x10032D ), ( "file_type_vba", 0x10032E ), ( "file_type_vbhtml", 0x10032F ), ( "file_type_vbproj", 0x100330 ), ( "file_type_vb", 0x100331 ), ( "file_type_vcxproj", 0x100332 ), ( "file_type_velocity", 0x100333 ), ( "file_type_verilog", 0x100334 ), ( "file_type_vhdl", 0x100335 ), ( "file_type_video", 0x100336 ), ( "file_type_view", 0x100337 ), ( "file_type_vim", 0x100338 ), ( "file_type_vlang", 0x100339 ), ( "file_type_volt", 0x10033A ), ( "file_type_vscode2", 0x10033B ), ( "file_type_vscode3", 0x10033C ), ( "file_type_vscode-insiders", 0x10033D ), ( "file_type_vscode", 0x10033E ), ( "file_type_vsixmanifest", 0x10033F ), ( "file_type_vsix", 0x100340 ), ( "file_type_vueconfig", 0x100341 ), ( "file_type_vue", 0x100342 ), ( "file_type_wallaby", 0x100343 ), ( "file_type_wasm", 0x100344 ), ( "file_type_watchmanconfig", 0x100345 ), ( "file_type_webpack", 0x100346 ), ( "file_type_webp", 0x100347 ), ( "file_type_wenyan", 0x100348 ), ( "file_type_wercker", 0x100349 ), ( "file_type_wolfram", 0x10034A ), ( "file_type_word2", 0x10034B ), ( "file_type_word", 0x10034C ), ( "file_type_wpml", 0x10034D ), ( "file_type_wurst", 0x10034E ), ( "file_type_wxml", 0x10034F ), ( "file_type_wxss", 0x100350 ), ( "file_type_xcode", 0x100351 ), ( "file_type_xfl", 0x100352 ), ( "file_type_xib", 0x100353 ), ( "file_type_xliff", 0x100354 ), ( "file_type_xmake", 0x100355 ), ( "file_type_xml", 0x100356 ), ( "file_type_xquery", 0x100357 ), ( "file_type_xsl", 0x100358 ), ( "file_type_yacc", 0x100359 ), ( "file_type_yamllint", 0x10035A ), ( "file_type_yaml", 0x10035B ), ( "file_type_yandex", 0x10035C ), ( "file_type_yang", 0x10035D ), ( "file_type_yarn", 0x10035E ), ( "file_type_yeoman", 0x10035F ), ( "file_type_zeit", 0x100360 ), ( "file_type_zig", 0x100361 ), ( "file_type_zip2", 0x100362 ), ( "file_type_zip", 0x100363 ), ] ================================================ FILE: resources/syntect/README.md ================================================ Broot uses [syntect](https://crates.io/crates/syntect) for syntax highlighting in text files. This (excellent) library needs Sublime Text syntax definitions for all languages (when a language definition isn't found, Broot displays the text monochrome). Syntect doesn't come with an extensive set of definitions. The [bat](https://github.com/sharkdp/bat) project maintains with care an important list of such definitions (most as submodules, some with patches). It's the best public list I found, so I've included the resulting syntax set here as `syntaxes.bin`. You may replace this file with your own, building it with Syntect's [`syntect::dumps::dump_to_uncompressed_file`](https://docs.rs/syntect/latest/syntect/dumps/fn.dump_to_uncompressed_file.html) function. ================================================ FILE: rustfmt.toml ================================================ edition = "2021" version = "Two" imports_layout = "Vertical" fn_params_layout = "Vertical" newline_style = "Unix" ================================================ FILE: src/app/app.rs ================================================ use { super::*, crate::{ browser::BrowserState, cli::TriBool, command::{ Command, Sequence, }, conf::Conf, display::*, errors::ProgramError, file_sum, git, kitty, launchable::Launchable, path::closest_dir, pattern::InputPattern, preview::PreviewState, skin::*, syntactic::SyntaxTheme, task_sync::{ Dam, Either, }, terminal, verb::Internal, watcher::Watcher, }, crokey::crossterm::event::Event, std::{ io::Write, path::PathBuf, str::FromStr, sync::{ Arc, Mutex, }, }, termimad::{ EventSource, EventSourceOptions, crossbeam::channel::{ Receiver, Sender, unbounded, }, }, }; /// The GUI pub struct App { /// the panels of the application, with their inputs panels: AppPanelsAndInputs, /// whether the app is in the (uncancellable) process of quitting quitting: bool, /// what must be done after having closed the TUI launch_at_end: Option, /// an optional copy of the root for the --server shared_root: Option>>, /// sender to the sequence channel tx_seqs: Sender, /// receiver to listen to the sequence channel rx_seqs: Receiver, /// a watcher for notify events watcher: Watcher, } impl App { pub fn new(con: &AppContext) -> Result { let mut panels = AppPanelsAndInputs::new(con)?; if let Some(path) = con.initial_file.as_ref() { // open initial_file in preview let preview_state = Box::new(PreviewState::new( path.clone(), InputPattern::none(), None, con.initial_tree_options.clone(), con, )); if let Err(err) = panels.new_panel( preview_state, PanelPurpose::Preview, HDir::Right, true, // activate con, ) { warn!("could not open preview: {err}"); } } let (tx_seqs, rx_seqs) = unbounded::(); let watcher = Watcher::new(tx_seqs.clone()); Ok(Self { panels, quitting: false, launch_at_end: None, shared_root: None, tx_seqs, rx_seqs, watcher, }) } /// apply a command. Change the states but don't redraw on screen. fn apply_command( &mut self, w: &mut W, cmd: &Command, panel_skin: &PanelSkin, app_state: &mut AppState, con: &mut AppContext, ) -> Result<(), ProgramError> { info!("app applying command: {:?}", &cmd); let is_input_invocation = cmd.is_verb_invocated_from_input(); let cmd_result = self .panels .apply_command(w, cmd, None, panel_skin, app_state, con)?; debug!("cmd_result: {:?}", &cmd_result); let mut error: Option = None; let mut new_active_panel_idx = None; match cmd_result { CmdResult::ApplyOnPanel { id } => { let aop_cmd_result = self.panels.apply_command( w, cmd, Some(PanelReference::Id(id)), panel_skin, app_state, con, )?; if let CmdResult::DisplayError(txt) = aop_cmd_result { // we should probably handle other results // which implies the possibility of a recursion error = Some(txt); } else if is_input_invocation { self.panels.clear_input(); } } CmdResult::ClosePanel { validate_purpose, panel_ref, clear_cache, } => { if is_input_invocation { self.panels.clear_input_invocation(con); } let close_idx = self.panels.idx_by_ref(panel_ref) .unwrap_or_else(|| // when there's a preview panel, we close it rather than the app if self.panels.len()==2 && self.panels.has_preview_panel() { 1 } else { self.panels.active_panel_idx() } ); let mut new_arg = None; if validate_purpose { let purpose = &self.panels.panel_by_idx_unchecked(close_idx).purpose; if let PanelPurpose::ArgEdition { .. } = purpose { new_arg = self .panels .panel_by_idx_unchecked(close_idx) .state() .selected_path() .map(|p| p.to_string_lossy().to_string()); } } if clear_cache { clear_caches(); } if self.panels.close(close_idx, con) { let screen = self.panels.screen(); self.panels.refresh_active_panel(con); if let Some(new_arg) = new_arg { self.panels.set_input_arg(new_arg); let new_input = self.panels.get_input_content(); let cmd = Command::from_raw(new_input, false); let app_cmd_context = AppCmdContext { panel_skin, preview_panel: self.panels.preview_panel_id(), stage_panel: self.panels.stage_panel_id(), screen, con, }; self.panels.mut_panel().apply_command( w, &cmd, app_state, &app_cmd_context, )?; } } else { self.quitting = true; } } CmdResult::ChangeLayout(instruction) => { con.layout_instructions.push(instruction); self.panels.resize_all(con); } CmdResult::DisplayError(txt) => { error = Some(txt); } CmdResult::ExecuteSequence { sequence } => { if is_input_invocation { self.panels.clear_input(); } self.tx_seqs.send(sequence).unwrap(); } CmdResult::HandleInApp(internal) => { debug!("handling internal {internal:?} at app level"); match internal { Internal::escape => { let mode = self.panels.state().get_mode(); let cmd = self.panels.do_input_escape(mode, con); debug!("cmd on escape: {cmd:?}"); self.apply_command(w, &cmd, panel_skin, app_state, con)?; } Internal::focus_staging_area_no_open => { self.panels.focus_by_type(PanelStateType::Stage); } Internal::focus_panel_left => { let len = self.panels.len(); new_active_panel_idx = Some((self.panels.active_panel_idx() + len - 1) % len); } Internal::focus_panel_right => { let len = self.panels.len(); new_active_panel_idx = Some((self.panels.active_panel_idx() + 1) % len); } Internal::panel_left_no_open => { // we're here because the state wants us to either move to the panel // to the left, or close the rightest one new_active_panel_idx = if self.panels.active_panel_idx() == 0 { self.panels.close(self.panels.len() - 1, con); None } else { Some(self.panels.active_panel_idx() - 1) }; } Internal::panel_right_no_open => { // we either move to the right or close the leftest panel new_active_panel_idx = if self.panels.active_panel_idx() + 1 == self.panels.len() { self.panels.close(0, con); None } else { Some(self.panels.active_panel_idx() + 1) }; } Internal::search_again => { if let Some(raw_pattern) = &self.panels.panel().last_raw_pattern { let sequence = Sequence::new_single(raw_pattern.clone()); self.tx_seqs.send(sequence).unwrap(); } } Internal::set_syntax_theme => { let arg = cmd.as_verb_invocation().and_then(|vi| vi.args.as_ref()); match arg { Some(arg) => match SyntaxTheme::from_str(arg) { Ok(theme) => { con.syntax_theme = Some(theme); self.panels.update_preview(true, con); } Err(e) => { error = Some(e.to_string()); } }, None => { error = Some("no theme provided".to_string()); } } } Internal::toggle_second_tree => { let panels_count = self.panels.len(); let trees_count = self.panels.count_of_type(PanelStateType::Tree); if trees_count < 2 { // we open a tree, closing a (non tree) panel if necessary if panels_count >= con.max_panels_count { self.panels.close_first_non_tree(con); } if let Some(selected_path) = self.panels.state().selected_path() { let dir = closest_dir(selected_path); let screen = self.panels.screen(); if let Ok(new_state) = BrowserState::new( dir, self.panels.state().tree_options().without_pattern(), screen, con, &Dam::unlimited(), ) { if let Err(s) = self.panels.new_panel( Box::new(new_state), PanelPurpose::None, HDir::Right, is_input_invocation, con, ) { error = Some(s); } } } } else { self.panels.close_rightest_inactive_tree(con); } } Internal::toggle_watch => { app_state.watch_tree ^= true; if is_input_invocation { self.panels.clear_input_invocation(con); } } _ => { let cmd = self.panels.on_input_internal(internal); if cmd.is_none() { warn!( "unhandled propagated internal. internal={internal:?} cmd={cmd:?}" ); } else { self.apply_command(w, &cmd, panel_skin, app_state, con)?; } } } } CmdResult::Keep => { if is_input_invocation { self.panels.clear_input_invocation(con); } } CmdResult::Message(md) => { if is_input_invocation { self.panels.clear_input_invocation(con); } self.panels.mut_panel().set_message(md); } CmdResult::Launch(launchable) => { self.launch_at_end = Some(*launchable); self.quitting = true; } CmdResult::NewPanel { state, purpose, direction, } => { if let Err(s) = self.panels .new_panel(state, purpose, direction, is_input_invocation, con) { error = Some(s); } } CmdResult::NewState { state, message } => { self.panels.clear_input(); self.panels.push_state(state); if let Some(md) = message { self.panels.mut_panel().set_message(md); } else { self.panels.refresh_input_status(app_state, panel_skin, con); } } CmdResult::PopState => { if is_input_invocation { self.panels.clear_input(); } if self.panels.remove_state(con) { let screen = self.panels.screen(); self.panels.mut_state().refresh(screen, con); self.panels.refresh_input_status(app_state, panel_skin, con); } else if con.quit_on_last_cancel { self.quitting = true; } } CmdResult::PopStateAndReapply => { if is_input_invocation { self.panels.clear_input(); } if self.panels.remove_state(con) { self.panels.apply_command( w, cmd, None, // active panel panel_skin, app_state, con, )?; } else if con.quit_on_last_cancel { self.quitting = true; } } CmdResult::Quit => { self.quitting = true; } CmdResult::RefreshState { clear_cache } => { info!("refreshing, clearing cache={clear_cache}"); if is_input_invocation { self.panels.clear_input_invocation(con); } if clear_cache { clear_caches(); } app_state.stage.refresh(); self.panels.refresh_all_panels(con); } } if let Some(text) = error { self.panels.mut_panel().set_error(text); } if let Some(idx) = new_active_panel_idx { debug!("activating panel idx {idx}"); if is_input_invocation { self.panels.clear_input(); } self.panels.activate(idx); self.panels.refresh_input_status(app_state, panel_skin, con); } app_state.other_panel_path = self.panels.get_other_panel_path(); if let Some(path) = self.panels.state().tree_root() { app_state.root = path.to_path_buf(); terminal::update_title(w, app_state, con); if con.update_work_dir { if let Err(e) = std::env::set_current_dir(&app_state.root) { warn!("Failed to set current dir: {e}"); } } if let Some(shared_root) = &mut self.shared_root { if let Ok(mut root) = shared_root.lock() { root.clone_from(&app_state.root); } } } self.panels.update_preview(false, con); Ok(()) } /// This is the main loop of the application pub fn run( mut self, w: &mut W, con: &mut AppContext, conf: &Conf, ) -> Result, ProgramError> { #[cfg(feature = "clipboard")] { // different systems have different clipboard capabilities // and it may be useful to know which one we have debug!("Clipboard backend: {:?}", terminal_clipboard::get_type()); } // we listen for events in a separate thread so that we can go on listening // when a long search is running, and interrupt it if needed w.flush()?; let combine_keys = conf.enable_kitty_keyboard.unwrap_or(false) && con.is_tty; let event_source = EventSource::with_options(EventSourceOptions { combine_keys, ..Default::default() })?; con.keyboard_enhanced = event_source.supports_multi_key_combinations(); info!( "event source is combining: {}", event_source.supports_multi_key_combinations() ); let rx_events = event_source.receiver(); let mut dam = Dam::from(rx_events); let skin = AppSkin::new(conf, con.launch_args.color == TriBool::No); let mut app_state = AppState::new(&con.initial_root); terminal::update_title(w, &app_state, con); self.panels .screen() .clear_bottom_right_char(w, &skin.focused)?; #[cfg(windows)] if con.cmd().is_some() { // Powershell sends to broot a resize event after it was launched // which interrupts its task queue. An easy fix is to wait for a // few ms for the terminal to be stabilized. // It's possible some other terminals, even not on Windows, might // need the same trick in the future let delay = std::time::Duration::from_millis(10); std::thread::sleep(delay); let dropped_events = dam.clear(); debug!("Dropped {dropped_events} events"); event_source.unblock(self.quitting); } if let Some(raw_sequence) = &con.cmd() { self.tx_seqs .send(Sequence::new_local((*raw_sequence).to_string())) .map_err(|e| ProgramError::Internal { details: format!("failed to send initial command: {e}"), })?; } #[cfg(unix)] let _server = con .server_name .as_ref() .map(|server_name| { let shared_root = Arc::new(Mutex::new(app_state.root.clone())); let server = crate::net::Server::new( server_name, self.tx_seqs.clone(), Arc::clone(&shared_root), ); self.shared_root = Some(shared_root); server }) .transpose()?; loop { if !self.quitting { self.panels.display_panels(w, &skin, &app_state, con)?; time!( Debug, "pending_tasks", self.panels .do_pending_tasks(w, &skin, &mut dam, &mut app_state, con)?, ); } // before starting to wait for events, we enable the watcher if needed if app_state.watch_tree { let paths = self.panels.state().watchable_paths(); if let Err(e) = self.watcher.watch(paths) { // errors aren't uncommon, especially on huge directories warn!("Failed to watch tree: {e}"); // we disable watching app_state.watch_tree = false; } } let event = dam.next(&self.rx_seqs); if app_state.watch_tree { // we must unwatch before applying the command, as it will probably do many system // calls that would trigger events self.watcher.stop_watching()?; } #[allow(unused_mut)] match event { Either::First(Some(event)) => { info!("<-- event: {:?}", &event); if let Some(key_combination) = event.key_combination { info!("key combination: {key_combination}"); } let mut handled = false; // app level handling if let Some((x, y)) = event.as_click() { let clicked_idx = self.panels.clicked_panel_index(x, y); if clicked_idx != self.panels.active_panel_idx() { // panel activation click self.panels.activate(clicked_idx); handled = true; } } else if let Event::Resize(mut width, mut height) = event.event { self.panels.set_terminal_size(width, height, con); handled = true; } // event handled by the panel if !handled { let cmd = self.panels.on_input_event(w, &event, &app_state, con)?; info!("command from panels.on_input_event: {:#?}", &cmd); self.apply_command(w, &cmd, &skin.focused, &mut app_state, con)?; } event_source.unblock(self.quitting); } Either::First(None) => { // this is how we quit the application, // when the input thread is properly closed break; } Either::Second(Some(sequence)) => { info!("got command sequence: {:?}", &sequence); for (input, arg_cmd) in sequence.parse(con)? { if !matches!(&arg_cmd, Command::Internal { .. }) { self.panels.input().set_content(&input); } self.apply_command(w, &arg_cmd, &skin.focused, &mut app_state, con)?; if self.quitting { return Ok(self.launch_at_end.take()); } self.panels.display_panels(w, &skin, &app_state, con)?; time!( "sequence pending tasks", self.panels.do_pending_tasks( w, &skin, &mut dam, &mut app_state, con )?, ); } } Either::Second(None) => { warn!("I didn't expect a None to occur here"); } } } terminal::reset_title(w, con); if let Ok(mut manager) = kitty::manager().lock() { manager.erase_images_before(w, usize::MAX)?; } w.flush()?; Ok(self.launch_at_end.take()) } } /// clear the file sizes and git stats cache. /// /// This should be done on Refresh actions and after any external command. fn clear_caches() { file_sum::clear_cache(); git::clear_status_computer_cache(); #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] crate::filesystems::clear_cache(); } ================================================ FILE: src/app/app_context.rs ================================================ use { super::*, crate::{ app::Mode, cli::{ Args, TriBool, }, conf::*, content_search, display::LayoutInstructions, errors::*, file_sum, icon::*, kitty::{ KittyGraphicsDisplay, TransmissionMedium, }, path::SpecialPaths, pattern::SearchModeMap, preview::PreviewTransformers, skin::ExtColorMap, syntactic::SyntaxTheme, tree::TreeOptions, verb::*, }, crokey::crossterm::tty::IsTty, std::{ convert::{ TryFrom, TryInto, }, io, num::NonZeroUsize, path::{ Path, PathBuf, }, }, }; /// The container that can be passed around to provide the configuration things /// for the whole life of the App pub struct AppContext { /// Whether the application is running in a normal TTY context pub is_tty: bool, /// The initial tree root pub initial_root: PathBuf, /// The initial file to select and preview pub initial_file: Option, /// Initial tree options pub initial_tree_options: TreeOptions, /// where's the config file we're using /// This vec can't be empty pub config_paths: Vec, /// all the arguments specified at launch pub launch_args: Args, /// the "launch arguments" found in the `default_flags` /// of the config file(s) pub config_default_args: Option, /// the verbs in use (builtins and configured ones) pub verb_store: VerbStore, /// the paths for which there's a special behavior to follow (comes from conf) pub special_paths: SpecialPaths, /// the map between search prefixes and the search mode to apply pub search_modes: SearchModeMap, /// whether to show a triangle left to selected lines pub show_selection_mark: bool, /// mapping from file extension to colors (comes from conf) pub ext_colors: ExtColorMap, /// the syntect theme to use for text files previewing pub syntax_theme: Option, /// precomputed status to display in standard cases /// (ie when no verb is involved) pub standard_status: StandardStatus, /// whether we can use 24 bits colors for previewed images pub true_colors: bool, /// map extensions to icons, icon set chosen based on config /// Send, Sync safely because once created, everything is immutable pub icons: Option>, /// modal (aka "vim) mode enabled pub modal: bool, /// the initial mode (only relevant when modal is true) pub initial_mode: Mode, /// Whether to support mouse interactions pub capture_mouse: bool, /// max number of panels (including preview) that can be /// open. Guaranteed to be at least 2. pub max_panels_count: usize, /// whether to quit broot when the user hits "escape" /// and there's nothing to cancel pub quit_on_last_cancel: bool, /// number of threads used by `file_sum` (count, size, date) computation pub file_sum_threads_count: usize, /// number of files which may be staged in one staging operation pub max_staged_count: usize, /// whether to automatically open the staging area panel when staging /// a file pub auto_open_staging_area: bool, /// max file size when searching file content pub content_search_max_file_size: usize, /// the optional pattern used to change the terminal's title /// (if none, the title isn't modified) pub terminal_title_pattern: Option, /// whether to reset the terminal's title on exit pub reset_terminal_title_on_exit: bool, /// whether to sync broot's work dir with the current panel's root pub update_work_dir: bool, /// Whether Kitty keyboard enhancement flags are pushed, so that /// we know whether we need to temporarily disable them during /// the execution of a terminal program. /// This is determined by `app::run` on launching the event source. pub keyboard_enhanced: bool, pub kitty_graphics_transmission: TransmissionMedium, /// How Kitty images are displayed pub kitty_graphics_display: KittyGraphicsDisplay, pub kept_kitty_temp_files: NonZeroUsize, /// Number of lines to display after a match in the preview pub lines_after_match_in_preview: usize, /// Number of lines to display before a match in the preview pub lines_before_match_in_preview: usize, /// The set of transformers called before previewing a file pub preview_transformers: PreviewTransformers, /// layout modifiers, like divider moves pub layout_instructions: LayoutInstructions, /// server name pub server_name: Option, } impl AppContext { pub fn from( launch_args: Args, verb_store: VerbStore, config: &Conf, ) -> Result { let is_tty = std::io::stdout().is_tty(); let config_default_args = config .default_flags .as_ref() .map(|flags| parse_default_flags(flags)) .transpose()?; let config_paths = config.files.clone(); let standard_status = StandardStatus::new(&verb_store); let true_colors = if let Some(value) = config.true_colors { value } else { are_true_colors_available() }; let icons = config.icon_theme.as_ref().and_then(|itn| icon_plugin(itn)); let mut special_paths: SpecialPaths = (&config.special_paths).try_into()?; special_paths.add_defaults(); let search_modes = config .search_modes .as_ref() .map(TryInto::try_into) .transpose()? .unwrap_or_default(); let ext_colors = ExtColorMap::try_from(&config.ext_colors).map_err(ConfError::from)?; let file_sum_threads_count = config .file_sum_threads_count .unwrap_or(file_sum::DEFAULT_THREAD_COUNT); if !(1..=50).contains(&file_sum_threads_count) { return Err(ConfError::InvalidThreadsCount { count: file_sum_threads_count, } .into()); } let max_panels_count = config.max_panels_count.unwrap_or(2).clamp(2, 100); let capture_mouse = match (config.capture_mouse, config.disable_mouse_capture) { (Some(b), _) => b, // the new "capture_mouse" argument takes precedence (_, Some(b)) => !b, _ => true, }; let max_staged_count = config.max_staged_count.unwrap_or(10_000).clamp(10, 100_000); let auto_open_staging_area = config.auto_open_staging_area.unwrap_or(true); let (initial_root, initial_file) = initial_root_file(&launch_args)?; // tree options are built from the default_flags // found in the config file(s) (if any) then overridden // by the cli args (order is important) let mut initial_tree_options = TreeOptions::default(); initial_tree_options.apply_config(config)?; if let Some(args) = &config_default_args { initial_tree_options.apply_launch_args(args); } initial_tree_options.apply_launch_args(&launch_args); if launch_args.color == TriBool::No { initial_tree_options.show_selection_mark = true; } let content_search_max_file_size = config .content_search_max_file_size .map(|u64value| usize::try_from(u64value).unwrap_or(usize::MAX)) .unwrap_or(content_search::DEFAULT_MAX_FILE_SIZE); let terminal_title_pattern = config.terminal_title.clone(); let reset_terminal_title_on_exit = config.reset_terminal_title_on_exit.unwrap_or(false); let preview_transformers = PreviewTransformers::new(&config.preview_transformers)?; let layout_instructions = config.layout_instructions.clone().unwrap_or_default(); let kept_kitty_temp_files = config.kept_kitty_temp_files.unwrap_or( #[expect(clippy::missing_panics_doc, reason = "infallible")] std::num::NonZeroUsize::new(500).unwrap(), ); let server_name = build_server_name(&launch_args) .or_else(|| build_server_name(config_default_args.as_ref()?)); Ok(Self { is_tty, initial_root, initial_file, initial_tree_options, config_paths, launch_args, config_default_args, verb_store, special_paths, search_modes, show_selection_mark: config.show_selection_mark.unwrap_or(false), ext_colors, syntax_theme: config.syntax_theme, standard_status, true_colors, icons, modal: config.modal.unwrap_or(false), initial_mode: config.initial_mode.unwrap_or(Mode::Command), capture_mouse, max_panels_count, quit_on_last_cancel: config.quit_on_last_cancel.unwrap_or(false), file_sum_threads_count, max_staged_count, auto_open_staging_area, content_search_max_file_size, terminal_title_pattern, reset_terminal_title_on_exit, update_work_dir: config.update_work_dir.unwrap_or(true), keyboard_enhanced: false, kitty_graphics_transmission: config.kitty_graphics_transmission.unwrap_or_default(), kitty_graphics_display: config.kitty_graphics_display.unwrap_or_default(), kept_kitty_temp_files, lines_after_match_in_preview: config.lines_after_match_in_preview.unwrap_or(0), lines_before_match_in_preview: config.lines_before_match_in_preview.unwrap_or(0), preview_transformers, layout_instructions, server_name, }) } /// Return the --cmd argument, coming from the launch arguments (preferred) /// or from the `default_flags` parameter of a config file pub fn cmd(&self) -> Option<&str> { self.launch_args .cmd .as_ref() .or(self .config_default_args .as_ref() .and_then(|args| args.cmd.as_ref())) .map(String::as_str) } #[must_use] pub fn initial_mode(&self) -> Mode { if self.modal { self.initial_mode } else { Mode::Input } } } /// An unsafe implementation of Default, for tests only #[cfg(test)] impl Default for AppContext { fn default() -> Self { let mut config = Conf::default(); let verb_store = VerbStore::new(&mut config).unwrap(); let launch_args = parse_default_flags("").unwrap(); Self::from(launch_args, verb_store, &config).unwrap() } } /// try to determine whether the terminal supports true /// colors. This doesn't work well, hence the use of an /// optional config setting. /// Based on fn are_true_colors_available() -> bool { if let Ok(colorterm) = std::env::var("COLORTERM") { debug!("COLORTERM env variable = {colorterm:?}"); if colorterm.contains("truecolor") || colorterm.contains("24bit") { debug!("true colors are available"); true } else { false } } else { // this is debatable... I've found some terminals with COLORTERM // unset but supporting true colors. As it's easy to determine // that true colors aren't supported when looking at previewed // images I prefer this value true } } /// Determine the initial root folder to show, and the optional /// initial file to open in preview fn initial_root_file(cli_args: &Args) -> Result<(PathBuf, Option), ProgramError> { let mut file = None; let mut root = match cli_args.root.as_ref() { Some(path) => canonicalize_root(path)?, None => std::env::current_dir()?, }; if !root.exists() { return Err(TreeBuildError::FileNotFound { path: format!("{root:?}"), } .into()); } if !root.is_dir() { // we try to open the parent directory if the passed file isn't one if let Some(parent) = root.parent() { file = Some(root.clone()); info!("Passed path isn't a directory => opening parent instead"); root = parent.to_path_buf(); } else { // this is a weird filesystem, let's give up return Err(TreeBuildError::NotADirectory { path: format!("{root:?}"), } .into()); } } Ok((root, file)) } #[cfg(not(windows))] fn canonicalize_root(root: &Path) -> io::Result { root.canonicalize() } #[cfg(windows)] fn canonicalize_root(root: &Path) -> io::Result { Ok(if root.is_relative() { std::env::current_dir()?.join(root) } else { root.to_path_buf() }) } /// Build a server name according to the launch arguments /// (none if there's neither 'listen' nor `listen_auto` arg) #[allow(unused_variables)] fn build_server_name(args: &Args) -> Option { #[cfg(unix)] if let Some(name) = &args.listen { return Some(name.clone()); } #[cfg(unix)] if args.listen_auto { return Some(crate::net::random_server_name()); } None } ================================================ FILE: src/app/app_panels.rs ================================================ use { super::*, crate::{ browser::*, command::*, display::*, errors::ProgramError, kitty, skin::*, task_sync::Dam, verb::*, }, crokey::crossterm::{ cursor::MoveTo, queue, }, std::{ io::Write, path::{ Path, PathBuf, }, }, termimad::TimedEvent, }; /// Stores panels of the application and their inputs. /// /// Most fields are private to enforce consistency. /// This thing is designed so that the inputs and panels can be /// borrowed separately, which is useful for input handling and drawing. pub struct AppPanelsAndInputs { /// a count of all panels created created_panels_count: usize, panels: AppPanels, /// one input per panel, in the same order. Never empty. inputs: Vec, /// counter incremented at every draw drawing_count: usize, } /// Stores panels of the application. /// /// This structure is designed to be borrowed by reference for state access and manipulation, /// especially when applying mutations to the input when handling events. /// /// Fields are private to enforce consistency. pub struct AppPanels { /// dimensions of the screen pub screen: Screen, active_panel_idx: usize, // guaranteed to be < panels.len() // /// panels from left to right, never empty panels: Vec, } impl AppPanelsAndInputs { /// Create the appPanelsAndInputs which should be kept for the whole life of /// the application, starting with a single panel (it can't be empty), based on /// the `initial_root` pub fn new(con: &AppContext) -> Result { let screen = Screen::new(con)?; let mut browser_state = Box::new(BrowserState::new( con.initial_root.clone(), con.initial_tree_options.clone(), screen, con, &Dam::unlimited(), )?); if let Some(path) = con.initial_file.as_ref() { browser_state.tree.try_select_path(path); } let areas = Areas::create(&mut Vec::new(), &con.layout_instructions, 0, screen, false); let input = PanelInput::new(areas.input.clone()); let panel = Panel::new(PanelId::from(0), browser_state, areas, con); debug!("initial panel areas: {:?}", panel.areas); Ok(Self { created_panels_count: 0, panels: AppPanels { screen, active_panel_idx: 0, panels: vec![panel], }, inputs: vec![input], drawing_count: 0, }) } // ---------------------------------------------------- // Accessors pub fn screen(&self) -> Screen { self.panels.screen } pub fn len(&self) -> usize { self.inputs.len() } // ---------------------------------------------------- // resizing and layout pub fn set_terminal_size( &mut self, w: u16, h: u16, con: &AppContext, ) { self.panels.screen.set_terminal_size(w, h, con); self.resize_all(con); } pub fn resize_all( &mut self, con: &AppContext, ) { let screen = self.screen(); let has_preview = self.has_preview_panel(); Areas::resize_all( self.panels.panels.as_mut_slice(), &con.layout_instructions, screen, has_preview, ); for panel in &mut self.panels.panels { panel.mut_state().refresh(screen, con); } } // ---------------------------------------------------- // state access pub fn state(&self) -> &dyn PanelState { self.panels.panels[self.active_panel_idx()].state() } pub fn mut_state(&mut self) -> &mut dyn PanelState { let idx = self.active_panel_idx(); self.panels.panels[idx].mut_state() } /// if there are exactly two non preview panels, return the selection /// in the non focused panel pub fn get_other_panel_path(&self) -> Option { let mut non_preview_count = 0; let mut other_panel_idx = None; for (idx, panel) in self.panels.panels.iter().enumerate() { if panel.state().get_type() != PanelStateType::Preview { non_preview_count += 1; if idx != self.active_panel_idx() { other_panel_idx = Some(idx); } } } if non_preview_count == 2 { if let Some(other_panel_idx) = other_panel_idx { return self.panels.panels[other_panel_idx] .state() .selected_path() .map(Path::to_path_buf); } } None } // ---------------------------------------------------- // state manipulation pub fn push_state( &mut self, new_state: Box, ) { let idx = self.active_panel_idx(); self.inputs[idx].set_content(&new_state.get_starting_input()); self.panels.panels[idx].push_state(new_state); } /// remove the top state of the current panel /// /// Close the panel too if that was its only state. /// Close nothing and return false if there's not /// at least two states in the app. pub fn remove_state( &mut self, con: &AppContext, ) -> bool { let idx = self.active_panel_idx(); if self.panels.panels[idx].remove_state() { let input_content = self.state().get_starting_input(); self.inputs[idx].set_content(&input_content); true } else { self.close(idx, con) } } // ---------------------------------------------------- // panels access pub fn panels(&self) -> &AppPanels { &self.panels } pub fn panel(&self) -> &Panel { &self.panels.panels[self.active_panel_idx()] } pub fn mut_panel(&mut self) -> &mut Panel { let idx = self.active_panel_idx(); &mut self.panels.panels[idx] } pub fn preview_panel_id(&self) -> Option { self.panels.by_type(PanelStateType::Preview).map(|p| p.id) } pub fn stage_panel_id(&self) -> Option { self.panels.by_type(PanelStateType::Stage).map(|p| p.id) } pub fn idx_by_ref( &self, panel_ref: PanelReference, ) -> Option { self.panels.idx_by_ref(panel_ref) } pub fn has_preview_panel(&self) -> bool { self.panels.has_type(PanelStateType::Preview) } pub fn has_stage_panel(&self) -> bool { self.panels.has_type(PanelStateType::Stage) } pub fn active_panel_idx(&self) -> usize { self.panels.active_panel_idx } pub fn preview_panel_idx(&self) -> Option { self.panels.idx_by_type(PanelStateType::Preview) } pub fn panel_by_idx_unchecked( &self, idx: usize, ) -> &Panel { &self.panels.panels[idx] } pub fn count_of_type( &self, state_type: PanelStateType, ) -> usize { self.panels .panels .iter() .filter(|panel| panel.state().get_type() == state_type) .count() } // ---------------------------------------------------- // panel manipulation pub fn new_panel( &mut self, state: Box, purpose: PanelPurpose, direction: HDir, activate: bool, con: &AppContext, ) -> Result<(), String> { let screen = self.screen(); match state.get_type() { PanelStateType::Preview if self.panels.has_type(PanelStateType::Preview) => { return Err("There can be only one preview panel".to_owned()); // todo replace instead ? } PanelStateType::Stage if self.panels.has_type(PanelStateType::Stage) => { return Err("There can be only one stage panel".to_owned()); // todo replace instead ? } _ => {} } let insertion_idx = if purpose.is_preview() { self.len() } else if direction == HDir::Right { self.active_panel_idx() + 1 } else { self.active_panel_idx() }; let with_preview = purpose.is_preview() || self.panels.has_type(PanelStateType::Preview); let areas = Areas::create( self.panels.panels.as_mut_slice(), &con.layout_instructions, insertion_idx, screen, with_preview, ); let mut input = PanelInput::new(areas.input.clone()); input.set_content(&state.get_starting_input()); let panel_id = self.created_panels_count.into(); if activate { self.panels.active_panel_idx = insertion_idx; } let mut panel = Panel::new(panel_id, state, areas, con); panel.purpose = purpose; self.created_panels_count += 1; self.panels.panels.insert(insertion_idx, panel); self.inputs.insert(insertion_idx, input); Ok(()) } pub fn activate( &mut self, panel_idx: usize, ) { if panel_idx < self.len() { self.panels.active_panel_idx = panel_idx; } } pub fn focus_by_type( // FIXME unconsistent naming with activate &mut self, state_type: PanelStateType, ) -> bool { if let Some(idx) = self.panels.idx_by_type(state_type) { self.panels.active_panel_idx = idx; true } else { false } } /// close the panel if it's not the last one /// /// Return true when the panel has been removed (ie it wasn't the last one) pub fn close( &mut self, panel_idx: usize, con: &AppContext, ) -> bool { let len = self.len(); let screen = self.screen(); if panel_idx >= len { return false; } if len < 2 { return false; // we can't remove the last panel } if len == 2 { let non_removed_idx = if panel_idx == 0 { 1 } else { 0 }; let non_removed_panel = &self.panels.panels[non_removed_idx]; if non_removed_panel.state().get_type() == PanelStateType::Preview || non_removed_panel.state().get_type() == PanelStateType::Stage { return false; // we don't want to stay with just the preview or stage } } let active_panel_id = self.panels.panels[self.active_panel_idx()].id; self.panels.panels.remove(panel_idx); self.inputs.remove(panel_idx); let has_preview = self.panels.has_type(PanelStateType::Preview); Areas::resize_all( &mut self.panels.panels, &con.layout_instructions, screen, has_preview, ); self.panels.active_panel_idx = self .panels .panels .iter() .position(|p| p.id == active_panel_id) .unwrap_or(self.len() - 1); true } pub fn close_first_non_tree( &mut self, con: &AppContext, ) -> bool { let idx = self .panels .panels .iter() .position(|panel| panel.state().get_type() != PanelStateType::Tree); if let Some(idx) = idx { self.close(idx, con) } else { false } } pub fn close_rightest_inactive_tree( &mut self, con: &AppContext, ) -> bool { let idx = self .panels .panels .iter() .enumerate() .rev() .find(|(idx, panel)| { *idx != self.active_panel_idx() && panel.state().get_type() == PanelStateType::Tree }) .map(|(idx, _)| idx); if let Some(idx) = idx { self.close(idx, con) } else { false } } // ---------------------------------------------------- // event handling /// get the index of the panel at x pub fn clicked_panel_index( &self, x: u16, _y: u16, ) -> usize { let len = self.len(); for (idx, panel) in self.panels.panels.iter().enumerate() { let area = &panel.areas.state; if area.left <= x && x < area.left + area.width { return idx; } } // fallback: distribute evenly, but it misses that panels // may have different widths (len * x as usize) / (self.screen().width as usize + 1) } pub fn on_input_event( &mut self, w: &mut W, timed_event: &TimedEvent, app_state: &AppState, con: &AppContext, ) -> Result { let panel_idx = self.panels.active_panel_idx; debug!("input event for panel idx: {} / {}", panel_idx, self.len()); self.inputs[panel_idx].on_event(w, timed_event, &self.panels, app_state, con) } // ---------------------------------------------------- // command execution fn app_cmd_context<'c>( &self, panel_skin: &'c PanelSkin, con: &'c AppContext, ) -> AppCmdContext<'c> { AppCmdContext { panel_skin, preview_panel: self.preview_panel_id(), stage_panel: self.stage_panel_id(), screen: self.screen(), con, } } pub fn on_input_internal( &mut self, internal: Internal, ) -> Command { let idx = self.active_panel_idx(); self.inputs[idx].on_internal(internal) } pub fn apply_command<'c>( &mut self, w: &'c mut W, cmd: &'c Command, // A panel ref which may override the one in the command panel_ref: Option, panel_skin: &PanelSkin, app_state: &mut AppState, con: &AppContext, ) -> Result { let panel_ref = panel_ref.unwrap_or_else(|| { cmd.triggered_verb(&con.verb_store) .map(|v| v.impacted_panel) .unwrap_or(PanelReference::Active) }); let panel_idx = self .panels .idx_by_ref(panel_ref) .unwrap_or(self.active_panel_idx()); let app_cmd_context = self.app_cmd_context(panel_skin, con); self.panels.panels[panel_idx].apply_command(w, cmd, app_state, &app_cmd_context) } pub fn has_pending_task(&mut self) -> bool { self.panels.panels.iter().any(Panel::has_pending_task) } /// do the pending tasks, if any, and refresh the screen accordingly pub fn do_pending_tasks( &mut self, w: &mut W, skin: &AppSkin, dam: &mut Dam, app_state: &mut AppState, con: &AppContext, ) -> Result<(), ProgramError> { while self.has_pending_task() && !dam.has_event() { let error = self.do_pending_task(app_state, con, dam).err(); self.update_preview(false, con); // the selection may have changed if let Some(error) = &error { self.mut_panel().set_error(error.to_string()); } else { let panel_skin = &skin.focused; self.refresh_input_status(app_state, panel_skin, con); } self.display_panels(w, skin, app_state, con)?; if error.is_some() { return Ok(()); // breaking pending tasks chain on first error/interruption } } Ok(()) } /// Do the next pending task pub fn do_pending_task( &mut self, app_state: &mut AppState, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { let screen = self.screen(); // we start with the focused panel let active_panel_idx = self.active_panel_idx(); if self.panels.panels[active_panel_idx].has_pending_task() { return self.panels.panels[active_panel_idx] .do_pending_task(app_state, screen, con, dam); } // then the other ones for idx in 0..self.len() { if idx != self.active_panel_idx() { let panel = &mut self.panels.panels[idx]; if panel.has_pending_task() { return panel.do_pending_task(app_state, screen, con, dam); } } } warn!("unexpected lack of pending task"); Ok(()) } pub fn refresh_active_panel( &mut self, con: &AppContext, ) { // FIXME the returned command is never used let idx = self.active_panel_idx(); let screen = self.screen(); let panel = &mut self.panels.panels[idx]; panel.mut_state().refresh(screen, con); } pub fn refresh_all_panels( &mut self, con: &AppContext, ) { let screen = self.screen(); for panel in &mut self.panels.panels { panel.mut_state().refresh(screen, con); } } pub fn refresh_input_status( &mut self, app_state: &mut AppState, panel_skin: &PanelSkin, con: &AppContext, ) { let idx = self.active_panel_idx(); let has_previous_state = self.len() > 1; let input = &mut self.inputs[idx]; let panel = &mut self.panels.panels[idx]; let cmd = Command::from_raw(input.get_content(), false); let areas = panel.areas.clone(); // FIXME avoid clone let purpose = panel.purpose; let status_width = panel.areas.status.width as usize; let app_cmd_context = self.app_cmd_context(panel_skin, con); let cc = CmdContext { cmd: &cmd, app: &app_cmd_context, panel: PanelCmdContext { areas: &areas, purpose, }, }; let status = self .state() .get_status(app_state, &cc, has_previous_state, status_width); self.panels.panels[idx].status = status; } /// update the state of the preview, if there's some pub fn update_preview( &mut self, refresh: bool, con: &AppContext, ) { let Some(preview_idx) = self.panels.idx_by_type(PanelStateType::Preview) else { return; }; if let Some(path) = self.state().selected_path() { let old_path = self.panels.panels[preview_idx].state().selected_path(); if refresh || Some(path) != old_path { let path = path.to_path_buf(); self.panels.panels[preview_idx] .mut_state() .set_selected_path(path, con); } } } // ---------------------------------------------------- // Input access and manipulation pub fn input(&mut self) -> &mut PanelInput { &mut self.inputs[self.panels.active_panel_idx] } pub fn get_input_content(&self) -> String { self.inputs[self.panels.active_panel_idx].get_content() } /// change the argument of the verb in the input, if there's one pub fn set_input_arg( &mut self, arg: String, ) { let input = &mut self.inputs[self.panels.active_panel_idx]; let mut command_parts = CommandParts::from(input.get_content()); if let Some(invocation) = &mut command_parts.verb_invocation { invocation.args = Some(arg); let new_input = format!("{command_parts}"); input.set_content(&new_input); } } pub fn do_input_escape( &mut self, mode: Mode, con: &AppContext, ) -> Command { let panel_idx = self.panels.active_panel_idx; let input = &mut self.inputs[panel_idx]; input.escape(mode, con) } pub fn clear_input(&mut self) { let panel_idx = self.panels.active_panel_idx; if let Some(input) = self.inputs.get_mut(panel_idx) { input.input_field.clear(); } } /// remove the verb invocation from the input but keep /// the filter if there's one pub fn clear_input_invocation( &mut self, con: &AppContext, ) { let panel_idx = self.panels.active_panel_idx; let mut command_parts = CommandParts::from(self.inputs[panel_idx].get_content()); if command_parts.verb_invocation.is_some() { command_parts.verb_invocation = None; let new_input = format!("{command_parts}"); self.inputs[panel_idx].set_content(&new_input); } self.mut_state().set_mode(con.initial_mode()); } // ---------------------------------------------------- // drawing /// redraw the whole screen. All drawing /// are supposed to happen here, and only here. pub fn display_panels( &mut self, w: &mut W, skin: &AppSkin, app_state: &AppState, con: &AppContext, ) -> Result<(), ProgramError> { self.drawing_count += 1; let screen = self.screen(); let mut cursor_pos = None; let active_panel_idx = self.active_panel_idx(); for (idx, panel) in self.panels.panels.iter_mut().enumerate() { let input = &mut self.inputs[idx]; let active = idx == active_panel_idx; let panel_skin = if active { &skin.focused } else { &skin.unfocused }; let disc = DisplayContext { count: self.drawing_count, active, screen, panel_skin, state_area: panel.areas.state.clone(), app_state, con, }; panel.mut_state().display(w, &disc)?; if disc.active || !WIDE_STATUS { let watching = disc.app_state.watch_tree; panel.write_status(w, watching, disc.panel_skin, disc.screen)?; } let mut input_area = panel.areas.input.clone(); if disc.active { panel.write_purpose(w, disc.panel_skin, disc.screen, disc.con)?; let flags = panel.state().get_flags(); #[allow(clippy::cast_possible_truncation)] let input_content_len = input.get_content().len() as u16; let flags_len = flags_display::visible_width(&flags); if input_area.width > input_content_len + 1 + flags_len { input_area.width -= flags_len + 1; disc.screen .goto(w, input_area.left + input_area.width, input_area.top)?; flags_display::write(w, &flags, disc.panel_skin)?; } } let mode = panel.state().get_mode(); if let Some(pos) = input.display(w, disc.active, mode, input_area, disc.panel_skin)? { cursor_pos = Some(pos); } } // after drawing all the panels, move cursor to the end of the active panel input, // so that input methods can popup at correct position. if let Some(cursor_pos) = cursor_pos { queue!(w, MoveTo(cursor_pos.0, cursor_pos.1))?; } match kitty::manager().lock() { Ok(mut manager) => { manager.erase_images_before(w, self.drawing_count)?; } Err(e) => { error!("failed to lock kitty manager to erase images: {e}"); } } w.flush()?; Ok(()) } } impl AppPanels { fn idx_by_type( &self, state_type: PanelStateType, ) -> Option { self.panels .iter() .position(|panel| panel.state().get_type() == state_type) } fn idx_by_ref( &self, panel_ref: PanelReference, ) -> Option { match panel_ref { PanelReference::Active => Some(self.active_panel_idx), PanelReference::Leftest => Some(0), PanelReference::Rightest => Some(self.panels.len() - 1), PanelReference::Idx(idx) => { if idx < self.panels.len() { Some(idx) } else { None } } PanelReference::Id(id) => self.panels.iter().position(|panel| panel.id == id), PanelReference::Preview => self.idx_by_type(PanelStateType::Preview), } } fn by_type( &self, state_type: PanelStateType, ) -> Option<&Panel> { self.panels .iter() .find(|panel| panel.state().get_type() == state_type) } fn by_ref( &self, panel_ref: PanelReference, ) -> Option<&Panel> { self.panels.get(self.idx_by_ref(panel_ref)?) } fn has_type( &self, state_type: PanelStateType, ) -> bool { self.idx_by_type(state_type).is_some() } pub fn state(&self) -> &dyn PanelState { self.panels[self.active_panel_idx].state() } pub fn mut_state(&mut self) -> &mut dyn PanelState { self.panels[self.active_panel_idx].mut_state() } pub fn state_by_ref( &self, panel_ref: PanelReference, ) -> Option<&dyn PanelState> { self.by_ref(panel_ref).map(Panel::state) } } ================================================ FILE: src/app/app_state.rs ================================================ use { crate::stage::Stage, std::path::PathBuf, }; /// global mutable state #[derive(Debug)] pub struct AppState { pub stage: Stage, /// the current root, updated when a panel with this concept /// becomes active or changes its root pub root: PathBuf, /// Whether to refresh the tree view when in case of inotify event /// on the current root pub watch_tree: bool, /// the selected path in another panel than the currently /// active one, if any pub other_panel_path: Option, } impl AppState { pub fn new>(root: P) -> Self { Self { stage: Stage::default(), root: root.into(), watch_tree: false, other_panel_path: None, } } } ================================================ FILE: src/app/cmd_context.rs ================================================ use { super::*, crate::{ command::*, display::{ Areas, Screen, }, skin::PanelSkin, }, }; /// short lived wrapping of a few things which are needed for the handling /// of a command in a panel and won't be modified during the operation. pub struct CmdContext<'c> { pub cmd: &'c Command, pub app: &'c AppCmdContext<'c>, pub panel: PanelCmdContext<'c>, } /// the part of the immutable command execution context which comes from the app pub struct AppCmdContext<'c> { pub panel_skin: &'c PanelSkin, // needed for example by print_tree verbs pub preview_panel: Option, // id of the app's preview panel pub stage_panel: Option, // id of the app's stage panel pub screen: Screen, pub con: &'c AppContext, } /// the part of the command execution context which comes from the panel /// in which the command is executed. pub struct PanelCmdContext<'c> { pub areas: &'c Areas, pub purpose: PanelPurpose, } ================================================ FILE: src/app/cmd_result.rs ================================================ use { super::*, crate::{ browser::BrowserState, command::Sequence, display::LayoutInstruction, errors::TreeBuildError, launchable::Launchable, verb::Internal, }, std::fmt, }; /// Either left or right #[derive(Debug, Clone, Copy, PartialEq)] pub enum HDir { Left, Right, } /// Result of applying a command to a state pub enum CmdResult { ApplyOnPanel { id: PanelId, }, ClosePanel { validate_purpose: bool, panel_ref: PanelReference, clear_cache: bool, }, ChangeLayout(LayoutInstruction), DisplayError(String), ExecuteSequence { sequence: Sequence, }, HandleInApp(Internal), // command must be handled at the app level Keep, Message(String), Launch(Box), NewPanel { state: Box, purpose: PanelPurpose, direction: HDir, }, NewState { state: Box, message: Option<&'static str>, // explaining why there's a new state }, PopStateAndReapply, // the state asks the command be executed on a previous state PopState, Quit, RefreshState { clear_cache: bool, }, } impl CmdResult { #[must_use] pub fn verb_not_found(text: &str) -> CmdResult { CmdResult::DisplayError(format!("verb not found: {:?}", &text)) } #[must_use] pub fn from_optional_browser_state( os: Result, message: Option<&'static str>, in_new_panel: bool, ) -> CmdResult { match os { Ok(os) => { if in_new_panel { CmdResult::NewPanel { // TODO keep the message ? state: Box::new(os), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::NewState { state: Box::new(os), message, } } } Err(TreeBuildError::Interrupted) => CmdResult::Keep, Err(e) => CmdResult::error(e.to_string()), } } #[must_use] pub fn new_state(state: Box) -> Self { Self::NewState { state, message: None, } } pub fn error>(message: S) -> Self { Self::DisplayError(message.into()) } } impl From for CmdResult { fn from(launchable: Launchable) -> Self { CmdResult::Launch(Box::new(launchable)) } } impl fmt::Debug for CmdResult { fn fmt( &self, f: &mut fmt::Formatter, ) -> fmt::Result { write!( f, "{}", match self { CmdResult::ApplyOnPanel { .. } => "ApplyOnPanel", CmdResult::ChangeLayout(_) => "ChangeLayout", CmdResult::ClosePanel { validate_purpose: false, .. } => "CancelPanel", CmdResult::ClosePanel { validate_purpose: true, .. } => "OkPanel", CmdResult::DisplayError(_) => "DisplayError", CmdResult::ExecuteSequence { .. } => "ExecuteSequence", CmdResult::Keep => "Keep", CmdResult::Message { .. } => "Message", CmdResult::Launch(_) => "Launch", CmdResult::NewState { .. } => "NewState", CmdResult::NewPanel { .. } => "NewPanel", CmdResult::PopStateAndReapply => "PopStateAndReapply", CmdResult::PopState => "PopState", CmdResult::HandleInApp(_) => "HandleInApp", CmdResult::Quit => "Quit", CmdResult::RefreshState { .. } => "RefreshState", } ) } } ================================================ FILE: src/app/display_context.rs ================================================ use { super::*, crate::{ display::Screen, skin::PanelSkin, }, termimad::Area, }; /// short lived wrapping of a few things which are needed for displaying /// panels pub struct DisplayContext<'c> { pub count: usize, pub active: bool, pub screen: Screen, pub state_area: Area, pub panel_skin: &'c PanelSkin, pub app_state: &'c AppState, pub con: &'c AppContext, } ================================================ FILE: src/app/mod.rs ================================================ mod app; mod app_context; mod app_panels; mod app_state; mod cmd_context; mod cmd_result; mod display_context; mod mode; mod panel; mod panel_id; mod panel_purpose; mod panel_reference; mod panel_state; mod sel_info; mod selection; mod standard_status; mod state_type; mod status; pub use { app::App, app_context::AppContext, app_panels::*, app_state::*, cmd_context::*, cmd_result::*, display_context::*, mode::*, panel::Panel, panel_id::PanelId, panel_purpose::PanelPurpose, panel_reference::*, panel_state::*, sel_info::*, selection::*, standard_status::StandardStatus, state_type::PanelStateType, status::Status, }; ================================================ FILE: src/app/mode.rs ================================================ use serde::Deserialize; /// modes are used when the application is configured to /// be "modal". If not, the only mode is the `Input` mode. #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Mode { Input, Command, } ================================================ FILE: src/app/panel.rs ================================================ use { super::*, crate::{ command::*, display::{ Areas, Screen, W, status_line, }, errors::ProgramError, keys::KEY_FORMAT, skin::PanelSkin, task_sync::Dam, verb::*, }, termimad::minimad::{ Alignment, Composite, }, }; /// A column on screen containing a stack of states, the top /// one being visible pub struct Panel { pub id: PanelId, states: Vec>, // stack: the last one is current pub areas: Areas, pub status: Status, pub purpose: PanelPurpose, pub last_raw_pattern: Option, } impl Panel { #[must_use] pub fn new( id: PanelId, state: Box, areas: Areas, con: &AppContext, ) -> Self { let mut input = PanelInput::new(areas.input.clone()); input.set_content(&state.get_starting_input()); let status = state.no_verb_status(false, con, areas.status.width as usize); Self { id, states: vec![state], areas, status, purpose: PanelPurpose::None, last_raw_pattern: None, } } pub fn set_error( &mut self, text: String, ) { self.status = Status::from_error(text); } pub fn set_message>( &mut self, md: S, ) { self.status = Status::from_message(md.into()); } /// apply a command on the current state, with no /// effect on screen pub fn apply_command<'c>( &mut self, w: &'c mut W, cmd: &'c Command, app_state: &mut AppState, app_cmd_context: &'c AppCmdContext<'c>, ) -> Result { if let Command::PatternEdit { raw, .. } = cmd { self.last_raw_pattern = Some(raw.clone()); } let state_idx = self.states.len() - 1; let cc = CmdContext { cmd, app: app_cmd_context, panel: PanelCmdContext { areas: &self.areas, purpose: self.purpose, }, }; let result = self.states[state_idx].on_command(w, app_state, &cc); let has_previous_state = self.states.len() > 1; self.status = self.state().get_status( app_state, &cc, has_previous_state, self.areas.status.width as usize, ); result } /// do the next pending task stopping as soon as there's an event /// in the dam pub fn do_pending_task( &mut self, app_state: &mut AppState, screen: Screen, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { self.mut_state() .do_pending_task(app_state, screen, con, dam) } #[must_use] pub fn has_pending_task(&self) -> bool { self.state().get_pending_task().is_some() } pub fn push_state( &mut self, new_state: Box, ) { self.states.push(new_state); } #[must_use] pub fn mut_state(&mut self) -> &mut dyn PanelState { #[expect( clippy::missing_panics_doc, reason = "there's always at least one state" )] self.states.last_mut().unwrap().as_mut() } #[must_use] pub fn state(&self) -> &dyn PanelState { #[expect( clippy::missing_panics_doc, reason = "there's always at least one state" )] self.states.last().unwrap().as_ref() } /// return true when the element has been removed pub fn remove_state(&mut self) -> bool { if self.states.len() > 1 { self.states.pop(); true } else { false } } pub fn write_status( &self, w: &mut W, watching: bool, panel_skin: &PanelSkin, screen: Screen, ) -> Result<(), ProgramError> { let task = self.state().get_pending_task(); status_line::write( w, watching, task, &self.status, &self.areas.status, panel_skin, screen, ) } /// if a panel has a specific purpose (i.e. is here for /// editing of the verb argument on another panel), render /// a hint of that purpose on screen pub fn write_purpose( &self, w: &mut W, panel_skin: &PanelSkin, screen: Screen, con: &AppContext, ) -> Result<(), ProgramError> { if !self.purpose.is_arg_edition() { return Ok(()); } if let Some(area) = &self.areas.purpose { let shortcut = con .verb_store .verbs() .iter() .filter(|v| match &v.execution { VerbExecution::Internal(exec) => exec.internal == Internal::start_end_panel, _ => false, }) .filter_map(|v| v.keys.first()) .map(|&k| KEY_FORMAT.to_string(k)) .next() .unwrap_or_else(|| ":start_end_panel".to_string()); let md = format!("hit *{shortcut}* to fill arg "); // Add verbindex in purpose ? screen.goto(w, area.left, area.top)?; panel_skin.purpose_skin.write_composite_fill( w, Composite::from_inline(&md), area.width as usize, Alignment::Right, )?; } Ok(()) } } ================================================ FILE: src/app/panel_id.rs ================================================ /// The unique identifiant of a panel #[derive(Debug, Clone, Copy, PartialEq)] pub struct PanelId(usize); impl From for PanelId { fn from(u: usize) -> Self { Self(u) } } impl PanelId { /// get the inner usize #[must_use] pub fn as_usize(&self) -> usize { self.0 } } ================================================ FILE: src/app/panel_purpose.rs ================================================ use super::SelectionType; /// the possible special reason the panel was open #[derive(Debug, Clone, Copy)] pub enum PanelPurpose { None, ArgEdition { arg_type: SelectionType }, Preview, } impl PanelPurpose { #[must_use] pub fn is_arg_edition(self) -> bool { matches!(self, PanelPurpose::ArgEdition { .. }) } #[must_use] pub fn is_preview(self) -> bool { matches!(self, PanelPurpose::Preview) } } ================================================ FILE: src/app/panel_reference.rs ================================================ use { crate::{ app::PanelId, errors::ConfError, }, lazy_regex::regex_switch, serde::{ Deserialize, Deserializer, Serialize, Serializer, de, }, std::{ fmt, str::FromStr, }, }; /// the symbolic reference to the panel to close #[derive(Debug, Clone, Copy, Default, PartialEq)] pub enum PanelReference { #[default] Active, Leftest, Rightest, Id(PanelId), Idx(usize), Preview, } impl PanelReference { /// whether this reference is the default one #[must_use] pub fn is_default(&self) -> bool { matches!(self, PanelReference::Active) } } impl fmt::Display for PanelReference { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { match self { PanelReference::Active => write!(f, "active"), PanelReference::Leftest => write!(f, "leftest"), PanelReference::Rightest => write!(f, "rightest"), PanelReference::Id(id) => write!(f, "id:{}", id.as_usize()), PanelReference::Idx(idx) => write!(f, "idx:{idx}"), PanelReference::Preview => write!(f, "preview"), } } } impl FromStr for PanelReference { type Err = ConfError; fn from_str(s: &str) -> Result { regex_switch!(s, "^active$"i => Self::Active, "^left(est)?$"i => Self::Leftest, "^right(est)?$"i => Self::Rightest, "^preview$"i => Self::Preview, r"^id:(?P\d{1,2})$"i => Self::Id(id.parse::().unwrap().into()), r"^idx:(?P\d{1,2})$"i => Self::Idx(idx.parse().unwrap()), ) .ok_or_else(|| ConfError::InvalidPanelReference { raw: s.to_string() }) } } impl Serialize for PanelReference { fn serialize( &self, serializer: S, ) -> Result where S: Serializer, { serializer.collect_str(self) } } impl<'de> Deserialize<'de> for PanelReference { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; FromStr::from_str(&s).map_err(de::Error::custom) } } ================================================ FILE: src/app/panel_state.rs ================================================ use { super::*, crate::{ command::*, display::*, errors::ProgramError, flag::Flag, help::HelpState, pattern::*, preview::*, print, stage::*, task_sync::Dam, tree::*, verb::*, }, std::{ path::{ Path, PathBuf, }, str::FromStr, }, }; /// a panel state, stackable to allow reverting /// to a previous one pub trait PanelState { fn get_type(&self) -> PanelStateType; fn set_mode( &mut self, mode: Mode, ); fn get_mode(&self) -> Mode; /// called on start of `on_command`, remove the pending task fn clear_pending(&mut self) {} fn on_click( &mut self, _x: u16, _y: u16, _screen: Screen, _con: &AppContext, ) -> Result { Ok(CmdResult::Keep) } fn on_double_click( &mut self, _x: u16, _y: u16, _screen: Screen, _con: &AppContext, ) -> Result { Ok(CmdResult::Keep) } fn on_pattern( &mut self, _pat: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { Ok(CmdResult::Keep) } fn on_mode_verb( &mut self, mode: Mode, con: &AppContext, ) -> CmdResult { if con.modal { self.set_mode(mode); CmdResult::Keep } else { CmdResult::error("modal mode not enabled in configuration") } } /// execute the internal with the optional given invocation. /// /// The invocation comes from the input and may be related /// to a different verb (the verb may have been triggered /// by a key shortcut) #[allow(clippy::too_many_arguments)] 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; /// a generic implementation of `on_internal` which may be /// called by states when they don't have a specific /// behavior to execute #[allow(clippy::too_many_arguments)] fn on_internal_generic( &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 { let con = &cc.app.con; let screen = cc.app.screen; let bang = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); Ok(match internal_exec.internal { Internal::apply_flags => { debug!("applying flags input_invocation: {input_invocation:#?}"); let flags = input_invocation.and_then(|inv| inv.args.as_ref()); if let Some(flags) = flags { self.with_new_options( screen, &|o| match o.apply_flags(flags) { Ok(()) => "*flags applied*", Err(e) => e, }, bang, con, ) } else { CmdResult::error(":apply_flags needs flags as arguments") } } Internal::back => CmdResult::PopState, Internal::copy_line | Internal::copy_path => { #[cfg(not(feature = "clipboard"))] { CmdResult::error("Clipboard feature not enabled at compilation") } #[cfg(feature = "clipboard")] { if let Some(path) = self.selected_path() { let path = path.to_string_lossy().to_string(); match terminal_clipboard::set_string(path) { Ok(()) => CmdResult::Keep, Err(_) => CmdResult::error("Clipboard error while copying path"), } } else { CmdResult::error("Nothing to copy") } } } Internal::close_panel_ok => CmdResult::ClosePanel { validate_purpose: true, panel_ref: PanelReference::Active, clear_cache: true, }, Internal::close_panel_cancel => CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Active, clear_cache: false, }, Internal::move_panel_divider => { let MoveDividerArgs { divider, dx } = get_arg( input_invocation, internal_exec, MoveDividerArgs { divider: 0, dx: 1 }, ); CmdResult::ChangeLayout(LayoutInstruction::MoveDivider { divider, dx }) } Internal::default_layout => CmdResult::ChangeLayout(LayoutInstruction::Clear), Internal::set_panel_width => { let SetPanelWidthArgs { panel, width } = get_arg( input_invocation, internal_exec, SetPanelWidthArgs { panel: 0, width: 100, }, ); CmdResult::ChangeLayout(LayoutInstruction::SetPanelWidth { panel, width }) } #[cfg(any( target_os = "windows", all( unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android") ) ))] Internal::purge_trash => { let res = trash::os_limited::list().and_then(trash::os_limited::purge_all); match res { Ok(()) => CmdResult::RefreshState { clear_cache: false }, Err(e) => CmdResult::DisplayError(format!("{e}")), } } #[cfg(any( target_os = "windows", all( unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android") ) ))] 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(any(target_os = "macos", target_os = "linux", target_os = "windows"))] Internal::filesystems => { let fs_state = crate::filesystems::FilesystemState::new( self.selected_path(), self.tree_options(), con, ); match fs_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}")), } } Internal::help => { 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(HelpState::new(self.tree_options(), screen, con)), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::new_state(Box::new(HelpState::new(self.tree_options(), screen, con))) } } Internal::mode_input => self.on_mode_verb(Mode::Input, con), Internal::mode_command => self.on_mode_verb(Mode::Command, con), Internal::open_leave => { if let Some(selection) = self.selection() { selection.to_opener(con)? } else { CmdResult::error("no selection to open") } } Internal::open_preview => self.open_preview(None, false, cc), Internal::preview_image => self.open_preview(Some(PreviewMode::Image), false, cc), Internal::preview_text => self.open_preview(Some(PreviewMode::Text), false, cc), Internal::preview_tty => self.open_preview(Some(PreviewMode::Tty), false, cc), Internal::preview_binary => self.open_preview(Some(PreviewMode::Hex), false, cc), Internal::toggle_preview => self.open_preview(None, true, cc), Internal::sort_by_count => self.with_new_options( screen, &|o| { if o.sort == Sort::Count { o.sort = Sort::None; o.show_counts = false; "*not sorting anymore*" } else { o.sort = Sort::Count; o.show_counts = true; "*now sorting by file count*" } }, bang, con, ), Internal::sort_by_date => self.with_new_options( screen, &|o| { if o.sort == Sort::Date { o.sort = Sort::None; o.show_dates = false; "*not sorting anymore*" } else { o.sort = Sort::Date; o.show_dates = true; "*now sorting by last modified date*" } }, bang, con, ), Internal::sort_by_size => self.with_new_options( screen, &|o| { if o.sort == Sort::Size { o.sort = Sort::None; o.show_sizes = false; "*not sorting anymore*" } else { o.sort = Sort::Size; o.show_sizes = true; o.show_root_fs = true; "*now sorting files and directories by total size*" } }, bang, con, ), Internal::sort_by_type => self.with_new_options( screen, &|o| match o.sort { Sort::TypeDirsFirst => { o.sort = Sort::TypeDirsLast; "*sorting by type, directories last*" } Sort::TypeDirsLast => { o.sort = Sort::None; "*not sorting anymore*" } _ => { o.sort = Sort::TypeDirsFirst; "*sorting by type, directories first*" } }, bang, con, ), Internal::sort_by_type_dirs_first => self.with_new_options( screen, &|o| { if o.sort == Sort::TypeDirsFirst { o.sort = Sort::None; "*not sorting anymore*" } else { o.sort = Sort::TypeDirsFirst; "*now sorting by type, directories first*" } }, bang, con, ), Internal::sort_by_type_dirs_last => self.with_new_options( screen, &|o| { if o.sort == Sort::TypeDirsLast { o.sort = Sort::None; "*not sorting anymore*" } else { o.sort = Sort::TypeDirsLast; "*now sorting by type, directories last*" } }, bang, con, ), Internal::no_sort => self.with_new_options( screen, &|o| { if o.sort == Sort::None { "*still not searching*" } else { o.sort = Sort::None; "*not sorting anymore*" } }, bang, con, ), Internal::toggle_counts => self.with_new_options( screen, &|o| { o.show_counts ^= true; if o.show_counts { "*displaying file counts*" } else { "*hiding file counts*" } }, bang, con, ), Internal::toggle_tree => self.with_new_options( screen, &|o| { o.show_tree ^= true; if o.show_tree { "*displaying tree structure (if possible)*" } else { "*displaying only current directory*" } }, bang, con, ), Internal::toggle_dates => self.with_new_options( screen, &|o| { o.show_dates ^= true; if o.show_dates { "*displaying last modified dates*" } else { "*hiding last modified dates*" } }, bang, con, ), Internal::toggle_device_id => self.with_new_options( screen, &|o| { o.show_device_id ^= true; if o.show_device_id { "*displaying device id*" } else { "*hiding device id*" } }, bang, con, ), Internal::toggle_files => self.with_new_options( screen, &|o| { o.only_folders ^= true; if o.only_folders { "*displaying only directories*" } else { "*displaying both files and directories*" } }, bang, con, ), Internal::toggle_hidden => self.with_new_options( screen, &|o| { o.show_hidden ^= true; if o.show_hidden { "h:**y** - *Hidden files displayed*" } else { "h:**n** - *Hidden files not displayed*" } }, bang, con, ), Internal::toggle_root_fs => self.with_new_options( screen, &|o| { o.show_root_fs ^= true; if o.show_root_fs { "*displaying filesystem info for the tree's root directory*" } else { "*removing filesystem info*" } }, bang, con, ), Internal::set_max_depth => { let args = input_invocation.and_then(|inv| inv.args.as_ref()); if let Some(flags) = args { self.with_new_options( screen, &|o| { if let Ok(max_depth) = flags.parse::() { o.max_depth = Some(max_depth); "*max depth updated*" } else { "*depth must be an integer*" } }, bang, con, ) } else { CmdResult::error(":set_max_depth needs a depth as an argument") } } Internal::unset_max_depth => self.with_new_options( screen, &|o| { o.max_depth = None; "*cleared max depth*" }, bang, con, ), Internal::toggle_git_ignore | Internal::toggle_ignore => self.with_new_options( screen, &|o| { o.respect_git_ignore ^= true; if o.respect_git_ignore { "gi:**y** - *applying gitignore rules*" } else { "gi:**n** - *not applying gitignore rules*" } }, bang, con, ), Internal::toggle_git_file_info => self.with_new_options( screen, &|o| { o.show_git_file_info ^= true; if o.show_git_file_info { "*displaying git info next to files*" } else { "*removing git file info*" } }, bang, con, ), Internal::toggle_git_status => self.with_new_options( screen, &|o| { if o.filter_by_git_status { o.filter_by_git_status = false; "*not filtering according to git status anymore*" } else { o.filter_by_git_status = true; o.show_hidden = true; "*only displaying new or modified files*" } }, bang, con, ), Internal::toggle_perm => self.with_new_options( screen, &|o| { o.show_permissions ^= true; if o.show_permissions { "*displaying file permissions*" } else { "*removing file permissions*" } }, bang, con, ), Internal::toggle_sizes => self.with_new_options( screen, &|o| { if o.show_sizes { o.show_sizes = false; o.show_root_fs = false; "*removing sizes of files and directories*" } else { o.show_sizes = true; o.show_root_fs = true; "*now displaying sizes of files and directories*" } }, bang, con, ), Internal::toggle_trim_root => self.with_new_options( screen, &|o| { o.trim_root ^= true; if o.trim_root { "*now trimming root from excess files*" } else { "*not trimming root files anymore*" } }, bang, con, ), Internal::close_preview => { if let Some(id) = cc.app.preview_panel { CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(id), clear_cache: false, } } else { CmdResult::Keep } } Internal::escape => CmdResult::HandleInApp(Internal::escape), Internal::focus_staging_area_no_open => { CmdResult::HandleInApp(Internal::focus_staging_area_no_open) } // panel_left depends on the kind of panel and is usually handled // in a specific state, contrary to panel_left_no_open Internal::panel_left | Internal::panel_left_no_open => { CmdResult::HandleInApp(Internal::panel_left_no_open) } Internal::focus_panel_left => CmdResult::HandleInApp(Internal::focus_panel_left), Internal::focus_panel_right => CmdResult::HandleInApp(Internal::focus_panel_right), // panel_right depends on the kind of panel and is usually handled // in a specific state, contrary to panel_right_no_open Internal::panel_right | Internal::panel_right_no_open => { CmdResult::HandleInApp(Internal::panel_right_no_open) } Internal::toggle_second_tree => CmdResult::HandleInApp(Internal::toggle_second_tree), Internal::toggle_watch => CmdResult::HandleInApp(Internal::toggle_watch), Internal::clear_stage => { app_state.stage.clear(); if let Some(panel_id) = cc.app.stage_panel { CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(panel_id), clear_cache: false, } } else { CmdResult::Keep } } Internal::stage => self.stage(app_state, cc, con), Internal::unstage => self.unstage(app_state, cc, con), Internal::toggle_stage => self.toggle_stage(app_state, cc, con), Internal::close_staging_area => { if let Some(id) = cc.app.stage_panel { CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(id), clear_cache: false, } } else { CmdResult::Keep } } Internal::open_staging_area => { if cc.app.stage_panel.is_none() { CmdResult::NewPanel { state: Box::new(StageState::new(app_state, self.tree_options(), con)), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::Keep } } Internal::toggle_staging_area => { if let Some(id) = cc.app.stage_panel { CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(id), clear_cache: false, } } else { CmdResult::NewPanel { state: Box::new(StageState::new(app_state, self.tree_options(), con)), purpose: PanelPurpose::None, direction: HDir::Right, } } } Internal::set_syntax_theme => CmdResult::HandleInApp(Internal::set_syntax_theme), Internal::print_path => print::print_paths(self.sel_info(app_state), con)?, Internal::print_relative_path => { print::print_relative_paths(self.sel_info(app_state), con)? } Internal::refresh => CmdResult::RefreshState { clear_cache: true }, Internal::quit => CmdResult::Quit, Internal::clear_output => { verb_clear_output(con).unwrap_or_else(|e| CmdResult::DisplayError(format!("{e}"))) } Internal::write_output => { let sel_info = self.sel_info(app_state); let mut exec_builder = match input_invocation { Some(inv) => ExecutionBuilder::with_invocation( invocation_parser, sel_info, app_state, inv.args.as_ref(), ), None => ExecutionBuilder::without_invocation(sel_info, app_state), }; let mut content = String::new(); // There's normally exactly one string, except when the selection // is multiple (stage), there's an arg, and the coarity is // per_selection, in which case we have one line per selection let coarity = internal_exec.coarity(); match (coarity, sel_info, internal_exec.arg.as_ref()) { (CommandCoarity::PerSelection, SelInfo::More(stage), Some(pattern)) => { let pattern = ExecPattern::from_string(pattern); debug!("write_output executed once per selection"); // we execute once per selection (may be zero if the stage is empty) let sels = stage.paths().iter().map(|path| Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, }); for sel in sels { content.push_str(&exec_builder.sel_shell_exec_string(&pattern, Some(sel), con)); } } (_, _, Some(pattern)) => { debug!("write_output executed once, with pattern"); let pattern = ExecPattern::from_string(pattern); // we execute only once, pattern is merging or ignoring the selection content.push_str(&exec_builder.shell_exec_string(&pattern, con)); } _ => { // no pattern, the selection is not relevant // (we're writing just what the input invocation contains) debug!("write_output executed once, with no pattern"); let line = input_invocation .and_then(|inv| inv.args.as_ref()) .map_or("", String::as_str); content.push_str(line); } } if !content.is_empty() { verb_write(con, &content)?; } CmdResult::Keep } internal if internal.is_input_related() => CmdResult::HandleInApp(internal), _ => CmdResult::Keep, }) } fn stage( &self, app_state: &mut AppState, cc: &CmdContext, con: &AppContext, ) -> CmdResult { if let Some(path) = self.selected_path() { let path = path.to_path_buf(); app_state.stage.add(path); if cc.app.con.auto_open_staging_area && cc.app.stage_panel.is_none() { return CmdResult::NewPanel { state: Box::new(StageState::new(app_state, self.tree_options(), con)), purpose: PanelPurpose::None, direction: HDir::Right, }; } } else { // TODO display error ? warn!("no path in state"); } CmdResult::Keep } fn unstage( &self, app_state: &mut AppState, cc: &CmdContext, _con: &AppContext, ) -> CmdResult { if let Some(path) = self.selected_path() { if app_state.stage.remove(path) && app_state.stage.is_empty() { if let Some(panel_id) = cc.app.stage_panel { return CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(panel_id), clear_cache: false, }; } } } CmdResult::Keep } fn toggle_stage( &self, app_state: &mut AppState, cc: &CmdContext, con: &AppContext, ) -> CmdResult { if let Some(path) = self.selected_path() { if app_state.stage.contains(path) { self.unstage(app_state, cc, con) } else { self.stage(app_state, cc, con) } } else { CmdResult::error("no selection") } } fn execute_verb( &mut self, w: &mut W, // needed because we may want to switch from alternate in some externals verb: &Verb, invocation: Option<&VerbInvocation>, trigger_type: TriggerType, app_state: &mut AppState, cc: &CmdContext, ) -> Result { if verb.needs_selection && !self.has_at_least_one_selection(app_state) { return Ok(CmdResult::error("This verb needs a selection")); } if verb.needs_another_panel && app_state.other_panel_path.is_none() { return Ok(CmdResult::error("This verb needs another panel")); } let res = match &verb.execution { VerbExecution::Internal(internal_exec) => self.on_internal( w, verb.invocation_parser.as_ref(), internal_exec, invocation, trigger_type, app_state, cc, ), VerbExecution::External(external) => { self.execute_external(w, verb, external, invocation, app_state, cc) } VerbExecution::Sequence(seq_ex) => { self.execute_sequence(w, verb, seq_ex, invocation, app_state, cc) } }; if res.is_ok() { // if the stage has been emptied by the operation (eg a "rm"), we // close it app_state.stage.refresh(); if app_state.stage.is_empty() { if let Some(id) = cc.app.stage_panel { return Ok(CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(id), clear_cache: true, }); } } } res } fn execute_external( &mut self, w: &mut W, verb: &Verb, external_execution: &ExternalExecution, invocation: Option<&VerbInvocation>, app_state: &mut AppState, cc: &CmdContext, ) -> Result { let sel_info = self.sel_info(app_state); if let Some(invocation) = &invocation { if let Some(error) = verb.check_args(sel_info, invocation, &app_state.other_panel_path) { debug!("verb.check_args prevented execution: {:?}", &error); return Ok(CmdResult::error(error)); } } let exec_builder = ExecutionBuilder::with_invocation( verb.invocation_parser.as_ref(), sel_info, app_state, if let Some(inv) = invocation { inv.args.as_ref() } else { None }, ); external_execution.to_cmd_result(w, exec_builder, cc.app.con) } fn execute_sequence( &mut self, _w: &mut W, verb: &Verb, seq_ex: &SequenceExecution, invocation: Option<&VerbInvocation>, app_state: &mut AppState, cc: &CmdContext, ) -> Result { let sel_info = self.sel_info(app_state); if matches!(sel_info, SelInfo::More(_)) { // sequences would be hard to execute as the execution on a file can change the // state in too many ways (changing selection, focused panel, parent, unstage or // stage files, removing the staged paths, etc.) return Ok(CmdResult::error( "sequences can't be executed on multiple selections", )); } let mut exec_builder = ExecutionBuilder::with_invocation( verb.invocation_parser.as_ref(), sel_info, app_state, if let Some(inv) = invocation { inv.args.as_ref() } else { None }, ); let sequence = exec_builder.sequence( &seq_ex.sequence, &cc.app.con.verb_store, cc.app.con, Some(self.get_type()), ); Ok(CmdResult::ExecuteSequence { sequence }) } /// change the state, does no rendering fn on_command( &mut self, w: &mut W, app_state: &mut AppState, cc: &CmdContext, ) -> Result { self.clear_pending(); let con = &cc.app.con; let screen = cc.app.screen; match &cc.cmd { Command::Click(x, y) => self.on_click(*x, *y, screen, con), Command::DoubleClick(x, y) => self.on_double_click(*x, *y, screen, con), Command::PatternEdit { raw, expr } => match InputPattern::new(raw.clone(), expr, con) { Ok(pattern) => self.on_pattern(pattern, app_state, con), Err(e) => Ok(CmdResult::DisplayError(format!("{e}"))), }, Command::VerbTrigger { verb_id, input_invocation, } => self.execute_verb( w, con.verb_store.verb(*verb_id), input_invocation.as_ref(), TriggerType::Other, app_state, cc, ), Command::Internal { internal, input_invocation, } => self.on_internal( w, None, &InternalExecution::from_internal(*internal), input_invocation.as_ref(), TriggerType::Other, app_state, cc, ), Command::VerbInvocate(invocation) => { let sel_info = self.sel_info(app_state); match con.verb_store.search_sel_info( &invocation.name, sel_info, Some(self.get_type()), ) { PrefixSearchResult::Match(_, verb) => self.execute_verb( w, verb, Some(invocation), TriggerType::Input(verb), app_state, cc, ), _ => Ok(CmdResult::verb_not_found(&invocation.name)), } } Command::None | Command::VerbEdit(_) => { // we do nothing here, the real job is done in get_status Ok(CmdResult::Keep) } } } /// return a cmdresult asking for the opening of a preview fn open_preview( &mut self, preferred_mode: Option, close_if_open: bool, cc: &CmdContext, ) -> CmdResult { if let Some(id) = cc.app.preview_panel { if close_if_open { CmdResult::ClosePanel { validate_purpose: false, panel_ref: PanelReference::Id(id), clear_cache: false, } } else if preferred_mode.is_some() { // we'll make the preview mode change be // applied on the preview panel CmdResult::ApplyOnPanel { id } } else { CmdResult::Keep } } else if let Some(path) = self.selected_path() { CmdResult::NewPanel { state: Box::new(PreviewState::new( path.to_path_buf(), InputPattern::none(), preferred_mode, self.tree_options(), cc.app.con, )), purpose: PanelPurpose::Preview, direction: HDir::Right, } } else { CmdResult::error("no selected file") } } /// must return None if the state doesn't display a file tree fn tree_root(&self) -> Option<&Path> { None } fn watchable_paths(&self) -> Vec { vec![] } fn selected_path(&self) -> Option<&Path>; fn selection(&self) -> Option>; fn sel_info<'c>( &'c self, _app_state: &'c AppState, ) -> SelInfo<'c> { // overloaded in stage_state match self.selection() { None => SelInfo::None, Some(selection) => SelInfo::One(selection), } } fn has_at_least_one_selection( &self, _app_state: &AppState, ) -> bool { true // overloaded in stage_state } fn refresh( &mut self, screen: Screen, con: &AppContext, ) -> Command; // FIXME this command is never used fn tree_options(&self) -> TreeOptions; /// Build a cmdResult in response to a command being a change of /// tree options. This may or not be a new state. /// /// The provided `change_options` function returns a status message /// explaining the change fn with_new_options( &mut self, screen: Screen, change_options: &dyn Fn(&mut TreeOptions) -> &'static str, in_new_panel: bool, con: &AppContext, ) -> CmdResult; fn do_pending_task( &mut self, _app_state: &mut AppState, _screen: Screen, _con: &AppContext, _dam: &mut Dam, ) -> Result<(), ProgramError> { // no pending task in default impl unreachable!(); } fn get_pending_task(&self) -> Option<&'static str> { None } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError>; /// return the flags to display fn get_flags(&self) -> Vec { vec![] } fn get_starting_input(&self) -> String { String::new() } fn set_selected_path( &mut self, _path: PathBuf, _con: &AppContext, ) { // this function is useful for preview states } /// return the status which should be used when there's no verb edited fn no_verb_status( &self, _has_previous_state: bool, _con: &AppContext, _width: usize, // available width ) -> Status { Status::from_message("Hit *esc* to get back, or a space to start a verb") } fn get_status( &self, app_state: &AppState, cc: &CmdContext, has_previous_state: bool, width: usize, ) -> Status { match &cc.cmd { Command::PatternEdit { .. } => { self.no_verb_status(has_previous_state, cc.app.con, width) } Command::VerbEdit(invocation) | Command::VerbTrigger { input_invocation: Some(invocation), .. } => { if invocation.name.is_empty() { Status::new( "Type a verb then *enter* to execute it (*?* for the list of verbs)", false, ) } else { let sel_info = self.sel_info(app_state); match cc.app.con.verb_store.search_sel_info( &invocation.name, sel_info, Some(self.get_type()), ) { PrefixSearchResult::NoMatch => { Status::new("No matching verb (*?* for the list of verbs)", true) } PrefixSearchResult::Match(_, verb) => { self.get_verb_status(verb, invocation, sel_info, cc, app_state) } PrefixSearchResult::Matches(completions) => Status::new( format!( "Possible verbs: {}", completions .iter() .map(|c| format!("*{c}*")) .collect::>() .join(", "), ), false, ), } } } _ => self.no_verb_status(has_previous_state, cc.app.con, width), } } fn get_verb_status( &self, verb: &Verb, invocation: &VerbInvocation, sel_info: SelInfo<'_>, cc: &CmdContext, app_state: &AppState, ) -> Status { if sel_info.count_paths() > 1 { if let VerbExecution::External(external) = &verb.execution { if external.exec_mode != ExternalExecutionMode::StayInBroot { let coarity = external.exec_pattern.coarity(); info!("coarity of the command is {coarity:?}"); if coarity == CommandCoarity::PerSelection { return Status::new(MULTI_SELECTION_ERROR.to_owned(), true); } } } // right now there's no check for sequences but they're inherently dangerous } if let Some(err) = verb.check_args(sel_info, invocation, &app_state.other_panel_path) { Status::new(err, true) } else { Status::new( verb.get_status_markdown(sel_info, app_state, invocation, cc.app.con), false, ) } } } pub fn get_arg( verb_invocation: Option<&VerbInvocation>, internal_exec: &InternalExecution, default: T, ) -> T { verb_invocation .and_then(|vi| vi.args.as_ref()) .or(internal_exec.arg.as_ref()) .and_then(|s| s.parse::().ok()) .unwrap_or(default) } ================================================ FILE: src/app/sel_info.rs ================================================ use { super::{ Selection, SelectionType, }, crate::{ stage::Stage, verb::FileTypeCondition, }, std::path::Path, }; /// Information regarding a potentially multiple set of selected paths #[derive(Debug, Clone, Copy)] pub enum SelInfo<'s> { None, One(Selection<'s>), More(&'s Stage), // by contract the stage contains at least 2 paths } impl<'a> SelInfo<'a> { pub fn to_selections(&self) -> Vec> { match self { SelInfo::None => Vec::new(), SelInfo::One(sel) => vec![*sel], SelInfo::More(stage) => stage .paths() .iter() .map(|path| Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, // OK, I don't know }) .collect(), } } #[must_use] pub fn from_path(path: &'a Path) -> Self { Self::One(Selection { stype: SelectionType::from(path), line: 0, path, is_exe: false, // OK, I don't know }) } #[must_use] pub fn count_paths(&self) -> usize { match self { SelInfo::None => 0, SelInfo::One(_) => 1, SelInfo::More(stage) => stage.len(), } } #[must_use] pub fn is_accepted_by( &self, condition: FileTypeCondition, ) -> bool { match self { SelInfo::None => true, SelInfo::One(sel) => condition.accepts_path(sel.path), SelInfo::More(stage) => { for path in stage.paths() { if !condition.accepts_path(path) { return false; } } true } } } #[must_use] pub fn common_stype(&self) -> Option { match self { SelInfo::None => None, SelInfo::One(sel) => Some(sel.stype), SelInfo::More(stage) => { let stype = SelectionType::from(&stage.paths()[0]); for path in stage.paths().iter().skip(1) { if stype != SelectionType::from(path) { return None; } } Some(stype) } } } #[must_use] pub fn one_sel(self) -> Option> { match self { SelInfo::One(sel) => Some(sel), _ => None, } } #[must_use] pub fn first_sel(self) -> Option> { match self { SelInfo::One(sel) => Some(sel), SelInfo::More(stage) => stage.paths().first().map(|path| Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, }), SelInfo::None => None, } } #[must_use] pub fn one_path(self) -> Option<&'a Path> { self.one_sel().map(|sel| sel.path) } #[must_use] pub fn extension(&self) -> Option<&str> { match self { SelInfo::None => None, SelInfo::One(sel) => sel.path.extension().and_then(|e| e.to_str()), SelInfo::More(stage) => { let common_extension = stage.paths()[0].extension().and_then(|e| e.to_str()); #[allow(clippy::question_mark)] if common_extension.is_none() { return None; } for path in stage.paths().iter().skip(1) { let extension = path.extension().and_then(|e| e.to_str()); if extension != common_extension { return None; } } common_extension } } } } ================================================ FILE: src/app/selection.rs ================================================ use { super::{ AppContext, CmdResult, }, crate::{ errors::ProgramError, launchable::Launchable, }, std::{ fs::OpenOptions, io::Write, path::Path, }, }; /// the id of a line, starting at 1 /// (0 if not specified) pub type LineNumber = usize; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum SelectionType { File, Directory, Any, } /// light information about the currently selected /// file and maybe line number #[derive(Debug, Clone, Copy)] pub struct Selection<'s> { pub path: &'s Path, pub line: LineNumber, // the line number in the file (0 if none selected) pub stype: SelectionType, pub is_exe: bool, } impl SelectionType { #[must_use] pub fn respects( self, constraint: Self, ) -> bool { constraint == Self::Any || self == constraint } #[must_use] pub fn is_respected_by( self, sel_type: Option, ) -> bool { match (self, sel_type) { (Self::File, Some(Self::File)) => true, (Self::Directory, Some(Self::Directory)) => true, (Self::Any, _) => true, _ => false, } } #[must_use] pub fn from(path: &Path) -> Self { if path.is_dir() { Self::Directory } else { Self::File } } } impl Selection<'_> { /// build a `CmdResult` with a launchable which will be used to /// open the relevant file the best possible way pub fn to_opener( self, con: &AppContext, ) -> Result { Ok(if self.is_exe { let path = self.path.to_string_lossy().to_string(); if let Some(export_path) = &con.launch_args.outcmd { // broot was launched as br, we can launch the executable from the shell let f = OpenOptions::new().append(true).open(export_path)?; writeln!(&f, "{path}")?; CmdResult::Quit } else { CmdResult::from(Launchable::program( vec![path], None, // we don't set the working directory true, // we switch the terminal during execution con, )?) } } else { CmdResult::from(Launchable::opener(self.path.to_path_buf())) }) } } ================================================ FILE: src/app/standard_status.rs ================================================ use { super::*, crate::verb::{ Internal, VerbStore, }, }; /// All the precomputed status which don't involve a verb pub struct StandardStatus { tree_top_focus: String, // go up (if not at root) tree_dir_focus: String, tree_dir_cd: Option, // TODO check outcmd tree_file_open_stay: Option, tree_file_open_stay_long: Option, tree_file_open_leave: Option, tree_unfiltered: String, tree_filtered: String, preview_unfiltered: String, // ctrl-left to close, or a pattern to filter preview_filtered: Option, preview_restorable_filter: Option, not_first_state: String, // "esc to go back" help: String, no_verb: String, pub all_files_hidden: Option, pub all_files_ignored: Option, } impl StandardStatus { #[must_use] pub fn new(verb_store: &VerbStore) -> Self { let tree_top_focus = "*enter* to go up".to_string(); // enter is hardcoded on focus let tree_dir_focus = "*enter* to focus".to_string(); let tree_dir_cd = verb_store .key_desc_of_internal_stype(Internal::open_leave, SelectionType::Directory) .map(|k| format!("*{k}* to cd")); let tree_file_open_stay = verb_store .key_desc_of_internal_stype(Internal::open_stay, SelectionType::File) .map(|k| format!("*{k}* to open")); let tree_file_open_stay_long = verb_store .key_desc_of_internal_stype(Internal::open_stay, SelectionType::File) .map(|k| format!("*{k}* to open the file")); let tree_file_open_leave = verb_store .key_desc_of_internal_stype(Internal::open_leave, SelectionType::File) .map(|k| format!("*{k}* to open and quit")); //let tree_file_enter = None; // TODO (for when enter is customized) let tree_unfiltered = "a few letters to search".to_string(); let tree_filtered = "*esc* to clear the filter".to_string(); let preview_unfiltered = "a pattern to filter".to_string(); let preview_filtered = verb_store .key_desc_of_internal(Internal::panel_right) .map(|k| format!("*{k}* to reveal the text")); let preview_restorable_filter = verb_store .key_desc_of_internal(Internal::panel_left_no_open) .map(|k| format!("*{k}* to restore the filter")); let not_first_state = "*esc* to go back".to_string(); let help = "*?* for help".to_string(); let no_verb = "a space then a verb".to_string(); let all_files_hidden = verb_store .key_desc_of_internal(Internal::toggle_hidden) .map(|k| format!("Some files are hidden, use *{k}* to display them")); let all_files_ignored = verb_store .key_desc_of_internal(Internal::toggle_ignore) .or(verb_store.key_desc_of_internal(Internal::toggle_git_ignore)) .map(|k| format!("Some files are ignored, use *{k}* to display them")); Self { tree_top_focus, tree_dir_focus, tree_dir_cd, tree_file_open_stay, tree_file_open_stay_long, tree_file_open_leave, //tree_file_enter, tree_unfiltered, tree_filtered, preview_unfiltered, preview_filtered, preview_restorable_filter, not_first_state, help, no_verb, all_files_hidden, all_files_ignored, } } #[must_use] pub fn builder<'s>( &'s self, state_type: PanelStateType, selection: Selection<'s>, width: usize, // available width ) -> StandardStatusBuilder<'s> { StandardStatusBuilder::new(self, state_type, selection, width) } } #[derive(Default)] struct StatusParts<'b> { md_parts: Vec<&'b str>, } impl<'b> StatusParts<'b> { fn add( &mut self, md: &'b str, ) { self.md_parts.push(md); } fn addo( &mut self, md: &'b Option, ) { if let Some(md) = md { self.md_parts.push(md); } } fn len(&self) -> usize { self.md_parts.len() } /// Build the markdown of the complete status by combining parts /// while not going much over the available width so that we /// don't have too much elision (otherwise it would be too hard to read) fn to_status( &self, available_width: usize, ) -> Status { let mut md = String::new(); // notes about the truncation: // - in case of truncation, we don't use the long ", or " // separator. It's OK, assuming truncation is only for // when the screen is very small, and not the standard case. let mut sum_len = 0; let max_len = available_width + 3; for (i, p) in self.md_parts.iter().enumerate() { let sep = if i == 0 { "Hit " } else if i == self.md_parts.len() - 1 { ", or " } else { ", " }; sum_len += sep.len() + p.len() - 2; // -2 is an estimate of hidden chars if i > 0 && sum_len > max_len { break; } md.push_str(sep); md.push_str(p); } Status::from_message(md) } } pub struct StandardStatusBuilder<'s> { ss: &'s StandardStatus, state_type: PanelStateType, selection: Selection<'s>, pub has_previous_state: bool, pub is_filtered: bool, pub has_removed_pattern: bool, pub on_tree_root: bool, // should this be part of the Selection struct ? pub width: usize, // available width } impl<'s> StandardStatusBuilder<'s> { fn new( ss: &'s StandardStatus, state_type: PanelStateType, selection: Selection<'s>, width: usize, // available width ) -> Self { Self { ss, state_type, selection, has_previous_state: true, is_filtered: false, has_removed_pattern: false, on_tree_root: false, width, } } pub fn status(self) -> Status { let ss = &self.ss; let mut parts = StatusParts::default(); if self.has_previous_state && !self.is_filtered { parts.add(&ss.not_first_state); } match self.state_type { PanelStateType::Tree => { if self.on_tree_root { if self.selection.path.file_name().is_some() { // it's not '/' parts.add(&ss.tree_top_focus); } } else if self.selection.stype == SelectionType::Directory { parts.add(&ss.tree_dir_focus); parts.addo(&ss.tree_dir_cd); } else if self.selection.stype == SelectionType::File { // maybe add "ctrl-right to preview" ? Or just sometimes ? // (need check no preview) if self.width > 105 { parts.addo(&ss.tree_file_open_stay_long); } else { parts.addo(&ss.tree_file_open_stay); } parts.addo(&ss.tree_file_open_leave); } if self.is_filtered { parts.add(&ss.tree_filtered); } if parts.len() < 3 { parts.add(&ss.help); } if parts.len() < 4 { if self.on_tree_root && !self.is_filtered { parts.add(&ss.tree_unfiltered); } else { parts.add(&ss.no_verb); } } } PanelStateType::Preview => { if self.is_filtered { parts.addo(&ss.preview_filtered); } else if self.has_removed_pattern { parts.addo(&ss.preview_restorable_filter); } else { parts.add(&ss.preview_unfiltered); } parts.add(&ss.no_verb); } PanelStateType::Help => { // not yet used, help_state has its own hard status if parts.len() < 4 { parts.add(&ss.no_verb); } } PanelStateType::Fs => { // TODO fs status } PanelStateType::Stage => { // TODO stage status } PanelStateType::Trash => { // TODO stage status ? Maybe the shortcuts to restore or delete ? } } parts.to_status(self.width) } } ================================================ FILE: src/app/state_type.rs ================================================ use serde::{ Deserialize, Serialize, }; /// one of the types of state that you could /// find in a panel today #[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum PanelStateType { /// filesystems Fs, /// help "screen" Help, /// preview panel, never alone on screen Preview, /// stage panel, never alone on screen Stage, /// content of the trash Trash, /// standard browsing tree Tree, } ================================================ FILE: src/app/status.rs ================================================ /// the status contains information written on the grey line /// near the bottom of the screen #[derive(Debug, Clone)] pub struct Status { pub message: String, // markdown pub error: bool, // is the current message an error? } impl Status { pub fn new>( message: S, error: bool, ) -> Status { Self { message: message.into(), error, } } pub fn from_message>(message: S) -> Status { Self { message: message.into(), error: false, } } pub fn from_error>(message: S) -> Status { Self { message: message.into(), error: true, } } } ================================================ FILE: src/browser/browser_state.rs ================================================ use { crate::{ app::*, command::*, display::*, errors::{ProgramError, TreeBuildError}, flag::Flag, git, path::{self, PathAnchor}, pattern::*, print, stage::*, task_sync::Dam, tree::*, tree_build::TreeBuilder, verb::*, }, opener, std::path::{Path, PathBuf}, }; /// An application state dedicated to displaying a tree. /// It's the first and main screen of broot. pub struct BrowserState { pub tree: Tree, pub filtered_tree: Option, mode: Mode, // whether we're in 'input' or 'normal' mode pending_task: Option, // note: there are some other pending task, see } /// A task that can be computed in background #[derive(Debug)] enum BrowserTask { Search { pattern: InputPattern, total: bool, }, StageAll { pattern: InputPattern, file_type_condition: FileTypeCondition, }, } impl BrowserState { /// build a new tree state if there's no error and there's no cancellation. pub fn new( path: PathBuf, mut options: TreeOptions, screen: Screen, con: &AppContext, dam: &Dam, ) -> Result { // on windows, canonicalize the path produces UNC paths, so we don't do it. // On other platforms, it's a desirable step, mainly because it simplifies the // paths you'd get for example when focusing a relative symlink containing "..". #[cfg(not(target_os = "windows"))] let path = path.canonicalize().unwrap_or(path); let pending_task = options .pattern .take() .as_option() .map(|pattern| BrowserTask::Search { pattern, total: false, }); let builder = TreeBuilder::from(path, options, BrowserState::page_height(screen), con)?; let tree = builder.build_tree(false, dam)?; Ok(BrowserState { tree, filtered_tree: None, mode: con.initial_mode(), pending_task, }) } fn search( &mut self, pattern: InputPattern, total: bool, ) { self.pending_task = Some(BrowserTask::Search { pattern, total }); } /// build a cmdResult asking for the addition of a new state /// being a browser state similar to the current one but with /// different options or a different root, or both fn modified( &self, screen: Screen, root: PathBuf, options: TreeOptions, message: Option<&'static str>, in_new_panel: bool, con: &AppContext, ) -> CmdResult { let tree = self.displayed_tree(); let mut new_state = BrowserState::new(root, options, screen, con, &Dam::unlimited()); if let Ok(bs) = &mut new_state { if tree.selection != 0 { bs.displayed_tree_mut() .try_select_path(&tree.selected_line().path); } } CmdResult::from_optional_browser_state(new_state, message, in_new_panel) } pub fn root(&self) -> &Path { self.tree.root() } pub fn page_height(screen: Screen) -> usize { screen.height as usize - 2 // br shouldn't be displayed when the screen is smaller } /// return a reference to the currently displayed tree, which /// is the filtered tree if there's one, the base tree if not. pub fn displayed_tree(&self) -> &Tree { self.filtered_tree.as_ref().unwrap_or(&self.tree) } /// return a mutable reference to the currently displayed tree, which /// is the filtered tree if there's one, the base tree if not. pub fn displayed_tree_mut(&mut self) -> &mut Tree { self.filtered_tree.as_mut().unwrap_or(&mut self.tree) } pub fn open_selection_stay_in_broot( &mut self, screen: Screen, con: &AppContext, in_new_panel: bool, keep_pattern: bool, ) -> Result { let tree = self.displayed_tree(); let line = tree.selected_line(); let mut target = line.target().to_path_buf(); if line.is_dir() { if tree.selection == 0 { // opening the root would be going to where we already are. // We go up one level instead if let Some(parent) = target.parent() { target = PathBuf::from(parent); } } let dam = Dam::unlimited(); Ok(CmdResult::from_optional_browser_state( BrowserState::new( target, if keep_pattern { tree.options.clone() } else { tree.options.without_pattern() }, screen, con, &dam, ), None, in_new_panel, )) } else { match opener::open(&target) { Ok(exit_status) => { info!("open returned with exit_status {exit_status:?}"); Ok(CmdResult::Keep) } Err(e) => Ok(CmdResult::error(format!("{e:?}"))), } } } pub fn go_to_parent( &mut self, screen: Screen, con: &AppContext, in_new_panel: bool, ) -> CmdResult { match &self.displayed_tree().selected_line().path.parent() { Some(path) => CmdResult::from_optional_browser_state( BrowserState::new( path.to_path_buf(), self.displayed_tree().options.without_pattern(), screen, con, &Dam::unlimited(), ), None, in_new_panel, ), None => CmdResult::error("no parent found"), } } } impl PanelState for BrowserState { fn tree_root(&self) -> Option<&Path> { Some(self.root()) } fn get_type(&self) -> PanelStateType { PanelStateType::Tree } fn set_mode( &mut self, mode: Mode, ) { self.mode = mode; } fn get_mode(&self) -> Mode { self.mode } fn get_pending_task(&self) -> Option<&'static str> { if self.displayed_tree().has_dir_missing_sum() { Some("computing stats") } else if self.displayed_tree().is_missing_git_status_computation() { Some("computing git status") } else { self.pending_task.as_ref().map(|task| match task { BrowserTask::Search { .. } => "searching", BrowserTask::StageAll { .. } => "staging", }) } } fn watchable_paths(&self) -> Vec { let mut paths = Vec::new(); for line in &self.tree.lines { paths.push(line.path.clone()); } paths } fn selected_path(&self) -> Option<&Path> { Some(&self.displayed_tree().selected_line().path) } fn selection(&self) -> Option> { let tree = self.displayed_tree(); let mut selection = tree.selected_line().as_selection(); selection.line = tree .options .pattern .pattern .get_match_line_count(selection.path) .unwrap_or(0); Some(selection) } fn tree_options(&self) -> TreeOptions { self.displayed_tree().options.clone() } /// build a cmdResult asking for the addition of a new state /// being a browser state similar to the current one but with /// different options fn with_new_options( &mut self, screen: Screen, change_options: &dyn Fn(&mut TreeOptions) -> &'static str, in_new_panel: bool, con: &AppContext, ) -> CmdResult { let tree = self.displayed_tree(); let mut options = tree.options.clone(); let message = change_options(&mut options); let message = Some(message); self.modified( screen, tree.root().clone(), options, message, in_new_panel, con, ) } fn clear_pending(&mut self) { self.pending_task = None; } fn on_click( &mut self, _x: u16, y: u16, _screen: Screen, _con: &AppContext, ) -> Result { self.displayed_tree_mut().try_select_y(y as usize); Ok(CmdResult::Keep) } fn on_double_click( &mut self, _x: u16, y: u16, screen: Screen, con: &AppContext, ) -> Result { if self.displayed_tree().selection == y as usize { self.open_selection_stay_in_broot(screen, con, false, false) } else { // A double click always come after a simple click at // same position. If it's not the selected line, it means // the click wasn't on a selectable/openable tree line Ok(CmdResult::Keep) } } fn on_pattern( &mut self, pat: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { if pat.is_none() { self.filtered_tree = None; } if let Some(filtered_tree) = &self.filtered_tree { if pat != filtered_tree.options.pattern { self.search(pat, false); } } else { self.search(pat, false); } Ok(CmdResult::Keep) } 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 { debug!("browser_state on_internal {internal_exec:?}"); let con = &cc.app.con; let screen = cc.app.screen; let page_height = BrowserState::page_height(cc.app.screen); let bang = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); Ok(match internal_exec.internal { Internal::back => { if let Some(filtered_tree) = &self.filtered_tree { let filtered_selection = &filtered_tree.selected_line().path; if self.tree.try_select_path(filtered_selection) { self.tree.make_selection_visible(page_height); } self.filtered_tree = None; CmdResult::Keep } else if self.tree.selection > 0 { self.tree.selection = 0; CmdResult::Keep } else { CmdResult::PopState } } Internal::focus => { let tree = self.displayed_tree(); internal_focus::on_internal( internal_exec, input_invocation, trigger_type, &tree.selected_line().path, tree.is_root_selected(), tree.options.clone(), app_state, cc, ) } Internal::line_down => { let count = get_arg(input_invocation, internal_exec, 1); self.displayed_tree_mut() .move_selection(count, page_height, true); CmdResult::Keep } Internal::line_down_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.displayed_tree_mut() .move_selection(count, page_height, false); CmdResult::Keep } Internal::line_up => { let count = get_arg(input_invocation, internal_exec, 1); self.displayed_tree_mut() .move_selection(-count, page_height, true); CmdResult::Keep } Internal::line_up_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.displayed_tree_mut() .move_selection(-count, page_height, false); CmdResult::Keep } Internal::next_dir => { self.displayed_tree_mut() .try_select_next_filtered(TreeLine::is_dir, page_height); CmdResult::Keep } Internal::next_match => { self.displayed_tree_mut() .try_select_next_filtered(|line| line.direct_match, page_height); CmdResult::Keep } Internal::next_same_depth => { self.displayed_tree_mut() .try_select_next_same_depth(page_height); CmdResult::Keep } Internal::open_stay => self.open_selection_stay_in_broot(screen, con, bang, false)?, Internal::open_stay_filter => { self.open_selection_stay_in_broot(screen, con, bang, true)? } Internal::page_down => { let tree = self.displayed_tree_mut(); if !tree.try_scroll(page_height as i32, page_height) { tree.try_select_last(page_height); } CmdResult::Keep } Internal::page_up => { let tree = self.displayed_tree_mut(); if !tree.try_scroll(-(page_height as i32), page_height) { tree.try_select_first(); } CmdResult::Keep } Internal::panel_left => { let areas = &cc.panel.areas; if areas.is_first() && areas.nb_pos < con.max_panels_count { // we ask for the creation of a panel to the left internal_focus::new_panel_on_path( self.displayed_tree().selected_line().path.clone(), screen, self.displayed_tree().options.clone(), PanelPurpose::None, con, HDir::Left, ) } else { // we let the app handle other cases CmdResult::HandleInApp(Internal::panel_left_no_open) } } Internal::panel_left_no_open => CmdResult::HandleInApp(Internal::panel_left_no_open), Internal::panel_right => { let areas = &cc.panel.areas; let selected_path = &self.displayed_tree().selected_line().path; if areas.is_last() && areas.nb_pos < con.max_panels_count { let purpose = if selected_path.is_file() && cc.app.preview_panel.is_none() { PanelPurpose::Preview } else { PanelPurpose::None }; // we ask for the creation of a panel to the right internal_focus::new_panel_on_path( selected_path.clone(), screen, self.displayed_tree().options.clone(), purpose, con, HDir::Right, ) } else { // we ask the app to handle other cases : // focus the panel to the right or close the leftest one CmdResult::HandleInApp(Internal::panel_right_no_open) } } Internal::panel_right_no_open => CmdResult::HandleInApp(Internal::panel_right_no_open), Internal::parent => self.go_to_parent(screen, con, bang), Internal::previous_dir => { self.displayed_tree_mut() .try_select_previous_filtered(TreeLine::is_dir, page_height); CmdResult::Keep } Internal::previous_match => { self.displayed_tree_mut() .try_select_previous_filtered(|line| line.direct_match, page_height); CmdResult::Keep } Internal::previous_same_depth => { self.displayed_tree_mut() .try_select_previous_same_depth(page_height); CmdResult::Keep } Internal::print_tree => { print::print_tree(self.displayed_tree(), cc.app.screen, cc.app.panel_skin, con)? } Internal::quit => CmdResult::Quit, Internal::root_down => { let tree = self.displayed_tree(); if tree.selection > 0 { let root_len = tree.root().components().count(); let new_root = tree .selected_line() .path .components() .take(root_len + 1) .collect(); self.modified(screen, new_root, tree.options.clone(), None, bang, con) } else { CmdResult::error("No selected line") } } Internal::root_up => { let tree = self.displayed_tree(); let root = tree.root(); if let Some(new_root) = root.parent() { self.modified( screen, new_root.to_path_buf(), tree.options.clone(), None, bang, con, ) } else { CmdResult::error(format!("{root:?} has no parent")) } } Internal::search_again => { match self.filtered_tree.as_ref().map(|t| t.total_search) { None => { // we delegate to the app the task of looking for a preview pattern // used before this state CmdResult::HandleInApp(Internal::search_again) } Some(true) => CmdResult::error( "search was already total: all possible matches have been ranked", ), Some(false) => { self.search(self.displayed_tree().options.pattern.clone(), true); CmdResult::Keep } } } Internal::select => internal_select::on_internal( internal_exec, input_invocation, trigger_type, self.displayed_tree_mut(), app_state, cc, ), Internal::select_first => { self.displayed_tree_mut().try_select_first(); CmdResult::Keep } Internal::select_last => { let page_height = BrowserState::page_height(screen); self.displayed_tree_mut().try_select_last(page_height); CmdResult::Keep } Internal::show => { let path = internal_path::determine_path( internal_exec, input_invocation, trigger_type, self.displayed_tree(), app_state, cc, ); match path { Some(path) => { let res = self.displayed_tree_mut().show_path(&path, con); match res { Ok(()) => { let page_height = BrowserState::page_height(screen); self.displayed_tree_mut() .make_selection_visible(page_height); CmdResult::Keep } Err(e) => CmdResult::DisplayError(format!("{e}")), } } None => CmdResult::Keep, } } Internal::stage_all_directories => { let pattern = self.displayed_tree().options.pattern.clone(); let file_type_condition = FileTypeCondition::Directory; self.pending_task = Some(BrowserTask::StageAll { pattern, file_type_condition, }); if cc.app.stage_panel.is_none() { let stage_options = self.tree.options.without_pattern(); CmdResult::NewPanel { state: Box::new(StageState::new(app_state, stage_options, con)), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::Keep } } Internal::stage_all_files => { let pattern = self.displayed_tree().options.pattern.clone(); let file_type_condition = FileTypeCondition::File; self.pending_task = Some(BrowserTask::StageAll { pattern, file_type_condition, }); if cc.app.stage_panel.is_none() { let stage_options = self.tree.options.without_pattern(); CmdResult::NewPanel { state: Box::new(StageState::new(app_state, stage_options, con)), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::Keep } } Internal::start_end_panel => { if cc.panel.purpose.is_arg_edition() { debug!("start_end understood as end"); CmdResult::ClosePanel { validate_purpose: true, panel_ref: PanelReference::Active, clear_cache: false, } } else { debug!("start_end understood as start"); let tree_options = self.displayed_tree().options.clone(); if let Some(input_invocation) = input_invocation { // we'll go for input arg editing let path = if let Some(input_arg) = &input_invocation.args { path::path_from(self.root(), PathAnchor::Unspecified, input_arg) } else { self.root().to_path_buf() }; let arg_type = SelectionType::Any; // We might do better later let purpose = PanelPurpose::ArgEdition { arg_type }; internal_focus::new_panel_on_path( path, screen, tree_options, purpose, con, HDir::Right, ) } else { // we just open a new panel on the selected path, // without purpose internal_focus::new_panel_on_path( self.displayed_tree().selected_line().path.clone(), screen, tree_options, PanelPurpose::None, con, HDir::Right, ) } } } Internal::total_search => match self.filtered_tree.as_ref().map(|t| t.total_search) { None => CmdResult::error("this verb can be used only after a search"), Some(true) => CmdResult::error( "search was already total: all possible matches have been ranked", ), Some(false) => { self.search(self.displayed_tree().options.pattern.clone(), true); CmdResult::Keep } }, Internal::trash => { let path = self.displayed_tree().selected_line().path.clone(); info!("trash {:?}", &path); #[cfg(any(target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android")))))] match trash::delete(&path) { Ok(()) => CmdResult::RefreshState { clear_cache: true }, Err(e) => { warn!("trash error: {:?}", &e); CmdResult::DisplayError(format!("trash error: {:?}", &e)) } } #[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android"))))))] CmdResult::DisplayError("trash not supported on this platform".into()) } Internal::up_tree => match self.displayed_tree().root().parent() { Some(path) => internal_focus::on_path( path.to_path_buf(), screen, self.displayed_tree().options.clone(), bang, con, ), None => CmdResult::error("no parent found"), }, _ => self.on_internal_generic( w, invocation_parser, internal_exec, input_invocation, trigger_type, app_state, cc, )?, }) } fn no_verb_status( &self, has_previous_state: bool, con: &AppContext, width: usize, ) -> Status { let tree = self.displayed_tree(); if tree.is_empty() && tree.build_report.hidden_count > 0 { let mut parts = Vec::new(); if let Some(md) = con.standard_status.all_files_hidden.clone() { parts.push(md); } if let Some(md) = con.standard_status.all_files_ignored.clone() { parts.push(md); } if !parts.is_empty() { return Status::from_error(parts.join(". ")); } } let mut ssb = con.standard_status.builder( PanelStateType::Tree, tree.selected_line().as_selection(), width, ); ssb.has_previous_state = has_previous_state; ssb.is_filtered = self.filtered_tree.is_some(); ssb.has_removed_pattern = false; ssb.on_tree_root = tree.selection == 0; ssb.status() } /// do some work, totally or partially, if there's some to do. /// Stop as soon as the dam asks for interruption fn do_pending_task( &mut self, app_state: &mut AppState, screen: Screen, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { if let Some(pending_task) = self.pending_task.take() { match pending_task { BrowserTask::Search { pattern, total } => { let pattern_str = pattern.raw.clone(); let mut options = self.tree.options.clone(); options.pattern = pattern; let root = self.tree.root().clone(); let page_height = BrowserState::page_height(screen); let builder = TreeBuilder::from(root, options, page_height, con)?; let filtered_tree = time!( Info, "tree filtering", &pattern_str, builder.build_tree(total, dam), ); if let Ok(mut ft) = filtered_tree { ft.try_select_best_match(); ft.make_selection_visible(BrowserState::page_height(screen)); self.filtered_tree = Some(ft); } } BrowserTask::StageAll { pattern, file_type_condition, } => { debug!("stage all pattern: {pattern:?}"); let tree = self.displayed_tree(); let root = tree.root().clone(); let mut options = tree.options.clone(); let total_search = true; options.pattern = pattern; // should be the same let builder = TreeBuilder::from(root, options, con.max_staged_count, con); let mut paths = builder.and_then(|mut builder| { builder.matches_max = Some(con.max_staged_count); time!(builder.build_paths(total_search, dam, |line| { debug!("??staging {:?}", &line.path); file_type_condition.accepts_path(&line.path) })) })?; for path in paths.drain(..) { app_state.stage.add(path); } } } } else if self.displayed_tree().is_missing_git_status_computation() { let root_path = self.displayed_tree().root(); let git_status = git::get_tree_status(root_path, dam); self.displayed_tree_mut().git_status = git_status; } else { self.displayed_tree_mut() .fetch_some_missing_dir_sum(dam, con); } Ok(()) } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError> { let dp = DisplayableTree { app_state: Some(disc.app_state), tree: self.displayed_tree(), skin: &disc.panel_skin.styles, ext_colors: &disc.con.ext_colors, area: disc.state_area.clone(), in_app: true, }; dp.write_on(w) } fn refresh( &mut self, screen: Screen, con: &AppContext, ) -> Command { let page_height = BrowserState::page_height(screen); // refresh the base tree if let Err(e) = self.tree.refresh(page_height, con) { warn!("refreshing base tree failed : {e:?}"); } // refresh the filtered tree, if any Command::from_pattern(match self.filtered_tree { Some(ref mut tree) => { if let Err(e) = tree.refresh(page_height, con) { warn!("refreshing filtered tree failed : {e:?}"); } &tree.options.pattern } None => &self.tree.options.pattern, }) } fn get_flags(&self) -> Vec { let options = &self.displayed_tree().options; vec![ Flag { name: "h", value: if options.show_hidden { "y" } else { "n" }, }, Flag { name: "gi", value: if options.respect_git_ignore { "y" } else { "n" }, }, ] } fn get_starting_input(&self) -> String { if let Some(BrowserTask::Search { pattern, .. }) = self.pending_task.as_ref() { pattern.raw.clone() } else { self.displayed_tree().options.pattern.raw.clone() } } } ================================================ FILE: src/browser/mod.rs ================================================ mod browser_state; pub use browser_state::BrowserState; ================================================ FILE: src/cli/args.rs ================================================ // Warning: this module can't import broot's stuff due to its use in build.rs use { clap::{ Parser, ValueEnum, }, std::{ path::PathBuf, str::FromStr, }, }; /// Launch arguments #[derive(Debug, Parser)] #[command(about, version, disable_version_flag = true, disable_help_flag = true)] pub struct Args { /// Print help information #[arg(long)] pub help: bool, /// print the version #[arg(long)] pub version: bool, /// Semicolon separated paths to specific config files #[arg(long, value_name = "paths")] pub conf: Option, /// Show the last modified date of files and directories #[arg(short, long)] pub dates: bool, /// Don't show the last modified date #[arg(short = 'D', long)] pub no_dates: bool, #[arg(short = 'f', long)] /// Only show folders pub only_folders: bool, /// Show folders and files alike #[arg(short = 'F', long)] pub no_only_folders: bool, /// Show filesystem info on top #[arg(long)] pub show_root_fs: bool, /// Only show trees up to a certain depth #[arg(long)] pub max_depth: Option, /// Show git statuses on files and stats on repo #[arg(short = 'g', long)] pub show_git_info: bool, /// Don't show git statuses on files and stats on repo #[arg(short = 'G', long)] pub no_show_git_info: bool, #[arg(long)] /// Only show files having an interesting git status, including hidden ones pub git_status: bool, #[arg(short = 'h', long)] /// Show hidden files pub hidden: bool, #[arg(short = 'H', long)] /// Don't show hidden files pub no_hidden: bool, #[arg(short = 'i', long)] /// Show git ignored files pub git_ignored: bool, #[arg(short = 'I', long)] /// Don't show git ignored files pub no_git_ignored: bool, #[arg(short = 'p', long)] /// Show permissions pub permissions: bool, #[arg(short = 'P', long)] /// Don't show permissions pub no_permissions: bool, #[arg(short = 's', long)] /// Show the size of files and directories pub sizes: bool, #[arg(short = 'S', long)] /// Don't show sizes pub no_sizes: bool, #[arg(long)] /// Sort by count (only show one level of the tree) pub sort_by_count: bool, #[arg(long)] /// Sort by date (only show one level of the tree) pub sort_by_date: bool, #[arg(long)] /// Sort by size (only show one level of the tree) pub sort_by_size: bool, #[arg(long)] /// Same as sort-by-type-dirs-first pub sort_by_type: bool, #[arg(long)] /// Do not show the tree, even if allowed by sorting mode. pub no_tree: bool, #[arg(long)] /// Show the tree, when allowed by sorting mode. pub tree: bool, #[arg(long)] /// Sort by type, directories first (only show one level of the tree) pub sort_by_type_dirs_first: bool, #[arg(long)] /// Sort by type, directories last (only show one level of the tree) pub sort_by_type_dirs_last: bool, /// Don't sort #[arg(long)] pub no_sort: bool, /// Sort by size, show ignored and hidden files #[arg(short, long)] pub whale_spotting: bool, /// No sort, no show hidden, no show git ignored #[arg(short = 'W', long)] pub no_whale_spotting: bool, /// Trim the root too and don't show a scrollbar #[arg(short = 't', long)] pub trim_root: bool, /// Don't trim the root level, show a scrollbar #[arg(short = 'T', long)] pub no_trim_root: bool, /// Where to write the produced cmd (if any) #[arg(long, value_name = "path")] pub outcmd: Option, /// Optional path for verbs using `:write_output` #[arg(long, value_name = "verb-output")] pub verb_output: Option, /// Semicolon separated commands to execute #[arg(short, long, value_name = "cmd")] pub cmd: Option, /// Whether to have styles and colors #[arg(long, default_value = "auto", value_name = "color")] pub color: TriBool, /// Height (if you don't want to fill the screen or for file export) #[arg(long, value_name = "height")] pub height: Option, /// Install or reinstall the br shell function #[arg(long)] pub install: bool, /// Manually set installation state #[arg(long, value_name = "state")] pub set_install_state: Option, /// Print to stdout the br function for a given shell #[arg(long, value_name = "shell")] pub print_shell_function: Option, /// A socket to listen to for commands #[cfg(unix)] #[arg(long, value_name = "socket")] pub listen: Option, /// create a random socket to listen to for commands #[cfg(unix)] #[arg(long)] pub listen_auto: bool, /// Ask for the current root of the remote broot #[cfg(unix)] #[arg(long)] pub get_root: bool, /// Write default conf files in given directory #[arg(long, value_name = "path")] pub write_default_conf: Option, /// A socket to send commands to #[cfg(unix)] #[arg(long, value_name = "socket")] pub send: Option, /// Root Directory pub root: Option, } /// This is an Option but I didn't find any way to configure /// clap to parse an Option as I want #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] pub enum TriBool { Auto, Yes, No, } impl TriBool { pub fn unwrap_or_else( self, f: F, ) -> bool where F: FnOnce() -> bool, { match self { Self::Auto => f(), Self::Yes => true, Self::No => false, } } } #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum CliShellInstallState { Undefined, // before any install, this is the initial state Refused, Installed, } impl FromStr for CliShellInstallState { type Err = String; fn from_str(state: &str) -> Result { match state { "undefined" => Ok(Self::Undefined), "refused" => Ok(Self::Refused), "installed" => Ok(Self::Installed), _ => Err(format!("unexpected install state: {state:?}")), } } } ================================================ FILE: src/cli/install_launch_args.rs ================================================ use { crate::{ cli::{ Args, CliShellInstallState, }, errors::ProgramError, }, std::env, }; /// launch arguments related to installation /// (not used by the application after the first step) pub struct InstallLaunchArgs { pub install: Option, // installation is required pub set_install_state: Option, // the state to set pub print_shell_function: Option, // shell function to print on stdout } impl InstallLaunchArgs { pub fn from(args: &Args) -> Result { let mut install = None; if let Ok(s) = env::var("BR_INSTALL") { if s == "yes" { install = Some(true); } else if s == "no" { install = Some(false); } else { warn!("Unexpected value of BR_INSTALL: {s:?}"); } } // the cli arguments may override the env var value if args.install { install = Some(true); } let print_shell_function = args.print_shell_function.clone(); let set_install_state = args.set_install_state; Ok(Self { install, set_install_state, print_shell_function, }) } } ================================================ FILE: src/cli/mod.rs ================================================ //! this module manages reading and translating //! the arguments passed on launch of the application. mod args; mod install_launch_args; pub use { args::*, install_launch_args::*, }; use { crate::{ app::{ App, AppContext, }, conf::{ Conf, write_default_conf_in, }, display, errors::ProgramError, launchable::Launchable, shell_install::{ ShellInstall, ShellInstallState, }, verb::VerbStore, }, clap::{ CommandFactory, Parser, }, clap_help::Printer, crokey::crossterm::{ QueueableCommand, cursor, event::{ DisableMouseCapture, EnableMouseCapture, }, terminal::{ EnterAlternateScreen, LeaveAlternateScreen, }, }, std::{ io::{ self, Write, }, path::PathBuf, }, }; static INTRO: &str = " **broot** lets you explore file hierarchies with a tree-like view, manipulate and preview files, launch actions, and define your own shortcuts. **broot** is best launched as `br`: this shell function gives you access to more commands, especially `cd.` The br shell function is interactively installed on first broot launch. Flags and options can be classically passed on launch but also written in the configuration file. Each flag has a counter-flag so that you can cancel at command line a flag which has been set in the configuration file. Complete documentation and tips at https://dystroy.org/broot "; /// run the application, and maybe return a launchable /// which must be run after broot pub fn run() -> Result, ProgramError> { // parse the launch arguments we got from cli let args = Args::parse(); let mut must_quit = false; if args.help { Printer::new(Args::command()) .with_max_width(110) .with("introduction", INTRO) .with("options", clap_help::TEMPLATE_OPTIONS_MERGED_VALUE) .without("author") .print_help(); must_quit = true; } if args.version { println!("broot {}", env!("CARGO_PKG_VERSION")); must_quit = true; } if let Some(dir) = &args.write_default_conf { write_default_conf_in(dir)?; must_quit = true; } // read the install related arguments let install_args = InstallLaunchArgs::from(&args)?; let mut shell_install = ShellInstall::new(install_args.install == Some(true)); // execute installation things required by launch args if let Some(state) = install_args.set_install_state { let state: ShellInstallState = state.into(); state.write(&shell_install)?; must_quit = true; } if let Some(shell) = &install_args.print_shell_function { ShellInstall::print(shell)?; must_quit = true; } if must_quit { return Ok(None); } // read the list of specific config files let specific_conf: Option> = args .conf .as_ref() .map(|s| s.split(';').map(PathBuf::from).collect()); // if we don't run on a specific config file, we check the // configuration if specific_conf.is_none() && install_args.install != Some(false) { // TODO clean the next few lines when inspect_err is stable let res = shell_install.check(); if let Err(e) = &res { shell_install.comment_error(e); } res?; if shell_install.should_quit { return Ok(None); } } // read the configuration file(s): either the standard one // or the ones required by the launch args let mut config = match &specific_conf { Some(conf_paths) => { let mut conf = Conf::default(); for path in conf_paths { conf.read_file(path.clone())?; } conf } _ => time!(Conf::from_default_location())?, }; debug!("config: {:#?}", &config); // verb store is completed from the config file(s) let verb_store = VerbStore::new(&mut config)?; let mut context = AppContext::from(args, verb_store, &config)?; #[cfg(unix)] if let Some(server_name) = &context.launch_args.send { use crate::{ command::Sequence, net::{ Client, Message, }, }; let client = Client::new(server_name); if let Some(seq) = &context.launch_args.cmd { let message = Message::Sequence(Sequence::new_local(seq.clone())); client.send(&message)?; } else if !context.launch_args.get_root { let message = Message::Command(format!(":focus {}", context.initial_root.to_string_lossy())); client.send(&message)?; } if context.launch_args.get_root { client.send(&Message::GetRoot)?; } return Ok(None); } let mut w = display::writer(); let app = App::new(&context)?; w.queue(EnterAlternateScreen)?; w.queue(cursor::Hide)?; if context.capture_mouse { w.queue(EnableMouseCapture)?; } let r = app.run(&mut w, &mut context, &config); w.flush()?; if context.capture_mouse { w.queue(DisableMouseCapture)?; } w.queue(cursor::Show)?; w.queue(LeaveAlternateScreen)?; w.flush()?; clear_resources(); r } fn clear_resources() { info!("clearing resources"); crate::kitty::manager().lock().unwrap().delete_temp_files(); } /// wait for user input, return `true` if they didn't answer 'n' pub fn ask_authorization() -> io::Result { let mut answer = String::new(); io::stdin().read_line(&mut answer)?; let answer = answer.trim(); Ok(!matches!(answer, "n" | "N")) } ================================================ FILE: src/command/command.rs ================================================ use { super::*, crate::{ pattern::*, verb::{ Internal, VerbId, VerbInvocation, VerbStore, Verb, }, }, bet::BeTree, }; /// a command which may result in a change in the application state. /// /// It may come from a shortcut, from the parsed input, from an argument /// given on launch. #[derive(Debug, Clone)] pub enum Command { /// no command None, /// a verb invocation, unfinished /// (user didn't hit enter) VerbEdit(VerbInvocation), /// verb invocation, finished /// (coming from --cmd, or after the user hit enter) VerbInvocate(VerbInvocation), /// call of an internal done without the input /// (using a trigger key for example) Internal { internal: Internal, input_invocation: Option, }, /// call of a verb done without the input /// (using a trigger key for example) VerbTrigger { verb_id: VerbId, input_invocation: Option, }, /// a pattern being edited PatternEdit { raw: String, expr: BeTree, }, /// a mouse click Click(u16, u16), /// a mouse double-click /// Always come after a simple click at same position DoubleClick(u16, u16), } impl Command { pub fn empty() -> Command { Command::None } pub fn is_none(&self) -> bool { matches!(self, Command::None) } pub fn triggered_verb<'v>( &self, verb_store: &'v VerbStore, ) -> Option<&'v Verb> { match self { Self::VerbTrigger { verb_id, .. } => Some(verb_store.verb(*verb_id)), _ => None, } } pub fn as_verb_invocation(&self) -> Option<&VerbInvocation> { match self { Self::VerbEdit(vi) => Some(vi), Self::VerbInvocate(vi) => Some(vi), Self::Internal { input_invocation, .. } => input_invocation.as_ref(), Self::VerbTrigger { input_invocation, .. } => input_invocation.as_ref(), _ => None, } } /// build a command from the parsed string representation /// /// The command being finished is the difference between /// a command being edited and a command launched (which /// happens on enter in the input). pub fn from_parts( mut cp: CommandParts, finished: bool, ) -> Self { if let Some(verb_invocation) = cp.verb_invocation.take() { if finished { Self::VerbInvocate(verb_invocation) } else { Self::VerbEdit(verb_invocation) } } else if finished { Self::Internal { internal: Internal::open_stay, input_invocation: None, } } else { Self::PatternEdit { raw: cp.raw_pattern, expr: cp.pattern, } } } /// tells whether this action is a verb being invocated on enter /// in the input field pub fn is_verb_invocated_from_input(&self) -> bool { matches!(self, Self::VerbInvocate(_)) } /// create a command from a raw input. /// /// `finished` makes the command an executed form, /// it's equivalent to using the Enter key in the Gui. pub fn from_raw( raw: String, finished: bool, ) -> Self { let parts = CommandParts::from(raw); Self::from_parts(parts, finished) } /// build a non executed command from a pattern pub fn from_pattern(pattern: &InputPattern) -> Self { Command::from_raw(pattern.raw.clone(), false) } } impl Default for Command { fn default() -> Command { Command::empty() } } ================================================ FILE: src/command/completion.rs ================================================ use { super::CommandParts, crate::{ app::*, path::{ self, PathAnchor, }, syntactic::SYNTAX_THEMES, verb::*, }, lazy_regex::regex_captures, std::{ io, path::Path, }, }; /// find the longest common start of a and b fn common_start<'l>( a: &'l str, b: &str, ) -> &'l str { let l = a.len().min(b.len()); for i in 0..l { if a.as_bytes()[i] != b.as_bytes()[i] { return &a[..i]; } } &a[..l] } /// how an input can be completed #[derive(Debug)] pub enum Completions { /// no completion found None, /// all possible completions have this common root Common(String), /// a list of possible completions List(Vec), } impl Completions { fn from_list(completions: Vec) -> Self { if completions.is_empty() { return Self::None; } let mut iter = completions.iter(); let mut common: &str = match iter.next() { Some(s) => s, _ => { return Self::None; } }; for c in iter { common = common_start(common, c); } if common.is_empty() { Self::List(completions) } else { Self::Common(common.to_string()) } } /// the wholes are assumed to all start with start. fn for_wholes( start: &str, wholes: &[&str], ) -> Self { let completions = wholes .iter() .map(|w| { if let Some(stripped) = w.strip_prefix(start) { stripped } else { // this might become a feature but right now it's a bug warn!("unexpected non completing whole: {w:?}"); *w } }) .map(ToString::to_string) .collect(); Self::from_list(completions) } fn for_verb( start: &str, con: &AppContext, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> Self { match con .verb_store .search(start, Some(sel_info), false, panel_state_type) { PrefixSearchResult::NoMatch => Self::None, PrefixSearchResult::Match(name, _) => { if start.len() >= name.len() { debug_assert!(name == start); Self::None } else { Self::Common(name[start.len()..].to_string()) } } PrefixSearchResult::Matches(completions) => Self::for_wholes(start, &completions), } } fn list_for_path( verb_name: &str, arg: &str, path: &Path, sel_info: SelInfo<'_>, con: &AppContext, panel_state_type: Option, ) -> io::Result> { let anchor = match con .verb_store .search(verb_name, Some(sel_info), false, panel_state_type) { PrefixSearchResult::Match(_, verb) => verb.get_unique_arg_anchor(), _ => PathAnchor::Unspecified, }; let (_, parent_part, child_part) = regex_captures!(r"^(.*?)([^/]*)$", arg).unwrap(); let parent = path::path_from(path, anchor, parent_part); let mut children = Vec::new(); if parent.exists() { for entry in parent.read_dir()? { let entry = entry?; let mut name = entry.file_name().to_string_lossy().to_string(); if !child_part.is_empty() { if !name.starts_with(child_part) { continue; } if name == child_part && entry.file_type()?.is_dir() { name = "/".to_string(); } else { name.drain(0..child_part.len()); } } children.push(name); } } else { debug!( "no path completion possible because {:?} doesn't exist", &parent ); } Ok(children) } /// we have a verb, we try to complete one of the args fn for_arg( verb_name: &str, arg: &str, con: &AppContext, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> Self { if arg.contains(' ') { return Self::None; } // we try to get the type of argument let is_theme = con .verb_store .search_sel_info_unique(verb_name, sel_info, panel_state_type) .and_then(|verb| verb.invocation_parser.as_ref()) .and_then(InvocationParser::get_unique_arg_def) .is_some_and(|arg_def| arg_def.has_flag(VerbArgFlag::Theme)); if is_theme { Self::for_theme_arg(arg) } else { Self::for_path_arg(verb_name, arg, con, sel_info, panel_state_type) } } /// we have a verb and it asks for a theme fn for_theme_arg(arg: &str) -> Self { let arg = arg.to_lowercase(); let completions: Vec = SYNTAX_THEMES .iter() .map(|st| st.name().to_lowercase()) .filter_map(|name| name.strip_prefix(&arg).map(ToString::to_string)) .collect(); Self::from_list(completions) } /// we have a verb and it asks for a path fn for_path_arg( verb_name: &str, arg: &str, con: &AppContext, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> Self { // in the future we might offer completion of other types // of arguments, maybe user supplied, but there's no use case // now so we'll just assume the user wants to complete a path. if arg.contains(' ') { return Self::None; } match &sel_info { SelInfo::None => Self::None, SelInfo::One(sel) => { match Self::list_for_path(verb_name, arg, sel.path, sel_info, con, panel_state_type) { Ok(list) => Self::from_list(list), Err(e) => { warn!("Error while trying to complete path: {e:?}"); Self::None } } } SelInfo::More(stage) => { // We're looking for the possible completions which // are valid for all elements of the stage let mut lists = stage.paths().iter().filter_map(|path| { Self::list_for_path(verb_name, arg, path, sel_info, con, panel_state_type).ok() }); let Some(mut list) = lists.next() else { return Self::None; }; for ol in lists { list.retain(|c| ol.contains(c)); if list.is_empty() { break; } } Self::from_list(list) } } } pub fn for_input( parts: &CommandParts, con: &AppContext, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> Self { match &parts.verb_invocation { Some(invocation) if !invocation.is_empty() => { match &invocation.args { None => { // looking into verb completion Self::for_verb(&invocation.name, con, sel_info, panel_state_type) } Some(args) if !args.is_empty() => { // looking into arg completion Self::for_arg(&invocation.name, args, con, sel_info, panel_state_type) } _ => { // nothing possible Self::None } } } _ => Self::None, // no possible completion if no verb invocation } } } ================================================ FILE: src/command/mod.rs ================================================ mod command; mod completion; mod panel_input; mod parts; mod scroll; mod sel; mod sequence; mod trigger_type; pub use { command::Command, completion::Completions, panel_input::PanelInput, parts::CommandParts, scroll::ScrollCommand, sel::move_sel, sequence::Sequence, trigger_type::TriggerType, }; ================================================ FILE: src/command/panel_input.rs ================================================ use { super::*, crate::{ app::*, display::W, errors::ProgramError, keys, skin::PanelSkin, verb::*, }, crokey::{ KeyCombination, crossterm::{ cursor, event::{ Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }, queue, }, key, }, termimad::{ Area, InputField, TimedEvent, }, }; /// Wrap the input of a panel, receive events and make commands pub struct PanelInput { pub input_field: InputField, tab_cycle_count: Option, // last displayed completion index input_before_cycle: Option, } impl PanelInput { pub fn new(area: Area) -> Self { Self { input_field: InputField::new(area), tab_cycle_count: None, input_before_cycle: None, } } pub fn set_content( &mut self, content: &str, ) { self.input_field.set_str(content); } pub fn get_content(&self) -> String { self.input_field.get_content() } pub fn display( &mut self, w: &mut W, active: bool, mode: Mode, mut area: Area, panel_skin: &PanelSkin, ) -> Result, ProgramError> { self.input_field .set_normal_style(panel_skin.styles.input.clone()); self.input_field.set_focus(active && mode == Mode::Input); if mode == Mode::Command && active { queue!(w, cursor::MoveTo(area.left, area.top))?; panel_skin.styles.mode_command_mark.queue_str(w, "C")?; area.width -= 1; area.left += 1; } self.input_field.set_area(area); let cursor_pos = self.input_field.display_on(w)?; Ok(cursor_pos) } /// consume the event to /// - maybe change the input /// - build a command /// then redraw the input field #[allow(clippy::too_many_arguments)] pub fn on_event( &mut self, w: &mut W, timed_event: &TimedEvent, app_panels: &AppPanels, app_state: &AppState, con: &AppContext, ) -> Result { let cmd = match timed_event { TimedEvent { event: Event::Mouse(MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE, }), .. } => self.on_mouse(timed_event, *kind, *column, *row), TimedEvent { key_combination: Some(key), .. } => self.on_key( timed_event, *key, app_panels, app_state, con, ), _ => Command::None, }; self.input_field.display_on(w)?; Ok(cmd) } /// check whether the verb is an action on the input (like /// deleting a word) and if it's the case, applies it and /// return true fn handle_input_related_verb( &mut self, verb: &Verb, _con: &AppContext, ) -> bool { if let VerbExecution::Internal(internal_exec) = &verb.execution { self.handle_input_related_internal(internal_exec.internal) } else { false } } /// Supporting direct calls of internals not on key events (eg from a verb /// having a cmd, or from a --cmd), update the input and build a command /// if the internal is an action on the input (like deleting a word) pub fn on_internal( &mut self, internal: Internal, ) -> Command { if self.handle_input_related_internal(internal) { Command::from_raw(self.input_field.get_content(), false) } else { Command::None } } /// check whether the internal is an action on the input (like /// deleting a word) and if it's the case, applies it and /// return true fn handle_input_related_internal( &mut self, internal: Internal, ) -> bool { match internal { Internal::input_clear => { if self.input_field.get_content().is_empty() { false } else { self.input_field.clear(); true } } Internal::input_del_char_left => self.input_field.del_char_left(), Internal::input_del_char_below => self.input_field.del_char_below(), Internal::input_del_word_left => self.input_field.del_word_left(), Internal::input_del_word_right => self.input_field.del_word_right(), Internal::input_go_left => self.input_field.move_left(), Internal::input_go_right => self.input_field.move_right(), Internal::input_go_word_left => self.input_field.move_word_left(), Internal::input_go_word_right => self.input_field.move_word_right(), Internal::input_go_to_start => self.input_field.move_to_start(), Internal::input_go_to_end => self.input_field.move_to_end(), #[cfg(feature = "clipboard")] Internal::input_selection_cut => { let s = self.input_field.cut_selection(); if let Err(err) = terminal_clipboard::set_string(s) { warn!("error in writing into clipboard: {}", err); } true } #[cfg(feature = "clipboard")] Internal::input_selection_copy => { let s = self.input_field.copy_selection(); if let Err(err) = terminal_clipboard::set_string(s) { warn!("error in writing into clipboard: {}", err); } true } #[cfg(feature = "clipboard")] Internal::input_paste => { match terminal_clipboard::get_string() { Ok(pasted) => { for c in pasted .chars() .filter(|c| c.is_alphanumeric() || c.is_ascii_punctuation()) { self.input_field.put_char(c); } } Err(e) => { warn!("Error in reading clipboard: {:?}", e); } } true } _ => false, } } /// when a key is used to enter input mode, we don't always /// consume it. Sometimes it should be consumed, sometimes it /// should be added to the input fn enter_input_mode_with_key( &mut self, key: KeyCombination, parts: &CommandParts, ) { if let Some(c) = key.as_letter() { let add = match c { // '/' if !parts.raw_pattern.is_empty() => true, ' ' if parts.verb_invocation.is_none() => true, ':' if parts.verb_invocation.is_none() => true, _ => false, }; if add { self.input_field.put_char(c); } } } /// escape (bound to the 'esc' key) /// /// This function is better called from the `on_key` method of /// panel input, when a key triggers it, because then it /// can also properly deal with completion sequence. /// When ':escape' is called from a verb's cmd sequence, then /// it's not called on `on_key` but by the app. pub fn escape( &mut self, mode: Mode, con: &AppContext, ) -> Command { self.tab_cycle_count = None; if let Some(raw) = self.input_before_cycle.take() { // we cancel the tab cycling self.input_field.set_str(&raw); self.input_before_cycle = None; Command::from_raw(raw, false) } else if con.modal && mode == Mode::Input { // leave insertion mode Command::Internal { internal: Internal::mode_command, input_invocation: None, } } else { // general back command let raw = self.input_field.get_content(); let parts = CommandParts::from(raw.clone()); self.input_field.clear(); let internal = Internal::back; Command::Internal { internal, input_invocation: parts.verb_invocation, } } } /// autocomplete a verb (bound to 'tab') fn auto_complete_verb( &mut self, con: &AppContext, sel_info: SelInfo<'_>, raw: String, parts: &CommandParts, panel_state_type: Option, backwards: bool, // backtab ) -> Command { let parts_before_cycle; let completable_parts = if let Some(s) = &self.input_before_cycle { parts_before_cycle = CommandParts::from(s.clone()); &parts_before_cycle } else { parts }; let completions = Completions::for_input( completable_parts, con, sel_info, panel_state_type, ); let added = match completions { Completions::None => { debug!("nothing to complete!"); self.tab_cycle_count = None; self.input_before_cycle = None; None } Completions::Common(completion) => { self.tab_cycle_count = None; Some(completion) } Completions::List(mut completions) => { // completions has a len > 1 let len = completions.len(); // self.tab_cycle_count is the next index to use, when going forward if self.tab_cycle_count.is_none() { self.input_before_cycle = Some(raw.clone()); } let idx = if backwards { match self.tab_cycle_count { Some(before) => (before + len - 1) % len, None => completions.len() - 1, } } else { match self.tab_cycle_count { Some(before) => (before + 1) % len, None => 0, } }; self.tab_cycle_count = Some(idx); Some(completions.swap_remove(idx)) } }; if let Some(added) = added { let mut raw = self .input_before_cycle .as_ref() .map_or(raw, ToString::to_string); raw.push_str(&added); self.input_field.set_str(&raw); Command::from_raw(raw, false) } else { Command::None } } fn find_key_verb<'c>( key: KeyCombination, panels: &AppPanels, app_state: &AppState, con: &'c AppContext, ) -> Option<&'c Verb> { let active_sel_info = panels.state().sel_info(app_state); for verb in con.verb_store.verbs() { // note that there can be several verbs with the same key and // not all of them can apply if !verb.keys.contains(&key) { continue; } let Some(panel_state) = panels.state_by_ref(verb.impacted_panel) else { continue; }; if !verb.can_be_called_in_panel(panel_state.get_type()) { continue; } let nasi; let sel_info = if verb.impacted_panel.is_default() { &active_sel_info } else { nasi = panel_state.sel_info(app_state); &nasi }; if !sel_info.is_accepted_by(verb.selection_condition) { continue; } if !verb.file_extensions.is_empty() { let extension = sel_info.extension(); if !extension.is_some_and(|ext| verb.file_extensions.iter().any(|ve| ve == ext)) { continue; } } return Some(verb); } None } /// Consume the event, maybe change the input, return a command fn on_mouse( &mut self, timed_event: &TimedEvent, kind: MouseEventKind, column: u16, row: u16, ) -> Command { if self.input_field.apply_timed_event(timed_event) { Command::empty() } else { match kind { MouseEventKind::Up(MouseButton::Left) => { if timed_event.double_click { Command::DoubleClick(column, row) } else { Command::Click(column, row) } } MouseEventKind::ScrollDown => Command::Internal { internal: Internal::line_down, input_invocation: None, }, MouseEventKind::ScrollUp => Command::Internal { internal: Internal::line_up, input_invocation: None, }, _ => Command::None, } } } fn is_key_allowed_for_verb( &self, key: KeyCombination, mode: Mode, ) -> bool { match mode { Mode::Input => match key { key!(left) => !self.input_field.can_move_left(), key!(right) => !self.input_field.can_move_right(), _ => !keys::is_key_only_modal(key), }, Mode::Command => true, } } /// Consume the event, maybe change the input, return a command #[allow(clippy::too_many_arguments)] fn on_key( &mut self, timed_event: &TimedEvent, key: KeyCombination, panels: &AppPanels, app_state: &AppState, con: &AppContext, ) -> Command { // value of raw and parts before any key related change let raw = self.input_field.get_content(); let parts = CommandParts::from(raw.clone()); // The mode we check is the one of the panel holding // the input, thus the active panel let mode = panels.state().get_mode(); let verb = if self.is_key_allowed_for_verb(key, mode) { Self::find_key_verb(key, panels, app_state, con) } else { None }; // WARNINGS: // - beware the execution order below: we must execute // escape before the else clause of next_match, and we must // be sure this else clause (which ends cycling) is always // executed when neither next_match or escape is triggered // - some behaviors can't really be handled as normally // triggered internals because of the interactions with // the input // usually 'esc' key if Verb::is_some_internal(verb, Internal::escape) { return self.escape(mode, con); } let mut panel_state = panels.state(); if let Some(verb) = verb { if let Some(ps) = panels.state_by_ref(verb.impacted_panel) { panel_state = ps; } } let sel_info = panel_state.sel_info(app_state); let panel_state_type = panel_state.get_type(); // 'tab' completion of a verb or one of its arguments if Verb::is_some_internal(verb, Internal::next_match) { if parts.verb_invocation.is_some() { return self.auto_complete_verb(con, sel_info, raw, &parts, Some(panel_state_type), false); } // if no verb is being edited, the state may handle this internal // in a specific way } else if Verb::is_some_internal(verb, Internal::previous_match) { if parts.verb_invocation.is_some() { return self.auto_complete_verb(con, sel_info, raw, &parts, Some(panel_state_type), true); } } else { self.tab_cycle_count = None; self.input_before_cycle = None; } // 'enter': trigger the verb if any on the input. If none, then may be // used as trigger of another verb if key == key!(enter) && parts.has_not_empty_verb_invocation() { return Command::from_parts(parts, true); } // a '?' opens the help when it's the first char or when it's part // of the verb invocation. It may be used as a verb name in other cases if (key == key!('?') || key == key!(shift - '?')) && (raw.is_empty() || parts.verb_invocation.is_some()) { return Command::Internal { internal: Internal::help, input_invocation: parts.verb_invocation, }; } if let Some(verb) = verb { if self.handle_input_related_verb(verb, con) { return Command::from_raw(self.input_field.get_content(), false); } if mode != Mode::Input && verb.is_internal(Internal::mode_input) { self.enter_input_mode_with_key(key, &parts); } if verb.auto_exec { return Command::VerbTrigger { verb_id: verb.id, input_invocation: parts.verb_invocation, }; } if let Some(invocation_parser) = &verb.invocation_parser { let exec_builder = ExecutionBuilder::without_invocation(sel_info, app_state); let verb_invocation = exec_builder .invocation_with_default(&invocation_parser.invocation_pattern, con); let mut parts = parts; parts.verb_invocation = Some(verb_invocation); self.set_content(&parts.to_string()); return Command::VerbEdit(parts.verb_invocation.unwrap()); } } // input field management if mode == Mode::Input && self.input_field.apply_timed_event(timed_event) { return Command::from_raw(self.input_field.get_content(), false); } Command::None } } ================================================ FILE: src/command/parts.rs ================================================ use { crate::{ pattern::*, verb::VerbInvocation, }, bet::BeTree, std::fmt, }; /// An intermediate parsed representation of the raw string #[derive(Debug, Clone, PartialEq)] pub struct CommandParts { pub raw_pattern: String, // may be empty pub pattern: BeTree, pub verb_invocation: Option, // may be empty if user typed the separator but no char after } impl fmt::Display for CommandParts { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { write!(f, "{}", self.raw_pattern)?; if let Some(invocation) = &self.verb_invocation { write!(f, "{invocation}")?; } Ok(()) } } impl CommandParts { pub fn has_not_empty_verb_invocation(&self) -> bool { self.verb_invocation .as_ref() .is_some_and(|vi| !vi.is_empty()) } pub fn from>(raw: S) -> Self { let mut raw = raw.into(); let mut invocation_start_pos: Option = None; let mut pt = BeTree::new(); let mut chars = raw.char_indices().peekable(); let mut escape_cur_char = false; let mut escape_next_char = false; // we loop on chars and build the pattern tree until we reach an unescaped ' ' or ':' while let Some((pos, cur_char)) = chars.next() { let between_slashes = pt .current_atom() .is_some_and(|pp: &PatternParts| pp.is_between_slashes()); match cur_char { c if escape_cur_char => { // Escaping is used to prevent characters from being consumed at the // composite pattern level (or, and, parens) or as the separator between // the pattern and the verb. An escaped char is usable in a pattern atom. pt.mutate_or_create_atom(PatternParts::default).push(c); } '\\' => { // Pattern escaping rules: // - when after '/': only ' ', ':', '/' and '\' need escaping // - otherwise, '&,' '|', '(', ')' need escaping too ('(' is only here for // symmetry) let between_slashes = match pt.current_atom() { Some(pattern_parts) => pattern_parts.is_between_slashes(), None => false, }; escape_next_char = match chars.peek() { None => false, // End of the string, we can't be escaping Some((_, next_char)) => match (next_char, between_slashes) { (' ' | ':' | '/' | '\\', _) => true, ('&' | '|' | '!' | '(' | ')', false) => true, _ => false, }, }; if !escape_next_char { // if the '\' isn't used for escaping, it's used as its char value pt.mutate_or_create_atom(PatternParts::default).push('\\'); } } ':' => { if matches!(chars.peek(), Some((_, ':'))) { // two successive ':' in pattern position are part of the // pattern pt.mutate_or_create_atom(PatternParts::default).push(':'); escape_next_char = true; } else { // ending the pattern part invocation_start_pos = Some(pos); break; } } ' ' => { // ending the pattern part invocation_start_pos = Some(pos); break; } '/' => { // starting an atom part pt.mutate_or_create_atom(PatternParts::default).add_part(); } '|' if !between_slashes && pt.accept_binary_operator() => { pt.push_operator(PatternOperator::Or); } '&' if !between_slashes && pt.accept_binary_operator() => { pt.push_operator(PatternOperator::And); } '!' if !between_slashes && pt.accept_unary_operator() => { pt.push_operator(PatternOperator::Not); } '(' if !between_slashes && pt.accept_opening_par() => { pt.open_par(); } ')' if !between_slashes && pt.accept_closing_par() => { pt.close_par(); } _ => { pt.mutate_or_create_atom(PatternParts::default) .push(cur_char); } } escape_cur_char = escape_next_char; escape_next_char = false; } let mut verb_invocation = None; if let Some(pos) = invocation_start_pos { verb_invocation = Some(VerbInvocation::from( raw[pos + 1..].trim_start(), // allowing extra spaces )); raw.truncate(pos); } CommandParts { raw_pattern: raw, pattern: pt, verb_invocation, } } /// split an input into its two possible parts, the pattern /// and the verb invocation. Each part, when defined, is /// suitable to create a command on its own. pub fn split(mut self) -> (Option, Option) { let verb_invocation = self.verb_invocation.take(); ( if self.raw_pattern.is_empty() { None } else { Some(CommandParts { raw_pattern: self.raw_pattern, pattern: self.pattern, verb_invocation: None, }) }, verb_invocation.map(|verb_invocation| CommandParts { raw_pattern: String::new(), pattern: BeTree::new(), verb_invocation: Some(verb_invocation), }), ) } } #[cfg(test)] mod test_command_parts { use { crate::{ command::CommandParts, pattern::*, verb::VerbInvocation, }, bet::{ BeTree, Token, }, }; fn pp(a: &[&str]) -> PatternParts { a.try_into().unwrap() } /// Check that the input is parsed as expected: /// - a raw pattern /// - the token (operators and pattern_parts) of the pattern /// - the verb invocation fn check( input: &str, raw_pattern: &str, mut pattern_tokens: Vec>, verb_invocation: Option<&str>, ) { let mut pattern = BeTree::new(); for token in pattern_tokens.drain(..) { pattern.push(token); } let left = CommandParts { raw_pattern: raw_pattern.to_string(), pattern, verb_invocation: verb_invocation.map(VerbInvocation::from), }; dbg!(&left); let right = CommandParts::from(input); dbg!(&right); assert_eq!(left, right); } #[test] fn parse_empty() { check("", "", vec![], None); } #[test] fn parse_just_semicolon() { check(":", "", vec![], Some("")); } #[test] fn parse_no_pattern() { check(" cd /", "", vec![], Some("cd /")); } #[test] fn parse_pattern_and_invocation() { check( "/r cd /", "/r", vec![Token::Atom(pp(&["", "r"]))], Some("cd /"), ); } #[test] fn allow_extra_spaces_before_invocation() { check(" cd /", "", vec![], Some("cd /")); check( "/r cd /", "/r", vec![Token::Atom(pp(&["", "r"]))], Some("cd /"), ); check( r#"a\ b e"#, r#"a\ b"#, vec![Token::Atom(pp(&["a b"]))], Some("e"), ); check( "/a: b", "/a", vec![Token::Atom(pp(&["", "a"]))], Some("b"), ); } #[test] fn parse_pattern_between_slashes() { check(r#"/&"#, r#"/&"#, vec![Token::Atom(pp(&["", "&"]))], None); check( r#"/&/&r/a(\w-)+/ rm"#, r#"/&/&r/a(\w-)+/"#, vec![ Token::Atom(pp(&["", "&", ""])), Token::Operator(PatternOperator::And), Token::Atom(pp(&["r", r#"a(\w-)+"#, ""])), ], Some("rm"), ); } #[test] fn parse_pattern_with_space() { check(r#"a\ b"#, r#"a\ b"#, vec![Token::Atom(pp(&["a b"]))], None); } #[test] fn parse_pattern_with_slash() { check( r#"r/a\ b\//i cd /"#, r#"r/a\ b\//i"#, vec![Token::Atom(pp(&["r", "a b/", "i"]))], Some("cd /"), ); } #[test] fn parse_fuzzy_pattern_searching_parenthesis() { check(r#"\("#, r#"\("#, vec![Token::Atom(pp(&["("]))], None); } #[test] fn parse_regex_pattern_searching_parenthesis() { check( r#"/\("#, r#"/\("#, vec![Token::Atom(pp(&["", r#"\("#]))], None, ); } #[test] fn parse_composite_pattern() { check( "(/txt$/&!truc)&c/rex", "(/txt$/&!truc)&c/rex", vec![ Token::OpeningParenthesis, Token::Atom(pp(&["", "txt$", ""])), Token::Operator(PatternOperator::And), Token::Operator(PatternOperator::Not), Token::Atom(pp(&["truc"])), Token::ClosingParenthesis, Token::Operator(PatternOperator::And), Token::Atom(pp(&["c", "rex"])), ], None, ); } #[test] fn parse_unclosed_composite_pattern() { check( r#"!/\.json$/&(c/isize/|c/i32:rm"#, r#"!/\.json$/&(c/isize/|c/i32"#, vec![ Token::Operator(PatternOperator::Not), Token::Atom(pp(&["", r#"\.json$"#, ""])), Token::Operator(PatternOperator::And), Token::OpeningParenthesis, Token::Atom(pp(&["c", "isize", ""])), Token::Operator(PatternOperator::Or), Token::Atom(pp(&["c", "i32"])), ], Some("rm"), ); } #[test] fn issue_592() { // https://github.com/Canop/broot/issues/592 check(r#"\t"#, r#"\t"#, vec![Token::Atom(pp(&[r#"\t"#]))], None); check( r#"r/@(\.[^.]+)+/ cp .."#, r#"r/@(\.[^.]+)+/"#, vec![Token::Atom(pp(&["r", r#"@(\.[^.]+)+"#, ""]))], Some("cp .."), ); } // two colons in pattern positions are something the user searches #[test] fn allow_non_escaped_double_colon() { check(r#"::"#, r#"::"#, vec![Token::Atom(pp(&[r#"::"#]))], None); check( r#":::"#, r#"::"#, vec![Token::Atom(pp(&[r#"::"#]))], Some(""), ); check( r#":::cd c:\"#, r#"::"#, vec![Token::Atom(pp(&[r#"::"#]))], Some(r#"cd c:\"#), ); check( r#"and::Sc:cd c:\"#, r#"and::Sc"#, vec![Token::Atom(pp(&[r#"and::Sc"#]))], Some(r#"cd c:\"#), ); check( r#"!:: "#, r#"!::"#, vec![ Token::Operator(PatternOperator::Not), Token::Atom(pp(&[r#"::"#])), ], Some(""), ); check( r#"::a:rm"#, r#"::a"#, vec![Token::Atom(pp(&[r#"::a"#]))], Some("rm"), ); } } ================================================ FILE: src/command/scroll.rs ================================================ #[derive(Debug, Clone, Copy)] pub enum ScrollCommand { Lines(i32), Pages(i32), } impl ScrollCommand { pub fn to_lines( self, page_height: usize, ) -> i32 { match self { Self::Lines(n) => n, Self::Pages(n) => n * page_height as i32, } } pub fn is_up(self) -> bool { match self { Self::Lines(n) => n < 0, Self::Pages(n) => n < 0, } } /// compute the new scroll value pub fn apply( self, scroll: usize, content_height: usize, page_height: usize, ) -> usize { (scroll as i32 + self.to_lines(page_height)) .min(content_height as i32 - page_height as i32 + 1) .max(0) as usize } pub fn is_thumb( y: u16, scrollbar: Option<(u16, u16)>, ) -> bool { if let Some((sctop, scbottom)) = scrollbar { if sctop <= y && y <= scbottom { return true; } } false } } ================================================ FILE: src/command/sel.rs ================================================ /// compute a new selection index for the given list len, /// taking into account whether we should cycle or not #[must_use] pub fn move_sel( selection: usize, len: usize, d: i32, // displacement cycle: bool, ) -> usize { if len == 0 { return 0; } let ns = (selection as i32) + d; if ns < 0 { if cycle { len - 1 } else { 0 } } else if ns >= len as i32 { if cycle { 0 } else { len - 1 } } else { ns as usize } } ================================================ FILE: src/command/sequence.rs ================================================ //! this mod achieves the transformation of a string containing //! one or several commands into a vec of parsed commands use { super::{ Command, CommandParts, }, crate::{ app::AppContext, errors::ProgramError, verb::*, }, }; /// an unparsed sequence with its separator (which may be /// different from the one provided by `local_separator()`) #[derive(Debug, Clone)] pub struct Sequence { pub raw: String, pub separator: String, } impl Sequence { /// return the separator to use to parse sequences. pub fn local_separator() -> String { match std::env::var("BROOT_CMD_SEPARATOR") { Ok(sep) if !sep.is_empty() => sep, _ => String::from(";"), } } pub fn new>( raw: S, separator: Option, ) -> Self { Self { raw: raw.into(), separator: separator.map_or_else(Sequence::local_separator, Into::into), } } pub fn new_single>(cmd: S) -> Self { Self { separator: String::new(), raw: cmd.into(), } } pub fn new_local(raw: String) -> Self { Self { separator: Self::local_separator(), raw, } } /// Parse the sequence into a vec of commands. /// /// Beware: `panel_state_type` filtering isn't applied ( /// and would be difficult a priori as we can change panel /// in the middle of a sequence) pub fn parse( &self, con: &AppContext, ) -> Result, ProgramError> { debug!("Splitting cmd sequence with {:?}", &self.separator); let mut commands = Vec::new(); if self.separator.is_empty() { add_commands(&self.raw, &mut commands, con)?; } else { for input in self.raw.split(&self.separator) { add_commands(input, &mut commands, con)?; } } Ok(commands) } pub fn has_selection_group(&self) -> bool { str_has_selection_group(&self.raw) } pub fn has_other_panel_group(&self) -> bool { str_has_other_panel_group(&self.raw) } } /// Add commands to a sequence. /// /// An input may be made of two parts: /// - a search pattern /// - a verb followed by its arguments /// /// We need to build a command for each part so /// that the search is effectively done before /// the verb invocation fn add_commands( input: &str, commands: &mut Vec<(String, Command)>, con: &AppContext, ) -> Result<(), ProgramError> { let raw_parts = CommandParts::from(input.to_string()); let (pattern, verb_invocation) = raw_parts.split(); if let Some(pattern) = pattern { commands.push((input.to_string(), Command::from_parts(pattern, false))); } if let Some(verb_invocation) = verb_invocation { let mut command = Command::from_parts(verb_invocation, true); if let Command::VerbInvocate(invocation) = &command { // we check that the verb exists to avoid running a sequence // of actions with some missing match con.verb_store.search_prefix(&invocation.name, None) { PrefixSearchResult::NoMatch => { return Err(ProgramError::UnknownVerb { name: invocation.name.clone(), }); } PrefixSearchResult::Matches(_) => { return Err(ProgramError::AmbiguousVerbName { name: invocation.name.clone(), }); } PrefixSearchResult::Match(_, verb) => { if let Some(internal) = verb.get_internal() { command = Command::Internal { internal, input_invocation: Some(invocation.clone()), }; } commands.push((input.to_string(), command)); } } } } Ok(()) } ================================================ FILE: src/command/trigger_type.rs ================================================ use crate::verb::Verb; /// This rather vague enum might be precised or removed. It /// serves today to characterize whether a verb execution /// comes from the input or not (in this case the input is /// consumed and cleared when the verb is executed). #[derive(Debug, Clone, Copy, PartialEq)] pub enum TriggerType<'v> { /// the verb was typed in the input and user has hit enter. Input(&'v Verb), /// probably a key shortcut Other, } ================================================ FILE: src/conf/conf.rs ================================================ //! manage reading the verb shortcuts from the configuration file, //! initializing if if it doesn't yet exist use { super::*, crate::{ app::Mode, display::{ ColsConf, LayoutInstructions, }, errors::*, kitty::KittyGraphicsDisplay, kitty::TransmissionMedium, path::*, preview::PreviewTransformerConf, skin::SkinEntry, syntactic::SyntaxTheme, verb::ExecPattern, }, crokey::crossterm::style::Attribute, rustc_hash::FxHashMap, serde::Deserialize, std::{ collections::HashMap, num::NonZeroUsize, path::PathBuf, }, }; macro_rules! overwrite { ($dst: ident, $prop: ident, $src: ident) => { if $src.$prop.is_some() { $dst.$prop = $src.$prop.take(); } }; } macro_rules! overwrite_map { ($dst: ident, $prop: ident, $src: ident) => { for (k, v) in $src.$prop { $dst.$prop.insert(k, v); } }; } macro_rules! overwrite_vec { ($dst: ident, $prop: ident, $src: ident) => { for v in $src.$prop { $dst.$prop.push(v); } }; } /// The configuration read from conf.toml or conf.hjson file(s) #[derive(Default, Clone, Debug, Deserialize)] pub struct Conf { #[serde(alias = "capture-mouse")] pub capture_mouse: Option, #[serde(alias = "cols-order")] pub cols_order: Option, #[serde( alias = "content-search-max-file-size", deserialize_with = "file_size::deserialize", default )] pub content_search_max_file_size: Option, #[serde(alias = "date-time-format")] pub date_time_format: Option, #[serde(alias = "default-flags")] pub default_flags: Option, // the flags to apply before cli ones /// Obsolete, kept for compatibility: you should now use `capture_mouse` #[serde(alias = "disable-mouse-capture")] pub disable_mouse_capture: Option, #[serde(alias = "enable-keyboard-enhancements")] pub enable_kitty_keyboard: Option, #[serde(default, alias = "ext-colors")] pub ext_colors: FxHashMap, pub file_sum_threads_count: Option, /// the files used to load this configuration #[serde(skip)] pub files: Vec, #[serde(alias = "icon-theme")] pub icon_theme: Option, #[serde(default)] pub imports: Vec, /// the initial mode (only relevant when modal is true) #[serde(alias = "initial-mode")] pub initial_mode: Option, #[serde(alias = "kitty-graphics-transmission")] pub kitty_graphics_transmission: Option, #[serde(alias = "kitty-graphics-display")] pub kitty_graphics_display: Option, #[serde(default, alias = "kept-kitty-temp-files")] pub kept_kitty_temp_files: Option, #[serde(default, alias = "preview-transformers")] pub preview_transformers: Vec, #[serde(alias = "lines-after-match-in-preview")] pub lines_after_match_in_preview: Option, #[serde(alias = "lines-before-match-in-preview")] pub lines_before_match_in_preview: Option, pub max_panels_count: Option, #[serde(alias = "max_staged_count")] pub max_staged_count: Option, #[serde(alias = "auto-open-staging-area")] pub auto_open_staging_area: Option, pub modal: Option, #[serde(alias = "quit-on-last-cancel")] pub quit_on_last_cancel: Option, #[serde(alias = "search-modes")] pub search_modes: Option>, #[serde(alias = "show-matching-characters-on-path-searches")] pub show_matching_characters_on_path_searches: Option, #[serde(alias = "show-selection-mark")] pub show_selection_mark: Option, pub skin: Option>, #[serde(default, alias = "special-paths")] pub special_paths: HashMap, #[serde(alias = "syntax-theme")] pub syntax_theme: Option, #[serde(alias = "terminal-title")] pub terminal_title: Option, #[serde(alias = "reset-terminal-title-on-exit")] pub reset_terminal_title_on_exit: Option, #[serde(alias = "true-colors")] pub true_colors: Option, #[serde(alias = "update-work-dir")] pub update_work_dir: Option, #[serde(default)] pub verbs: Vec, #[serde(alias = "layout-instructions")] pub layout_instructions: Option, // BEWARE: entries added here won't be usable unless also // added in read_file! } impl Conf { /// return the path to the default conf.toml file. /// If there's no conf.hjson file in the default conf directory, /// and if there's a toml file, return this toml file. pub fn default_location() -> PathBuf { let hjson_file = super::dir().join("conf.hjson"); if !hjson_file.exists() { let toml_file = super::dir().join("conf.toml"); if toml_file.exists() { return toml_file; } } // neither file exists, we return the default one hjson_file } /// read the configuration file from the default OS specific location. /// Create it if it doesn't exist pub fn from_default_location() -> Result { let conf_dir = super::dir(); let conf_filepath = Conf::default_location(); if !conf_filepath.exists() { write_default_conf_in(conf_dir)?; println!( "New Configuration files written in {}{:?}{}.", Attribute::Bold, &conf_dir, Attribute::Reset, ); println!( "You should have a look at them: their comments will help you configure broot." ); println!("You should especially set up your favourite editor in verbs.hjson."); } let mut conf = Conf::default(); conf.read_file(conf_filepath)?; Ok(conf) } pub fn solve_conf_path( &self, path: &str, ) -> Option { if path_has_ext(path, "toml") || path_has_ext(path, "hjson") { for conf_file in self.files.iter().rev() { let solved = path_from(conf_file, PathAnchor::Parent, path); if solved.exists() { return Some(solved); } } } None } /// read the configuration from a given path. Assume it exists. /// Values set in the read file replace the ones of self. /// Errors are printed on stderr (assuming this function is called /// before terminal alternation). pub fn read_file( &mut self, path: PathBuf, ) -> Result<(), ProgramError> { debug!("reading conf file: {:?}", &path); let mut conf: Conf = SerdeFormat::read_file(&path)?; overwrite!(self, default_flags, conf); overwrite!(self, date_time_format, conf); overwrite!(self, icon_theme, conf); overwrite!(self, syntax_theme, conf); overwrite!(self, disable_mouse_capture, conf); overwrite!(self, capture_mouse, conf); overwrite!(self, true_colors, conf); overwrite!(self, show_selection_mark, conf); overwrite!(self, cols_order, conf); overwrite!(self, skin, conf); overwrite!(self, search_modes, conf); overwrite!(self, max_panels_count, conf); overwrite!(self, modal, conf); overwrite!(self, initial_mode, conf); overwrite!(self, quit_on_last_cancel, conf); overwrite!(self, file_sum_threads_count, conf); overwrite!(self, max_staged_count, conf); overwrite!(self, auto_open_staging_area, conf); overwrite!(self, show_matching_characters_on_path_searches, conf); overwrite!(self, content_search_max_file_size, conf); overwrite!(self, terminal_title, conf); overwrite!(self, reset_terminal_title_on_exit, conf); overwrite!(self, update_work_dir, conf); overwrite!(self, enable_kitty_keyboard, conf); overwrite!(self, kitty_graphics_transmission, conf); overwrite!(self, kitty_graphics_display, conf); overwrite!(self, kept_kitty_temp_files, conf); overwrite!(self, lines_after_match_in_preview, conf); overwrite!(self, lines_before_match_in_preview, conf); overwrite!(self, layout_instructions, conf); self.verbs.append(&mut conf.verbs); // the following prefs are "additive": we can add entries from several // config files and they still make sense overwrite_map!(self, special_paths, conf); overwrite_map!(self, ext_colors, conf); overwrite_vec!(self, preview_transformers, conf); self.files.push(path); // read the imports for import in &conf.imports { let file = import.file().trim(); if !import.applies() { debug!("skipping not applying conf file : {file:?}"); continue; } let import_path = self.solve_conf_path(file) .ok_or_else(|| ConfError::ImportNotFound { path: file.to_string(), })?; if self.files.contains(&import_path) { debug!("skipping import already read: {import_path:?}"); continue; } self.read_file(import_path)?; } Ok(()) } } ================================================ FILE: src/conf/default.rs ================================================ use { include_dir::{ Dir, DirEntry, include_dir, }, std::{ fs, io, path::Path, }, }; static DEFAULT_CONF_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/resources/default-conf"); /// Write the default configuration files in the destination directory, not /// overwriting existing ones pub fn write_default_conf_in(dir: &Path) -> Result<(), io::Error> { info!("writing default conf in {dir:?}"); if dir.exists() && !dir.is_dir() { return Err(io::Error::other(format!("{dir:?} isn't a directory"))); } let mut files = Vec::new(); find_files(&DEFAULT_CONF_DIR, &mut files); for file in files { let dest_path = dir.join(file.path()); if dest_path.exists() { warn!("not overwriting {dest_path:?}"); } else { if let Some(dir) = dest_path.parent() { if !dir.exists() { fs::create_dir_all(dir)?; } } info!("writing file {:?}", file.path()); fs::write(dest_path, file.contents())?; } } Ok(()) } fn find_files<'d>( dir: &'d Dir<'d>, files: &mut Vec<&'d include_dir::File<'d>>, ) { for entry in dir.entries() { match entry { DirEntry::Dir(sub_dir) => { find_files(sub_dir, files); } DirEntry::File(file) => { files.push(file); } } } } /// Check that all the files in the default_conf directory are valid /// configuration files #[test] fn check_default_conf_files() { use crate::conf::*; let mut files = Vec::new(); find_files(&DEFAULT_CONF_DIR, &mut files); for file in files { println!("Checking {}", file.path().display()); let file_content = std::str::from_utf8(file.contents()).unwrap(); SerdeFormat::read_string::(file.path(), file_content).unwrap(); } } ================================================ FILE: src/conf/default_flags.rs ================================================ use { crate::{ cli::Args, errors::ConfError, }, clap::Parser, lazy_regex::*, }; /// parse the `default_flags` parameter of a conf. pub fn parse_default_flags(s: &str) -> Result { let prefixed; let mut tokens: Vec<&str> = if regex_is_match!("^[a-zA-Z]+$", s) { // this covers the old syntax like `default_flags: gh` prefixed = format!("-{s}"); vec![&prefixed] } else { splitty::split_unquoted_whitespace(s).collect() }; tokens.insert(0, "broot"); Args::try_parse_from(&tokens).map_err(|_| ConfError::InvalidDefaultFlags { flags: s.to_string(), }) } ================================================ FILE: src/conf/file_size.rs ================================================ use ::serde::{ Deserialize, de, }; pub fn parse_file_size(input: &str) -> Result { let s = input.to_lowercase(); let s = s.trim_end_matches('b'); let (s, binary) = match s.strip_suffix('i') { Some(s) => (s, true), None => (s, false), }; let cut = s.find(|c: char| !(c.is_ascii_digit() || c == '.')); let (digits, factor): (&str, u64) = match cut { Some(idx) => ( &s[..idx], match (&s[idx..], binary) { ("k", false) => 1000, ("k", true) => 1024, ("m", false) => 1000 * 1000, ("m", true) => 1024 * 1024, ("g", false) => 1000 * 1000 * 1000, ("g", true) => 1024 * 1024 * 1024, ("t", false) => 1000 * 1000 * 1000 * 1000, ("t", true) => 1024 * 1024 * 1024 * 1024, _ => { // it's not a number return Err(format!("{input:?} can't be parsed as file size")); } }, ), None => (s, 1), }; match digits.parse::() { Ok(n) => Ok((n * factor as f64).ceil() as u64), _ => Err(format!("{input:?} can't be parsed as file size")), } } #[test] fn test_parse_file_size() { assert_eq!(parse_file_size("33"), Ok(33)); assert_eq!(parse_file_size("55G"), Ok(55_000_000_000)); assert_eq!(parse_file_size("2kb"), Ok(2_000)); assert_eq!(parse_file_size("1.23kiB"), Ok(1260)); } pub fn deserialize<'de, D>(d: D) -> Result, D::Error> where D: de::Deserializer<'de>, { as Deserialize>::deserialize(d)? .map(|s| parse_file_size(&s).map_err(de::Error::custom)) .transpose() } ================================================ FILE: src/conf/format.rs ================================================ use { crate::errors::{ ConfError, ProgramError, }, deser_hjson, serde::de::DeserializeOwned, std::{ fs, path::Path, }, toml, }; /// Formats usable for reading configuration files #[derive(Default, PartialEq, Eq, Debug, Clone, Copy)] pub enum SerdeFormat { #[default] Hjson, Toml, } pub static FORMATS: &[SerdeFormat] = &[SerdeFormat::Hjson, SerdeFormat::Toml]; impl SerdeFormat { pub fn key(self) -> &'static str { match self { Self::Hjson => "hjson", Self::Toml => "toml", } } pub fn from_key(key: &str) -> Option { match key { "hjson" => Some(SerdeFormat::Hjson), "toml" => Some(SerdeFormat::Toml), _ => None, } } pub fn from_path(path: &Path) -> Result { path.extension() .and_then(|os| os.to_str()) .map(str::to_lowercase) .and_then(|key| Self::from_key(&key)) .ok_or_else(|| ConfError::UnknownFileExtension { path: path.to_string_lossy().to_string(), }) } pub fn read_string( path: &Path, s: &str, ) -> Result where T: DeserializeOwned, { let format = Self::from_path(path)?; match format { Self::Hjson => deser_hjson::from_str::(s).map_err(|e| ProgramError::ConfFile { path: path.to_string_lossy().to_string(), details: e.into(), }), Self::Toml => toml::from_str::(s).map_err(|e| ProgramError::ConfFile { path: path.to_string_lossy().to_string(), details: e.into(), }), } } pub fn read_file(path: &Path) -> Result where T: DeserializeOwned, { let file_content = fs::read_to_string(path)?; Self::read_string(path, &file_content) } } ================================================ FILE: src/conf/import.rs ================================================ use { crate::display::LumaCondition, serde::Deserialize, }; /// A file to import, with optionally a condition #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum Import { Simple(String), Detailed(DetailedImport), } #[derive(Clone, Debug, Deserialize)] pub struct DetailedImport { /// a condition on terminal light pub luma: Option, /// path, either absolute or relative to the current file /// or the conf directory pub file: String, } impl Import { pub fn applies(&self) -> bool { self.luma().is_none_or(LumaCondition::is_verified) } pub fn luma(&self) -> Option<&LumaCondition> { match self { Self::Simple(_) => None, Self::Detailed(detailed) => detailed.luma.as_ref(), } } pub fn file(&self) -> &str { match self { Self::Simple(s) => s, Self::Detailed(detailed) => &detailed.file, } } } ================================================ FILE: src/conf/mod.rs ================================================ use { crate::path::untilde, directories, once_cell::sync::Lazy, std::path::{ Path, PathBuf, }, }; mod conf; mod default; mod default_flags; mod format; mod import; mod special_handling_conf; mod verb_conf; pub mod file_size; pub use { conf::Conf, default::write_default_conf_in, default_flags::*, format::*, import::*, special_handling_conf::*, verb_conf::VerbConf, }; /// return the instance of `ProjectDirs` holding broot's specific paths /// /// # Panics /// if the configuration directories can't be found (sytem misconfiguration) #[must_use] pub fn app_dirs() -> directories::ProjectDirs { directories::ProjectDirs::from("org", "dystroy", "broot") .expect("Unable to find configuration directories") } #[must_use] fn env_conf_dir() -> Option { std::env::var("BROOT_CONFIG_DIR") .ok() .as_deref() .map(untilde) } #[cfg(not(target_os = "macos"))] fn find_conf_dir() -> PathBuf { env_conf_dir().unwrap_or_else(|| app_dirs().config_dir().to_path_buf()) } #[cfg(target_os = "macos")] fn find_conf_dir() -> PathBuf { if let Some(env_dir) = env_conf_dir() { env_dir } else if let Some(user_dirs) = directories::UserDirs::new() { // We first search in ~/.config/broot which should be the preferred solution let preferred = user_dirs.home_dir().join(".config/broot"); if preferred.exists() { return preferred; } // The directories crate has a non usual choice of config directory, // especially for a CLI application. We use it only when // the preferred directory doesn't exist and this one exists. // See https://github.com/Canop/broot/issues/103 let second_choice = app_dirs().config_dir().to_path_buf(); if second_choice.exists() { // An older version of broot was used to write the // config, we don't want to lose it. return second_choice; } // Either the config has been scraped or it's a new installation preferred } else { // there's no home. There are probably other problems too but here we // are just looking for a place for our config, not for a shelter for all // so the default will do app_dirs().config_dir().to_path_buf() } } static CONF_DIR: Lazy = Lazy::new(find_conf_dir); /// return the path to the config directory #[must_use] pub fn dir() -> &'static Path { &CONF_DIR } ================================================ FILE: src/conf/special_handling_conf.rs ================================================ use { crate::{ errors::ConfError, path::*, }, directories::UserDirs, lazy_regex::*, serde::Deserialize, std::collections::HashMap, }; type SpecialPathsConf = HashMap; #[derive(Clone, Debug, Deserialize, Hash, PartialEq, Eq)] #[serde(transparent)] pub struct GlobConf { pub pattern: String, } #[derive(Clone, Copy, Debug, Deserialize)] #[serde(untagged)] pub enum SpecialHandlingConf { Shortcut(SpecialHandlingShortcut), Detailed(SpecialHandling), } #[derive(Clone, Debug, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SpecialHandlingShortcut { None, Enter, #[serde(alias = "no-enter")] NoEnter, Hide, #[serde(alias = "no-hide")] NoHide, } impl From for SpecialHandling { fn from(shortcut: SpecialHandlingShortcut) -> Self { use Directive::*; match shortcut { SpecialHandlingShortcut::None => SpecialHandling { show: Default, list: Default, sum: Default, }, SpecialHandlingShortcut::Enter => SpecialHandling { show: Always, list: Always, sum: Always, }, SpecialHandlingShortcut::NoEnter => SpecialHandling { show: Default, list: Never, sum: Never, }, SpecialHandlingShortcut::Hide => SpecialHandling { show: Never, list: Default, sum: Never, }, SpecialHandlingShortcut::NoHide => SpecialHandling { show: Always, list: Default, sum: Default, }, } } } impl From for SpecialHandling { fn from(conf: SpecialHandlingConf) -> Self { match conf { SpecialHandlingConf::Shortcut(shortcut) => shortcut.into(), SpecialHandlingConf::Detailed(handling) => handling, } } } impl TryFrom<&SpecialPathsConf> for SpecialPaths { type Error = ConfError; fn try_from(map: &SpecialPathsConf) -> Result { let mut entries = Vec::new(); for (k, v) in map { entries.push(SpecialPath::new(k.to_glob()?, (*v).into())); } Ok(Self { entries }) } } impl GlobConf { pub fn to_glob(&self) -> Result { let s = regex_replace!(r"^~(/|$)", &self.pattern, |_, sep| { match UserDirs::new() { Some(dirs) => format!("{}{}", dirs.home_dir().to_string_lossy(), sep), None => "~/".to_string(), } }); let glob = if s.starts_with('/') || s.starts_with('~') { glob::Pattern::new(&s) } else { let pattern = format!("**/{}", &s); glob::Pattern::new(&pattern) }; glob.map_err(|_| ConfError::InvalidGlobPattern { pattern: self.pattern.clone(), }) } } ================================================ FILE: src/conf/verb_conf.rs ================================================ use { crate::{ app::{ PanelStateType, PanelReference, }, verb::*, }, serde::{ Deserialize, Serialize, }, }; /// A deserializable verb entry in the configuration #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct VerbConf { pub invocation: Option, pub internal: Option, pub external: Option, pub execution: Option, pub cmd: Option, pub cmd_separator: Option, pub key: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub keys: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec, pub shortcut: Option, pub leave_broot: Option, pub from_shell: Option, #[serde(default, skip_serializing_if = "FileTypeCondition::is_default")] pub apply_to: FileTypeCondition, /// The panel to which the verb applies (even if triggered from /// another panel) #[serde(default, skip_serializing_if = "PanelReference::is_default")] pub impacted_panel: PanelReference, pub set_working_dir: Option, pub working_dir: Option, pub description: Option, pub auto_exec: Option, pub switch_terminal: Option, /// The type of panels filtering the verb #[serde(default, skip_serializing_if = "Vec::is_empty")] pub panels: Vec, pub refresh_after: Option, } ================================================ FILE: src/content_search/content_match.rs ================================================ /// a displayable representation of where /// the needle was found, with some text around #[derive(Debug, Clone)] pub struct ContentMatch { pub extract: String, pub needle_start: usize, // position in the extract, in bytes pub needle_end: usize, // length in bytes } impl ContentMatch { pub fn build( hay: &[u8], pos: usize, // position in the hay needle: &str, desired_len: usize, // max length of the extract in bytes ) -> Self { if hay.is_empty() { // this happens if you search `cr/.*` and a file starts with an empty line return Self { extract: String::new(), needle_start: 0, needle_end: 0, }; } let mut extract_start = pos; let mut extract_end = pos + needle.len(); // not included loop { if extract_start == 0 || extract_end - extract_start >= desired_len / 2 { break; } let c = hay[extract_start - 1]; if c < 32 { break; } extract_start -= 1; } // left trimming while (hay[extract_start] == 32) && extract_start < pos { extract_start += 1; } loop { if extract_end == hay.len() || extract_end - extract_start >= desired_len { break; } let c = hay[extract_end]; if c < 32 { break; } extract_end += 1; } // at this point we're unsure whether we start at a correct char boundary, hence // the from_utf8_lossy let extract = String::from_utf8_lossy(&hay[extract_start..extract_end]).to_string(); let needle_start = extract.find(needle).unwrap_or(0); Self { extract, needle_start, needle_end: needle_start + needle.len(), } } } ================================================ FILE: src/content_search/content_search_result.rs ================================================ /// result of a full text search #[derive(Debug, Clone, Copy, PartialEq)] pub enum ContentSearchResult { /// the needle has been found at the given pos Found { pos: usize }, /// the needle hasn't been found NotFound, // no match /// the file wasn't searched because it's binary or too big NotSuitable, } impl ContentSearchResult { pub fn is_found(self) -> bool { matches!(self, Self::Found { .. }) } } ================================================ FILE: src/content_search/mod.rs ================================================ mod content_match; mod content_search_result; mod needle; pub use { crate::content_type::{ self, extensions, magic_numbers, }, content_match::ContentMatch, content_search_result::ContentSearchResult, needle::Needle, std::io::{ BufRead, BufReader, }, }; use { memmap2::Mmap, std::{ fs::File, io, path::Path, }, }; pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; pub fn get_mmap>(hay_path: P) -> io::Result { let file = File::open(hay_path.as_ref())?; let hay = unsafe { Mmap::map(&file)? }; Ok(hay) } /// return the memmap to the file except if it was determined /// that the file is binary (from its extension, size, or first bytes) /// or is too big pub fn get_mmap_if_suitable>( hay_path: P, max_size: usize, ) -> io::Result> { if let Some(ext) = hay_path.as_ref().extension().and_then(|s| s.to_str()) { if extensions::is_known_binary(ext) { return Ok(None); } } let hay = get_mmap(&hay_path)?; if hay.len() > max_size || magic_numbers::is_known_binary(&hay) { return Ok(None); } Ok(Some(hay)) } /// return true when the file looks suitable for searching as text. /// /// If a memmap will be needed afterwards, prefer to use `get_mmap_if_not_binary` /// which optimizes testing and getting the mmap. pub fn is_path_suitable>( path: P, max_size: usize, ) -> bool { let path = path.as_ref(); let Ok(metadata) = path.metadata() else { return false; }; if metadata.len() > max_size as u64 { return false; } content_type::is_file_text(path).unwrap_or(false) } /// Return the 1-indexed line number for the byte at position pos pub fn line_count_at_pos>( path: P, pos: usize, ) -> io::Result { let mut reader = BufReader::new(File::open(path)?); let mut line = String::new(); let mut line_count = 1; let mut bytes_count = 0; while reader.read_line(&mut line)? > 0 { bytes_count += line.len(); if bytes_count >= pos { return Ok(line_count); } line_count += 1; line.clear(); } Err(io::Error::new( io::ErrorKind::UnexpectedEof, "too short".to_string(), )) } ================================================ FILE: src/content_search/needle.rs ================================================ // Don't look here for search functions to reuse or even for efficient or proven tricks. // Benchmarks proved that the approach here was fast in the context of broot but that's all. use { super::*, memmap2::Mmap, std::{ convert::TryInto, fmt, io, path::Path, }, }; /// a strict (non fuzzy, case sensitive) pattern which may /// be searched in file contents #[derive(Clone)] pub struct Needle { /// bytes of the searched string /// (guaranteed to be valid UTF8 by construct) bytes: Box<[u8]>, max_file_size: usize, } impl fmt::Debug for Needle { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { f.debug_struct("Needle") .field("bytes", &self.bytes) .finish_non_exhaustive() } } impl Needle { pub fn new( pat: &str, max_file_size: usize, ) -> Self { let bytes = pat.as_bytes().to_vec().into_boxed_slice(); Self { bytes, max_file_size, } } pub fn is_empty(&self) -> bool { self.bytes.is_empty() } pub fn as_str(&self) -> &str { unsafe { std::str::from_utf8_unchecked(&self.bytes) } } // no, it doesn't bring more than a few % in speed fn find_naive_1( &self, hay: &Mmap, ) -> Option { let n = self.bytes[0]; hay.iter().position(|&b| b == n) } /// look for matches of the needle when it's length is 2 /// /// Calling this function with a hay of less than 2 bytes may result in undefined behavior. fn find_naive_2( &self, mut pos: usize, hay: &Mmap, ) -> Option { let max_pos = hay.len() - 2; let b0 = self.bytes[0]; let b1 = self.bytes[1]; unsafe { while pos <= max_pos { if *hay.get_unchecked(pos) == b0 && *hay.get_unchecked(pos + 1) == b1 { return Some(pos); } pos += 1; } } None } /// look for matches of the needle when it's length is 3 /// /// Calling this function with a hay of less than 3 bytes may result in undefined behavior. fn find_naive_3( &self, mut pos: usize, hay: &Mmap, ) -> Option { let max_pos = hay.len() - 3; let b0 = self.bytes[0]; let b1 = self.bytes[1]; let b2 = self.bytes[2]; unsafe { while pos <= max_pos { if *hay.get_unchecked(pos) == b0 && *hay.get_unchecked(pos + 1) == b1 && *hay.get_unchecked(pos + 2) == b2 { return Some(pos); } pos += 1; } } None } /// look for matches of the needle when it's length is 4 /// /// Calling this function with a hay of less than 4 bytes may result in undefined behavior. fn find_naive_4( &self, mut pos: usize, hay: &Mmap, ) -> Option { let max_pos = hay.len() - 4; let needle = u32::from_ne_bytes((&*self.bytes).try_into().unwrap()); while pos <= max_pos { if u32::from_ne_bytes((&hay[pos..pos + 4]).try_into().unwrap()) == needle { return Some(pos); } pos += 1; } None } /// look for matches of the needle when it's length is 6 /// /// Calling this function with a hay of less than 6 bytes may result in undefined behavior. fn find_naive_6( &self, mut pos: usize, hay: &Mmap, ) -> Option { let max_pos = hay.len() - 6; let b0 = self.bytes[0]; let b1 = self.bytes[1]; let b2 = self.bytes[2]; let b3 = self.bytes[3]; let b4 = self.bytes[4]; let b5 = self.bytes[5]; unsafe { while pos <= max_pos { if *hay.get_unchecked(pos) == b0 && *hay.get_unchecked(pos + 1) == b1 && *hay.get_unchecked(pos + 2) == b2 && *hay.get_unchecked(pos + 3) == b3 && *hay.get_unchecked(pos + 4) == b4 && *hay.get_unchecked(pos + 5) == b5 { return Some(pos); } pos += 1; } } None } fn is_at_pos( &self, hay_stack: &Mmap, pos: usize, ) -> bool { unsafe { for (i, b) in self.bytes.iter().enumerate() { if hay_stack.get_unchecked(i + pos) != b { return false; } } } true } /// look for matches of the needle for any length fn find_naive( &self, mut pos: usize, hay: &Mmap, ) -> Option { let max_pos = hay.len() - self.bytes.len(); while pos <= max_pos { if self.is_at_pos(hay, pos) { return Some(pos); } pos += 1; } None } /// search the mem map to find the first occurrence of the needle. /// /// Known limit: if the file has an encoding where the needle would /// be represented in a way different than UTF-8, the needle won't /// be found (I noticed the problem with other grepping tools, too, /// which is understandable as detecting the encoding and translating /// the needle would multiply the search time). /// /// /// The exact search algorithm used here (I removed Boyer-Moore) /// and the optimizations (loop unrolling, etc.) don't really matter /// as their impact is dwarfed by the whole mem map related set /// of problems. An alternate implementation should probably focus /// on avoiding mem maps. fn search_mmap( &self, hay: &Mmap, ) -> ContentSearchResult { if hay.len() < self.bytes.len() { return ContentSearchResult::NotFound; } // we tell the system how we intent to use the mmap // to increase the likehod the memory is available // for our loop #[cfg(not(any(target_family = "windows", target_os = "android")))] unsafe { libc::posix_madvise( hay.as_ptr() as *mut std::ffi::c_void, hay.len(), libc::POSIX_MADV_SEQUENTIAL, ); // TODO the Windows equivalent might be PrefetchVirtualMemory } let pos = match self.bytes.len() { 1 => self.find_naive_1(hay), 2 => self.find_naive_2(0, hay), 3 => self.find_naive_3(0, hay), 4 => self.find_naive_4(0, hay), 6 => self.find_naive_6(0, hay), _ => self.find_naive(0, hay), }; pos.map_or(ContentSearchResult::NotFound, |pos| { ContentSearchResult::Found { pos } }) } /// determine whether the file contains the needle pub fn search>( &self, hay_path: P, ) -> io::Result { super::get_mmap_if_suitable(hay_path, self.max_file_size).map(|om| { om.map_or(ContentSearchResult::NotSuitable, |hay| { self.search_mmap(&hay) }) }) } /// this is supposed to be called only when it's known that there's /// a match pub fn get_match>( &self, hay_path: P, desired_len: usize, ) -> Option { let Ok(hay) = get_mmap(hay_path) else { return None; }; match self.search_mmap(&hay) { ContentSearchResult::Found { pos } => { Some(ContentMatch::build(&hay, pos, self.as_str(), desired_len)) } _ => None, } } } #[cfg(test)] mod content_search_tests { use super::*; #[test] fn test_found() -> Result<(), io::Error> { let needle = Needle::new("inception", 1_000_000); let res = needle.search("src/content_search/needle.rs")?; assert!(res.is_found()); Ok(()) } } ================================================ FILE: src/content_type/extensions.rs ================================================ use phf::{ Set, phf_set, }; /// a short list of extensions that shouldn't be searched /// by content /// /// If you feel this list should maybe be changed, contact /// me on miaou or raise an issue. static BINARY_EXTENSIONS: Set<&'static str> = phf_set! { "a", "aif", "AIF", "ap_", "apk", "bin", "BIN", "bmp", "BMP", "bzip", "BZIP", "bzip2", "BZIP2", "cab", "CAB", "class", "com", "COM", "crx", "dat", "DAT", "db", "DB", "dbf", "DBF", "deb", "doc", "DOC", "docx", "DOCX", "eps", "EPS", "exe", "EXE", "dll", "DLL", "gif", "GIF", "gz", "gzip", "ico", "ICO", "iso", "ISO", "jar", "JAR", "jpg", "JPG", "jpeg", "JPEG", "lz4", "LZ4", "mdb", "MDB", "mp3", "MP3", "mp4", "MP4", "mpa", "MPa", "mpg", "MPG", "mpeg", "MPEG", "msi", "MSI", "o", "odf", "ODF", "odp", "ODP", "ods", "ODS", "odt", "ODT", "ogg", "OGG", "pdb", "pdf", "PDF", "pkg", "PKG", "png", "PNG", "ppt", "PPT", "pptx", "PPTX", "psd", "PSD", "ps", "PS", "rar", "RAR", "rpm", "RPM", "rsrc", "rtf", "so", "tar", "tar.gz", "ttf", "TTF", "tgz", "TGZ", "xls", "XLS", "xlsx", "XLSX", "vob", "VOB", "vsd", "VSD", "vsdx", "VSDX", "war", "WAR", "wasm", "wav", "WAV", "woff", "WOFF", "woff2", "WOFF2", "zip", "ZIP", "z", "Z", }; /// tells whether the file extension is one of a file format /// which shouldn't be searched as text #[must_use] pub fn is_known_binary(ext: &str) -> bool { BINARY_EXTENSIONS.contains(ext) } ================================================ FILE: src/content_type/magic_numbers.rs ================================================ use { phf::{ Set, phf_set, }, std::{ fs::File, io::{ self, Read, }, path::Path, }, }; pub const MIN_FILE_SIZE: usize = 100; // those ones are now removed because of the extension filtering // static SIGNATURES_2: [[u8;2];2] = [ // [ 0x4D, 0x5A ], // exe, dll // [ 0x42, 0x4D ], // BMP - Is that still necessary ? // ]; // those ones are now removed because of the extension filtering // static SIGNATURES_3: [[u8;3];2] = [ // [ 0x49, 0x44, 0x33 ], // mp3 // [ 0x77, 0x4F, 0x46 ], // WOFF // ]; // signatures starting with 00, FF or FE don't need to be put here // note: the phf_set macro doesn't seem to allow u32 literals like 0x504B0304 static SIGNATURES_4: Set<[u8; 4]> = phf_set! { [ 0x50, 0x4B, 0x03, 0x04 ], // zip file format and formats based on it, such as EPUB, JAR, ODF, OOXML [ 0x50, 0x4B, 0x05, 0x06 ], // zip file format and formats based on it, such as EPUB, JAR, ODF, OOXML [ 0x50, 0x4B, 0x07, 0x08 ], // zip file format and formats based on it, such as EPUB, JAR, ODF, OOXML [ 0xED, 0xAB, 0xEE, 0xDB ], // rpm [ 0x49, 0x49, 0x2A, 0x00 ], // tif [ 0x4D, 0x4D, 0x00, 0x2A ], // tiff [ 0x7F, 0x45, 0x4C, 0x46 ], // elf [ 0xCA, 0xFE, 0xBA, 0xBE ], // java class [ 0x25, 0x21, 0x50, 0x53 ], // ps [ 0x4F, 0x67, 0x67, 0x53 ], // ogg [ 0x38, 0x42, 0x50, 0x53 ], // psd [ 0x57, 0x41, 0x56, 0x45 ], // wave [ 0x41, 0x56, 0x49, 0x20 ], // avi [ 0x4D, 0x54, 0x68, 0x64 ], // midi [ 0xD0, 0xCF, 0x11, 0xE0 ], // old MS Office things [ 0x43, 0x72, 0x32, 0x34 ], // old Chrome extensions [ 0x78, 0x61, 0x72, 0x21 ], // xar [ 0x75, 0x73, 0x74, 0x61 ], // tar [ 0x37, 0x7A, 0xBC, 0xAF ], // 7zip [ 0x4D, 0x53, 0x43, 0x46 ], // Microsoft Cabinet file [ 0x52, 0x49, 0x46, 0x46 ], // riff (including WebP) [ 0x47, 0x49, 0x46, 0x38 ], // gif (common start of GIF87a and GIF89a ) [ 0x4C, 0x5A, 0x49, 0x50 ], // lzip [ 0xCE, 0xFA, 0xED, 0xFE ], // Mach-O [ 0xCF, 0xFA, 0xED, 0xFE ], // Mach-O [ 0x46, 0x4C, 0x49, 0x46 ], // flif [ 0x62, 0x76, 0x78, 0x32 ], // lzfse }; // those ones are now removed because of the extension and size filterings // static SIGNATURES_5: [[u8;5];2] = [ // [ 0x25, 0x50, 0x44, 0x46, 0x2d ], // pdf // [ 0x43, 0x44, 0x30, 0x30, 0x31 ], // iso (cd/dvd) // ]; // those ones are now removed because of the extension filterings // static SIGNATURES_6: [[u8;6];4] = [ // [ 0x52, 0x61, 0x72, 0x21, 0x1A, 0x07 ], // rar // [ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A ], // png // [ 0x21, 0x3C, 0x61, 0x72, 0x63, 0x68 ], // deb // [ 0x7B, 0x5C, 0x72, 0x74, 0x66, 0x31 ], // rtf // ]; /// return true when the first bytes of the file aren't polite or match one /// of the known binary signatures. /// Signatures are taken in /// Some signatures are omitted from list because they would not go past the /// specific test of the first byte anyway. /// /// If you feel this list should maybe be changed, contact /// me on miaou or raise an issue. #[must_use] pub fn is_known_binary(bytes: &[u8]) -> bool { if bytes.len() < 4 { return false; } let c = bytes[0]; if c < 9 || (c > 13 && c < 32) || c >= 254 { // c < 9 include several signatures // 14 to 31 includes several signatures among them some variants of zip, gzip, etc. // FE is "þ", FF is "ÿ" // the FE/FF cases includes several signatures like Mach-O, jpeg or mpeg // TODO Some non ASCII UTF-8 chars start with FE or FF - check it's OK return true; } // for signature in &SIGNATURES_2 { // if signature == &bytes[0..2] { // return true; // } // } // for signature in &SIGNATURES_3 { // if signature == &bytes[0..3] { // return true; // } // } if SIGNATURES_4.contains(&bytes[0..4]) { return true; } // for signature in &SIGNATURES_5 { // if signature == &bytes[0..5] { // return true; // } // } // for signature in &SIGNATURES_6 { // if signature == &bytes[0..6] { // return true; // } // } false } /// Tell whether the file i pub fn is_file_known_binary>(path: P) -> io::Result { let mut buf = [0; 4]; let mut file = File::open(path)?; let n = file.read(&mut buf)?; Ok(is_known_binary(&buf[0..n])) } ================================================ FILE: src/content_type/mod.rs ================================================ pub mod extensions; pub mod magic_numbers; use std::{ io, path::Path, }; /// Assuming the path is already checked to be to a file /// (not a link or directory), tell whether it looks like a text file pub fn is_file_text>(path: P) -> io::Result { // the current algorithm is rather crude. If needed I'll add // more, like checking the start of the file is UTF8 compatible Ok(!is_file_binary(path)?) } /// Assuming the path is already checked to be to a file /// (not a link or directory), tell whether it looks like a binary file pub fn is_file_binary>(path: P) -> io::Result { if let Some(ext) = path.as_ref().extension().and_then(|s| s.to_str()) { if extensions::is_known_binary(ext) { return Ok(true); } } magic_numbers::is_file_known_binary(path) } ================================================ FILE: src/display/areas.rs ================================================ use { super::*, crate::app::Panel, termimad::Area, }; /// the areas of the various parts of a panel. It's /// also where a state usually checks how many panels /// there are, and their respective positions #[derive(Debug, Clone)] pub struct Areas { pub state: Area, pub status: Area, pub input: Area, pub purpose: Option, pub pos_idx: usize, // from left to right pub nb_pos: usize, // number of displayed panels } const MINIMAL_PANEL_HEIGHT: u16 = 4; const MINIMAL_PANEL_WIDTH: u16 = 8; const MINIMAL_SCREEN_WIDTH: u16 = 16; enum Slot<'a> { Panel(usize), New(&'a mut Areas), } impl Areas { /// compute an area for a new panel which will be inserted pub fn create( present_panels: &mut [Panel], layout_instructions: &LayoutInstructions, mut insertion_idx: usize, screen: Screen, with_preview: bool, // slightly larger last panel ) -> Self { if insertion_idx > present_panels.len() { insertion_idx = present_panels.len(); } let mut areas = Areas { state: Area::uninitialized(), status: Area::uninitialized(), input: Area::uninitialized(), purpose: None, pos_idx: 0, nb_pos: 1, }; let mut slots = Vec::with_capacity(present_panels.len() + 1); for i in 0..insertion_idx { slots.push(Slot::Panel(i)); } slots.push(Slot::New(&mut areas)); for i in insertion_idx..present_panels.len() { slots.push(Slot::Panel(i)); } Self::compute_areas( present_panels, layout_instructions, &mut slots, screen, with_preview, ); areas } pub fn resize_all( panels: &mut [Panel], layout_instructions: &LayoutInstructions, screen: Screen, with_preview: bool, // slightly larger last panel ) { let mut slots = Vec::new(); for i in 0..panels.len() { slots.push(Slot::Panel(i)); } Self::compute_areas( panels, layout_instructions, &mut slots, screen, with_preview, ); } /// Compute the areas for all panels fn compute_areas( panels: &mut [Panel], layout_instructions: &LayoutInstructions, slots: &mut [Slot], screen: Screen, with_preview: bool, // slightly larger last panel ) { let screen_height = screen.height.max(MINIMAL_PANEL_HEIGHT); let screen_width = screen.width.max(MINIMAL_SCREEN_WIDTH); let n = slots.len() as u16; // compute auto/default panel widths let mut panel_width = if with_preview { 3 * screen_width / (3 * n + 1) } else { screen_width / n }; if panel_width < MINIMAL_PANEL_WIDTH { panel_width = panel_width.max(MINIMAL_PANEL_WIDTH); } let nb_pos = slots.len(); let mut panel_widths = vec![panel_width; nb_pos]; panel_widths[nb_pos - 1] = screen_width - (nb_pos as u16 - 1) * panel_width; // adjust panel widths with layout instructions if nb_pos > 1 { for instruction in &layout_instructions.instructions { debug!("Applying {instruction:?}"); debug!("panel_widths before: {panel_widths:?}"); match *instruction { LayoutInstruction::Clear => {} // not supposed to happen LayoutInstruction::MoveDivider { divider, dx } => { if divider + 1 >= nb_pos { continue; } let (decr, incr, diff) = if dx < 0 { (divider, divider + 1, (-dx) as u16) } else { (divider + 1, divider, dx as u16) }; let diff = diff.min(panel_widths[decr] - MINIMAL_PANEL_WIDTH); panel_widths[decr] -= diff; panel_widths[incr] += diff; } LayoutInstruction::SetPanelWidth { panel, width } => { if panel >= nb_pos { continue; } let width = width.max(MINIMAL_PANEL_WIDTH); if width > panel_widths[panel] { let mut diff = width - panel_widths[panel]; // as we try to increase the width of 'panel' we have to decrease the // widths of the other ones while diff > 0 { let mut freed = 0; let step = diff / (nb_pos as u16 - 1); for i in 0..nb_pos { if i != panel { let step = step.min(panel_widths[i] - MINIMAL_PANEL_WIDTH); panel_widths[i] -= step; freed += step; } } if freed == 0 { break; } diff -= freed; panel_widths[panel] += freed; } } else { // we distribute the freed width among other panels let freed = panel_widths[panel] - width; panel_widths[panel] = width; let step = freed / (nb_pos as u16 - 1); for i in 0..nb_pos { if i != panel { panel_widths[i] += step; } } let rem = freed - (nb_pos as u16 - 1) * freed; for i in 0..nb_pos { if i != panel { panel_widths[i] += rem; break; } } } } } debug!("panel_widths after: {:?}", &panel_widths); } } // compute the areas of each slot, and give it to their panels let mut x = 0; #[allow(clippy::needless_range_loop)] for slot_idx in 0..nb_pos { let panel_width = panel_widths[slot_idx]; let areas: &mut Areas = match &mut slots[slot_idx] { Slot::Panel(panel_idx) => &mut panels[*panel_idx].areas, Slot::New(areas) => areas, }; let y = screen_height - 2; areas.state = Area::new(x, 0, panel_width, y); areas.status = if WIDE_STATUS { Area::new(0, y, screen_width, 1) } else { Area::new(x, y, panel_width, 1) }; let y = y + 1; areas.input = Area::new(x, y, panel_width, 1); if slot_idx == nb_pos - 1 { // the char at the bottom right of the terminal should not be touched // (it makes some terminals flicker) so the input area is one char shorter areas.input.width -= 1; } areas.purpose = if slot_idx > 0 { // the purpose area is over the panel at left let area_width = panel_widths[slot_idx - 1] / 2; Some(Area::new(x - area_width, y, area_width, 1)) } else { None }; areas.pos_idx = slot_idx; areas.nb_pos = nb_pos; x += panel_width; } } pub fn is_first(&self) -> bool { self.pos_idx == 0 } pub fn is_last(&self) -> bool { self.pos_idx + 1 == self.nb_pos } } ================================================ FILE: src/display/cell_size.rs ================================================ /// find and return the size of a cell (a char location) in pixels /// as (width, height). /// Many terminals don't fill this information correctly, so an /// error is expected (it works on kitty, where I use the data /// to compute the rendering dimensions of images) #[cfg(unix)] pub fn cell_size_in_pixels() -> std::io::Result<(u32, u32)> { use { libc::{ STDOUT_FILENO, TIOCGWINSZ, c_ushort, ioctl, }, std::io, }; // see http://www.delorie.com/djgpp/doc/libc/libc_495.html #[repr(C)] struct winsize { ws_row: c_ushort, /* rows, in characters */ ws_col: c_ushort, /* columns, in characters */ ws_xpixel: c_ushort, /* horizontal size, pixels */ ws_ypixel: c_ushort, /* vertical size, pixels */ } let mut w = winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0, }; #[allow(clippy::useless_conversion)] let r = unsafe { ioctl(STDOUT_FILENO, TIOCGWINSZ.into(), &mut w) }; if r == 0 && w.ws_xpixel > w.ws_col && w.ws_ypixel > w.ws_row { Ok(( (w.ws_xpixel / w.ws_col) as u32, (w.ws_ypixel / w.ws_row) as u32, )) } else { warn!("failed to fetch cell dimension with ioctl"); Err(io::Error::other( "failed to fetch terminal dimension with ioctl", )) } } #[cfg(not(unix))] pub fn cell_size_in_pixels() -> std::io::Result<(u32, u32)> { // there's probably a way but I don't know it Err(std::io::Error::new( std::io::ErrorKind::Other, "fetching cell size isn't supported on Windows", )) } ================================================ FILE: src/display/col.rs ================================================ use { crate::{ app::AppState, errors::ConfError, tree::Tree, }, serde::Deserialize, std::{ convert::TryFrom, str::FromStr, }, }; // number of columns in enum const COLS_COUNT: usize = 10; /// One of the "columns" of the tree view #[derive(Debug, Clone, Copy, PartialEq)] pub enum Col { /// selection mark, typically a triangle on the selected line Mark, /// Git file status Git, /// the branch showing filliation Branch, /// The filesystem's device id (unix only) DeviceId, /// file mode and ownership Permission, /// last modified date Date, /// file size, including size bar in `sort_by_size` mode Size, /// number of files in the directory Count, /// marks whether the path is staged (not used for now, may be removed) Staged, /// name of the file, or subpath if relevant due to filtering mode Name, } pub type Cols = [Col; COLS_COUNT]; #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum ColsConf { /// the old representation, with one character per column Compact(String), /// the newer representation, with column names in clear Array(Vec), } /// Default column order pub static DEFAULT_COLS: Cols = [ Col::Mark, Col::Git, Col::DeviceId, Col::Size, Col::Date, Col::Permission, Col::Count, Col::Branch, Col::Staged, Col::Name, ]; impl FromStr for Col { type Err = ConfError; fn from_str(s: &str) -> Result { let s = s.to_lowercase(); match s.as_ref() { "m" | "mark" => Ok(Self::Mark), "g" | "git" => Ok(Self::Git), "dev" | "device" | "device-id" => Ok(Self::DeviceId), "b" | "branch" => Ok(Self::Branch), "p" | "permission" => Ok(Self::Permission), "d" | "date" => Ok(Self::Date), "s" | "size" => Ok(Self::Size), "c" | "count" => Ok(Self::Count), "staged" => Ok(Self::Staged), "n" | "name" => Ok(Self::Name), _ => Err(ConfError::InvalidCols { details: format!("column not recognized : {s}"), }), } } } impl Col { /// return the index of the column among the complete Cols ordered list pub fn index_in( self, cols: &Cols, ) -> Option { for (idx, col) in cols.iter().enumerate() { if *col == self { return Some(idx); } } None } /// tell whether this column should have an empty character left pub fn needs_left_margin(self) -> bool { match self { Col::Mark => false, Col::Git => false, Col::DeviceId => true, Col::Size => true, Col::Date => true, Col::Permission => true, Col::Count => false, Col::Branch => false, Col::Staged => false, Col::Name => false, } } pub fn is_visible( self, tree: &Tree, _app_state: Option<&AppState>, ) -> bool { let tree_options = &tree.options; match self { Col::Mark => tree_options.show_selection_mark, Col::Git => tree.git_status.is_some(), Col::DeviceId => tree_options.show_device_id, Col::Size => tree_options.show_sizes, Col::Date => tree_options.show_dates, Col::Permission => tree_options.show_permissions, Col::Count => tree_options.show_counts, Col::Branch => true, //Col::Staged => app_state.map_or(false, |a| !a.stage.is_empty()), Col::Staged => false, Col::Name => true, } } } impl TryFrom<&ColsConf> for Cols { type Error = ConfError; fn try_from(cc: &ColsConf) -> Result { match cc { ColsConf::Compact(s) => parse_cols_single_str(s), ColsConf::Array(arr) => parse_cols(arr), } } } /// return a Cols which tries to take the s setting into account /// but is guaranteed to have every Col exactly once. #[allow(clippy::ptr_arg)] // &[String] won't compile on all platforms pub fn parse_cols(arr: &Vec) -> Result { let mut cols = DEFAULT_COLS; for (idx, s) in arr.iter().enumerate() { if idx >= COLS_COUNT { return Err(ConfError::InvalidCols { details: format!("too long: {arr:?}"), }); } // we swap the cols, to ensure both keeps being present let col = Col::from_str(s)?; let dest_idx = col.index_in(&cols).unwrap(); // can't be none by construct cols[dest_idx] = cols[idx]; cols[idx] = col; } debug!("cols from conf = {cols:?}"); Ok(cols) } /// return a Cols which tries to take the s setting into account /// but is guaranteed to have every Col exactly once. pub fn parse_cols_single_str(s: &str) -> Result { parse_cols(&s.chars().map(String::from).collect()) } ================================================ FILE: src/display/displayable_tree.rs ================================================ use { super::{ BRANCH_FILLING, Col, CropWriter, GitStatusDisplay, MatchedString, SPACE_FILLING, num_format::format_count, }, crate::{ app::AppState, content_search::ContentMatch, errors::ProgramError, file_sum::FileSum, pattern::PatternObject, skin::{ ExtColorMap, StyleMap, }, task_sync::ComputationResult, tree::{ Tree, TreeLine, TreeLineType, }, }, chrono::{ DateTime, Local, LocalResult, TimeZone, }, crokey::crossterm::{ QueueableCommand, cursor, }, file_size, git2::Status, std::io::Write, termimad::{ CompoundStyle, ProgressBar, }, unicode_width::{ UnicodeWidthChar, UnicodeWidthStr, }, }; /// A tree wrapper which can be used either /// - to write on the screen in the application, /// - or to write in a file or an exported string. /// /// Using it in the application (with `in_app` true) means that /// - the selection is drawn /// - a scrollbar may be drawn /// - the empty lines will be erased pub struct DisplayableTree<'a, 's, 't> { pub app_state: Option<&'a AppState>, pub tree: &'t Tree, pub skin: &'s StyleMap, pub area: termimad::Area, pub in_app: bool, // if true we show the selection and scrollbar pub ext_colors: &'s ExtColorMap, } impl<'a, 's, 't> DisplayableTree<'a, 's, 't> { pub fn out_of_app( tree: &'t Tree, skin: &'s StyleMap, ext_colors: &'s ExtColorMap, width: u16, height: u16, ) -> DisplayableTree<'a, 's, 't> { DisplayableTree { app_state: None, tree, skin, ext_colors, area: termimad::Area { left: 0, top: 0, width, height, }, in_app: false, } } fn label_style( &self, line: &TreeLine, selected: bool, ) -> CompoundStyle { let style = match &line.line_type { TreeLineType::Dir => &self.skin.directory, TreeLineType::File => { if line.is_exe() { &self.skin.exe } else { &self.skin.file } } TreeLineType::BrokenSymLink(_) | TreeLineType::SymLink { .. } => &self.skin.link, TreeLineType::Pruning => &self.skin.pruning, }; let mut style = style.clone(); if let Some(ext_color) = line.extension().and_then(|ext| self.ext_colors.get(ext)) { style.set_fg(ext_color); } if selected { if let Some(c) = self.skin.selected_line.get_bg() { style.set_bg(c); } } style } fn write_line_count( &self, cw: &mut CropWriter, line: &TreeLine, count_len: usize, selected: bool, ) -> Result { Ok(if let Some(s) = line.sum { cond_bg!(count_style, self, selected, self.skin.count); let s = format_count(s.to_count()); cw.queue_g_string(count_style, format!("{s:>count_len$}"))?; 1 } else { count_len + 1 }) } #[allow(unused_variables, dead_code)] fn write_line_device_id( &self, cw: &mut CropWriter, line: &TreeLine, selected: bool, ) -> Result { #[cfg(any(target_os = "linux", target_os = "macos"))] { let device_id = line.device_id(); cond_bg!(style, self, selected, self.skin.device_id_major); cw.queue_g_string(style, format!("{:>3}", device_id.major))?; cond_bg!(style, self, selected, self.skin.device_id_sep); cw.queue_char(style, ':')?; cond_bg!(style, self, selected, self.skin.device_id_minor); cw.queue_g_string(style, format!("{:<3}", device_id.minor))?; } #[cfg(target_os = "windows")] { // Windows has a simpler device id, we use the "major" field only let device_id = line.device_id().map_or(" ".to_string(), |dev| dev.to_string()); cond_bg!(style, self, selected, self.skin.device_id_major); cw.queue_g_string(style, format!("{}", device_id))?; } Ok(0) } fn write_line_selection_mark( cw: &mut CropWriter, style: &CompoundStyle, selected: bool, ) -> Result { Ok(if selected { cw.queue_char(style, '▶')?; 0 } else { 1 }) } fn write_line_size( cw: &mut CropWriter, line: &TreeLine, style: &CompoundStyle, _selected: bool, ) -> Result { Ok(if let Some(s) = line.sum { cw.queue_g_string(style, format!("{:>4}", file_size::fit_4(s.to_size())))?; 1 } else { 5 }) } /// only makes sense when there's only one level /// (so in sort mode) fn write_line_size_with_bar( &self, cw: &mut CropWriter, line: &TreeLine, label_style: &CompoundStyle, total_size: FileSum, selected: bool, ) -> Result { Ok(if let Some(s) = line.sum { let pb = ProgressBar::new(s.part_of_size(total_size), 10); cond_bg!(sparse_style, self, selected, self.skin.sparse); cw.queue_g_string(label_style, format!("{:>4}", file_size::fit_4(s.to_size())))?; cw.queue_char( sparse_style, if s.is_sparse() && line.is_file() { 's' } else { ' ' }, )?; cw.queue_g_string(label_style, format!("{pb:<10}"))?; 1 } else { 16 }) } fn write_line_git_status( &self, cw: &mut CropWriter, line: &TreeLine, selected: bool, ) -> Result { let (style, char) = if line.is_selectable() { match line.git_status.map(|s| s.status) { Some(Status::CURRENT) => (&self.skin.git_status_current, ' '), Some(Status::WT_NEW) => (&self.skin.git_status_new, 'N'), Some(Status::CONFLICTED) => (&self.skin.git_status_conflicted, 'C'), Some(Status::WT_MODIFIED) => (&self.skin.git_status_modified, 'M'), Some(Status::IGNORED) => (&self.skin.git_status_ignored, 'I'), None => (&self.skin.tree, ' '), _ => (&self.skin.git_status_other, '?'), } } else { (&self.skin.tree, ' ') }; cond_bg!(git_style, self, selected, style); cw.queue_char(git_style, char)?; Ok(0) } fn write_date( &self, cw: &mut CropWriter, seconds: i64, selected: bool, ) -> Result { if let LocalResult::Single(date_time) = Local.timestamp_opt(seconds, 0) { cond_bg!(date_style, self, selected, self.skin.dates); cw.queue_g_string( date_style, date_time .format(self.tree.options.date_time_format) .to_string(), )?; } Ok(1) } fn write_branch( &self, cw: &mut CropWriter, line_index: usize, line: &TreeLine, selected: bool, staged: bool, ) -> Result { cond_bg!(branch_style, self, selected, self.skin.tree); let mut branch = String::new(); for depth in 0..line.depth { branch.push_str(if line.left_branches[depth as usize] { if self.tree.has_branch(line_index + 1, depth as usize) { // TODO: If a theme is on, remove the horizontal lines if depth == line.depth - 1 { if staged { "├◍─" } else { "├──" } } else { "│ " } } else if staged { "└◍─" } else { "└──" } } else { " " }); } if !branch.is_empty() { cw.queue_g_string(branch_style, branch)?; } Ok(0) } /// write the symbol showing whether the path is staged fn write_line_stage_mark( cw: &mut CropWriter, style: &CompoundStyle, staged: bool, ) -> Result { Ok(if staged { cw.queue_char(style, '◍')?; // ▣ 0 } else { 1 }) } /// write the name or subpath, depending on the `pattern_object` fn write_line_label( &self, cw: &mut CropWriter, line: &TreeLine, style: &CompoundStyle, pattern_object: PatternObject, selected: bool, ) -> Result { cond_bg!(char_match_style, self, selected, self.skin.char_match); if let Some(icon) = line.icon { cw.queue_char(style, icon)?; cw.queue_char(style, ' ')?; cw.queue_char(style, ' ')?; } if pattern_object.subpath { if self.tree.options.show_matching_characters_on_path_searches && line.unlisted == 0 { let name_match = self.tree.options.pattern.pattern.find_string(&line.subpath); let mut path_ms = MatchedString::new(name_match, &line.subpath, style, char_match_style); let name_ms = path_ms.split_on_last('/'); cond_bg!(parent_style, self, selected, self.skin.parent); if let Some(name_ms) = name_ms { path_ms.base_style = parent_style; let allowed = cw.allowed - 2.min(cw.allowed); let tail_len = name_ms.width(); if tail_len < allowed { let path_width = path_ms.width(); if path_width > 1 && path_width + tail_len > allowed + 1 { cw.queue_char(style, '…')?; path_ms.cut_left_to_fit(allowed - tail_len - 1); } path_ms.queue_on(cw)?; } name_ms.queue_on(cw)?; } else { path_ms.queue_on(cw)?; } } else { cw.queue_str(style, &line.name)?; } } else { let name_match = self.tree.options.pattern.pattern.find_string(&line.name); let matched_string = MatchedString::new(name_match, &line.name, style, char_match_style); matched_string.queue_on(cw)?; } match &line.line_type { TreeLineType::Dir => { if line.unlisted > 0 { cw.queue_str(style, " …")?; } } TreeLineType::BrokenSymLink(direct_path) => { cw.queue_str(style, " -> ")?; cond_bg!(error_style, self, selected, self.skin.file_error); cw.queue_str(error_style, direct_path)?; } TreeLineType::SymLink { final_is_dir, direct_target, .. } => { cw.queue_str(style, " -> ")?; let target_style = if *final_is_dir { &self.skin.directory } else { &self.skin.file }; cond_bg!(target_style, self, selected, target_style); cw.queue_str(target_style, direct_target)?; } _ => {} } Ok(1) } fn write_content_extract( &self, cw: &mut CropWriter, extract: &ContentMatch, selected: bool, ) -> Result<(), ProgramError> { cond_bg!(extract_style, self, selected, self.skin.content_extract); cond_bg!(match_style, self, selected, self.skin.content_match); cw.queue_str(extract_style, " ")?; if extract.needle_start > 0 { cw.queue_str(extract_style, &extract.extract[0..extract.needle_start])?; } cw.queue_str( match_style, &extract.extract[extract.needle_start..extract.needle_end], )?; if extract.needle_end < extract.extract.len() { cw.queue_str(extract_style, &extract.extract[extract.needle_end..])?; } Ok(()) } pub fn write_root_line( &self, cw: &mut CropWriter, selected: bool, ) -> Result<(), ProgramError> { cond_bg!(style, self, selected, self.skin.directory); let line = &self.tree.lines[0]; if self.tree.options.show_sizes { if let Some(s) = line.sum { cw.queue_g_string(style, format!("{:>4} ", file_size::fit_4(s.to_size())))?; } } let title = line.path.to_string_lossy(); let title_len = UnicodeWidthStr::width(title.as_ref()); if title_len > cw.allowed { cw.queue_char(style, '…')?; // we take the last chars making up to allowed - 1 columns // we'll assume there's no backspace let mut width = 0; let mut bytes = 0; for c in title.chars().rev() { let char_width = c.width().unwrap_or(0); if width + char_width > cw.allowed - 1 { break; } width += char_width; bytes += c.len_utf8(); } let right_cropped_title = &title[title.len() - bytes..]; cw.queue_str(style, right_cropped_title)?; } else { cw.queue_str(style, &title)?; } if self.in_app && !cw.is_full() { if let ComputationResult::Done(git_status) = &self.tree.git_status { let git_status_display = GitStatusDisplay::from(git_status, self.skin, cw.allowed); git_status_display.write(cw, selected)?; } #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] if self.tree.options.show_root_fs { if let Some(mount) = line.mount() { let fs_space_display = crate::filesystems::MountSpaceDisplay::from(&mount, self.skin, cw.allowed); fs_space_display.write(cw, selected)?; } } self.extend_line_bg(cw, selected)?; } Ok(()) } /// if in app, extend the background till the end of screen row pub fn extend_line_bg( &self, cw: &mut CropWriter, selected: bool, ) -> Result<(), ProgramError> { if self.in_app && !cw.is_full() { let style = if selected { &self.skin.selected_line } else { &self.skin.default }; cw.fill(style, &SPACE_FILLING)?; } Ok(()) } /// write the whole tree on the given `W` pub fn write_on( &self, f: &mut W, ) -> Result<(), ProgramError> { #[cfg(not(any(target_family = "windows", target_os = "android")))] let perm_writer = super::PermWriter::for_tree(self.skin, self.tree); let tree = self.tree; let total_size = tree.total_sum(); let scrollbar = if self.in_app { termimad::compute_scrollbar( tree.scroll, tree.lines.len() - 1, // the root line isn't scrolled self.area.height - 1, // the scrollbar doesn't cover the first line self.area.top + 1, ) } else { None }; if self.in_app { f.queue(cursor::MoveTo(self.area.left, self.area.top))?; } let mut cw = CropWriter::new(f, self.area.width as usize); let pattern_object = tree.options.pattern.pattern.object(); self.write_root_line(&mut cw, self.in_app && tree.selection == 0)?; self.skin.queue_reset(f)?; let visible_cols: Vec = tree .options .cols_order .iter() .filter(|col| col.is_visible(tree, self.app_state)) .copied() .collect(); // if necessary we compute the width of the count column let count_len = if tree.options.show_counts { tree.lines .iter() .skip(1) // we don't show the counts of the root .map(|l| l.sum.map_or(0, FileSum::to_count)) .max() .map(|c| format_count(c).len()) .unwrap_or(0) } else { 0 }; // we compute the length of the dates, depending on the format let date_len = if tree.options.show_dates { let date_time: DateTime = Local::now(); date_time .format(tree.options.date_time_format) .to_string() .len() } else { 0 // we don't care }; for y in 1..self.area.height { if self.in_app { f.queue(cursor::MoveTo(self.area.left, y + self.area.top))?; } else { write!(f, "\r\n")?; } let mut line_index = y as usize; if line_index > 0 { line_index += tree.scroll; } let mut selected = false; let mut cw = CropWriter::new(f, self.area.width as usize); let cw = &mut cw; if line_index < tree.lines.len() { let line = &tree.lines[line_index]; selected = self.in_app && line_index == tree.selection; let label_style = self.label_style(line, selected); let mut in_branch = false; let space_style = if selected { &self.skin.selected_line } else { &self.skin.default }; if visible_cols[0].needs_left_margin() { cw.queue_char(space_style, ' ')?; } let staged = self .app_state .is_some_and(|a| a.stage.contains(&line.path)); for col in &visible_cols { let void_len = match col { Col::Mark => Self::write_line_selection_mark(cw, &label_style, selected)?, Col::Git => self.write_line_git_status(cw, line, selected)?, Col::Branch => { in_branch = true; self.write_branch(cw, line_index, line, selected, staged)? } Col::DeviceId => { #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { 0 } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] self.write_line_device_id(cw, line, selected)? } Col::Permission => { #[cfg(any(target_family = "windows", target_os = "android"))] { 0 } #[cfg(not(any(target_family = "windows", target_os = "android")))] perm_writer.write_permissions(cw, line, selected)? } Col::Date => { if let Some(seconds) = line.sum.and_then(FileSum::to_valid_seconds) { self.write_date(cw, seconds, selected)? } else { date_len + 1 } } Col::Size => { if tree.options.sort.prevent_deep_display() { // as soon as there's only one level displayed we can show the size bars self.write_line_size_with_bar( cw, line, &label_style, total_size, selected, )? } else { Self::write_line_size(cw, line, &label_style, selected)? } } Col::Count => self.write_line_count(cw, line, count_len, selected)?, Col::Staged => Self::write_line_stage_mark(cw, &label_style, staged)?, Col::Name => { in_branch = false; self.write_line_label(cw, line, &label_style, pattern_object, selected)? } }; // void: intercol & replacing missing cells if in_branch && void_len > 2 { cond_bg!(void_style, self, selected, self.skin.tree); cw.repeat(void_style, &BRANCH_FILLING, void_len)?; } else { cond_bg!(void_style, self, selected, self.skin.default); cw.repeat(void_style, &SPACE_FILLING, void_len)?; } } if cw.allowed > 8 && pattern_object.content { let extract = tree .options .pattern .pattern .find_content(&line.path, cw.allowed - 2); if let Some(extract) = extract { self.write_content_extract(cw, &extract, selected)?; } } } self.extend_line_bg(cw, selected)?; self.skin.queue_reset(f)?; if self.in_app { if let Some((sctop, scbottom)) = scrollbar { f.queue(cursor::MoveTo(self.area.left + self.area.width - 1, y))?; let style = if sctop <= y && y <= scbottom { &self.skin.scrollbar_thumb } else { &self.skin.scrollbar_track }; style.queue_str(f, "▐")?; } } } if !self.in_app { write!(f, "\r\n")?; } Ok(()) } } ================================================ FILE: src/display/flags_display.rs ================================================ use { super::W, crate::{ errors::ProgramError, flag::Flag, skin::PanelSkin, }, }; /// compute the needed length for displaying the flags #[must_use] pub fn visible_width(flags: &[Flag]) -> u16 { let mut width = flags.len() * 2 + 1; for flag in flags { width += flag.name.len(); // we assume only ascii chars width += flag.value.len(); } width as u16 } /// draw the flags pub fn write( w: &mut W, flags: &[Flag], panel_skin: &PanelSkin, ) -> Result<(), ProgramError> { for flag in flags { panel_skin .styles .flag_label .queue_str(w, format!(" {}:", flag.name))?; panel_skin.styles.flag_value.queue(w, flag.value)?; panel_skin.styles.flag_label.queue(w, ' ')?; } Ok(()) } ================================================ FILE: src/display/git_status_display.rs ================================================ use { super::CropWriter, crate::{ errors::ProgramError, git::TreeGitStatus, skin::StyleMap, }, }; pub struct GitStatusDisplay<'a, 's> { status: &'a TreeGitStatus, skin: &'s StyleMap, show_branch: bool, show_wide: bool, show_stats: bool, pub width: usize, } impl<'a, 's> GitStatusDisplay<'a, 's> { pub fn from( status: &'a TreeGitStatus, skin: &'s StyleMap, available_width: usize, ) -> Self { let mut show_branch = false; let mut width = 0; if let Some(branch) = &status.current_branch_name { let branch_width = branch.chars().count(); if branch_width < available_width { width += branch_width; show_branch = true; } } let mut show_stats = false; let unstyled_stats = format!("+{}-{}", status.insertions, status.deletions); let stats_width = unstyled_stats.len(); if width + stats_width < available_width { width += stats_width; show_stats = true; } let show_wide = width + 3 < available_width; if show_wide { width += 3; // difference between compact and wide format widths } Self { status, skin, show_branch, show_wide, show_stats, width, } } pub fn write( &self, cw: &mut CropWriter, selected: bool, ) -> Result<(), ProgramError> where W: std::io::Write, { if self.show_branch { cond_bg!(branch_style, self, selected, self.skin.git_branch); if let Some(name) = &self.status.current_branch_name { if self.show_wide { cw.queue_str(branch_style, " ᚜ ")?; } else { cw.queue_char(branch_style, ' ')?; } cw.queue_str(branch_style, name)?; cw.queue_char(branch_style, ' ')?; } } if self.show_stats { cond_bg!(insertions_style, self, selected, self.skin.git_insertions); cw.queue_g_string(insertions_style, format!("+{}", self.status.insertions))?; cond_bg!(deletions_style, self, selected, self.skin.git_deletions); cw.queue_g_string(deletions_style, format!("-{}", self.status.deletions))?; } Ok(()) } } ================================================ FILE: src/display/layout_instructions.rs ================================================ use { lazy_regex::*, serde::Deserialize, std::str::FromStr, }; #[derive(Debug, Clone, Default, Deserialize)] #[serde(transparent)] pub struct LayoutInstructions { pub instructions: Vec, } #[derive(Debug, Clone, Copy, Deserialize)] #[serde(untagged)] pub enum LayoutInstruction { Clear, // clear all instructions MoveDivider { divider: usize, dx: i16 }, SetPanelWidth { panel: usize, width: u16 }, } /// arguments for moving a divider, read from a string eg "0 -5" /// (move the first divider 5 cells to the left) #[derive(Debug, Clone, Copy)] pub struct MoveDividerArgs { pub divider: usize, pub dx: i16, } impl FromStr for MoveDividerArgs { type Err = &'static str; fn from_str(s: &str) -> Result { if let Some((_, divider, dx)) = regex_captures!(r"^\s*(\d)\s+(-?\d{1,3})\s*$", s) { Ok(Self { divider: divider.parse().unwrap(), dx: dx.parse().unwrap(), }) } else { Err("not the expected move_divider args") } } } /// arguments for setting the width of a panel, read from a string eg "1 150" #[derive(Debug, Clone, Copy)] pub struct SetPanelWidthArgs { pub panel: usize, pub width: u16, } impl FromStr for SetPanelWidthArgs { type Err = &'static str; fn from_str(s: &str) -> Result { if let Some((_, panel, width)) = regex_captures!(r"^\s*(\d)\s+(\d{1,4})\s*$", s) { Ok(Self { panel: panel.parse().unwrap(), width: width.parse().unwrap(), }) } else { Err("not the expected set_panel_width args") } } } impl LayoutInstruction { pub fn is_moving_divider( self, idx: usize, ) -> bool { match self { Self::MoveDivider { divider, .. } => divider == idx, _ => false, } } } impl LayoutInstructions { pub fn push( &mut self, new_instruction: LayoutInstruction, ) { use LayoutInstruction::*; match new_instruction { Clear => { self.instructions.clear(); } SetPanelWidth { panel: new_panel, .. } => { // all previous SetPanelWidth for the same panel are now irrelevant self.instructions.retain(|i| match i { SetPanelWidth { panel, .. } => *panel != new_panel, _ => true, }); } MoveDivider { divider: new_divider, dx: new_dx, } => { // if the last instruction is a move of the same divider, we adjust it if let Some(MoveDivider { divider, dx }) = self.instructions.last_mut() { if *divider == new_divider { *dx += new_dx; return; } } } } self.instructions.push(new_instruction); } } ================================================ FILE: src/display/luma.rs ================================================ pub use { crokey::crossterm::tty::IsTty, once_cell::sync::Lazy, serde::Deserialize, }; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Luma { Light, Unknown, Dark, } /// Return the light of the terminal background, which is a value /// between 0 (black) and 1 (white). pub fn luma() -> &'static Result { static LUMA: Lazy> = Lazy::new(|| { let luma = time!(Debug, terminal_light::luma()); info!("terminal's luma: {:?}", &luma); luma }); &LUMA } impl Luma { pub fn read() -> Self { match luma() { Ok(luma) if *luma > 0.6 => Self::Light, Ok(_) => Self::Dark, _ => Self::Unknown, } } } #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum LumaCondition { Simple(Luma), Array(Vec), } impl LumaCondition { pub fn is_verified(&self) -> bool { let luma = if std::io::stdout().is_tty() { Luma::read() } else { Luma::Unknown }; self.includes(luma) } pub fn includes( &self, other: Luma, ) -> bool { match self { Self::Simple(luma) => other == *luma, Self::Array(arr) => arr.contains(&other), } } } ================================================ FILE: src/display/matched_string.rs ================================================ use { super::{ CropWriter, SPACE_FILLING, }, crate::pattern::NameMatch, termimad::{ CompoundStyle, StrFit, minimad::Alignment, }, unicode_width::{ UnicodeWidthChar, UnicodeWidthStr, }, }; pub struct MatchedString<'a> { pub name_match: Option, pub string: &'a str, pub base_style: &'a CompoundStyle, pub match_style: &'a CompoundStyle, pub display_width: Option, pub align: Alignment, } impl<'a> MatchedString<'a> { pub fn new( name_match: Option, string: &'a str, base_style: &'a CompoundStyle, match_style: &'a CompoundStyle, ) -> Self { Self { name_match, string, base_style, match_style, display_width: None, align: Alignment::Left, } } /// If the string contains sep, then cut the tail of this matched /// string and return it. /// Note: a non none `display_width` currently prevents splitting /// (i.e. it's not yet implemented and would involve compute width) pub fn split_on_last( &mut self, sep: char, ) -> Option { if self.display_width.is_some() { // the proper algo would need measuring the left part I guess None } else { self.string.rfind(sep).map(|sep_idx| { let right = &self.string[sep_idx + 1..]; self.string = &self.string[..=sep_idx]; let left_chars_count = self.string.chars().count(); let right_name_match = self .name_match .as_mut() .map(|nm| nm.cut_after(left_chars_count)); MatchedString { name_match: right_name_match, string: right, base_style: self.base_style, match_style: self.match_style, display_width: None, align: self.align, } }) } } pub fn fill( &mut self, width: usize, align: Alignment, ) { self.display_width = Some(width); self.align = align; } pub fn width(&self) -> usize { UnicodeWidthStr::width(self.string) } /// Remove characters left so that the visible width is equal or /// less to the required width pub fn cut_left_to_fit( &mut self, max_width: usize, ) -> usize { let mut removed_char_count = 0; let mut break_idx = 0; let mut width = self.width(); for (idx, c) in self.string.char_indices() { if width <= max_width { break; } break_idx = idx + c.len_utf8(); let char_width = c.width().unwrap_or(0); if char_width > width { warn!("inconsistent char/str widths"); break; } width -= char_width; removed_char_count += 1; } if removed_char_count > 0 { self.string = &self.string[break_idx..]; self.name_match = self .name_match .take() .map(|mut nm| nm.cut_after(removed_char_count - 1)); } removed_char_count } pub fn queue_on( &self, cw: &mut CropWriter<'_, W>, ) -> Result<(), termimad::Error> where W: std::io::Write, { if let Some(m) = &self.name_match { let mut pos_idx: usize = 0; let mut combined_style = self.base_style.clone(); combined_style.overwrite_with(self.match_style); let mut right_filling = 0; let mut s = self.string; if let Some(dw) = self.display_width { let w = unicode_width::UnicodeWidthStr::width(s); #[allow(clippy::comparison_chain)] if w > dw { let (count_bytes, _) = StrFit::count_fitting(s, dw); s = &s[0..count_bytes]; } else if w < dw { match self.align { Alignment::Right => { cw.repeat(self.base_style, &SPACE_FILLING, dw - w)?; } Alignment::Center => { right_filling = (dw - w) / 2; cw.repeat(self.base_style, &SPACE_FILLING, dw - w - right_filling)?; } _ => { right_filling = dw - w; } } } } // we might call queue_char more than allowed but that's okay // because the cropwriter will crop them for (cand_idx, cand_char) in s.chars().enumerate() { if pos_idx < m.pos.len() && m.pos[pos_idx] == cand_idx { cw.queue_char(&combined_style, cand_char)?; pos_idx += 1; } else { cw.queue_char(self.base_style, cand_char)?; } } if right_filling > 0 { cw.repeat(self.base_style, &SPACE_FILLING, right_filling)?; } } else if let Some(w) = self.display_width { match self.align { Alignment::Center => { cw.queue_str(self.base_style, &format!("{:^w$}", self.string, w = w))?; } Alignment::Right => { cw.queue_str(self.base_style, &format!("{:>w$}", self.string, w = w))?; } _ => { cw.queue_str(self.base_style, &format!("{: { let mut cloned_style; let $dst = if $selected { cloned_style = $src.clone(); if let Some(c) = $self.skin.selected_line.get_bg() { cloned_style.set_bg(c); } &cloned_style } else { &$src }; }; } mod areas; mod cell_size; mod col; mod displayable_tree; pub mod flags_display; mod git_status_display; mod layout_instructions; mod luma; mod matched_string; mod num_format; mod screen; pub mod status_line; #[cfg(not(any(target_family = "windows", target_os = "android")))] mod permissions; pub use { areas::Areas, cell_size::*, col::*, cond_bg, displayable_tree::DisplayableTree, git_status_display::GitStatusDisplay, layout_instructions::*, luma::LumaCondition, matched_string::MatchedString, screen::Screen, }; use { once_cell::sync::Lazy, termimad::*, }; #[cfg(not(any(target_family = "windows", target_os = "android")))] pub use permissions::PermWriter; pub static BRANCH_FILLING: Lazy = Lazy::new(|| Filling::from_char('─')); /// if true then the status of a panel covers the whole width /// of the terminal (over the other panels) pub const WIDE_STATUS: bool = true; /// the type used by all GUI writing functions pub type W = std::io::BufWriter; /// return the writer used by the application #[must_use] pub fn writer() -> W { std::io::BufWriter::new(std::io::stderr()) } ================================================ FILE: src/display/num_format.rs ================================================ /// Format a number with commas as thousands separators pub fn format_count(count: usize) -> String { let mut s = count.to_string(); let l = s.len(); for i in 1..l { if i % 3 == 0 { s.insert(l - i, ','); } } s } #[test] fn test_format_count() { assert_eq!(&format_count(1), "1"); assert_eq!(&format_count(12), "12"); assert_eq!(&format_count(123), "123"); assert_eq!(&format_count(1234), "1,234"); assert_eq!(&format_count(12345), "12,345"); assert_eq!(&format_count(123456), "123,456"); assert_eq!(&format_count(1234567), "1,234,567"); assert_eq!(&format_count(12345678), "12,345,678"); assert_eq!(&format_count(1234567890), "1,234,567,890"); } ================================================ FILE: src/display/permissions.rs ================================================ use { super::CropWriter, crate::{ errors::ProgramError, permissions, skin::StyleMap, tree::{ Tree, TreeLine, }, }, std::{ io::Write, os::unix::fs::MetadataExt, }, umask::*, }; /// an object which writes file permissions (mode, owner, group) pub struct PermWriter<'s> { pub skin: &'s StyleMap, max_user_len: usize, max_group_len: usize, } impl<'s> PermWriter<'s> { pub fn new( skin: &'s StyleMap, max_user_len: usize, max_group_len: usize, ) -> Self { Self { skin, max_user_len, max_group_len, } } pub fn for_tree( skin: &'s StyleMap, tree: &Tree, ) -> Self { let (max_user_len, max_group_len) = user_group_max_lengths(tree); Self::new(skin, max_user_len, max_group_len) } fn write_mode( &self, cw: &mut CropWriter, mode: Mode, selected: bool, ) -> Result<(), termimad::Error> { cond_bg!(n_style, self, selected, self.skin.perm__); cond_bg!(r_style, self, selected, self.skin.perm_r); cond_bg!(w_style, self, selected, self.skin.perm_w); cond_bg!(x_style, self, selected, self.skin.perm_x); if mode.has(USER_READ) { cw.queue_char(r_style, 'r')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(USER_WRITE) { cw.queue_char(w_style, 'w')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(USER_EXEC) { cw.queue_char(x_style, if mode.has_extra(SETUID) { 's' } else { 'x' })?; } else { cw.queue_char(n_style, if mode.has_extra(SETUID) { 'S' } else { '_' })?; } if mode.has(GROUP_READ) { cw.queue_char(r_style, 'r')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(GROUP_WRITE) { cw.queue_char(w_style, 'w')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(GROUP_EXEC) { cw.queue_char(x_style, if mode.has_extra(SETGID) { 's' } else { 'x' })?; } else { cw.queue_char(n_style, if mode.has_extra(SETGID) { 'S' } else { '_' })?; } if mode.has(OTHERS_READ) { cw.queue_char(r_style, 'r')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(OTHERS_WRITE) { cw.queue_char(w_style, 'w')?; } else { cw.queue_char(n_style, '_')?; } if mode.has(OTHERS_EXEC) { cw.queue_char(x_style, if mode.has_extra(STICKY) { 't' } else { 'x' })?; } else { cw.queue_char(n_style, if mode.has_extra(STICKY) { 'T' } else { '_' })?; } Ok(()) } #[cfg(not(any(target_family = "windows", target_os = "android")))] pub fn write_permissions( &self, cw: &mut CropWriter, line: &TreeLine, selected: bool, ) -> Result { Ok(if line.is_selectable() { self.write_mode(cw, line.mode(), selected)?; let owner = permissions::user_name(line.metadata.uid()); cond_bg!(owner_style, self, selected, self.skin.owner); cw.queue_g_string( owner_style, format!(" {:w$}", &owner, w = self.max_user_len), )?; let group = permissions::group_name(line.metadata.gid()); cond_bg!(group_style, self, selected, self.skin.group); cw.queue_g_string( group_style, format!(" {:w$}", &group, w = self.max_group_len), )?; 1 } else { 9 + 1 + self.max_user_len + 1 + self.max_group_len + 1 }) } } fn user_group_max_lengths(tree: &Tree) -> (usize, usize) { let mut max_user_len = 0; let mut max_group_len = 0; if tree.options.show_permissions { for i in 1..tree.lines.len() { let line = &tree.lines[i]; let user = permissions::user_name(line.metadata.uid()); max_user_len = max_user_len.max(user.len()); let group = permissions::group_name(line.metadata.gid()); max_group_len = max_group_len.max(group.len()); } } (max_user_len, max_group_len) } ================================================ FILE: src/display/screen.rs ================================================ use { super::W, crate::{ app::AppContext, errors::ProgramError, skin::PanelSkin, }, crokey::crossterm::{ QueueableCommand, cursor, terminal::{ Clear, ClearType, }, }, termimad::Area, }; /// The dimensions of the screen #[derive(Clone, Copy)] pub struct Screen { pub width: u16, pub height: u16, } impl Screen { pub fn new(con: &AppContext) -> Result { let mut screen = Screen { width: 0, height: 0, }; screen.read_size(con)?; Ok(screen) } pub fn set_terminal_size( &mut self, w: u16, h: u16, con: &AppContext, ) { self.width = w; self.height = h; if let Some(h) = con.launch_args.height { self.height = h; } } pub fn read_size( &mut self, con: &AppContext, ) -> Result<(), ProgramError> { let (w, h) = termimad::terminal_size(); self.set_terminal_size(w, h, con); Ok(()) } /// move the cursor to x,y pub fn goto( self, w: &mut W, x: u16, y: u16, ) -> Result<(), ProgramError> { w.queue(cursor::MoveTo(x, y))?; Ok(()) } /// clear from the cursor to the end of line pub fn clear_line( self, w: &mut W, ) -> Result<(), ProgramError> { w.queue(Clear(ClearType::UntilNewLine))?; Ok(()) } /// clear the area and everything to the right. /// Should be used with parcimony as it could lead to flickering. pub fn clear_area_to_right( self, w: &mut W, area: &Area, ) -> Result<(), ProgramError> { for y in area.top..area.top + area.height { self.goto(w, area.left, y)?; self.clear_line(w)?; } Ok(()) } /// just clears the char at the bottom right. /// (any redraw of this position makes the whole terminal flicker on some /// terminals like win/conemu, so we draw it only once at start of the /// app) pub fn clear_bottom_right_char( &self, w: &mut W, panel_skin: &PanelSkin, ) -> Result<(), ProgramError> { self.goto(w, self.width, self.height)?; panel_skin.styles.default.queue(w, ' ')?; Ok(()) } } ================================================ FILE: src/display/status_line.rs ================================================ use { super::{ Screen, W, }, crate::{ app::Status, errors::ProgramError, skin::PanelSkin, }, termimad::{ Area, StyledChar, minimad::{ Alignment, Composite, }, }, unicode_width::UnicodeWidthStr, }; /// write the whole status line (task + status) pub fn write( w: &mut W, watching: bool, task: Option<&str>, status: &Status, area: &Area, panel_skin: &PanelSkin, screen: Screen, ) -> Result<(), ProgramError> { let y = area.top; screen.goto(w, area.left, y)?; let mut x = area.left; if watching { let eye = "👁 "; x += eye.width() as u16; panel_skin.styles.status_job.queue(w, eye)?; } if let Some(pending_task) = task { let pending_task = format!(" {pending_task}… "); x += pending_task.chars().count() as u16; panel_skin.styles.status_job.queue(w, pending_task)?; } screen.goto(w, x, y)?; let style = if status.error { &panel_skin.status_skin.error } else { &panel_skin.status_skin.normal }; style.write_inline_on(w, " ")?; let remaining_width = (area.width - (x - area.left) - 1) as usize; style.write_composite_fill( w, Composite::from_inline(&status.message), remaining_width, Alignment::Unspecified, )?; Ok(()) } /// erase the whole status line pub fn erase( w: &mut W, area: &Area, panel_skin: &PanelSkin, screen: Screen, ) -> Result<(), ProgramError> { screen.goto(w, area.left, area.top)?; let sc = StyledChar::new( panel_skin .status_skin .normal .paragraph .compound_style .clone(), ' ', ); sc.queue_repeat(w, area.width as usize)?; Ok(()) } ================================================ FILE: src/errors.rs ================================================ //! Definitions of custom errors used in broot use { custom_error::custom_error, lazy_regex::regex, std::io, }; custom_error! {pub ProgramError AmbiguousVerbName {name: String} = "Ambiguous name: More than one verb matches {name:?}", ArgParse {bad: String, valid: String} = "{bad:?} can't be parsed (valid values: {valid:?})", ConfFile {path:String, details: ConfError} = "Bad configuration file {path:?} : {details}", Conf {source: ConfError} = "Bad configuration: {source}", ImageError {details: String} = "Image error: {details}", Internal {details: String} = "Internal error: {details}", // should not happen Io {source: io::Error} = "IO Error : {source}", LaunchError {program: String, source: io::Error} = "Unable to launch {program}: {source}", Lfs {details: String} = "Failed to fetch mounts: {details}", NetError {source: NetError} = "{source}", OpenError { source: opener::OpenError } = "Open error: {source}", ShelInstall { source: ShellInstallError } = "{source}", Svg {source: SvgError} = "SVG error: {source}", SyntectCrashed { details: String } = "Syntect crashed on {details:?}", Termimad {source: termimad::Error} = "Termimad Error : {source}", Trash {message: String} = "Trash error: {message}", TreeBuild {source: TreeBuildError} = "{source}", UnknownShell {shell: String} = "Unknown shell: {shell}", UnknownVerb {name: String} = "No verb matches {name:?}", UnmappableFile = "File can't be mapped", UnmatchingVerbArgs {name: String} = "No matching argument found for verb {name:?}", UnprintableFile = "File can't be printed", // has characters that can't be printed without escaping Unrecognized {token: String} = "Unrecognized: {token}", ZeroLenFile = "File seems empty", Notify { source: notify::Error } = "Notify error: {source}", } custom_error! {pub ShellInstallError Io {source: io::Error, when: String} = "IO Error {source} on {when}", } impl ShellInstallError { pub fn is_permission_denied(&self) -> bool { match self { Self::Io { source, .. } => { if source.kind() == io::ErrorKind::PermissionDenied { true } else { cfg!(windows) && source.raw_os_error().unwrap_or(0) == 1314 } } } } } pub trait IoToShellInstallError { fn context( self, f: &dyn Fn() -> String, ) -> Result; } impl IoToShellInstallError for Result { fn context( self, f: &dyn Fn() -> String, ) -> Result { self.map_err(|source| ShellInstallError::Io { source, when: f() }) } } custom_error! {pub TreeBuildError FileNotFound { path: String } = "File not found: {path}", Interrupted = "Task Interrupted", InvalidUtf8 { path: String } = "Invalid UTF-8 in {path}", //Io {source: io::Error} = "IO Error : {source}", NotADirectory { path: String } = "Not a directory: {path}", NotARootDescendant { path: String } = "Not a descendant of the root: {path}", TooManyMatches { max: usize } = "Too many matches (max allowed: {max})", InconsistentData { message:String } = "Inconsistent data: {message}", // maybe refresh ? } custom_error! {pub ConfError Io {source: io::Error} = "unable to read from the file: {source}", ImportNotFound {path: String} = "import file not found: {path:?}", UnknownFileExtension { path: String} = "unexpected file extension in {path:?}", Toml {source: toml::de::Error} = "unable to parse TOML: {source}", Hjson {source: deser_hjson::Error} = "unable to parse Hjson: {source}", Invalid = "unexpected conf structure", // not expected MissingField {txt: String} = "missing field in conf", InvalidVerbInvocation {invocation: String} = "invalid verb invocation: {invocation}", InvalidVerbConf {details: String} = "invalid verb conf: {details}", UnknownInternal {verb: String} = "not a known internal: {verb}", InvalidSearchMode {details: String} = "invalid search mode: {details}", InvalidKey {raw: String} = "not a valid key: {raw}", ParseKey {source: crokey::ParseKeyError} = "{source}", ReservedKey {key: String} = "reserved key: {key}", UnexpectedInternalArg {invocation: String} = "unexpected argument for internal: {invocation}", InvalidCols {details: String} = "invalid cols definition: {details}", InvalidSkin {source: InvalidSkinError} = "invalid skin: {source}", InvalidThreadsCount { count: usize } = "invalid threads count: {count}", InvalidDefaultFlags { flags: String } = "invalid default flags: {flags:?}", InvalidSyntaxTheme { name: String } = "invalid syntax theme: {name:?}", InvalidGlobPattern { pattern: String } = "invalid glob pattern: {pattern:?}", InvalidVerbName { name: String } = "invalid verb name: {name:?} (must either not start with a special character or be only made of special characters)", UnknownVerbArgFlag { name: String } = "Unknown verb argument flag: {name:?}", InvalidPanelReference { raw: String } = "invalid panel reference: {raw:?}", } // error which can be raised when parsing a pattern the user typed custom_error! {pub PatternError InvalidMode { mode: String } = "Invalid search mode: {mode:?}", InvalidRegex {source: regex::Error} = @{ format!("Invalid Regular Expression: {}", source.to_string().lines().last().unwrap_or("")) }, UnknownRegexFlag {bad: char} = "Unknown regular expression flag: {bad:?}", } custom_error! {pub InvalidSkinError InvalidColor {source: termimad::ParseColorError} = "invalid color: {source}", InvalidAttribute {raw : String} = "'{raw}' is not a valid style attribute", InvalidGreyLevel {level: u8} = "grey level must be between 0 and 23 (got {level})", InvalidStyle {style: String} = "Invalid skin style : {style}", InvalidStyleToken {source: termimad::ParseStyleTokenError} = "{source}", } custom_error! {pub NetError SocketNotAvailable { path : String } = "Can't open socket: {path} already exists - consider removing it", Io {source: io::Error} = "error on the socket: {source}", InvalidMessage = "invalid message received", } custom_error! {pub SvgError Io {source: io::Error} = "IO Error : {source}", Internal { message: &'static str } = "Internal error : {message}", Svg {source: resvg::usvg::Error} = "SVG Error: {source}", } custom_error! {pub PreviewTransformerError InvalidInput = "Invalid input", NoOutput = "No output", Io {source: io::Error} = "IO Error : {source}", ProcessInterrupted = "Process interrupted", ProcessFailed { code: i32 } = "Execution failed with code {code}", } ================================================ FILE: src/file_sum/mod.rs ================================================ /// compute consolidated data for directories: modified date, size, and count. /// A cache is used to avoid recomputing the same directories again and again. /// On unix, hard links are checked to avoid counting twice an inode. mod sum_computation; use { crate::{ app::*, task_sync::Dam, }, once_cell::sync::Lazy, rustc_hash::FxHashMap, std::{ ops::AddAssign, path::{ Path, PathBuf, }, sync::Mutex, }, }; pub const DEFAULT_THREAD_COUNT: usize = 5; static SUM_CACHE: Lazy>> = Lazy::new(|| Mutex::new(FxHashMap::default())); pub fn clear_cache() { #[allow(clippy::missing_panics_doc)] // panics if the mutex is poisoned (in which case it's better) SUM_CACHE.lock().unwrap().clear(); } /// Reduction of counts, dates and sizes on a file or directory #[derive(Debug, Copy, Clone)] pub struct FileSum { real_size: u64, // bytes, the space it takes on disk count: usize, // number of files modified: u32, // seconds from Epoch to last modification, or 0 if there was an error sparse: bool, // only for non directories: tells whether the file is sparse } impl FileSum { pub fn new( real_size: u64, sparse: bool, count: usize, modified: u32, ) -> Self { Self { real_size, count, modified, sparse, } } pub fn zero() -> Self { Self::new(0, false, 0, 0) } pub fn incr(&mut self) { self.count += 1; } /// return the sum of the given file, which is assumed /// to be a normal file (ie not a directory) pub fn from_file(path: &Path) -> Self { sum_computation::compute_file_sum(path) } /// Return the sum of the directory, either by computing it of by /// fetching it from cache. /// If the lifetime expires before complete computation, None is returned. pub fn from_dir( path: &Path, dam: &Dam, con: &AppContext, ) -> Option { #[allow(clippy::missing_panics_doc)] // panics on mutex poisoning (good) let mut sum_cache = SUM_CACHE.lock().unwrap(); match sum_cache.get(path) { Some(sum) => Some(*sum), None => { let sum = time!( "sum computation", path, sum_computation::compute_dir_sum(path, &mut sum_cache, dam, con), ); if let Some(sum) = sum { sum_cache.insert(PathBuf::from(path), sum); } sum } } } pub fn part_of_size( self, total: Self, ) -> f32 { if total.real_size == 0 { 0.0 } else { self.real_size as f32 / total.real_size as f32 } } /// return the number of files (normally at least 1) pub fn to_count(self) -> usize { self.count } /// return the number of seconds from Epoch to last modification, /// or 0 if the computation failed pub fn to_seconds(self) -> u32 { self.modified } /// return the size in bytes pub fn to_size(self) -> u64 { self.real_size } pub fn to_valid_seconds(self) -> Option { if self.modified != 0 { Some(i64::from(self.modified)) } else { None } } /// tell whether the file has holes (in which case the size displayed by /// other tools may be greater than the "real" one returned by broot). /// Not computed (return false) on windows or for directories. pub fn is_sparse(self) -> bool { self.sparse } } impl AddAssign for FileSum { #[allow(clippy::suspicious_op_assign_impl)] fn add_assign( &mut self, other: Self, ) { *self = Self::new( self.real_size + other.real_size, self.sparse | other.sparse, self.count + other.count, self.modified.max(other.modified), ); } } ================================================ FILE: src/file_sum/sum_computation.rs ================================================ use { super::FileSum, crate::{ app::*, path::*, task_sync::Dam, }, rayon::{ ThreadPool, ThreadPoolBuilder, }, rustc_hash::FxHashMap, std::{ convert::TryInto, fs, path::{ Path, PathBuf, }, sync::{ Arc, Mutex, atomic::{ AtomicIsize, Ordering, }, }, }, termimad::crossbeam::channel, }; #[cfg(unix)] use std::os::unix::fs::MetadataExt; struct DirSummer { thread_count: usize, thread_pool: ThreadPool, } /// a node id, taking the device into account to be sure to discriminate /// nodes with the same inode but on different devices #[cfg(unix)] #[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)] struct NodeId { /// inode number inode: u64, /// device number dev: u64, } impl DirSummer { pub fn new(thread_count: usize) -> Self { let thread_pool = ThreadPoolBuilder::new() .num_threads(thread_count) .build() .unwrap(); Self { thread_count, thread_pool, } } /// compute the consolidated numbers for a directory, with implementation /// varying depending on the OS: /// On unix, the computation is done on blocks of 512 bytes /// see pub fn compute_dir_sum( &mut self, path: &Path, cache: &mut FxHashMap, dam: &Dam, con: &AppContext, ) -> Option { let threads_count = self.thread_count; if con.special_paths.sum(path) == Directive::Never { return Some(FileSum::zero()); } // there are problems in /proc - See issue #637 if path.starts_with("/proc") { debug!("not summing in /proc"); return Some(FileSum::zero()); } if path.starts_with("/run") && !path.starts_with("/run/media") { debug!("not summing in /run"); return Some(FileSum::zero()); } // to avoid counting twice a node, we store their id in a set #[cfg(unix)] let nodes = Arc::new(Mutex::new(rustc_hash::FxHashSet::::default())); // busy is the number of directories which are either being processed or queued // We use this count to determine when threads can stop waiting for tasks let mut busy = 0; let mut sum = compute_file_sum(path); // this MPMC channel contains the directory paths which must be handled. // A None means there's nothing left and the thread may send its result and stop let (dirs_sender, dirs_receiver) = channel::unbounded(); let special_paths = con.special_paths.reduce(path); // the first level is managed a little differently: we look at the cache // before adding. This enables faster computations in two cases: // - for the root line (assuming it's computed after the content) // - when we navigate up the tree if let Ok(entries) = fs::read_dir(path) { for e in entries.flatten() { if let Ok(md) = e.metadata() { if md.is_dir() { let entry_path = e.path(); if con.special_paths.sum(&entry_path) == Directive::Never { debug!("not summing special path {entry_path:?}"); continue; } // we check the cache if let Some(entry_sum) = cache.get(&entry_path) { sum += *entry_sum; continue; } // we add the directory to the channel of dirs needing // processing busy += 1; dirs_sender.send(Some(entry_path)).unwrap(); } else { #[cfg(unix)] if md.nlink() > 1 { let mut nodes = nodes.lock().unwrap(); let node_id = NodeId { inode: md.ino(), dev: md.dev(), }; if !nodes.insert(node_id) { // it was already in the set continue; } } } sum += md_sum(&md); } } } if busy == 0 { return Some(sum); } let busy = Arc::new(AtomicIsize::new(busy)); // this MPMC channel is here for the threads to send their results // at end of computation let (thread_sum_sender, thread_sum_receiver) = channel::bounded(threads_count); // Each thread does a summation without merge and the data are merged // at the end (this avoids waiting for a mutex during computation) for _ in 0..threads_count { let busy = Arc::clone(&busy); let (dirs_sender, dirs_receiver) = (dirs_sender.clone(), dirs_receiver.clone()); #[cfg(unix)] let nodes = nodes.clone(); let special_paths = special_paths.clone(); let observer = dam.observer(); let thread_sum_sender = thread_sum_sender.clone(); self.thread_pool.spawn(move || { let mut thread_sum = FileSum::zero(); loop { let o = dirs_receiver.recv(); if let Ok(Some(open_dir)) = o { if let Ok(entries) = fs::read_dir(open_dir) { for e in entries.flatten() { if let Ok(md) = e.metadata() { if md.is_dir() { let path = e.path(); if special_paths.sum(&path) == Directive::Never { debug!("not summing (deep) special path {path:?}"); continue; } // we add the directory to the channel of dirs needing // processing busy.fetch_add(1, Ordering::Relaxed); dirs_sender.send(Some(path)).unwrap(); } else { #[cfg(unix)] if md.nlink() > 1 { let mut nodes = nodes.lock().unwrap(); let node_id = NodeId { inode: md.ino(), dev: md.dev(), }; if !nodes.insert(node_id) { // it was already in the set continue; } } } thread_sum += md_sum(&md); } else { // we can't measure much but we can count the file thread_sum.incr(); } } } busy.fetch_sub(1, Ordering::Relaxed); } if observer.has_event() { dirs_sender.send(None).unwrap(); // to unlock the next waiting thread break; } if busy.load(Ordering::Relaxed) < 1 { dirs_sender.send(None).unwrap(); // to unlock the next waiting thread break; } } thread_sum_sender.send(thread_sum).unwrap(); }); } // Wait for the threads to finish and consolidate their results for _ in 0..threads_count { match thread_sum_receiver.recv() { Ok(thread_sum) => { sum += thread_sum; } Err(e) => { warn!("Error while recv summing thread result : {e:?}"); } } } if dam.has_event() { return None; } Some(sum) } } /// compute the consolidated numbers for a directory, with implementation /// varying depending on the OS: /// On unix, the computation is done on blocks of 512 bytes /// see pub fn compute_dir_sum( path: &Path, cache: &mut FxHashMap, dam: &Dam, con: &AppContext, ) -> Option { use once_cell::sync::OnceCell; static DIR_SUMMER: OnceCell> = OnceCell::new(); DIR_SUMMER .get_or_init(|| Mutex::new(DirSummer::new(con.file_sum_threads_count))) .lock() .unwrap() .compute_dir_sum(path, cache, dam, con) } /// compute the sum for a regular file (not a folder) pub fn compute_file_sum(path: &Path) -> FileSum { match fs::symlink_metadata(path) { Ok(md) => { let seconds = extract_seconds(&md); #[cfg(unix)] { let nominal_size = md.size(); let block_size = md.blocks() * 512; FileSum::new( block_size.min(nominal_size), block_size < nominal_size, 1, seconds, ) } #[cfg(not(unix))] FileSum::new(md.len(), false, 1, seconds) } Err(_) => FileSum::new(0, false, 1, 0), } } #[cfg(unix)] fn extract_seconds(md: &fs::Metadata) -> u32 { md.mtime().try_into().unwrap_or(0) } #[cfg(not(unix))] fn extract_seconds(md: &fs::Metadata) -> u32 { if let Ok(st) = md.modified() { if let Ok(d) = st.duration_since(std::time::UNIX_EPOCH) { if let Ok(secs) = d.as_secs().try_into() { return secs; } } } 0 } fn md_sum(md: &fs::Metadata) -> FileSum { #[cfg(unix)] let size = md.blocks() * 512; #[cfg(not(unix))] let size = md.len(); let seconds = extract_seconds(md); FileSum::new(size, false, 1, seconds) } ================================================ FILE: src/filesystems/filesystems_state.rs ================================================ use { super::*, crate::{ app::*, browser::BrowserState, command::*, display::*, errors::ProgramError, pattern::*, task_sync::Dam, tree::TreeOptions, verb::*, }, crokey::crossterm::{ QueueableCommand, cursor, style::Color, }, lfs_core::{ DeviceId, Mount, }, std::{ convert::TryInto, path::Path, }, strict::NonEmptyVec, termimad::{ minimad::Alignment, *, }, }; struct FilteredContent { pattern: Pattern, mounts: Vec, // may be empty selection_idx: usize, } /// an application state showing the currently mounted filesystems pub struct FilesystemState { mounts: NonEmptyVec, selection_idx: usize, scroll: usize, page_height: usize, tree_options: TreeOptions, filtered: Option, mode: Mode, } impl FilesystemState { /// create a state listing the filesystem, trying to select /// the one containing the path given in argument. /// Not finding any filesystem is considered an error and prevents /// the opening of this state. pub fn new( path: Option<&Path>, tree_options: TreeOptions, con: &AppContext, ) -> Result { let mut mount_list = MOUNTS.lock().unwrap(); let show_only_disks = false; let mounts = mount_list .load()? .iter() .filter(|mount| { if show_only_disks { mount.disk.is_some() } else { mount.stats().is_some() } }) .cloned() .collect::>(); let mounts: NonEmptyVec = match mounts.try_into() { Ok(nev) => nev, _ => { return Err(ProgramError::Lfs { details: "no disk in lfs-core list".to_string(), }); } }; let selection_idx = path .and_then(|path| DeviceId::of_path(path).ok()) .and_then(|device_id| { mounts.iter().position(|m| m.info.dev == device_id) }) .unwrap_or(0); Ok(FilesystemState { mounts, 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.mounts.len()) .unwrap_or_else(|| self.mounts.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); if self.selection_idx < self.scroll { self.selection_idx = self.scroll; } else if self.selection_idx >= self.scroll + self.page_height { self.selection_idx = self.scroll + self.page_height - 1; } self.scroll != old_scroll } /// 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 dir = dir * count; if let Some(f) = self.filtered.as_mut() { f.selection_idx = move_sel(f.selection_idx, f.mounts.len(), dir, cycle); } else { self.selection_idx = move_sel(self.selection_idx, self.mounts.len().get(), dir, cycle); } if self.selection_idx < self.scroll { self.scroll = self.selection_idx; } else if self.selection_idx >= self.scroll + self.page_height { self.scroll = self.selection_idx + 1 - self.page_height; } CmdResult::Keep } fn no_opt_selected_path(&self) -> &Path { &self.mounts[self.selection_idx].info.mount_point } fn no_opt_selection(&self) -> Selection<'_> { Selection { path: self.no_opt_selected_path(), stype: SelectionType::Directory, is_exe: false, line: 0, } } } impl PanelState for FilesystemState { fn get_type(&self) -> PanelStateType { PanelStateType::Fs } fn set_mode( &mut self, mode: Mode, ) { self.mode = mode; } fn get_mode(&self) -> Mode { self.mode } fn selected_path(&self) -> Option<&Path> { Some(self.no_opt_selected_path()) } 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 } fn selection(&self) -> Option> { Some(self.no_opt_selection()) } fn refresh( &mut self, _screen: Screen, _con: &AppContext, ) -> Command { Command::empty() } fn on_pattern( &mut self, pattern: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { if pattern.is_none() { self.filtered = None; } else { let mut selection_idx = 0; let mut mounts = Vec::new(); let pattern = pattern.pattern; for (idx, mount) in self.mounts.iter().enumerate() { if pattern.score_of_string(&mount.info.fs).is_none() && mount .disk .as_ref() .and_then(|d| pattern.score_of_string(d.disk_type())) .is_none() && pattern.score_of_string(&mount.info.fs_type).is_none() && pattern .score_of_string(&mount.info.mount_point.to_string_lossy()) .is_none() { continue; } if idx <= self.selection_idx { selection_idx = mounts.len(); } mounts.push(mount.clone()); } self.filtered = Some(FilteredContent { pattern, mounts, selection_idx, }); } Ok(CmdResult::Keep) } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError> { let area = &disc.state_area; let con = &disc.con; self.page_height = area.height as usize - 2; let (mounts, selection_idx) = if let Some(filtered) = &self.filtered { (filtered.mounts.as_slice(), filtered.selection_idx) } else { (self.mounts.as_slice(), self.selection_idx) }; let scrollbar = area.scrollbar(self.scroll, mounts.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 and selection of columns to display let width = area.width as usize; let w_fs = mounts .iter() .map(|m| m.info.fs.chars().count()) .max() .unwrap_or(0) .max("filesystem".len()); let mut wc_fs = w_fs; // width of the column (may include selection mark) if con.show_selection_mark { wc_fs += 1; } let w_dsk = 5; // max width of a lfs-core disk type let w_type = mounts .iter() .map(|m| m.info.fs_type.chars().count()) .max() .unwrap_or(0) .max("type".len()); let w_size = 4; let w_use = 4; let mut w_use_bar = 1; // min size, may grow if space available let w_use_share = 4; let mut wc_use = w_use; // sum of all the parts of the usage column let w_free = 4; let w_mount_point = mounts .iter() .map(|m| m.info.mount_point.to_string_lossy().chars().count()) .max() .unwrap_or(0) .max("mount point".len()); let w_mandatory = wc_fs + 1 + w_size + 1 + w_free + 1 + w_mount_point; let mut e_dsk = false; let mut e_type = false; let mut e_use_bar = false; let mut e_use_share = false; let mut e_use = false; if w_mandatory + 1 < width { let mut rem = width - w_mandatory - 1; if rem > w_use { rem -= w_use + 1; e_use = true; } if e_use && rem > w_use_share { rem -= w_use_share; // no separation with use e_use_share = true; wc_use += w_use_share; } if rem > w_dsk { rem -= w_dsk + 1; e_dsk = true; } if e_use && rem > w_use_bar { rem -= w_use_bar + 1; e_use_bar = true; wc_use += w_use_bar + 1; } if rem > w_type { rem -= w_type + 1; e_type = true; } if e_use_bar && rem > 0 { let incr = rem.min(9); w_use_bar += incr; wc_use += incr; } } //- titles w.queue(cursor::MoveTo(area.left, area.top))?; let mut cw = CropWriter::new(w, width); cw.queue_g_string(&styles.default, format!("{:wc_fs$}", "filesystem"))?; cw.queue_char(border_style, '│')?; if e_dsk { cw.queue_g_string(&styles.default, "disk ".to_string())?; cw.queue_char(border_style, '│')?; } if e_type { cw.queue_g_string(&styles.default, format!("{:^w_type$}", "type"))?; cw.queue_char(border_style, '│')?; } if e_use { cw.queue_g_string( &styles.default, format!( "{:^width$}", if wc_use > 4 { "usage" } else { "use" }, width = wc_use ), )?; cw.queue_char(border_style, '│')?; } cw.queue_g_string(&styles.default, "free".to_string())?; cw.queue_char(border_style, '│')?; cw.queue_g_string(&styles.default, "size".to_string())?; cw.queue_char(border_style, '│')?; cw.queue_g_string(&styles.default, "mount point".to_string())?; cw.fill(border_style, &SPACE_FILLING)?; //- horizontal line w.queue(cursor::MoveTo(area.left, 1 + area.top))?; let mut cw = CropWriter::new(w, width); cw.queue_g_string(border_style, format!("{:─>width$}", '┼', width = wc_fs + 1))?; if e_dsk { cw.queue_g_string(border_style, format!("{:─>width$}", '┼', width = w_dsk + 1))?; } if e_type { cw.queue_g_string( border_style, format!("{:─>width$}", '┼', width = w_type + 1), )?; } cw.queue_g_string( border_style, format!("{:─>width$}", '┼', width = w_size + 1), )?; if e_use { cw.queue_g_string( border_style, format!("{:─>width$}", '┼', width = wc_use + 1), )?; } cw.queue_g_string( border_style, format!("{:─>width$}", '┼', width = w_free + 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 == 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(mount) = mounts.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 { ' ' })?; } // fs let s = &mount.info.fs; 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_fs, Alignment::Left); matched_string.queue_on(&mut cw)?; cw.queue_char(border_style, '│')?; // dsk if e_dsk { if let Some(disk) = mount.disk.as_ref() { let s = disk.disk_type(); 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(5, Alignment::Center); matched_string.queue_on(&mut cw)?; } else { cw.queue_g_string(txt_style, " ".to_string())?; } cw.queue_char(border_style, '│')?; } // type if e_type { let s = &mount.info.fs_type; 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_type, Alignment::Center); matched_string.queue_on(&mut cw)?; cw.queue_char(border_style, '│')?; } // size, used, free if let Some(stats) = mount.stats().filter(|s| s.size() > 0) { let share_color = styles.good_to_bad_color(stats.use_share()); // used if e_use { cw.queue_g_string( txt_style, format!("{:>4}", file_size::fit_4(stats.used())), )?; if e_use_share { cw.queue_g_string( txt_style, format!("{:>3.0}%", 100.0 * stats.use_share()), )?; } if e_use_bar { cw.queue_char(txt_style, ' ')?; let pb = ProgressBar::new(stats.use_share() as f32, w_use_bar); let mut bar_style = styles.default.clone(); bar_style.set_bg(share_color); cw.queue_g_string(&bar_style, format!("{pb:4}", file_size::fit_4(stats.available())), )?; cw.queue_char(border_style, '│')?; // size if let Some(stats) = mount.stats() { cw.queue_g_string( txt_style, format!("{:>4}", file_size::fit_4(stats.size())), )?; } else { cw.repeat(txt_style, &SPACE_FILLING, 4)?; } cw.queue_char(border_style, '│')?; } else { // used if e_use { cw.repeat(txt_style, &SPACE_FILLING, wc_use)?; cw.queue_char(border_style, '│')?; } // free cw.repeat(txt_style, &SPACE_FILLING, w_free)?; cw.queue_char(border_style, '│')?; // size cw.repeat(txt_style, &SPACE_FILLING, w_size)?; cw.queue_char(border_style, '│')?; } // mount point let s = &mount.info.mount_point.to_string_lossy(); let matched_string = MatchedString::new( self.filtered .as_ref() .and_then(|f| f.pattern.search_string(s)), s, txt_style, match_style, ); matched_string.queue_on(&mut cw)?; idx += 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 { let screen = cc.app.screen; let con = &cc.app.con; use Internal::*; Ok(match internal_exec.internal { Internal::back => { if let Some(f) = self.filtered.take() { if !f.mounts.is_empty() { self.selection_idx = self .mounts .iter() .position(|m| m.info.id == f.mounts[f.selection_idx].info.id) .unwrap(); // all filtered mounts come from self.mounts } 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 => { let in_new_panel = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); let dam = Dam::unlimited(); let mut tree_options = self.tree_options(); tree_options.show_root_fs = true; CmdResult::from_optional_browser_state( BrowserState::new( self.no_opt_selected_path().to_path_buf(), tree_options, screen, con, &dam, ), None, in_new_panel, ) } Internal::panel_left => { let areas = &cc.panel.areas; if areas.is_first() && areas.nb_pos < con.max_panels_count { // we ask for the creation of a panel to the left internal_focus::new_panel_on_path( self.no_opt_selected_path().to_path_buf(), screen, self.tree_options(), PanelPurpose::None, con, HDir::Left, ) } else { // we ask the app to focus the panel to the left CmdResult::HandleInApp(Internal::panel_left_no_open) } } Internal::panel_left_no_open => CmdResult::HandleInApp(Internal::panel_left_no_open), Internal::panel_right => { let areas = &cc.panel.areas; if areas.is_last() && areas.nb_pos < con.max_panels_count { // we ask for the creation of a panel to the right internal_focus::new_panel_on_path( self.no_opt_selected_path().to_path_buf(), screen, self.tree_options(), PanelPurpose::None, con, HDir::Right, ) } else { // we ask the app to focus the panel to the right CmdResult::HandleInApp(Internal::panel_right_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 = self.count() - 1; } CmdResult::Keep } Internal::page_up => { if !self.try_scroll(ScrollCommand::Pages(-1)) { self.selection_idx = 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 { if y >= 2 { let y = y as usize - 2 + self.scroll; let len: usize = self.mounts.len().into(); if y < len { self.selection_idx = y; } } Ok(CmdResult::Keep) } } ================================================ FILE: src/filesystems/mod.rs ================================================ //! The whole module is only available on linux and macos mod filesystems_state; mod mount_list; mod mount_space_display; pub use { filesystems_state::FilesystemState, mount_list::MountList, mount_space_display::MountSpaceDisplay, }; use { once_cell::sync::Lazy, std::sync::Mutex, }; pub static MOUNTS: Lazy> = Lazy::new(|| Mutex::new(MountList::default())); pub fn clear_cache() { let mut mount_list = MOUNTS.lock().unwrap(); mount_list.clear_cache(); } ================================================ FILE: src/filesystems/mount_list.rs ================================================ use { crate::errors::ProgramError, lfs_core::{ DeviceId, Mount, ReadOptions, read_mounts, }, }; #[derive(Default)] pub struct MountList { mounts: Option>, } impl MountList { pub fn clear_cache(&mut self) { self.mounts = None; } /// try to load the mounts if they aren't loaded. pub fn load(&mut self) -> Result<&Vec, ProgramError> { if self.mounts.is_none() { let options = ReadOptions::default().remote_stats(false); match read_mounts(&options) { Ok(mut vec) => { debug!("{} mounts loaded", vec.len()); vec.sort_by_key(|m| { let size = m.stats().map_or(0, |s| s.size()); u64::MAX - size }); self.mounts = Some(vec); } Err(e) => { warn!("Failed to load mounts: {:?}", e); return Err(ProgramError::Lfs { details: e.to_string(), }); } } } Ok( // this unwrap will be fixed as soon as there's option.insert in stable self.mounts.as_ref().unwrap(), ) } pub fn get_by_device_id( &self, dev: DeviceId, ) -> Option<&Mount> { self.mounts .as_ref() .and_then(|mounts| mounts.iter().find(|m| m.info.dev == dev)) } } ================================================ FILE: src/filesystems/mount_space_display.rs ================================================ use { crate::{ display::cond_bg, errors::ProgramError, skin::StyleMap, }, crokey::crossterm::{ QueueableCommand, style::{ ResetColor, SetBackgroundColor, SetForegroundColor, }, }, lfs_core::Mount, termimad::*, }; /// an abstract of the space info relative to a block device. /// It's supposed to be shown on top of screen next to the root. pub struct MountSpaceDisplay<'m, 's> { mount: &'m Mount, skin: &'s StyleMap, pub available_width: usize, } impl<'m, 's> MountSpaceDisplay<'m, 's> { pub fn from( mount: &'m Mount, skin: &'s StyleMap, available_width: usize, ) -> Self { Self { mount, skin, available_width, } } pub fn write( &self, cw: &mut CropWriter, selected: bool, ) -> Result<(), ProgramError> where W: std::io::Write, { if self.available_width < 4 { return Ok(()); } let bg = if selected { self.skin.selected_line.get_bg() } else { self.skin.default.get_bg() }; cond_bg!(txt_style, self, selected, self.skin.default); let w_fs = self.mount.info.fs.chars().count(); if let Some(s) = &self.mount.stats() { //- width computation let mut e_fs = false; let dsk = self.mount.disk.as_ref().map_or("", |d| d.disk_type()); let w_dsk = dsk.chars().count(); let mut e_dsk = false; let w_fraction = 9; let mut e_fraction = false; let mut w_bar = 2; // min width let mut e_bar = false; let w_percent = 4; let mut rem = self.available_width - w_percent; let share_color = self.skin.good_to_bad_color(s.use_share()); if rem > 1 { // left margin for readability rem -= 1; cw.queue_char(txt_style, ' ')?; } if rem > w_fs { rem -= w_fs + 1; // 1 for margin e_fs = true; } if rem > w_fraction { rem -= w_fraction + 1; e_fraction = true; } if rem > w_bar { rem -= w_bar + 1; e_bar = true; } if rem > w_dsk && w_dsk > 0 { rem -= w_dsk + 1; e_dsk = true; } if e_bar && rem > 0 { w_bar += rem.min(7); } //- display if e_fs { cw.queue_g_string(txt_style, format!(" {}", &self.mount.info.fs))?; } if e_dsk { cw.queue_char(txt_style, ' ')?; cw.queue_g_string(txt_style, dsk.to_string())?; } if e_fraction { if let Some(bg_color) = bg { cw.w.queue(SetBackgroundColor(bg_color))?; } else { cw.w.queue(ResetColor {})?; } cw.w.queue(SetForegroundColor(share_color))?; cw.queue_unstyled_char(' ')?; cw.queue_unstyled_g_string(file_size::fit_4(s.used()))?; cw.queue_g_string(txt_style, format!("/{}", file_size::fit_4(s.size())))?; } if e_bar { let pb = ProgressBar::new(s.use_share() as f32, w_bar); cw.w.queue(ResetColor {})?; if let Some(bg_color) = bg { cw.w.queue(SetBackgroundColor(bg_color))?; } cw.queue_unstyled_char(' ')?; cw.w.queue(SetBackgroundColor(share_color))?; cw.queue_unstyled_g_string(format!("{pb:3.0}%", 100.0 * s.use_share()))?; } else { // there's not much to print if there's no size info cw.queue_g_string(txt_style, format!(" {}", &self.mount.info.fs))?; } cw.w.queue(ResetColor {})?; Ok(()) } } ================================================ FILE: src/flag/mod.rs ================================================ /// Right now the flag is just a vessel for display. #[derive(Clone, Copy)] pub struct Flag { pub name: &'static str, pub value: &'static str, } ================================================ FILE: src/git/ignore.rs ================================================ //! Implements parsing and applying .gitignore and .ignore files. // TODO rename without the "Git" prefix, as it's not only for gitignore use { git2, glob, id_arena::{ Arena, Id, }, lazy_regex::regex, once_cell::sync::Lazy, std::{ fmt, fs::File, io::{ BufRead, BufReader, Result, }, path::{ Path, PathBuf, }, }, }; #[derive(Default)] pub struct Ignorer { files: Arena, } #[derive(Debug, Clone, Default)] pub struct IgnoreChain { in_repo: bool, file_ids: Vec>, } /// The rules of a gitignore file #[derive(Debug, Clone)] pub struct IgnoreFile { rules: Vec, /// whether this is a git dedicated file (as opposed to a .ignore file) git: bool, local_git_ignore: bool, } /// a simple rule of a gitignore file #[derive(Clone)] struct IgnoreRule { ok: bool, // does this rule when matched means the file is good? (usually false) directory: bool, // whether this rule only applies to directories filename: bool, // does this rule apply to just the filename pattern: glob::Pattern, pattern_options: glob::MatchOptions, } impl fmt::Debug for IgnoreRule { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { f.debug_struct("IgnoreRule") .field("ok", &self.ok) .field("directory", &self.directory) .field("filename", &self.filename) .field("pattern", &self.pattern.as_str()) .finish_non_exhaustive() } } impl IgnoreRule { /// parse a line of a .gitignore file. /// The `ref_dir` is used if the line starts with '/' fn from( line: &str, ref_dir: &Path, ) -> Option { if line.starts_with('#') { return None; // comment line } let r = regex!( r"(?x) ^\s* (!)? # 1 : negation (.+?) # 2 : pattern (/)? # 3 : directory \s*$ " ); if let Some(c) = r.captures(line) { if let Some(p) = c.get(2) { let p = p.as_str(); let has_separator = p.contains('/'); let p = if has_separator { if p.starts_with('/') { format!("{}{}", ref_dir.to_string_lossy(), p) } else { format!("**/{p}") } } else { p.to_string() }; match glob::Pattern::new(&p) { Ok(pattern) => { let pattern_options = glob::MatchOptions { case_sensitive: true, require_literal_leading_dot: false, require_literal_separator: has_separator, }; return Some(IgnoreRule { ok: c.get(1).is_some(), // if negation pattern, directory: c.get(3).is_some(), filename: !has_separator, pattern_options, }); } Err(e) => { info!(" wrong glob pattern {:?} : {}", &p, e); } } } } None } } impl IgnoreFile { /// build a new gitignore file, from either a global ignore file or /// a .gitignore file found inside a git repository. /// The `ref_dir` is either: /// - the path of the current repository for the global gitignore /// - the directory containing the .gitignore file pub fn new( file_path: &Path, ref_dir: &Path, local_git_ignore: bool, ) -> Result { let f = File::open(file_path)?; let git = file_path.file_name().is_some_and(|f| f == ".gitignore"); let mut rules: Vec = Vec::new(); for line in BufReader::new(f).lines() { if let Some(rule) = IgnoreRule::from(&line?, ref_dir) { rules.push(rule); } } // the last rule applicable to a path is the right one. So // we reverse the list to easily iterate from the last one to the first one rules.reverse(); Ok(IgnoreFile { rules, git, local_git_ignore, }) } /// return the global gitignore file interpreted for /// the given repo dir pub fn global(repo_dir: &Path) -> Option { static GLOBAL_GI_PATH: Lazy> = Lazy::new(find_global_ignore); if let Some(path) = &*GLOBAL_GI_PATH { IgnoreFile::new(path, repo_dir, true).ok() } else { None } } } pub fn find_global_ignore() -> Option { git2::Config::open_default() .and_then(|global_config| global_config.get_path("core.excludesfile")) .ok() .or_else(|| { directories::BaseDirs::new() .map(|base_dirs| base_dirs.config_dir().join("git/ignore")) .filter(|path| path.exists()) }) .or_else(|| { directories::UserDirs::new() .map(|user_dirs| user_dirs.home_dir().join(".config/git/ignore")) .filter(|path| path.exists()) }) } impl IgnoreChain { pub fn push( &mut self, id: Id, ) { self.file_ids.push(id); } } impl Ignorer { pub fn root_chain( &mut self, mut dir: &Path, ) -> IgnoreChain { let mut chain = IgnoreChain::default(); loop { let is_repo = is_repo(dir); if is_repo { if let Some(gif) = IgnoreFile::global(dir) { chain.push(self.files.alloc(gif)); } } for (filename, local_git_ignore) in [ (".gitignore", true), (".git/info/exclude", true), (".ignore", false), ] { if chain.in_repo && local_git_ignore { // we don't add outside .gitignore files when we're in a repo continue; } let file = dir.join(filename); if let Ok(gif) = IgnoreFile::new(&file, dir, local_git_ignore) { chain.push(self.files.alloc(gif)); } } if is_repo { chain.in_repo = true; } if let Some(parent) = dir.parent() { dir = parent; } else { break; } } chain } /// Build a new chain by going deeper in the file system. /// /// The chain contains /// - the global gitignore file (if any) /// - all the .ignore files found in the current directory and in parents /// - the .git/info/exclude file of the current git repository /// - all the .gitignore files found in the current directory and in parents but not outside /// the current git repository /// /// Deeper file have a bigger priority. /// .ignore files have a bigger priority than .gitignore files. pub fn deeper_chain( &mut self, parent_chain: &IgnoreChain, dir: &Path, ) -> IgnoreChain { let mut chain = if is_repo(dir) { let mut chain = IgnoreChain::default(); for &id in &parent_chain.file_ids { if !self.files[id].local_git_ignore { chain.file_ids.push(id); } } chain.in_repo = true; chain } else { parent_chain.clone() }; for (filename, local_git_ignore) in [(".gitignore", true), (".ignore", false)] { if local_git_ignore && !chain.in_repo { // we don't add outside .gitignore files when we're in a repo continue; } let ignore_file = dir.join(filename); if let Ok(gif) = IgnoreFile::new(&ignore_file, dir, local_git_ignore) { chain.push(self.files.alloc(gif)); } } chain } /// return true if the given path should not be ignored pub fn accepts( &self, chain: &IgnoreChain, path: &Path, filename: &str, directory: bool, ) -> bool { // we start with deeper files: deeper rules have a bigger priority for id in chain.file_ids.iter().rev() { let file = &self.files[*id]; if file.git && !chain.in_repo { // git rules are irrelevant outside a git repository continue; } for rule in &file.rules { if rule.directory && !directory { continue; } let ok = if rule.filename { rule.pattern.matches_with(filename, rule.pattern_options) } else { rule.pattern.matches_path_with(path, rule.pattern_options) }; if ok { // as we read the rules in reverse, the first applying is OK return rule.ok; } } } true } } pub fn is_repo(root: &Path) -> bool { root.join(".git").exists() } ================================================ FILE: src/git/mod.rs ================================================ mod ignore; mod status; mod status_computer; pub use { ignore::{ IgnoreChain, Ignorer, }, status::{ LineGitStatus, LineStatusComputer, TreeGitStatus, }, status_computer::{ clear_status_computer_cache, get_tree_status, }, }; use std::path::{ Path, PathBuf, }; /// return the closest parent (or self) containing a .git file pub fn closest_repo_dir(mut path: &Path) -> Option { loop { let c = path.join(".git"); if c.exists() { return Some(path.to_path_buf()); } path = match path.parent() { Some(path) => path, None => { return None; } }; } } ================================================ FILE: src/git/status.rs ================================================ use { git2::{ self, Repository, Status, }, rustc_hash::FxHashMap, std::path::{ Path, PathBuf, }, }; const INTERESTING: Status = Status::from_bits_truncate( Status::WT_NEW.bits() | Status::CONFLICTED.bits() | Status::WT_MODIFIED.bits() | Status::IGNORED.bits(), ); /// A git status #[derive(Debug, Clone, Copy)] pub struct LineGitStatus { pub status: Status, } impl LineGitStatus { pub fn from( repo: &Repository, relative_path: &Path, ) -> Option { repo.status_file(relative_path) .ok() .map(|status| LineGitStatus { status }) } pub fn is_interesting(self) -> bool { self.status.intersects(INTERESTING) } } /// As a git repo can't tell whether a path has a status, this computer /// looks at all the statuses of the repo and build a map path->status /// which can then be efficiently queried pub struct LineStatusComputer { interesting_statuses: FxHashMap, } impl LineStatusComputer { pub fn from(repo: &Repository) -> Option { let workdir = repo.workdir()?; let mut interesting_statuses = FxHashMap::default(); let statuses = repo.statuses(None).ok()?; for entry in statuses.iter() { let status = entry.status(); if status.intersects(INTERESTING) { if let Some(path) = entry.path() { let path = workdir.join(path); interesting_statuses.insert(path, status); } } } Some(Self { interesting_statuses, }) } pub fn line_status( &self, path: &Path, ) -> Option { self.interesting_statuses .get(path) .map(|&status| LineGitStatus { status }) } pub fn is_interesting( &self, path: &Path, ) -> bool { self.interesting_statuses.contains_key(path) } } #[derive(Debug, Clone)] pub struct TreeGitStatus { pub current_branch_name: Option, pub insertions: usize, pub deletions: usize, } impl TreeGitStatus { pub fn from(repo: &Repository) -> Option { let current_branch_name = repo .head() .ok() .and_then(|head| head.shorthand().map(String::from)); let stats = match repo.diff_index_to_workdir(None, None) { Ok(diff) => match diff.stats() { Ok(stats) => stats, Err(e) => { debug!("get stats failed : {e:?}"); return None; } }, Err(e) => { debug!("get diff failed : {e:?}"); return None; } }; Some(Self { current_branch_name, insertions: stats.insertions(), deletions: stats.deletions(), }) } } ================================================ FILE: src/git/status_computer.rs ================================================ use { super::TreeGitStatus, crate::{ git, task_sync::{ Computation, ComputationResult, Dam, }, }, git2::Repository, once_cell::sync::Lazy, rustc_hash::FxHashMap, std::{ path::{ Path, PathBuf, }, sync::Mutex, }, termimad::crossbeam::channel::bounded, }; fn compute_tree_status(root_path: &Path) -> ComputationResult { match Repository::open(root_path) { Ok(git_repo) => { let tree_git_status = time!(TreeGitStatus::from(&git_repo),); match tree_git_status { Some(gs) => ComputationResult::Done(gs), None => ComputationResult::None, } } Err(e) => { debug!("failed to discover repo: {e:?}"); ComputationResult::None } } } // the key is the path of the repository static TS_CACHE_MX: Lazy>>> = Lazy::new(|| Mutex::new(FxHashMap::default())); /// try to get the result of the computation of the tree git status. /// This may be immediate if a previous computation was finished. /// This may wait for the result of a new computation or of a previously /// launched one. /// In any case: /// - this function returns as soon as the dam asks for it (ie when there's an event) /// - computations are never dropped unless the program ends: they continue in background /// and the result may be available for following queries #[allow(clippy::missing_panics_doc)] // panics if the mutex is poisoned (in which case it's better) pub fn get_tree_status( root_path: &Path, dam: &mut Dam, ) -> ComputationResult { match git::closest_repo_dir(root_path) { None => ComputationResult::None, Some(repo_path) => { let comp = TS_CACHE_MX .lock() .unwrap() .get(&repo_path) .map(|c| (*c).clone()); match comp { Some(Computation::Finished(comp_res)) => { // already computed comp_res } Some(Computation::InProgress(comp_receiver)) => { // computation in progress // We do a select! to wait for either the dam // or the receiver debug!("start select on in progress computation"); dam.select(comp_receiver) } None => { // not yet started. We launch the computation and store // the receiver immediately. // We use the dam to return from this function when // needed (while letting the underlying thread finish // the job) // // note: must also update the TS_CACHE entry at end let (s, r) = bounded(1); TS_CACHE_MX .lock() .unwrap() .insert(repo_path.clone(), Computation::InProgress(r)); dam.try_compute(move || { let comp_res = compute_tree_status(&repo_path); TS_CACHE_MX .lock() .unwrap() .insert(repo_path.clone(), Computation::Finished(comp_res.clone())); if let Err(e) = s.send(comp_res.clone()) { debug!("error while sending comp result: {e:?}"); } comp_res }) } } } } } /// clear the finished or in progress computation. /// Limit: we may receive in cache the result of a computation /// which started before the clear (if this is a problem we could /// store a cleaning counter alongside the cache to prevent insertions) #[allow(clippy::missing_panics_doc)] // panics if the mutex is poisoned (in which case it's better) pub fn clear_status_computer_cache() { let mut ts_cache = TS_CACHE_MX.lock().unwrap(); ts_cache.clear(); } ================================================ FILE: src/help/help_content.rs ================================================ use termimad::minimad::{ TextTemplate, TextTemplateExpander, }; static MD: &str = r" # broot ${version} **broot** lets you explore directory trees and launch commands. It's best used when launched as **br**. See **https://dystroy.org/broot** for a complete guide. The *esc* key gets you back to the previous state. The *↑* and *↓* arrow keys can be used to change selection. The mouse can be used to select (on click) or open (on double-click). ## Search Modes Type some letters to search the tree and select the most relevant file. ${default-search For example, ${default-search-example}. } Various types of search can be used: |:-:|:-:|:- |**prefix**|**search**|**example**| |-:|:-|:- ${search-mode-rows |`${search-prefix}`|${search-type}|${search-example} } |- You can combine searches with logical operators. For example, to search all toml or rs files containing `tomat`, you may type `(${nr-prefix}toml/|${nr-prefix}rs$/)&${ce-prefix}tomat`. For efficiency, place content search last. ## Verbs To execute a verb, type a space or `:` then start of its name or shortcut. This table is searchable. Hit a few letters to filter it. |:-:|:-:|:-:|:-: |**name**|**shortcut**|**key**|**description** |-:|:-:|:-:|:- ${verb-rows |${name}|${shortcut}|${key}|${description}`${execution}` } |-: ## Configuration Verbs, skin, and more, are configured in ${config-files * **${path}** } (hit *enter* to open the main configuration file) ## Launch Arguments Some options can be set on launch: * `-h` or `--hidden` : show hidden files * `-i` : show files which are normally hidden due to .gitignore rules * `-d` or `--dates` : display last modified dates * `-w` : whale-spotting mode (for the complete list, run `broot --help`) ## Flags Flags are displayed at bottom right: * `h:y` or `h:n` : whether hidden files are shown * `gi:y`, `gi:n` : whether gitignore rules are active or not ## Special Features ${features-text} ${features * **${feature-name}:** ${feature-description} } "; /// build a markdown expander which will need to be /// completed with data and which then would be used to /// produce the markdown of the help page pub fn expander() -> TextTemplateExpander<'static, 'static> { use once_cell::sync::Lazy; static TEMPLATE: Lazy> = Lazy::new(|| TextTemplate::from(MD)); TEMPLATE.expander() } ================================================ FILE: src/help/help_features.rs ================================================ /// find the list of optional features which are enabled pub fn list() -> Vec<(&'static str, &'static str)> { #[allow(unused_mut)] let mut features: Vec<(&'static str, &'static str)> = Vec::new(); #[cfg(not(any(target_family = "windows", target_os = "android")))] features.push(("permissions", "allow showing file mode, owner and group")); #[cfg(feature = "clipboard")] features.push(( "clipboard", ":copy_path (copying the current path), and :input_paste (pasting into the input)", )); features } ================================================ FILE: src/help/help_search_modes.rs ================================================ use crate::{ app::AppContext, pattern::*, }; /// what should be shown for a `search_mode` in the help screen, after /// filtering pub struct SearchModeHelp { pub prefix: String, pub description: String, pub example: String, } /// return the rows of the "Search Modes" table in help. pub fn search_mode_help( mode: SearchMode, con: &AppContext, ) -> SearchModeHelp { let prefix = mode.prefix(con); let description = format!( "{} search on {}", match mode.kind() { SearchKind::Exact => "exact string", SearchKind::Fuzzy => "fuzzy", SearchKind::Regex => "regex", SearchKind::Tokens => "tokens", }, match mode.object() { SearchObject::Name => "file name", SearchObject::Path => "sub path", SearchObject::Content => "file content", }, ); let example = match mode { SearchMode::NameExact => format!("`{prefix}feat` matches *help_features.rs*"), SearchMode::NameFuzzy => format!("`{prefix}conh` matches *DefaultConf.hjson*"), SearchMode::NameRegex => format!("`{prefix}rs$` matches *build.rs*"), SearchMode::NameTokens => format!("`{prefix}fea,he` matches *HelpFeature.java*"), SearchMode::PathExact => format!("`{prefix}te\\/do` matches *website/docs*"), SearchMode::PathFuzzy => format!("`{prefix}flam` matches *src/flag/mod.rs*"), SearchMode::PathRegex => format!(r"`{prefix}\d{{3}}.*txt` matches *dir/a123/b.txt*"), SearchMode::PathTokens => format!("`{prefix}help,doc` matches *website/docs/help.md*"), SearchMode::ContentExact => { format!("`{prefix}find(` matches a file containing *a.find(b);*") } SearchMode::ContentRegex => { format!("`{prefix}find/i` matches a file containing *A::Find(b)*") } }; SearchModeHelp { prefix, description, example, } } ================================================ FILE: src/help/help_state.rs ================================================ use { super::{ SearchModeHelp, help_content, }, crate::{ app::*, command::{ Command, TriggerType, }, conf::Conf, display::{ Screen, W, }, errors::ProgramError, launchable::Launchable, pattern::*, tree::TreeOptions, verb::*, }, std::path::{ Path, PathBuf, }, termimad::{ Area, FmtText, TextView, }, }; /// an application state dedicated to help pub struct HelpState { pub scroll: usize, pub text_area: Area, dirty: bool, // background must be cleared pattern: Pattern, tree_options: TreeOptions, config_path: PathBuf, // the last config path when several were used mode: Mode, } impl HelpState { pub fn new( tree_options: TreeOptions, _screen: Screen, con: &AppContext, ) -> HelpState { let text_area = Area::uninitialized(); // will be fixed at drawing time let config_path = con .config_paths .first() .cloned() .unwrap_or_else(Conf::default_location); HelpState { text_area, scroll: 0, dirty: true, pattern: Pattern::None, tree_options, config_path, mode: con.initial_mode(), } } } impl PanelState for HelpState { fn get_type(&self) -> PanelStateType { PanelStateType::Help } fn set_mode( &mut self, mode: Mode, ) { self.mode = mode; } fn get_mode(&self) -> Mode { self.mode } fn selected_path(&self) -> Option<&Path> { Some(&self.config_path) } 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 a tree if true _con: &AppContext, ) -> CmdResult { change_options(&mut self.tree_options); CmdResult::Keep } fn selection(&self) -> Option> { Some(Selection { path: &self.config_path, stype: SelectionType::File, is_exe: false, line: 0, }) } fn refresh( &mut self, _screen: Screen, _con: &AppContext, ) -> Command { self.dirty = true; Command::empty() } fn on_pattern( &mut self, pat: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { self.pattern = pat.pattern; Ok(CmdResult::Keep) } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError> { let con = &disc.con; let mut text_area = disc.state_area.clone(); text_area.pad_for_max_width(120); if text_area != self.text_area { self.dirty = true; self.text_area = text_area; } if self.dirty { disc.panel_skin.styles.default.queue_bg(w)?; disc.screen.clear_area_to_right(w, &disc.state_area)?; self.dirty = false; } let mut expander = help_content::expander(); expander.set("version", env!("CARGO_PKG_VERSION")); let config_paths: Vec = con .config_paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); for path in &config_paths { expander.sub("config-files").set("path", path); } let verb_rows = super::help_verbs::matching_verb_rows(&self.pattern, con); for row in &verb_rows { let sub = expander .sub("verb-rows") .set_md("name", row.name()) .set_md("shortcut", row.shortcut()) .set("key", &row.keys_desc); if row.verb.description.code { sub.set("description", ""); sub.set("execution", &row.verb.description.content); } else { sub.set_md("description", &row.verb.description.content); sub.set("execution", ""); } } let mode_help; if let Ok(default_mode) = con.search_modes.search_mode(None) { mode_help = super::search_mode_help(default_mode, con); expander .sub("default-search") .set_md("default-search-example", &mode_help.example); } let search_rows: Vec = SEARCH_MODES .iter() .map(|mode| super::search_mode_help(*mode, con)) .collect(); for row in &search_rows { expander .sub("search-mode-rows") .set("search-prefix", &row.prefix) .set("search-type", &row.description) .set_md("search-example", &row.example); } let nr_prefix = SearchMode::NameRegex.prefix(con); let ce_prefix = SearchMode::ContentExact.prefix(con); expander .set("nr-prefix", &nr_prefix) .set("ce-prefix", &ce_prefix); let features = super::help_features::list(); expander.set( "features-text", if features.is_empty() { "This release was compiled with no optional feature enabled." } else { "This release was compiled with those optional features enabled:" }, ); for feature in &features { expander .sub("features") .set("feature-name", feature.0) .set("feature-description", feature.1); } let text = expander.expand(); let fmt_text = FmtText::from_text( &disc.panel_skin.help_skin, text, Some((self.text_area.width - 1) as usize), ); let mut text_view = TextView::from(&self.text_area, &fmt_text); self.scroll = text_view.set_scroll(self.scroll); Ok(text_view.write_on(w)?) } 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 { use Internal::*; Ok(match internal_exec.internal { Internal::back => { if self.pattern.is_some() { self.pattern = Pattern::None; CmdResult::Keep } else { CmdResult::PopState } } help => CmdResult::Keep, line_down | line_down_no_cycle => { self.scroll += get_arg(input_invocation, internal_exec, 1); CmdResult::Keep } line_up | line_up_no_cycle => { let dy = get_arg(input_invocation, internal_exec, 1); self.scroll = self.scroll.saturating_sub(dy); CmdResult::Keep } open_stay => match opener::open(Conf::default_location()) { Ok(exit_status) => { info!("open returned with exit_status {exit_status:?}"); CmdResult::Keep } Err(e) => CmdResult::DisplayError(format!("{e:?}")), }, // FIXME check we can't use the generic one open_leave => CmdResult::from(Launchable::opener(Conf::default_location())), page_down => { self.scroll += self.text_area.height as usize; CmdResult::Keep } page_up => { let height = self.text_area.height as usize; self.scroll = if self.scroll > height { self.scroll - self.text_area.height as usize } else { 0 }; CmdResult::Keep } _ => self.on_internal_generic( w, invocation_parser, internal_exec, input_invocation, trigger_type, app_state, cc, )?, }) } } ================================================ FILE: src/help/help_verbs.rs ================================================ use crate::{ app::AppContext, keys, pattern::*, verb::*, }; /// what should be shown for a verb in the help screen, after /// filtering pub struct MatchingVerbRow<'v> { name: Option, shortcut: Option, pub verb: &'v Verb, pub keys_desc: String, } impl MatchingVerbRow<'_> { /// the name in markdown (with matching chars in bold if /// some filtering occurred) pub fn name(&self) -> &str { // there should be a better way to write this self.name .as_deref() .unwrap_or_else(|| match self.verb.names.first() { Some(s) => s.as_str(), _ => " ", }) } pub fn shortcut(&self) -> &str { // there should be a better way to write this self.shortcut .as_deref() .unwrap_or_else(|| match self.verb.names.get(1) { Some(s) => s.as_str(), _ => " ", }) } } /// return the rows of the verbs table in help, taking the current filter /// into account pub fn matching_verb_rows<'v>( pat: &Pattern, con: &'v AppContext, ) -> Vec> { let mut rows = Vec::new(); for verb in con.verb_store.verbs() { if !verb.show_in_doc { continue; } let mut name = None; let mut shortcut = None; if pat.is_some() { let mut ok = false; name = verb.names.first().and_then(|s| { pat.search_string(s).map(|nm| { ok = true; nm.wrap(s, "**", "**") }) }); shortcut = verb.names.get(1).and_then(|s| { pat.search_string(s).map(|nm| { ok = true; nm.wrap(s, "**", "**") }) }); if !ok { continue; } } let keys_desc = verb .keys .iter() .filter(|&&k| con.modal || !keys::is_key_only_modal(k)) .map(|&k| keys::KEY_FORMAT.to_string(k)) .collect::>() // no way to join an iterator today ? .join(", "); rows.push(MatchingVerbRow { name, shortcut, verb, keys_desc, }); } rows } ================================================ FILE: src/help/mod.rs ================================================ mod help_content; mod help_features; mod help_search_modes; mod help_state; mod help_verbs; pub use { help_search_modes::*, help_state::HelpState, }; ================================================ FILE: src/hex/byte.rs ================================================ use { crate::skin::StyleMap, termimad::CompoundStyle, }; pub enum ByteCategory { Null, AsciiGraphic, AsciiWhitespace, AsciiOther, NonAscii, } #[derive(Clone, Copy)] pub struct Byte(u8); impl From for Byte { fn from(u: u8) -> Self { Self(u) } } impl Byte { pub fn category(self) -> ByteCategory { if self.0 == 0x00 { ByteCategory::Null } else if self.0.is_ascii_graphic() { ByteCategory::AsciiGraphic } else if self.0.is_ascii_whitespace() { ByteCategory::AsciiWhitespace } else if self.0.is_ascii() { ByteCategory::AsciiOther } else { ByteCategory::NonAscii } } pub fn style( self, styles: &StyleMap, ) -> &CompoundStyle { match self.category() { ByteCategory::Null => &styles.hex_null, ByteCategory::AsciiGraphic => &styles.hex_ascii_graphic, ByteCategory::AsciiWhitespace => &styles.hex_ascii_whitespace, ByteCategory::AsciiOther => &styles.hex_ascii_other, ByteCategory::NonAscii => &styles.hex_non_ascii, } } pub fn as_char(self) -> char { match self.category() { ByteCategory::Null => '0', ByteCategory::AsciiGraphic => self.0 as char, ByteCategory::AsciiWhitespace if self.0 == 0x20 => ' ', ByteCategory::AsciiWhitespace => '_', ByteCategory::AsciiOther => '•', ByteCategory::NonAscii => '×', } } } ================================================ FILE: src/hex/hex_view.rs ================================================ use { super::byte::Byte, crate::{ command::ScrollCommand, display::{ Screen, W, }, errors::ProgramError, skin::PanelSkin, }, crokey::crossterm::{ QueueableCommand, cursor, style::{ Color, Print, SetForegroundColor, }, }, memmap2::Mmap, std::{ fs::File, io, path::PathBuf, }, termimad::{ Area, CropWriter, SPACE_FILLING, }, }; pub struct HexLine { pub bytes: Vec, // from 1 to 16 bytes } /// a preview showing the content of a file in hexa pub struct HexView { path: PathBuf, len: usize, scroll: usize, page_height: usize, } impl HexView { pub fn new(path: PathBuf) -> io::Result { let len = path.metadata()?.len() as usize; Ok(Self { path, len, scroll: 0, page_height: 0, }) } pub fn line_count(&self) -> usize { self.len / 16 + usize::from(self.len % 16 != 0) } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { let old_scroll = self.scroll; self.scroll = cmd.apply(self.scroll, self.line_count(), self.page_height); self.scroll != old_scroll } pub fn select_first(&mut self) { self.scroll = 0; } pub fn select_last(&mut self) { if self.page_height < self.line_count() { self.scroll = self.line_count() - self.page_height; } } pub fn get_page( &mut self, start_line_idx: usize, line_count: usize, ) -> io::Result> { let file = File::open(&self.path)?; let mut lines = Vec::new(); if self.len == 0 { return Ok(lines); } let mmap = unsafe { Mmap::map(&file)? }; let new_len = mmap.len(); if new_len != self.len { warn!( "previewed file len changed from {} to {}", self.len, new_len ); self.len = new_len; } let mut start_idx = 16 * start_line_idx; while start_idx < self.len { let line_len = 16.min(self.len - start_idx); let mut bytes: Vec = vec![0; line_len]; bytes[0..line_len].copy_from_slice(&mmap[start_idx..start_idx + line_len]); lines.push(HexLine { bytes }); if lines.len() >= line_count { break; } start_idx += line_len; } Ok(lines) } pub fn display( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let line_count = area.height as usize; self.page_height = area.height as usize; let page = self.get_page(self.scroll, line_count)?; let addresses_len = if self.len < 0xffff { 4 } else if self.len < 0xff_ffff { 6 } else { 8 }; let mut margin_around_addresses = false; let styles = &panel_skin.styles; let mut left_margin = false; let mut addresses = false; let mut hex_middle_space = false; let mut chars_middle_space = false; let mut inter_hex = false; let mut chars = false; const MIN: i32 = 1 // margin + 32 // 32 hex + 1; // scrollbar let mut rem = area.width as i32 - MIN; if rem > 17 { chars = true; rem -= 17; } if rem > 16 { inter_hex = true; rem -= 16; } if rem > addresses_len { addresses = true; rem -= addresses_len; } if rem > 1 { hex_middle_space = true; rem -= 1; } if rem > 1 { left_margin = true; rem -= 1; } if rem > 1 { chars_middle_space = true; rem -= 1; } if addresses && rem >= 2 { margin_around_addresses = true; //rem -= 2; } let scrollbar = area.scrollbar(self.scroll, self.line_count()); let scrollbar_fg = styles .scrollbar_thumb .get_fg() .or_else(|| styles.preview.get_fg()) .unwrap_or(Color::White); for y in 0..line_count { w.queue(cursor::MoveTo(area.left, y as u16 + area.top))?; let mut cw = CropWriter::new(w, area.width as usize - 1); // -1 for scrollbar let cw = &mut cw; if y < page.len() { if addresses { let addr = (self.scroll + y) * 16; cw.queue_g_string( &styles.preview_line_number, match (addresses_len, margin_around_addresses) { (4, false) => format!("{addr:04x}"), (6, false) => format!("{addr:06x}"), (_, false) => format!("{addr:08x}"), (4, true) => format!(" {addr:04x} "), (6, true) => format!(" {addr:06x} "), (_, true) => format!(" {addr:08x} "), }, )?; } if left_margin { cw.queue_char(&styles.default, ' ')?; } let line = &page[y]; for x in 0..16 { if x == 8 && hex_middle_space { cw.queue_char(&styles.default, ' ')?; } if let Some(b) = line.bytes.get(x) { let byte = Byte::from(*b); if inter_hex { cw.queue_g_string(byte.style(styles), format!("{b:02x} "))?; } else { cw.queue_g_string(byte.style(styles), format!("{b:02x}"))?; } } else { cw.queue_str(&styles.default, if inter_hex { " " } else { " " })?; } } if chars { cw.queue_char(&styles.default, ' ')?; for x in 0..16 { if x == 8 && chars_middle_space { cw.queue_char(&styles.default, ' ')?; } if let Some(b) = line.bytes.get(x) { let byte = Byte::from(*b); cw.queue_char(byte.style(styles), byte.as_char())?; } } } } cw.fill(&styles.default, &SPACE_FILLING)?; if is_thumb(y as u16 + area.top, scrollbar) { w.queue(SetForegroundColor(scrollbar_fg))?; w.queue(Print('▐'))?; } else { w.queue(Print(' '))?; } } Ok(()) } pub fn display_info( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let width = area.width as usize; let mut s = format!("{}", self.len); if s.len() > width { return Ok(()); } if s.len() + " bytes".len() < width { s = format!("{s} bytes"); } else if s.len() + 1 < width { s = format!("{s}b"); } w.queue(cursor::MoveTo( area.left + area.width - s.len() as u16, area.top, ))?; panel_skin.styles.default.queue(w, s)?; Ok(()) } } fn is_thumb( y: u16, scrollbar: Option<(u16, u16)>, ) -> bool { if let Some((sctop, scbottom)) = scrollbar { if sctop <= y && y <= scbottom { return true; } } false } ================================================ FILE: src/hex/mod.rs ================================================ mod byte; mod hex_view; pub use hex_view::HexView; ================================================ FILE: src/icon/font.rs ================================================ use { super::*, crate::tree::TreeLineType, rustc_hash::FxHashMap, }; pub struct FontPlugin { icon_name_to_icon_codepoint_map: FxHashMap<&'static str, u32>, file_name_to_icon_name_map: FxHashMap<&'static str, &'static str>, double_extension_to_icon_name_map: FxHashMap<&'static str, &'static str>, extension_to_icon_name_map: FxHashMap<&'static str, &'static str>, default_icon_point: u32, } impl FontPlugin { #[cfg(debug_assertions)] fn sanity_check( part_to_icon_name_map: &FxHashMap<&str, &str>, icon_name_to_icon_codepoint_map: &FxHashMap<&str, u32>, ) { let offending_entries = part_to_icon_name_map .values() .map(|icon_name| { ( icon_name, icon_name_to_icon_codepoint_map.contains_key(icon_name), ) }) // Find if any entry is not present .filter(|(_entry, entry_present)| !entry_present) .collect::>(); for oe in &offending_entries { eprintln!("{} is not a valid icon name", oe.0); } if !offending_entries.is_empty() { eprintln!("Terminating execution"); std::process::exit(53); } } pub fn new( icon_name_to_icon_codepoint_map: &'static [(&'static str, u32)], double_extension_to_icon_name_map: &'static [(&'static str, &'static str)], extension_to_icon_name_map: &'static [(&'static str, &'static str)], file_name_to_icon_name_map: &'static [(&'static str, &'static str)], ) -> Self { let icon_name_to_icon_codepoint_map: FxHashMap<_, _> = icon_name_to_icon_codepoint_map.iter().copied().collect(); let double_extension_to_icon_name_map: FxHashMap<_, _> = double_extension_to_icon_name_map.iter().copied().collect(); let extension_to_icon_name_map: FxHashMap<_, _> = extension_to_icon_name_map.iter().copied().collect(); let file_name_to_icon_name_map: FxHashMap<_, _> = file_name_to_icon_name_map.iter().copied().collect(); #[cfg(debug_assertions)] { Self::sanity_check( &file_name_to_icon_name_map, &icon_name_to_icon_codepoint_map, ); Self::sanity_check( &double_extension_to_icon_name_map, &icon_name_to_icon_codepoint_map, ); Self::sanity_check( &extension_to_icon_name_map, &icon_name_to_icon_codepoint_map, ); } let default_icon_point = *icon_name_to_icon_codepoint_map.get("default_file").unwrap(); Self { icon_name_to_icon_codepoint_map, file_name_to_icon_name_map, double_extension_to_icon_name_map, extension_to_icon_name_map, default_icon_point, } } fn handle_single_extension( &self, ext: Option, ) -> &'static str { match ext { None => "default_file", Some(ref e) => match self.extension_to_icon_name_map.get(e as &str) { None => "default_file", Some(icon_name) => icon_name, }, } } fn handle_file( &self, name: &str, double_ext: Option, ext: Option, ) -> &'static str { match self.file_name_to_icon_name_map.get(name) { Some(icon_name) => icon_name, _ => self.handle_double_extension(double_ext, ext), } } fn handle_double_extension( &self, double_ext: Option, ext: Option, ) -> &'static str { match double_ext { None => self.handle_single_extension(ext), Some(ref de) => match self.double_extension_to_icon_name_map.get(de as &str) { None => self.handle_single_extension(ext), Some(icon_name) => icon_name, }, } } } impl IconPlugin for FontPlugin { fn get_icon( &self, tree_line_type: &TreeLineType, name: &str, double_ext: Option<&str>, ext: Option<&str>, ) -> char { let icon_name = match tree_line_type { TreeLineType::Dir => "default_folder", TreeLineType::SymLink { .. } => "emoji_type_link", //bad but nothing better TreeLineType::File => self.handle_file( &name.to_ascii_lowercase(), double_ext.map(str::to_ascii_lowercase), ext.map(str::to_ascii_lowercase), ), TreeLineType::Pruning => "file_type_kite", //irrelevant TreeLineType::BrokenSymLink(_) => "default_file", }; let entry_icon = unsafe { std::char::from_u32_unchecked( *self .icon_name_to_icon_codepoint_map .get(icon_name) .unwrap_or(&self.default_icon_point), ) }; entry_icon } } ================================================ FILE: src/icon/icon_plugin.rs ================================================ use crate::tree::TreeLineType; pub trait IconPlugin { fn get_icon( &self, tree_line_type: &TreeLineType, name: &str, double_ext: Option<&str>, ext: Option<&str>, ) -> char; } ================================================ FILE: src/icon/mod.rs ================================================ mod font; mod icon_plugin; use font::FontPlugin; pub use icon_plugin::IconPlugin; pub fn icon_plugin(icon_set: &str) -> Option> { match icon_set { "vscode" => Some(Box::new(FontPlugin::new( &include!("../../resources/icons/vscode/data/icon_name_to_icon_code_point_map.rs"), &include!("../../resources/icons/vscode/data/double_extension_to_icon_name_map.rs"), &include!("../../resources/icons/vscode/data/extension_to_icon_name_map.rs"), &include!("../../resources/icons/vscode/data/file_name_to_icon_name_map.rs"), ))), "nerdfont" => Some(Box::new(FontPlugin::new( &include!("../../resources/icons/nerdfont/data/icon_name_to_icon_code_point_map.rs"), &include!("../../resources/icons/nerdfont/data/double_extension_to_icon_name_map.rs"), &include!("../../resources/icons/nerdfont/data/extension_to_icon_name_map.rs"), &include!("../../resources/icons/nerdfont/data/file_name_to_icon_name_map.rs"), ))), _ => None, } } ================================================ FILE: src/image/double_line.rs ================================================ use { super::zune_compat::Rgba, crate::{ display::W, errors::ProgramError, }, crokey::crossterm::{ QueueableCommand, style::{ Color, Colors, Print, SetColors, }, }, termimad::{ fill_bg, coolor, }, }; const UPPER_HALF_BLOCK: char = '▀'; /// A "double line" normally contains two lines of pixels /// which are displayed as one line of characters, the /// `UPPER_HALF_BLOCK` foreground for the upper pixel and /// the background of the char for the lower pixel. /// It acts as a buffer which is dumped to screen when /// full or when the image is totally read. pub struct DoubleLine { img_width: usize, pixels: Vec, // size twice img_width true_colors: bool, } impl DoubleLine { pub fn new( img_width: usize, true_colors: bool, ) -> Self { Self { img_width, pixels: Vec::with_capacity(2 * img_width), true_colors, } } pub fn push( &mut self, rgba: Rgba, ) { self.pixels.push(if self.true_colors { Color::Rgb { r: rgba[0], g: rgba[1], b: rgba[2], } } else { Color::AnsiValue( coolor::Rgb::new(rgba[0], rgba[1], rgba[2]) .to_ansi() .code ) }); } pub fn is_empty(&self) -> bool { self.pixels.is_empty() } pub fn is_full(&self) -> bool { self.pixels.len() == 2 * self.img_width } pub fn write( &mut self, w: &mut W, left_margin: usize, right_margin: usize, bg: Color, ) -> Result<(), ProgramError> { debug_assert!( self.pixels.len() == self.img_width || self.pixels.len() == 2 * self.img_width ); // we may have either one or two lines let simple = self.pixels.len() < 2 * self.img_width; fill_bg(w, left_margin, bg)?; for i in 0..self.img_width { let foreground_color = self.pixels[i]; let background_color = if simple { bg } else { self.pixels[i + self.img_width] }; w.queue(SetColors(Colors::new(foreground_color, background_color)))?; w.queue(Print(UPPER_HALF_BLOCK))?; } fill_bg(w, right_margin, bg)?; self.pixels.clear(); Ok(()) } } ================================================ FILE: src/image/image_view.rs ================================================ use { super::{ SourceImage, double_line::DoubleLine, zune_compat::DynamicImage, }, crate::{ app::*, display::{ Screen, W, }, errors::ProgramError, kitty::{ self, KittyImageId, }, skin::PanelSkin, }, crokey::crossterm::{ QueueableCommand, cursor, style::{ Color, SetBackgroundColor, }, }, std::path::{ Path, PathBuf, }, termimad::{ Area, fill_bg, }, }; #[derive(Debug)] struct DrawingInfo { drawing_count: usize, area: Area, } impl DrawingInfo { pub fn follows_in_place( &self, previous: &DrawingInfo, ) -> bool { self.drawing_count == previous.drawing_count + 1 && self.area == previous.area } } /// an already resized image, with the dimensions it /// was computed for (which may be different from the /// dimensions we got) struct CachedImage { img: DynamicImage, target_width: u32, target_height: u32, } /// an imageview can display an image in the terminal with /// a ratio of one pixel per char in width. pub struct ImageView { path: PathBuf, source_img: SourceImage, display_img: Option, last_drawing: Option, kitty_image_id: Option, } impl ImageView { pub fn new(path: &Path) -> Result { let source_img = time!("decode image", path, SourceImage::new(path)?); Ok(Self { path: path.to_path_buf(), source_img, display_img: None, last_drawing: None, kitty_image_id: None, }) } pub fn display( &mut self, w: &mut W, disc: &DisplayContext, area: &Area, ) -> Result<(), ProgramError> { let styles = &disc.panel_skin.styles; let bg_color = styles.preview.get_bg().or_else(|| styles.default.get_bg()); let bg = bg_color.unwrap_or(Color::Reset); // we avoid drawing when we were just displayed // on the last drawing_count and the area is the same. let drawing_info = DrawingInfo { drawing_count: disc.count, area: area.clone(), }; let must_draw = self .last_drawing .as_ref() .is_none_or(|previous| !drawing_info.follows_in_place(previous)); if must_draw { debug!("image_view must be cleared"); } else { debug!("no need to clear image_view"); } self.last_drawing = Some(drawing_info); #[allow(clippy::missing_panics_doc)] // panics on mutex poisoning (good) let mut kitty_manager = kitty::manager().lock().unwrap(); if !must_draw { if let Some(kitty_image_id) = self.kitty_image_id { // we tell the manager the images must be kept, otherwise // they would be erased at end of drawing, as obsolete kitty_manager.keep(kitty_image_id, disc.count); } return Ok(()); } self.kitty_image_id = kitty_manager.try_print_image( w, &self.source_img, &self.path, area, bg, disc.count, disc.con, )?; if self.kitty_image_id.is_some() { return Ok(()); } let target_width = area.width as u32; let target_height = (area.height * 2) as u32; let cached = self .display_img .as_ref() .filter(|ci| ci.target_width == target_width && ci.target_height == target_height); let img = match cached { Some(ci) => &ci.img, None => { let img = time!( "resize image", self.source_img .fitting(target_width, target_height, bg_color), )?; &self .display_img .insert(CachedImage { img, target_width, target_height, }) .img } }; let (width, height) = img.dimensions(); debug!("resized image dimensions: {width},{height}"); debug_assert!(width <= area.width as u32); let mut double_line = DoubleLine::new(width as usize, disc.con.true_colors); let mut y = area.top; let img_top_offset = (area.height - (height / 2) as u16) / 2; for _ in 0..img_top_offset { w.queue(cursor::MoveTo(area.left, y))?; fill_bg(w, area.width as usize, bg)?; y += 1; } let margin = area.width as usize - width as usize; let left_margin = margin / 2; let right_margin = margin - left_margin; w.queue(cursor::MoveTo(area.left, y))?; for pixel in img.pixels() { double_line.push(pixel.2); if double_line.is_full() { double_line.write(w, left_margin, right_margin, bg)?; y += 1; w.queue(cursor::MoveTo(area.left, y))?; } } if !double_line.is_empty() { double_line.write(w, left_margin, right_margin, bg)?; y += 1; } w.queue(SetBackgroundColor(bg))?; for y in y..area.top + area.height { w.queue(cursor::MoveTo(area.left, y))?; fill_bg(w, area.width as usize, bg)?; } Ok(()) } pub fn display_info( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let dim = self.source_img.dimensions(); let s = format!("{} x {}", dim.0, dim.1); if s.len() > area.width as usize { return Ok(()); } w.queue(cursor::MoveTo( area.left + area.width - s.len() as u16, area.top, ))?; panel_skin.styles.default.queue(w, s)?; Ok(()) } } ================================================ FILE: src/image/mod.rs ================================================ mod double_line; mod image_view; mod source_image; mod svg; pub mod zune_compat; pub use { image_view::ImageView, source_image::SourceImage, }; ================================================ FILE: src/image/source_image.rs ================================================ use { super::{ svg, zune_compat::DynamicImage, }, crate::errors::ProgramError, std::path::Path, termimad::{ coolor, crossterm::style::Color as CrosstermColor, }, }; #[allow(clippy::large_enum_variant)] pub enum SourceImage { Bitmap(DynamicImage), Svg(resvg::usvg::Tree), } impl SourceImage { pub fn new(path: &Path) -> Result { let is_svg = matches!(path.extension(), Some(ext) if ext == "svg" || ext == "SVG"); let img = if is_svg { Self::Svg(svg::load(path)?) } else { Self::Bitmap(DynamicImage::from_path(path)?) }; Ok(img) } pub fn dimensions(&self) -> (u32, u32) { match self { Self::Bitmap(img) => img.dimensions(), Self::Svg(tree) => ( f32_to_u32(tree.size().width()), f32_to_u32(tree.size().height()), ), } } pub fn fitting( &self, mut max_width: u32, mut max_height: u32, bg_color: Option, ) -> Result { let img = match self { Self::Bitmap(img) => { let dim = self.dimensions(); if dim.0 <= max_width && dim.1 <= max_height { img.clone() } else { max_width = max_width.min(dim.0); max_height = max_height.min(dim.1); img.resize(max_width, max_height)? } } Self::Svg(tree) => { let bg_color: Option = bg_color.map(Into::into); svg::render_tree(tree, max_width, max_height, bg_color)? } }; Ok(img) } } fn f32_to_u32(v: f32) -> u32 { if v <= 0.0 || v >= u32::MAX as f32 { 0 } else { v as u32 } } ================================================ FILE: src/image/svg.rs ================================================ use { super::zune_compat::DynamicImage, crate::errors::SvgError, resvg::{ tiny_skia, usvg, }, std::path::PathBuf, termimad::coolor, }; fn compute_zoom( w: f32, h: f32, max_width: u32, max_height: u32, ) -> Result { let mw: f32 = max_width.max(2) as f32; let mh: f32 = max_height.max(2) as f32; let zoom = 1.0f32.min(mw / w).min(mh / h); if zoom > 0.0f32 { Ok(zoom) } else { Err(SvgError::Internal { message: "invalid SVG dimensions", }) } } pub fn load>(path: P) -> Result { let path: PathBuf = path.into(); let mut opt = usvg::Options { resources_dir: Some(path.clone()), ..Default::default() }; opt.fontdb_mut().load_system_fonts(); let svg_data = std::fs::read(path)?; let tree = usvg::Tree::from_data(&svg_data, &opt)?; Ok(tree) } pub fn render_tree( tree: &usvg::Tree, max_width: u32, max_height: u32, bg_color: Option, ) -> Result { let t_width = tree.size().width(); let t_height = tree.size().height(); debug!("SVG natural size: {t_width} x {t_height}"); let zoom = compute_zoom(t_width, t_height, max_width, max_height)?; debug!("svg rendering zoom: {zoom}"); let px_width = (t_width * zoom) as u32; let px_height = (t_height * zoom) as u32; if px_width == 0 || px_height == 0 { return Err(SvgError::Internal { message: "invalid SVG dimensions", }); } debug!("px_size: ({px_width}, {px_height})"); let mut pixmap = tiny_skia::Pixmap::new(px_width, px_height).ok_or(SvgError::Internal { message: "unable to create pixmap buffer", })?; if let Some(bg_color) = bg_color { let rgb = bg_color.rgb(); let bg_color = tiny_skia::Color::from_rgba8(rgb.r, rgb.g, rgb.b, 255); pixmap.fill(bg_color); } resvg::render( tree, tiny_skia::Transform::from_scale(zoom, zoom), &mut pixmap.as_mut(), ); let width = pixmap.width(); let height = pixmap.height(); let data = pixmap.take(); DynamicImage::from_rgba8(width, height, data) .map_err(|_| SvgError::Internal { message: "failed to create image from RGBA data", }) } /// Generate a bitmap at the natural dimensions of the SVG unless it's too big /// in which case a smaller one is generated to fit into `max_width x max_height`. /// /// Background will be black. #[allow(dead_code)] pub fn render>( path: P, max_width: u32, max_height: u32, ) -> Result { let tree = load(path)?; let image = render_tree(&tree, max_width, max_height, None)?; Ok(image) } ================================================ FILE: src/image/zune_compat.rs ================================================ /// Compatibility layer supporting both zune-image (fast) and image crate (fallback) use { crate::errors::ProgramError, image::GenericImageView, std::path::Path, zune_core::colorspace::ColorSpace, }; impl From for ProgramError { fn from(err: zune_image::errors::ImageErrors) -> Self { ProgramError::ImageError { details: err.to_string(), } } } impl From for ProgramError { fn from(err: image::ImageError) -> Self { ProgramError::ImageError { details: err.to_string(), } } } /// Image type that uses either zune-image (fast) or image crate (fallback) #[derive(Clone)] pub enum DynamicImage { /// Fast decoder (zune-image) - used for JPEG, PNG, BMP, etc. Zune(zune_image::image::Image), /// Fallback decoder (image crate) - used for WebP, GIF, TIFF, etc. Image(image::DynamicImage), } impl DynamicImage { pub fn from_path_as_zune(path: &Path) -> Result { let img = zune_image::image::Image::open(path)?; let nb_components = img.colorspace().num_components(); if nb_components < 3 { // Current implementation of the module requires an RGB image and // zune panics if we try to convert an image with less than 3 channels to RGB // (when calling Frame::flatten) return Err(ProgramError::ImageError { details: format!( "Unsupported color space with {nb_components} components in image: {path:?}" ) }); } Ok(Self::Zune(img)) } pub fn from_path(path: &Path) -> Result { // Try zune-image first (fast path) match Self::from_path_as_zune(path) { Ok(img) => { debug!("Loaded with zune-image: {path:?}"); Ok(img) } Err(_) => { // Fall back to image crate for unsupported formats debug!("Falling back to image crate for: {path:?}"); let img = image::ImageReader::open(path)?.decode()?; Ok(Self::Image(img)) } } } pub fn from_rgba8(width: u32, height: u32, data: Vec) -> Result { let expected_len = (width as usize) * (height as usize) * 4; if data.len() != expected_len { return Err(ProgramError::Internal { details: format!( "Invalid RGBA data length: expected {}, got {}", expected_len, data.len() ) }); } let img = zune_image::image::Image::from_u8(&data, width as usize, height as usize, ColorSpace::RGBA); Ok(Self::Zune(img)) } pub fn dimensions(&self) -> (u32, u32) { match self { Self::Zune(img) => { let dims = img.dimensions(); (dims.0 as u32, dims.1 as u32) } Self::Image(img) => img.dimensions(), } } pub fn resize(&self, max_width: u32, max_height: u32) -> Result { match self { Self::Zune(img) => { let (width, height) = self.dimensions(); if width <= max_width && height <= max_height { return Ok(self.clone()); } // Calculate new dimensions maintaining aspect ratio let ratio = (max_width as f32 / width as f32).min(max_height as f32 / height as f32); let new_width = (width as f32 * ratio) as usize; let new_height = (height as f32 * ratio) as usize; // Use bilinear resize let colorspace = img.colorspace(); let frames = img.frames_ref(); if let Some(frame) = frames.first() { let src_data: Vec = frame.flatten(colorspace); let dst_data = resize_bilinear( &src_data, width as usize, height as usize, new_width, new_height, colorspace.num_components(), ); let img = zune_image::image::Image::from_u8(&dst_data, new_width, new_height, colorspace); Ok(Self::Zune(img)) } else { Ok(self.clone()) } } Self::Image(img) => { let (width, height) = img.dimensions(); if width <= max_width && height <= max_height { Ok(self.clone()) } else { let new_img = img.resize(max_width, max_height, image::imageops::FilterType::Triangle); Ok(Self::Image(new_img)) } } } } pub fn as_rgb8(&self) -> Option { match self { Self::Zune(img) => { if img.colorspace() == ColorSpace::RGB { Some(RgbImage::Zune(img.clone())) } else { None } } Self::Image(img) => img.as_rgb8().map(|rgb| RgbImage::Image(rgb.clone())), } } pub fn as_rgba8(&self) -> Option { match self { Self::Zune(img) => { if img.colorspace() == ColorSpace::RGBA { Some(RgbaImage::Zune(img.clone())) } else { None } } Self::Image(img) => img.as_rgba8().map(|rgba| RgbaImage::Image(rgba.clone())), } } pub fn to_rgb8(&self) -> RgbImage { match self { Self::Zune(img) => { let mut img = img.clone(); // Convert to RGB if needed if img.colorspace() != ColorSpace::RGB { let frames = img.frames_ref(); if let Some(frame) = frames.first() { // beware that zune panics on next line if the image has less than 3 channels let data: Vec = frame.flatten(ColorSpace::RGB); let (w, h) = img.dimensions(); img = zune_image::image::Image::from_u8(&data, w, h, ColorSpace::RGB); } } RgbImage::Zune(img) } Self::Image(img) => RgbImage::Image(img.to_rgb8()), } } pub fn pixels(&self) -> PixelIterator { match self { Self::Zune(img) => { let (width, height) = self.dimensions(); let colorspace = img.colorspace(); let frames = img.frames_ref(); let data = if let Some(frame) = frames.first() { frame.flatten::(colorspace) } else { Vec::new() }; let components = colorspace.num_components(); PixelIterator::Zune { data, width: width as usize, height: height as usize, components, index: 0, } } Self::Image(img) => { let pixels: Vec<_> = img.pixels().collect(); PixelIterator::Image { pixels, index: 0, } } } } } fn resize_bilinear( src: &[u8], src_width: usize, src_height: usize, dst_width: usize, dst_height: usize, channels: usize, ) -> Vec { let mut dst = vec![0u8; dst_width * dst_height * channels]; let x_ratio = (src_width - 1) as f32 / dst_width.max(1) as f32; let y_ratio = (src_height - 1) as f32 / dst_height.max(1) as f32; for dst_y in 0..dst_height { for dst_x in 0..dst_width { // Calculate the source coordinates (floating point) let src_x_f = dst_x as f32 * x_ratio; let src_y_f = dst_y as f32 * y_ratio; // Get the four surrounding pixels let x0 = src_x_f.floor() as usize; let y0 = src_y_f.floor() as usize; let x1 = (x0 + 1).min(src_width - 1); let y1 = (y0 + 1).min(src_height - 1); // Calculate the fractional parts (weights) let x_frac = src_x_f - x0 as f32; let y_frac = src_y_f - y0 as f32; // Bilinear interpolation weights let w00 = (1.0 - x_frac) * (1.0 - y_frac); let w10 = x_frac * (1.0 - y_frac); let w01 = (1.0 - x_frac) * y_frac; let w11 = x_frac * y_frac; // Calculate pixel indices let idx00 = (y0 * src_width + x0) * channels; let idx10 = (y0 * src_width + x1) * channels; let idx01 = (y1 * src_width + x0) * channels; let idx11 = (y1 * src_width + x1) * channels; let dst_idx = (dst_y * dst_width + dst_x) * channels; // Interpolate each channel for c in 0..channels { let p00 = src[idx00 + c] as f32; let p10 = src[idx10 + c] as f32; let p01 = src[idx01 + c] as f32; let p11 = src[idx11 + c] as f32; let value = p00 * w00 + p10 * w10 + p01 * w01 + p11 * w11; dst[dst_idx + c] = value.round().clamp(0.0, 255.0) as u8; } } } dst } pub enum RgbImage { Zune(zune_image::image::Image), Image(image::RgbImage), } impl RgbImage { pub fn as_raw(&self) -> Vec { match self { Self::Zune(img) => { let frames = img.frames_ref(); if let Some(frame) = frames.first() { frame.flatten(ColorSpace::RGB) } else { Vec::new() } } Self::Image(img) => img.as_raw().clone(), } } } pub enum RgbaImage { Zune(zune_image::image::Image), Image(image::RgbaImage), } impl RgbaImage { pub fn as_raw(&self) -> Vec { match self { Self::Zune(img) => { let frames = img.frames_ref(); if let Some(frame) = frames.first() { frame.flatten(ColorSpace::RGBA) } else { Vec::new() } } Self::Image(img) => img.as_raw().clone(), } } pub fn from_vec(width: u32, height: u32, data: Vec) -> Option { let expected_len = (width as usize) * (height as usize) * 4; if data.len() != expected_len { return None; } let img = zune_image::image::Image::from_u8(&data, width as usize, height as usize, ColorSpace::RGBA); Some(Self::Zune(img)) } } #[derive(Clone, Copy)] pub struct Rgba(pub [T; 4]); impl std::ops::Index for Rgba { type Output = T; fn index(&self, index: usize) -> &Self::Output { &self.0[index] } } pub enum PixelIterator { Zune { data: Vec, width: usize, height: usize, components: usize, index: usize, }, Image { pixels: Vec<(u32, u32, image::Rgba)>, index: usize, }, } impl Iterator for PixelIterator { type Item = (u32, u32, Rgba); fn next(&mut self) -> Option { match self { PixelIterator::Zune { data, width, height, components, index, } => { let total_pixels = *width * *height; if *index >= total_pixels { return None; } let x = (*index % *width) as u32; let y = (*index / *width) as u32; let byte_index = *index * *components; let rgba = if *components == 4 { Rgba([ data[byte_index], data[byte_index + 1], data[byte_index + 2], data[byte_index + 3], ]) } else if *components == 3 { Rgba([ data[byte_index], data[byte_index + 1], data[byte_index + 2], 255, ]) } else if *components == 1 { let gray = data[byte_index]; Rgba([gray, gray, gray, 255]) } else { Rgba([0, 0, 0, 255]) }; *index += 1; Some((x, y, rgba)) } PixelIterator::Image { pixels, index } => { if *index >= pixels.len() { return None; } let (x, y, p) = pixels[*index]; let rgba = Rgba([p[0], p[1], p[2], p[3]]); *index += 1; Some((x, y, rgba)) } } } } ================================================ FILE: src/keys.rs ================================================ use { crokey::*, crossterm::event::{ KeyCode, KeyModifiers, }, once_cell::sync::Lazy, }; pub static KEY_FORMAT: Lazy = Lazy::new(|| KeyCombinationFormat::default().with_lowercase_modifiers()); pub fn is_reserved(key: KeyCombination) -> bool { key == key!(backspace) || key == key!(delete) || key == key!(esc) } /// Tell whether the key can only be used as a shortcut key if the /// modal mode is active. pub fn is_key_only_modal(key: KeyCombination) -> bool { matches!( key, KeyCombination { codes: OneToThree::One(KeyCode::Char(_)), modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, } ) } ================================================ FILE: src/kitty/detect_support.rs ================================================ use { crate::kitty::KittyGraphicsDisplay, cli_log::*, std::env, }; /// Determine whether Kitty's graphics protocol is supported /// by the terminal running broot. /// /// This is called only once, and cached in the `KittyManager`'s /// `MaybeRenderer` state #[allow(unreachable_code)] pub fn detect_kitty_graphics_protocol_display() -> KittyGraphicsDisplay { debug!("is_kitty_graphics_protocol_supported ?"); #[cfg(not(unix))] { // because cell_size_in_pixels isn't implemented on Windows debug!("no kitty support yet on Windows"); return KittyGraphicsDisplay::None; } // we detect Kitty by the $TERM or $TERMINAL env var // check its version to be sure it's one with support for env_var in ["TERM", "TERMINAL"] { if let Ok(env_val) = env::var(env_var) { debug!("${env_var} = {env_val:?}"); let env_val = env_val.to_ascii_lowercase(); if env_val.contains("kitty") { debug!(" -> this terminal seems to be Kitty"); return KittyGraphicsDisplay::Direct; } } } // we detect Ghostty by the $TERM env var if let Ok(env_val) = env::var("TERM") { debug!("$TERM = {env_val:?}"); if env_val == "xterm-ghostty" { debug!(" -> this terminal seems to be Ghostty"); return KittyGraphicsDisplay::Direct; } } // we detect Wezterm with the $TERM_PROGRAM env var and we // check its version to be sure it's one with support if let Ok(term_program) = env::var("TERM_PROGRAM") { debug!("$TERM_PROGRAM = {term_program:?}"); if term_program == "WezTerm" { if let Ok(version) = env::var("TERM_PROGRAM_VERSION") { debug!("$TERM_PROGRAM_VERSION = {version:?}"); if &*version < "20220105-201556-91a423da" { debug!("WezTerm's version predates Kitty Graphics protocol support"); } else { debug!("this looks like a compatible version"); return KittyGraphicsDisplay::Direct; } } else { warn!("$TERM_PROGRAM_VERSION unexpectedly missing"); } } else if term_program == "ghostty" { debug!("Ghostty implements Kitty Graphics protocol"); return KittyGraphicsDisplay::Direct; } else if term_program == "iTerm.app" { if let Ok(version) = env::var("TERM_PROGRAM_VERSION") { debug!("$TERM_PROGRAM_VERSION = {version:?}"); if &*version < "3.6.6" { debug!("iTerm2's version predates Kitty Graphics protocol support"); } else { debug!("this looks like a compatible version"); return KittyGraphicsDisplay::Direct; } } else { warn!("$TERM_PROGRAM_VERSION unexpectedly missing"); } } } // Checking support with a proper CSI sequence should be the preferred way but // it doesn't work reliably on wezterm and requires a wait on other terminals. // As both Kitty and WezTerm set env vars allowing an easy detection, this // CSI based querying isn't necessary right now. // This feature is kept gated and should only be tried if other terminals // appear and can't be detected without CSI sequence. #[cfg(feature = "kitty-csi-check")] { let start = std::time::Instant::now(); const TIMEOUT_MS: u64 = 200; let response = xterm_query::query_osc( "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c", TIMEOUT_MS, ); let s = match response { Err(e) => { debug!("xterm querying failed: {}", e); KittyGraphicsDisplay::None } Ok(response) if response == "_Gi=31;OK" => KittyGraphicsDisplay::Direct, Ok(_) => KittyGraphicsDisplay::None, }; debug!("Xterm querying took {:?}", start.elapsed()); debug!("kitty protocol support: {:?}", s); return s; } KittyGraphicsDisplay::None } /// Determine whether we're in tmux. /// /// This is called only once, and cached in `KittyImageRenderer` #[allow(unreachable_code)] pub fn is_tmux() -> bool { debug!("is_tmux ?"); for env_var in ["TERM", "TERMINAL"] { if let Ok(env_val) = env::var(env_var) { debug!("${env_var} = {env_val:?}"); let env_val = env_val.to_ascii_lowercase(); if env_val.contains("tmux") { debug!(" -> this terminal seems to be Tmux"); return true; } } } false } /// Custom environment variable to store how deeply tmux is nested. Starts at 1 when there's no nesting. pub fn get_tmux_nest_count() -> u32 { std::env::var("TMUX_NEST_COUNT") .map(|s| str::parse(&s).unwrap_or(1)) .unwrap_or(1) } /// Determine whether we're in SSH. /// /// This is called only once, and cached in `KittyImageRenderer` #[allow(unreachable_code)] pub fn is_ssh() -> bool { debug!("is_ssh ?"); for env_var in ["SSH_CLIENT", "SSH_CONNECTION"] { if env::var(env_var).is_ok() { debug!(" -> this seems to be under SSH"); return true; } } false } ================================================ FILE: src/kitty/image_renderer.rs ================================================ use { super::{ detect_support::{ detect_kitty_graphics_protocol_display, get_tmux_nest_count, is_ssh, }, terminal_esc::{ get_esc_seq, get_tmux_header, get_tmux_tail, }, }, crate::{ display::{ W, cell_size_in_pixels, }, errors::ProgramError, }, base64::{ self, Engine, engine::general_purpose::STANDARD as BASE64, }, cli_log::*, crokey::crossterm::{ QueueableCommand, cursor, style::Color, }, flate2::{ Compression, write::ZlibEncoder, }, crate::image::zune_compat::{ DynamicImage, RgbImage, RgbaImage, }, lru::LruCache, rustc_hash::FxBuildHasher, serde::Deserialize, std::{ fs::File, io::{ self, Read, Write, }, num::NonZeroUsize, path::{ Path, PathBuf, }, }, tempfile, termimad::{ Area, fill_bg, }, }; /// How to send the image to kitty /// /// Note that I didn't test yet the named shared memory /// solution offered by kitty. /// /// Documentation: /// #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TransmissionMedium { /// write a temp file, then give its path to kitty /// in the payload of the escape sequence. It's quite /// fast on SSD but a big downside is that it doesn't /// work if you're distant #[default] TempFile, /// send the whole rgb or rgba data, encoded in base64, /// in the payloads of several escape sequence (each one /// containing at most 4096 bytes). Works if broot runs /// on remote. Chunks, } /// How to display the image #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum KittyGraphicsDisplay { /// Not supported None, /// detect support automatically #[default] Detect, /// display directly Direct, /// use Unicode placeholders Unicode, } #[derive(Debug, Clone)] pub struct KittyImageRendererOptions { pub display: KittyGraphicsDisplay, pub transmission_medium: TransmissionMedium, pub kept_temp_files: NonZeroUsize, pub is_tmux: bool, } enum ImageData { Rgb(RgbImage), Rgba(RgbaImage), } impl From<&DynamicImage> for ImageData { fn from(img: &DynamicImage) -> Self { if let Some(rgba) = img.as_rgba8() { debug!("using rgba"); Self::Rgba(rgba) } else if let Some(rgb) = img.as_rgb8() { debug!("using rgb"); Self::Rgb(rgb) } else { debug!("converting to rgb8"); Self::Rgb(img.to_rgb8()) } } } impl ImageData { fn kitty_format(&self) -> &'static str { match self { Self::Rgba(_) => "32", Self::Rgb(_) => "24", } } fn bytes(&self) -> Vec { match self { Self::Rgb(img) => img.as_raw(), Self::Rgba(img) => img.as_raw(), } } } /// The max size of a data payload in a kitty escape sequence /// according to kitty's documentation const CHUNK_SIZE: usize = 4096; /// Unicode placeholder character const PLACHOLDER: &str = "\u{10EEEE}"; /// Unicode placeholder diacritic characters #[rustfmt::skip] const DIACRITICS: &[&str] = &[ "\u{0305}", "\u{030D}", "\u{030E}", "\u{0310}", "\u{0312}", "\u{033D}", "\u{033E}", "\u{033F}", "\u{0346}", "\u{034A}", "\u{034B}", "\u{034C}", "\u{0350}", "\u{0351}", "\u{0352}", "\u{0357}", "\u{035B}", "\u{0363}", "\u{0364}", "\u{0365}", "\u{0366}", "\u{0367}", "\u{0368}", "\u{0369}", "\u{036A}", "\u{036B}", "\u{036C}", "\u{036D}", "\u{036E}", "\u{036F}", "\u{0483}", "\u{0484}", "\u{0485}", "\u{0486}", "\u{0487}", "\u{0592}", "\u{0593}", "\u{0594}", "\u{0595}", "\u{0597}", "\u{0598}", "\u{0599}", "\u{059C}", "\u{059D}", "\u{059E}", "\u{059F}", "\u{05A0}", "\u{05A1}", "\u{05A8}", "\u{05A9}", "\u{05AB}", "\u{05AC}", "\u{05AF}", "\u{05C4}", "\u{0610}", "\u{0611}", "\u{0612}", "\u{0613}", "\u{0614}", "\u{0615}", "\u{0616}", "\u{0617}", "\u{0657}", "\u{0658}", "\u{0659}", "\u{065A}", "\u{065B}", "\u{065D}", "\u{065E}", "\u{06D6}", "\u{06D7}", "\u{06D8}", "\u{06D9}", "\u{06DA}", "\u{06DB}", "\u{06DC}", "\u{06DF}", "\u{06E0}", "\u{06E1}", "\u{06E2}", "\u{06E4}", "\u{06E7}", "\u{06E8}", "\u{06EB}", "\u{06EC}", "\u{0730}", "\u{0732}", "\u{0733}", "\u{0735}", "\u{0736}", "\u{073A}", "\u{073D}", "\u{073F}", "\u{0740}", "\u{0741}", "\u{0743}", "\u{0745}", "\u{0747}", "\u{0749}", "\u{074A}", "\u{07EB}", "\u{07EC}", "\u{07ED}", "\u{07EE}", "\u{07EF}", "\u{07F0}", "\u{07F1}", "\u{07F3}", "\u{0816}", "\u{0817}", "\u{0818}", "\u{0819}", "\u{081B}", "\u{081C}", "\u{081D}", "\u{081E}", "\u{081F}", "\u{0820}", "\u{0821}", "\u{0822}", "\u{0823}", "\u{0825}", "\u{0826}", "\u{0827}", "\u{0829}", "\u{082A}", "\u{082B}", "\u{082C}", "\u{082D}", "\u{0951}", "\u{0953}", "\u{0954}", "\u{0F82}", "\u{0F83}", "\u{0F86}", "\u{0F87}", "\u{135D}", "\u{135E}", "\u{135F}", "\u{17DD}", "\u{193A}", "\u{1A17}", "\u{1A75}", "\u{1A76}", "\u{1A77}", "\u{1A78}", "\u{1A79}", "\u{1A7A}", "\u{1A7B}", "\u{1A7C}", "\u{1B6B}", "\u{1B6D}", "\u{1B6E}", "\u{1B6F}", "\u{1B70}", "\u{1B71}", "\u{1B72}", "\u{1B73}", "\u{1CD0}", "\u{1CD1}", "\u{1CD2}", "\u{1CDA}", "\u{1CDB}", "\u{1CE0}", "\u{1DC0}", "\u{1DC1}", "\u{1DC3}", "\u{1DC4}", "\u{1DC5}", "\u{1DC6}", "\u{1DC7}", "\u{1DC8}", "\u{1DC9}", "\u{1DCB}", "\u{1DCC}", "\u{1DD1}", "\u{1DD2}", "\u{1DD3}", "\u{1DD4}", "\u{1DD5}", "\u{1DD6}", "\u{1DD7}", "\u{1DD8}", "\u{1DD9}", "\u{1DDA}", "\u{1DDB}", "\u{1DDC}", "\u{1DDD}", "\u{1DDE}", "\u{1DDF}", "\u{1DE0}", "\u{1DE1}", "\u{1DE2}", "\u{1DE3}", "\u{1DE4}", "\u{1DE5}", "\u{1DE6}", "\u{1DFE}", "\u{20D0}", "\u{20D1}", "\u{20D4}", "\u{20D5}", "\u{20D6}", "\u{20D7}", "\u{20DB}", "\u{20DC}", "\u{20E1}", "\u{20E7}", "\u{20E9}", "\u{20F0}", "\u{2CEF}", "\u{2CF0}", "\u{2CF1}", "\u{2DE0}", "\u{2DE1}", "\u{2DE2}", "\u{2DE3}", "\u{2DE4}", "\u{2DE5}", "\u{2DE6}", "\u{2DE7}", "\u{2DE8}", "\u{2DE9}", "\u{2DEA}", "\u{2DEB}", "\u{2DEC}", "\u{2DED}", "\u{2DEE}", "\u{2DEF}", "\u{2DF0}", "\u{2DF1}", "\u{2DF2}", "\u{2DF3}", "\u{2DF4}", "\u{2DF5}", "\u{2DF6}", "\u{2DF7}", "\u{2DF8}", "\u{2DF9}", "\u{2DFA}", "\u{2DFB}", "\u{2DFC}", "\u{2DFD}", "\u{2DFE}", "\u{2DFF}", "\u{A66F}", "\u{A67C}", "\u{A67D}", "\u{A6F0}", "\u{A6F1}", "\u{A8E0}", "\u{A8E1}", "\u{A8E2}", "\u{A8E3}", "\u{A8E4}", "\u{A8E5}", "\u{A8E6}", "\u{A8E7}", "\u{A8E8}", "\u{A8E9}", "\u{A8EA}", "\u{A8EB}", "\u{A8EC}", "\u{A8ED}", "\u{A8EE}", "\u{A8EF}", "\u{A8F0}", "\u{A8F1}", "\u{AAB0}", "\u{AAB2}", "\u{AAB3}", "\u{AAB7}", "\u{AAB8}", "\u{AABE}", "\u{AABF}", "\u{AAC1}", "\u{FE20}", "\u{FE21}", "\u{FE22}", "\u{FE23}", "\u{FE24}", "\u{FE25}", "\u{FE26}", "\u{10A0F}", "\u{10A38}", "\u{1D185}", "\u{1D186}", "\u{1D187}", "\u{1D188}", "\u{1D189}", "\u{1D1AA}", "\u{1D1AB}", "\u{1D1AC}", "\u{1D1AD}", "\u{1D242}", "\u{1D243}", "\u{1D244}" ]; fn div_ceil( a: u32, b: u32, ) -> u32 { a / b + u32::from(0 != a % b) } /// The image renderer, with knowledge of the console cells /// dimensions, and built only on a compatible terminal #[derive(Debug)] pub struct KittyImageRenderer { cell_width: u32, cell_height: u32, next_id: usize, options: KittyImageRendererOptions, /// paths of temp files which have been written, with key /// being the input image path temp_files: LruCache, } enum KittyImageData { Png { path: PathBuf }, Image { data: ImageData }, } /// An image prepared for a precise area on screen struct KittyImage { id: usize, data: KittyImageData, img_width: u32, img_height: u32, area: Area, display: KittyGraphicsDisplay, is_tmux: bool, tmux_nest_count: u32, } impl KittyImage { fn new( src: &DynamicImage, png_path: Option, available_area: &Area, renderer: &mut KittyImageRenderer, ) -> Self { let (img_width, img_height) = src.dimensions(); let area = renderer.rendering_area(img_width, img_height, available_area); let data = if let Some(path) = png_path { KittyImageData::Png { path } } else { KittyImageData::Image { data: src.into() } }; let id = renderer.new_id(); let display = renderer.options.display; let is_tmux = renderer.options.is_tmux; let tmux_nest_count = if is_tmux { get_tmux_nest_count() } else { 0 }; Self { id, data, img_width, img_height, area, display, is_tmux, tmux_nest_count, } } fn print_placeholder_grid( &self, w: &mut W, ) -> Result<(), ProgramError> { let id_str = if self.id < 256 { format!("\u{1b}[38;5;{}m", self.id) } else { format!( "\u{1b}[38;2;{};{};{}m", (self.id >> 16) & 0xff, (self.id >> 8) & 0xff, self.id & 0xff ) }; let id_msb_str = if self.id >= (1 << 24) { DIACRITICS[self.id >> 24] } else { "" }; for y in 0..(self.area.height).min(DIACRITICS.len() as u16) { w.queue(cursor::MoveTo(self.area.left, self.area.top + y))?; write!(w, "{}", &id_str)?; if id_msb_str.is_empty() { write!(w, "{}{}", PLACHOLDER, DIACRITICS[y as usize])?; } else { write!( w, "{}{}{}{}", PLACHOLDER, DIACRITICS[y as usize], DIACRITICS[0], id_msb_str )?; } write!(w, "{}", PLACHOLDER.repeat(self.area.width as usize - 1),)?; write!(w, "\u{1b}[39m")?; } Ok(()) } fn compress(data: &[u8]) -> Result, ProgramError> { let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); encoder.write_all(data).expect("Zlib encoder error"); Ok(encoder.finish().expect("Zlib encoder error")) } /// Render the image by sending multiple kitty escape sequences, each /// one with part of the image raw data (encoded as base64) fn print_with_chunks( &self, w: &mut W, ) -> Result<(), ProgramError> { let esc = get_esc_seq(self.tmux_nest_count); let tmux_header = self .is_tmux .then_some(get_tmux_header(self.tmux_nest_count)); let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count)); let display_tag = match self.display { KittyGraphicsDisplay::Unicode => "q=2,U=1,", _ => "", }; let mut png_buf = Vec::new(); let (bytes, compression_tag, format) = match &self.data { KittyImageData::Png { path } => { // Compressing PNG files increases the size File::open(path)?.read_to_end(&mut png_buf)?; (png_buf, "", "100") } KittyImageData::Image { data } => ( KittyImage::compress(&data.bytes())?, "o=z,", data.kitty_format(), ), }; let encoded = BASE64.encode(bytes); let mut pos = 0; if self.display == KittyGraphicsDisplay::Direct { w.queue(cursor::MoveTo(self.area.left, self.area.top))?; } if let Some(s) = &tmux_header { write!(w, "{s}")?; } write!( w, "{}_Gq=2,a=t,f={},t=d,i={},s={},v={},{}", &esc, format, self.id, self.img_width, self.img_height, compression_tag, )?; loop { if pos != 0 { if let Some(s) = &tmux_header { write!(w, "{s}")?; } write!(w, "{}_Gq=2,", &esc)?; } if pos + CHUNK_SIZE < encoded.len() { write!(w, "m=1;{}{}\\", &encoded[pos..pos + CHUNK_SIZE], &esc)?; pos += CHUNK_SIZE; if let Some(s) = &tmux_tail { write!(w, "{s}")?; } } else { // last chunk write!(w, "m=0;{}{}\\", &encoded[pos..encoded.len()], &esc)?; if let Some(s) = &tmux_tail { write!(w, "{s}")?; } // display image if let Some(s) = &tmux_header { write!(w, "{s}")?; } write!( w, "{}_G{}a=p,i={},c={},r={}{}\\", &esc, display_tag, self.id, self.area.width, self.area.height, &esc, )?; if let Some(s) = &tmux_tail { write!(w, "{s}")?; } if self.display == KittyGraphicsDisplay::Unicode { self.print_placeholder_grid(w)?; } break; } } Ok(()) } /// Render the image by giving to kitty the path to a file in the /// payload of a unique kitty escape sequence fn print_with_path( &self, w: &mut W, path: &Path, format: &str, transmission: &str, ) -> Result<(), ProgramError> { let esc = get_esc_seq(self.tmux_nest_count); let tmux_header = self .is_tmux .then_some(get_tmux_header(self.tmux_nest_count)); let tmux_tail = self.is_tmux.then_some(get_tmux_tail(self.tmux_nest_count)); if self.display == KittyGraphicsDisplay::Direct { w.queue(cursor::MoveTo(self.area.left, self.area.top))?; } let display_tag = match self.display { KittyGraphicsDisplay::Unicode => "q=2,U=1,", _ => "", }; let path = path .to_str() .ok_or_else(|| io::Error::other("Path can't be converted to UTF8"))?; let encoded_path = BASE64.encode(path); if let KittyImageData::Image { data: _ } = self.data { debug!("temp file written: {path:?}"); } if let Some(s) = &tmux_header { write!(w, "{s}")?; } write!( w, "{}_G{}a=T,f={},t={},i={},s={},v={},c={},r={};{}{}\\", &esc, display_tag, format, transmission, self.id, self.img_width, self.img_height, self.area.width, self.area.height, encoded_path, &esc, )?; if let Some(s) = &tmux_tail { write!(w, "{s}")?; } if self.display == KittyGraphicsDisplay::Unicode { self.print_placeholder_grid(w)?; } Ok(()) } /// Render the image by giving to kitty the path to a PNG file in /// the payload of a unique kitty escape sequence pub fn print_with_png( &self, w: &mut W, ) -> Result<(), ProgramError> { // Compression slows things down if let KittyImageData::Png { path } = &self.data { self.print_with_path(w, path.as_path(), "100", "f")?; } Ok(()) } /// Render the image by writing the raw data in a temporary file /// then giving to kitty the path to this file in the payload of /// a unique kitty escape sequence pub fn print_with_temp_file( &self, w: &mut W, temp_file: Option, // if None, no need to write it temp_file_path: &Path, ) -> Result<(), ProgramError> { // Compression slows things down if let KittyImageData::Image { data } = &self.data { if let Some(mut temp_file) = temp_file { temp_file.write_all(&data.bytes())?; temp_file.flush()?; debug!("file len: {}", temp_file.metadata().unwrap().len()); } self.print_with_path(w, temp_file_path, data.kitty_format(), "t")?; } Ok(()) } } impl KittyImageRenderer { /// Called only once (at most) by the `KittyManager` pub fn new(mut options: KittyImageRendererOptions) -> Option { if options.display == KittyGraphicsDisplay::Detect { options.display = detect_kitty_graphics_protocol_display(); } if options.display == KittyGraphicsDisplay::None { return None; } let hasher = FxBuildHasher; let temp_files = LruCache::with_hasher(options.kept_temp_files, hasher); let options = if is_ssh() { KittyImageRendererOptions { transmission_medium: TransmissionMedium::Chunks, ..options } } else { options }; cell_size_in_pixels() .ok() .map(|(cell_width, cell_height)| Self { cell_width, cell_height, next_id: 1, options, temp_files, }) } pub fn delete_temp_files(&mut self) { for (_, temp_file_path) in &self.temp_files { debug!("removing temp file: {temp_file_path:?}"); if let Err(e) = std::fs::remove_file(temp_file_path) { error!("failed to remove temp file: {e:?}"); } } } /// return a new image id fn new_id(&mut self) -> usize { let new_id = self.next_id; self.next_id += 1; new_id } fn is_path_png(path: &Path) -> bool { match path.extension() { Some(ext) => ext == "png" || ext == "PNG", None => false, } } /// Clean the area, then print the dynamicImage and /// return the `KittyImageId` for later removal from screen pub fn print( &mut self, w: &mut W, src: &DynamicImage, src_path: &Path, area: &Area, bg: Color, ) -> Result { // clean the background below (and around) the image for y in area.top..area.top + area.height { w.queue(cursor::MoveTo(area.left, y))?; fill_bg(w, area.width as usize, bg)?; } let png_path = KittyImageRenderer::is_path_png(src_path).then_some(src_path.to_path_buf()); let is_png = png_path.is_some(); let img = KittyImage::new(src, png_path, area, self); debug!( "transmission medium: {:?}", self.options.transmission_medium ); w.flush()?; match self.options.transmission_medium { TransmissionMedium::TempFile if is_png => { img.print_with_png(w)?; } TransmissionMedium::TempFile => { let temp_file_key = format!("{:?}-{}x{}", src_path, img.img_width, img.img_height,); let mut old_path = None; if let Some(cached_path) = self.temp_files.pop(&temp_file_key) { if cached_path.exists() { old_path = Some(cached_path); } } let temp_file_path = if let Some(temp_file_path) = old_path { // the temp file is still there img.print_with_temp_file(w, None, &temp_file_path)?; temp_file_path } else { // either the temp file itself has been removed (unlikely), the temp // cache entry has been removed, or we just never viewed this image // with this size before let (temp_file, path) = tempfile::Builder::new() .prefix("broot-tty-graphics-protocol-") .tempfile()? .keep() .map_err(|_| io::Error::other("temp file can't be kept"))?; img.print_with_temp_file(w, Some(temp_file), &path)?; path }; if let Some((_, old_path)) = self.temp_files.push(temp_file_key, temp_file_path) { debug!("removing temp file: {:?}", &old_path); if let Err(e) = std::fs::remove_file(&old_path) { error!("failed to remove temp file: {e:?}"); } } } TransmissionMedium::Chunks => img.print_with_chunks(w)?, } Ok(img.id) } fn rendering_area( &self, img_width: u32, img_height: u32, area: &Area, ) -> Area { let area_cols: u32 = area.width.into(); let area_rows: u32 = area.height.into(); let rdim = self.rendering_dim(img_width, img_height, area_cols, area_rows); Area::new( area.left + ((area_cols - rdim.0) / 2) as u16, area.top + ((area_rows - rdim.1) / 2) as u16, rdim.0 as u16, rdim.1 as u16, ) } fn rendering_dim( &self, img_width: u32, img_height: u32, area_cols: u32, area_rows: u32, ) -> (u32, u32) { let optimal_cols = div_ceil(img_width, self.cell_width); let optimal_rows = div_ceil(img_height, self.cell_height); debug!("area: {:?}", (area_cols, area_rows)); debug!("optimal: {:?}", (optimal_cols, optimal_rows)); if optimal_cols <= area_cols && optimal_rows <= area_rows { // no constraint (TODO center?) (optimal_cols, optimal_rows) } else if optimal_cols * area_rows > optimal_rows * area_cols { // we're constrained in width debug!("constrained in width"); (area_cols, optimal_rows * area_cols / optimal_cols) } else { // we're constrained in height debug!("constrained in height"); (optimal_cols * area_rows / optimal_rows, area_rows) } } } ================================================ FILE: src/kitty/mod.rs ================================================ mod detect_support; mod image_renderer; mod terminal_esc; pub use image_renderer::*; use crate::display::cell_size_in_pixels; use { crate::{ app::AppContext, display::W, errors::ProgramError, image::SourceImage, kitty::detect_support::is_tmux, }, crokey::crossterm::style::Color, once_cell::sync::Lazy, std::{ io::Write, path::Path, sync::Mutex, }, termimad::Area, }; pub type KittyImageId = usize; static MANAGER: Lazy> = Lazy::new(|| { let manager = KittyManager { rendered_images: Vec::new(), renderer: MaybeRenderer::Untested, }; Mutex::new(manager) }); pub fn manager() -> &'static Mutex { &MANAGER } #[derive(Debug)] pub struct KittyManager { rendered_images: Vec, renderer: MaybeRenderer, } #[derive(Debug)] struct RenderedImage { image_id: KittyImageId, drawing_count: usize, } #[derive(Debug)] enum MaybeRenderer { Untested, Disabled, Enabled { renderer: KittyImageRenderer }, } impl KittyManager { /// return the renderer if it's already checked and enabled, none if /// it's disabled or if it hasn't been tested yet pub fn renderer_if_tested(&mut self) -> Option<&mut KittyImageRenderer> { match &mut self.renderer { MaybeRenderer::Enabled { renderer } => Some(renderer), _ => None, } } pub fn delete_temp_files(&mut self) { if let MaybeRenderer::Enabled { renderer } = &mut self.renderer { renderer.delete_temp_files(); } } pub fn renderer( &mut self, con: &AppContext, ) -> Option<&mut KittyImageRenderer> { if matches!(self.renderer, MaybeRenderer::Disabled) { return None; } if matches!(self.renderer, MaybeRenderer::Enabled { .. }) { return self.renderer_if_tested(); } let options = KittyImageRendererOptions { display: con.kitty_graphics_display, transmission_medium: con.kitty_graphics_transmission, kept_temp_files: con.kept_kitty_temp_files, is_tmux: is_tmux(), }; match KittyImageRenderer::new(options) { Some(renderer) => { self.renderer = MaybeRenderer::Enabled { renderer }; self.renderer_if_tested() } None => { self.renderer = MaybeRenderer::Disabled; None } } } pub fn keep( &mut self, kept_id: KittyImageId, drawing_count: usize, ) { for image in &mut self.rendered_images { if image.image_id == kept_id { image.drawing_count = drawing_count; } } } #[allow(clippy::too_many_arguments)] // yes, I know pub fn try_print_image( &mut self, w: &mut W, src: &SourceImage, src_path: &Path, // used to build a cache key area: &Area, bg: Color, drawing_count: usize, con: &AppContext, ) -> Result, ProgramError> { if let Some(renderer) = self.renderer(con) { let (cell_width, cell_height) = cell_size_in_pixels()?; let area_width = area.width as u32 * cell_width; let area_height = area.height as u32 * cell_height; let img = src.fitting(area_width, area_height, None)?; let new_id = renderer.print(w, &img, src_path, area, bg)?; self.rendered_images.push(RenderedImage { image_id: new_id, drawing_count, }); Ok(Some(new_id)) } else { Ok(None) } } pub fn erase_images_before( &mut self, w: &mut W, drawing_count: usize, ) -> Result<(), ProgramError> { let mut kept_images = Vec::new(); let is_tmux = detect_support::is_tmux(); let tmux_nest_count = if is_tmux { detect_support::get_tmux_nest_count() } else { 0 }; let tmux_header = is_tmux.then_some(terminal_esc::get_tmux_header(tmux_nest_count)); let tmux_tail = is_tmux.then_some(terminal_esc::get_tmux_tail(tmux_nest_count)); let esc = terminal_esc::get_esc_seq(tmux_nest_count); for image in self.rendered_images.drain(..) { if image.drawing_count >= drawing_count { kept_images.push(image); } else { let id = image.image_id; debug!("erase kitty image {id}"); if let Some(s) = &tmux_header { write!(w, "{s}")?; } write!(w, "{}_Ga=d,d=I,i={}{}\\", &esc, id, &esc)?; if let Some(s) = &tmux_tail { write!(w, "{s}")?; } } } self.rendered_images = kept_images; Ok(()) } } ================================================ FILE: src/kitty/terminal_esc.rs ================================================ pub fn get_esc_seq(tmux_nest_count: u32) -> String { "\u{1b}".repeat(2usize.pow(tmux_nest_count)) } pub fn get_tmux_header(tmux_nest_count: u32) -> String { let mut header: String = String::new(); for i in 0..tmux_nest_count { header.push_str(&"\u{1b}".repeat(2usize.pow(i))); header.push_str("Ptmux;"); } header } pub fn get_tmux_tail(tmux_nest_count: u32) -> String { let mut tail: String = String::new(); for i in (0..tmux_nest_count).rev() { tail.push_str(&"\u{1b}".repeat(2usize.pow(i))); tail.push('\\'); } tail } ================================================ FILE: src/launchable.rs ================================================ use { crate::{ app::AppContext, display::{ DisplayableTree, Screen, W, }, errors::ProgramError, skin::{ ExtColorMap, StyleMap, }, tree::Tree, }, crokey::crossterm::{ QueueableCommand, cursor, event::{ DisableMouseCapture, EnableMouseCapture, }, terminal::{ self, EnterAlternateScreen, LeaveAlternateScreen, }, }, opener, std::{ env, io::{ self, Write, }, path::PathBuf, path::Path, process::Command, }, which::which, }; /// description of a possible launch of an external program /// A launchable can only be executed on end of life of broot. #[derive(Debug)] pub enum Launchable { /// just print something on stdout on end of broot Printer { to_print: String }, /// print the tree on end of broot TreePrinter { tree: Box, skin: Box, ext_colors: ExtColorMap, width: u16, height: u16, }, /// execute an external program Program { exe: String, args: Vec, working_dir: Option, switch_terminal: bool, capture_mouse: bool, keyboard_enhanced: bool, }, /// open a path SystemOpen { path: PathBuf }, } /// If a part starts with a '$', replace it by the environment variable of the same name. /// This part is split too (because of ) fn resolve_env_variables(parts: Vec) -> Vec { let mut resolved = Vec::new(); for part in parts { if let Some(var_name) = part.strip_prefix('$') { if let Ok(val) = env::var(var_name) { resolved.extend(val.split(' ').map(ToString::to_string)); continue; } if var_name == "EDITOR" { debug!("Env var $EDITOR not set, looking at editor command for fallback"); if let Ok(editor) = which("editor") { if let Some(editor) = editor.to_str() { debug!("Using editor solved as {editor:?}"); resolved.push(editor.to_string()); continue; } } } } resolved.push(part); } resolved } impl Launchable { pub fn opener(path: PathBuf) -> Launchable { Launchable::SystemOpen { path } } pub fn printer(to_print: String) -> Launchable { Launchable::Printer { to_print } } pub fn tree_printer( tree: &Tree, screen: Screen, style_map: StyleMap, ext_colors: ExtColorMap, ) -> Launchable { Launchable::TreePrinter { tree: Box::new(tree.clone()), skin: Box::new(style_map), ext_colors, width: screen.width, height: (tree.lines.len() as u16).min(screen.height - 1), } } pub fn program( parts: Vec, working_dir: Option, switch_terminal: bool, con: &AppContext, ) -> io::Result { let mut parts = resolve_env_variables(parts).into_iter(); match parts.next() { Some(exe) => Ok(Launchable::Program { exe, args: parts.collect(), working_dir, switch_terminal, capture_mouse: con.capture_mouse, keyboard_enhanced: con.keyboard_enhanced, }), None => Err(io::Error::other("Empty launch string")), } } pub fn execute( &self, mut w: Option<&mut W>, ) -> Result<(), ProgramError> { match self { Launchable::Printer { to_print } => { println!("{to_print}"); Ok(()) } Launchable::TreePrinter { tree, skin, ext_colors, width, height, } => { let dp = DisplayableTree::out_of_app(tree, skin, ext_colors, *width, *height); dp.write_on(&mut std::io::stdout()) } Launchable::Program { working_dir, switch_terminal, exe, args, capture_mouse, keyboard_enhanced, } => { debug!("working_dir: {working_dir:?}"); debug!("switch_terminal: {switch_terminal:?}"); if *switch_terminal { // we restore the normal terminal in case the executable // is a terminal application, and we'll switch back to // broot's alternate terminal when we're back to broot if let Some(ref mut w) = &mut w { if *keyboard_enhanced { crokey::pop_keyboard_enhancement_flags()?; } w.queue(cursor::Show)?; w.queue(LeaveAlternateScreen)?; if *capture_mouse { w.queue(DisableMouseCapture)?; } terminal::disable_raw_mode()?; w.flush()?; } } let mut old_working_dir = None; if let Some(working_dir) = working_dir { old_working_dir = std::env::current_dir().ok(); if !try_set_current_dir(working_dir) { warn!("Unable to set working dir to {working_dir:?}"); old_working_dir = None; } } let exec_res = Command::new(exe) .args(args.iter()) .spawn() .and_then(|mut p| p.wait()) .map_err(|source| ProgramError::LaunchError { program: exe.clone(), source, }); if *switch_terminal { if let Some(ref mut w) = &mut w { terminal::enable_raw_mode()?; if *capture_mouse { w.queue(EnableMouseCapture)?; } w.queue(EnterAlternateScreen)?; w.queue(cursor::Hide)?; w.flush()?; if *keyboard_enhanced { crokey::push_keyboard_enhancement_flags()?; } } } if let Some(old_working_dir) = old_working_dir { if !try_set_current_dir(&old_working_dir) { warn!("Unable to restore working dir to {old_working_dir:?}"); } } exec_res?; // we trigger the error display after restoration Ok(()) } Launchable::SystemOpen { path } => { opener::open(path)?; Ok(()) } } } } /// Try set the current dir to the given path, and if it fails, try to climb the path until an /// existing folder is found. Return true if the current dir has been changed, false otherwise. pub fn try_set_current_dir(mut dir: &Path) -> bool { loop { if std::env::set_current_dir(dir).is_ok() { debug!("Working dir set to {dir:?}"); return true; } let Some(parent_dir) = dir.parent() else { return false; }; dir = parent_dir; } } ================================================ FILE: src/lib.rs ================================================ #[macro_use] extern crate cli_log; pub mod app; pub mod browser; pub mod cli; pub mod command; pub mod conf; pub mod content_search; pub mod content_type; pub mod display; pub mod errors; pub mod file_sum; pub mod flag; pub mod git; pub mod help; pub mod hex; pub mod icon; pub mod image; pub mod keys; pub mod kitty; pub mod launchable; pub mod path; pub mod pattern; pub mod permissions; pub mod preview; pub mod print; pub mod shell_install; pub mod skin; pub mod stage; pub mod syntactic; pub mod task_sync; pub mod terminal; pub mod tree; pub mod tree_build; pub mod tty; pub mod verb; pub mod watcher; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub mod filesystems; #[cfg(unix)] pub mod net; #[cfg(any( target_os = "windows", all( unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android") ) ))] pub mod trash; ================================================ FILE: src/main.rs ================================================ use cli_log::*; fn main() { init_cli_log!(); debug!("env::args(): {:#?}", std::env::args().collect::>()); match broot::cli::run() { Ok(Some(launchable)) => { info!("launching {:#?}", launchable); if let Err(e) = launchable.execute(None) { warn!("Failed to launch {:?}", &launchable); warn!("Error: {:?}", e); eprintln!("{e}"); std::process::exit(1); } } Ok(None) => {} Err(e) => { // this usually happens when the passed path isn't of a directory warn!("Error: {}", e); eprintln!("{e}"); std::process::exit(1); } }; log_mem(Level::Info); info!("bye"); } ================================================ FILE: src/net/client.rs ================================================ use { super::Message, crate::errors::NetError, std::{ io::BufReader, os::unix::net::UnixStream, }, }; pub struct Client { path: String, } impl Client { pub fn new(socket_name: &str) -> Self { Self { path: super::socket_file_path(socket_name), } } pub fn send( &self, message: &Message, ) -> Result<(), NetError> { debug!("try connecting {:?}", &self.path); let mut stream = UnixStream::connect(&self.path)?; message.write(&mut stream)?; if let Message::GetRoot = message { // we wait for the answer let mut br = BufReader::new(&stream); match Message::read(&mut br) { Ok(answer) => { debug!("got an answer: {:?}", &answer); if let Message::Root(root) = answer { println!("{root}"); } } Err(e) => { warn!("got no answer but error {:?}", e); } } } Ok(()) } } ================================================ FILE: src/net/message.rs ================================================ use { crate::{ command::Sequence, errors::NetError, }, std::io::{ self, BufRead, Write, }, }; /// A message which may be sent by a client or server to the other part #[derive(Debug)] pub enum Message { Command(String), Hi, GetRoot, Root(String), Sequence(Sequence), } fn read_line(r: &mut BR) -> Result { let mut line = String::new(); r.read_line(&mut line)?; debug!("read line => {:?}", &line); while line.ends_with('\n') || line.ends_with('\r') { line.pop(); } Ok(line) } impl Message { pub fn read(r: &mut BR) -> Result { // the first line gives the type of message match read_line(r)?.as_ref() { "CMD" => Ok(Self::Command(read_line(r)?)), "GET_ROOT" => Ok(Self::GetRoot), "ROOT" => Ok(Self::Root(read_line(r)?)), "SEQ" => Ok(Self::Sequence(Sequence::new( read_line(r)?, Some(read_line(r)?), ))), _ => Err(NetError::InvalidMessage), } } pub fn write( &self, w: &mut W, ) -> io::Result<()> { match self { Self::Command(c) => { writeln!(w, "CMD")?; writeln!(w, "{c}") } Self::GetRoot => { writeln!(w, "GET_ROOT") } Self::Hi => { writeln!(w, "HI") } Self::Root(path) => { writeln!(w, "ROOT")?; writeln!(w, "{path}") } Self::Sequence(Sequence { separator, raw }) => { writeln!(w, "SEQ")?; writeln!(w, "{raw}")?; writeln!(w, "{separator}") } } } } ================================================ FILE: src/net/mod.rs ================================================ mod client; mod message; mod server; pub use { client::Client, message::Message, server::Server, }; pub fn socket_file_path(server_name: &str) -> String { #[cfg(target_os = "android")] { // On termux, /tmp is not writable and we're supposed // to use /data/data/com.termux/files/usr/tmp let usr_dir = "/data/data/com.termux/files/usr"; if std::path::Path::new(usr_dir).is_dir() { return format!("{}/tmp/broot-server-{}.sock", usr_dir, server_name); } // maybe we're not in termux ? Fallback to /tmp } format!("/tmp/broot-server-{server_name}.sock") } pub fn random_server_name() -> String { use rand::{distributions::Alphanumeric, Rng}; let name: String = rand::thread_rng() .sample_iter(&Alphanumeric) .take(10) .map(char::from) .collect(); name } ================================================ FILE: src/net/server.rs ================================================ use { super::Message, crate::{ command::Sequence, errors::NetError, }, std::{ fs, io::BufReader, os::unix::net::UnixListener, path::PathBuf, sync::{ Arc, Mutex, }, thread, }, termimad::crossbeam::channel::Sender, }; pub struct Server { path: String, } impl Server { pub fn new( name: &str, tx: Sender, root: Arc>, ) -> Result { let path = super::socket_file_path(name); if fs::metadata(&path).is_ok() { match fs::remove_file(&path) { Ok(_) => {} Err(e) => return Err(NetError::Io { source: e }), } } let listener = UnixListener::bind(&path)?; info!("listening on {}", &path); // we use only one thread as we don't want to support long connections thread::spawn(move || { for stream in listener.incoming() { match stream { Ok(mut stream) => { let mut br = BufReader::new(&stream); if let Some(sequence) = match Message::read(&mut br) { Ok(Message::Command(command)) => { info!("got single command {:?}", &command); // we convert it to a sequence Some(Sequence::new_single(command)) } Ok(Message::GetRoot) => { debug!("got get root query"); let root = root.lock().unwrap(); let answer = Message::Root(root.to_string_lossy().to_string()); match answer.write(&mut stream) { Ok(()) => debug!("root path successfully returned"), Err(e) => warn!("error while answering: {:?}", e), } None } Ok(Message::Sequence(sequence)) => { debug!("got sequence {:?}", &sequence); Some(sequence) } Ok(message) => { debug!("got something not yet handled: {:?}", message); None } Err(e) => { warn!("Read error : {:?}", e); None } } { if let Err(e) = tx.send(sequence) { warn!("error while sending {:?}", e); return; } } } Err(e) => { warn!("Stream error : {:?}", e); } } } }); Ok(Self { path }) } } impl Drop for Server { fn drop(&mut self) { debug!("removing socket file"); fs::remove_file(&self.path).unwrap(); } } ================================================ FILE: src/path/anchor.rs ================================================ #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum PathAnchor { #[default] Unspecified, Parent, Directory, } ================================================ FILE: src/path/closest.rs ================================================ use std::path::{ Path, PathBuf, }; /// return the closest enclosing directory pub fn closest_dir(mut path: &Path) -> PathBuf { loop { if path.exists() && path.is_dir() { return path.to_path_buf(); } match path.parent() { Some(parent) => path = parent, None => { debug!("no existing parent"); // unexpected return path.to_path_buf(); } } } } ================================================ FILE: src/path/common.rs ================================================ use std::path::{ Components, PathBuf, }; pub fn longest_common_ancestor(paths: &[PathBuf]) -> PathBuf { match paths.len() { 0 => PathBuf::new(), // empty 1 => paths[0].clone(), _ => { let cs0 = paths[0].components(); let mut csi: Vec = paths.iter().skip(1).map(|p| p.components()).collect(); let mut lca = PathBuf::new(); for component in cs0 { for cs in &mut csi { if cs.next() != Some(component) { return lca; } } lca.push(component); } lca } } } ================================================ FILE: src/path/from.rs ================================================ use { super::*, directories::UserDirs, lazy_regex::*, std::path::{ Path, PathBuf, }, }; pub static TILDE_REGEX: Lazy = lazy_regex!(r"^~(/|$)"); /// If the input has a tilde as first complete element, replace it /// with the user's home directory. Return the input as a path without /// transformation in other cases pub fn untilde(input: &str) -> PathBuf { PathBuf::from(&*TILDE_REGEX.replace(input, |c: &Captures| { if let Some(user_dirs) = UserDirs::new() { format!("{}{}", user_dirs.home_dir().to_string_lossy(), &c[1],) } else { warn!("no user dirs found, no expansion of ~"); c[0].to_string() } })) } /// Build a usable path from a user input which may be absolute /// (if it starts with / or ~) or relative to the supplied `base_dir`. /// (we might want to try detect windows drives in the future, too) pub fn path_from + std::fmt::Debug>( base_dir: P, anchor: PathAnchor, input: &str, ) -> PathBuf { if input.starts_with('/') { // if the input starts with a `/`, we use it as is input.into() } else if TILDE_REGEX.is_match(input) { // if the input starts with `~` as first token, we replace // this `~` with the user home directory untilde(input) } else { // we put the input behind the source (the selected directory // or its parent) and we normalize so that the user can type // paths with `../` let base_dir = match anchor { PathAnchor::Parent => base_dir .as_ref() .parent() .unwrap_or_else(|| base_dir.as_ref()) .to_path_buf(), _ => closest_dir(base_dir.as_ref()), }; normalize_path(base_dir.join(input)) } } pub fn path_str_from + std::fmt::Debug>( base_dir: P, input: &str, ) -> String { path_from(base_dir, PathAnchor::Unspecified, input) .to_string_lossy() .to_string() } ================================================ FILE: src/path/mod.rs ================================================ mod anchor; mod closest; mod common; mod from; mod normalize; mod special_path; pub use { anchor::*, closest::*, common::*, from::*, normalize::*, special_path::*, }; use std::{ ffi::OsStr, path::Path, }; pub fn path_has_ext>( path: P, ext: &str, ) -> bool { path.as_ref() .extension() .and_then(OsStr::to_str) .is_some_and(|e| e.eq_ignore_ascii_case(ext)) } ================================================ FILE: src/path/normalize.rs ================================================ use std::path::{ Component, Path, PathBuf, }; /// Improve the path to try remove and solve .. token. /// /// This assumes that `a/b/../c` is `a/c` which might be different from /// what the OS would have chosen when b is a link. This is OK /// for broot verb arguments but can't be generally used elsewhere /// (a more general solution would probably query the FS and just /// resolve b in case of links). /// /// This function ensures a given path ending with '/' still /// ends with '/' after normalization. pub fn normalize_path>(path: P) -> PathBuf { let ends_with_slash = path.as_ref().to_str().is_some_and(|s| s.ends_with('/')); let mut normalized = PathBuf::new(); for component in path.as_ref().components() { match &component { Component::ParentDir => { if !normalized.pop() { normalized.push(component); } } _ => { normalized.push(component); } } } if ends_with_slash { normalized.push(""); } normalized } #[cfg(test)] mod path_normalize_tests { use super::normalize_path; fn check( before: &str, after: &str, ) { println!("-----------------\nnormalizing {before:?}"); // As seen by Stargateur, the test here doesn't work on Windows // // There are two problems, at least: // // * strings used for test use the '/' separator. This is a test problem // * we do a "end with '/'" test in the tested function. This might // lead to suboptimal interaction on windows assert_eq!(normalize_path(before).to_string_lossy(), after); } #[test] fn test_path_normalization() { check("/abc/test/../thing.png", "/abc/thing.png"); check("/abc/def/../../thing.png", "/thing.png"); check("/home/dys/test", "/home/dys/test"); check("/home/dys", "/home/dys"); check("/home/dys/", "/home/dys/"); check("/home/dys/..", "/home"); check("/home/dys/../", "/home/"); check("/..", "/.."); check("../test", "../test"); check("/home/dys/../../../test", "/../test"); check("π/2", "π/2"); check( "/home/dys/dev/broot/../../../canop/test", "/home/canop/test", ); } } ================================================ FILE: src/path/special_path.rs ================================================ use { glob, serde::Deserialize, std::path::Path, }; ///// Wrap a glob pattern to add the Deserialize trait //#[derive(Debug, Clone, PartialEq, Hash, Eq)] //pub struct Glob { // pattern: glob::Pattern, //} #[derive(Clone, Copy, Debug, Deserialize, Default, PartialEq)] pub struct SpecialHandling { #[serde(default)] pub show: Directive, #[serde(default)] pub list: Directive, #[serde(default)] pub sum: Directive, } #[derive(Clone, Debug, Copy, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum Directive { #[default] Default, Never, Always, } #[derive(Debug, Clone)] pub struct SpecialPath { pub pattern: glob::Pattern, pub handling: SpecialHandling, } #[derive(Debug, Clone)] pub struct SpecialPaths { pub entries: Vec, } impl SpecialPaths { pub fn find>( &self, path: P, ) -> SpecialHandling { self.entries .iter() .find(|sp| sp.pattern.matches_path(path.as_ref())) .map(|sp| sp.handling) .unwrap_or_default() } pub fn show( &self, path: &Path, ) -> Directive { self.find(path).show } pub fn list( &self, path: &Path, ) -> Directive { self.find(path).list } pub fn sum( &self, path: &Path, ) -> Directive { self.find(path).sum } /// Add a special handling, if none was previously defined for that path pub fn add_default( &mut self, path: &str, handling: SpecialHandling, ) { if self.find(path) != Default::default() { return; } match glob::Pattern::new("/proc") { Ok(pattern) => { self.entries.push(SpecialPath { pattern, handling }); } Err(e) => { warn!("Invalid glob pattern: {path:?} : {e}"); } } } pub fn add_defaults(&mut self) { // see https://github.com/Canop/broot/issues/639 self.add_default( "/proc", SpecialHandling { show: Directive::Default, list: Directive::Never, sum: Directive::Never, }, ); } /// Return a potentially smaller set of special paths, reduced /// to what can be in path pub fn reduce( &self, path: &Path, ) -> Self { let entries = self .entries .iter() .filter(|sp| sp.can_have_matches_in(path)) .cloned() .collect(); Self { entries } } } impl SpecialPath { pub fn new( pattern: glob::Pattern, handling: SpecialHandling, ) -> Self { Self { pattern, handling } } pub fn can_have_matches_in( &self, path: &Path, ) -> bool { path.to_str() .map_or(false, |p| self.pattern.as_str().starts_with(p)) } } ================================================ FILE: src/pattern/candidate.rs ================================================ use { std::{ path::Path, }, }; /// something which can be evaluated by a pattern to produce /// either a score or a more precise match #[derive(Debug, Clone, Copy)] pub struct Candidate<'c> { /// path to the file to open if the pattern searches into files pub path: &'c Path, /// path from the current root pub subpath: &'c str, /// filename pub name: &'c str, } ================================================ FILE: src/pattern/composite_pattern.rs ================================================ use { super::*, crate::content_search::ContentMatch, bet::*, smallvec::smallvec, std::path::Path, }; /// A pattern composing other ones with operators #[derive(Debug, Clone)] pub struct CompositePattern { pub expr: BeTree, pub content_search: bool, } impl CompositePattern { pub fn new(expr: BeTree) -> Self { let content_search = expr.iter_atoms().any(|p| p.is_content_search()); Self { expr, content_search, } } pub fn is_content_search(&self) -> bool { self.content_search } pub fn score_of_string( &self, candidate: &str, ) -> Option { use PatternOperator as PO; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.score_of_string(candidate), // operator |op, a, b| { match (op, a, b) { (PO::And, None, _) => None, // normally not called due to short-circuit (PO::And, Some(sa), Some(Some(sb))) => Some(sa + sb), (PO::Or, None, Some(Some(sb))) => Some(sb), (PO::Or, Some(sa), Some(None)) => Some(sa), (PO::Or, Some(sa), Some(Some(sb))) => Some(sa + sb), (PO::Not, Some(_), _) => None, (PO::Not, None, _) => Some(1), _ => None, } }, // short-circuit. We don't short circuit on 'or' because // we want to use both scores |op, a| match (op, a) { (PO::And, None) => true, _ => false, }, ); composite_result.unwrap_or_else(|| { warn!("unexpectedly missing result "); None }) } pub fn score_of( &self, candidate: Candidate, ) -> Option { use PatternOperator as PO; if self.is_content_search() && candidate.path.is_dir() { // we can't score content on a directory return None; } let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.score_of(candidate), // operator |op, a, b| { match (op, a, b) { (PO::And, None, _) => None, // normally not called due to short-circuit (PO::And, Some(sa), Some(Some(sb))) => Some(sa + sb), (PO::Or, None, Some(Some(sb))) => Some(sb), (PO::Or, Some(sa), Some(None)) => Some(sa), (PO::Or, Some(sa), Some(Some(sb))) => Some(sa + sb), (PO::Not, Some(_), _) => None, (PO::Not, None, _) => Some(1), _ => None, } }, // short-circuit. We don't short circuit on 'or' because // we want to use both scores |op, a| match (op, a) { (PO::And, None) => true, _ => false, }, ); composite_result.unwrap_or_else(|| { warn!("unexpectedly missing result "); None }) } pub fn search_string( &self, candidate: &str, ) -> Option { // an ideal algorithm would call score_of on patterns when the object is different // to deal with exclusions but I'll start today with something simpler use PatternOperator::*; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.search_string(candidate), // operator |op, a, b| match (op, a, b) { (And, None, _) => None, // normally not called due to short-circuit (And, Some(sa), Some(Some(_))) => Some(sa), // we have to choose a match (Or, None, Some(Some(sb))) => Some(sb), (Or, Some(sa), Some(None)) => Some(sa), (Or, Some(sa), Some(Some(_))) => Some(sa), // we have to choose (Not, Some(_), _) => None, (Not, None, _) => { // this is quite arbitrary. Matching the whole string might be // costly for some use, so we match only the start Some(NameMatch { score: 1, pos: smallvec![0], }) } _ => None, }, |op, a| match (op, a) { (Or, Some(_)) => true, (And, None) => true, _ => false, }, ); // it's possible we didn't find a result because the composition composite_result.unwrap_or_else(|| { warn!("unexpectedly missing result "); None }) } pub fn search_content( &self, candidate: &Path, desired_len: usize, // available space for content match display ) -> Option { use PatternOperator::*; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.search_content(candidate, desired_len), // operator |op, a, b| match (op, a, b) { (And, None, _) => None, // normally not called due to short-circuit (And, Some(sa), Some(Some(_))) => Some(sa), // we have to choose (Or, None, Some(Some(sb))) => Some(sb), (Or, Some(sa), Some(None)) => Some(sa), (Or, Some(sa), Some(Some(_))) => Some(sa), // we have to choose (Not, Some(_), _) => None, (Not, None, _) => { // We can't generate a content match for a whole file // content, so we build one of length 0. Some(ContentMatch { extract: "".to_string(), needle_start: 0, needle_end: 0, }) } _ => None, }, |op, a| match (op, a) { (Or, Some(_)) => true, (And, None) => true, _ => false, }, ); composite_result.unwrap_or_else(|| { warn!("unexpectedly missing result "); None }) } /// Search for a string, trying to return a match /// /// It's used when a composite returns something but may be matching also on other parts /// that we can't compute, like the content. /// /// This function isn't called on all potential candidates, only on those that we want /// to show so we're allowed to do more costly operations here. pub fn find_string( &self, candidate: &str, ) -> Option { // an ideal algorithm would call score_of on patterns when the object is different // to deal with exclusions but I'll start today with something simpler use PatternOperator::*; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.search_string(candidate), // operator |op, a, b| match (op, a, b) { (Not, Some(_), _) => None, (Or | And, Some(ma), Some(Some(mb))) => { Some(ma.merge_with(mb)) }, (_, Some(ma), _) => Some(ma), (_, None, Some(omb)) => omb, _ => None, }, // short-circuit: we don't short circuit on 'or' because we want to use // both matches. |_op, _a| false, ); // it's possible we didn't find a result because the composition matches // on non name parts composite_result.unwrap_or(None) } /// Search for a string in content, trying to return a match as soon as some /// part of the composite matches pub fn find_content( &self, candidate: &Path, desired_len: usize, // available space for content match display ) -> Option { use PatternOperator::*; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.search_content(candidate, desired_len), // operator |op, a, b| match (op, a, b) { (Not, Some(_), _) => None, (_, Some(ma), _) => Some(ma), (_, None, Some(omb)) => omb, _ => None, }, |op, a| match (op, a) { (Or, Some(_)) => true, _ => false, }, ); // it's possible we didn't find a result because the composition matches // on non content parts composite_result.unwrap_or(None) } pub fn get_match_line_count( &self, candidate: &Path, ) -> Option { use PatternOperator::*; let composite_result: Option> = self.expr.eval( // score evaluation |pat| pat.get_match_line_count(candidate), // operator |op, a, b| match (op, a, b) { (Not, Some(_), _) => None, (_, Some(ma), _) => Some(ma), (_, None, Some(omb)) => omb, _ => None, }, |op, a| match (op, a) { (Or, Some(_)) => true, _ => false, }, ); composite_result.unwrap_or(None) } pub fn has_real_scores(&self) -> bool { self.expr.iter_atoms().fold(false, |r, p| match p { Pattern::NameFuzzy(_) | Pattern::PathFuzzy(_) => true, _ => r, }) } pub fn is_empty(&self) -> bool { let is_not_empty = self.expr.iter_atoms().any(|p| p.is_some()); !is_not_empty } } ================================================ FILE: src/pattern/content_pattern.rs ================================================ use { super::*, crate::content_search::*, std::{ fmt, path::Path, }, }; /// A pattern for searching in file content #[derive(Debug, Clone)] pub struct ContentExactPattern { needle: Needle, } impl fmt::Display for ContentExactPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { f.write_str(self.as_str()) } } impl ContentExactPattern { pub fn new( pat: &str, max_file_size: usize, ) -> Self { Self { needle: Needle::new(pat, max_file_size), } } pub fn as_str(&self) -> &str { self.needle.as_str() } pub fn is_empty(&self) -> bool { self.needle.is_empty() } pub fn to_regex_parts(&self) -> (String, String) { (regex::escape(self.as_str()), "".to_string()) } pub fn score_of( &self, candidate: Candidate, ) -> Option { match self.needle.search(candidate.path) { Ok(ContentSearchResult::Found { .. }) => Some(1), Ok(ContentSearchResult::NotFound) => None, Ok(ContentSearchResult::NotSuitable) => None, Err(e) => { debug!("error while scanning {:?} : {:?}", &candidate.path, e); None } } } /// get the line of the first match, if any pub fn get_match_line_count( &self, path: &Path, ) -> Option { if let Ok(ContentSearchResult::Found { pos }) = self.needle.search(path) { line_count_at_pos(path, pos).ok() } else { None } } pub fn get_content_match( &self, path: &Path, desired_len: usize, ) -> Option { self.needle.get_match(path, desired_len) } } ================================================ FILE: src/pattern/content_regex_pattern.rs ================================================ use { super::*, crate::content_search::*, lazy_regex::regex, std::{ fmt, fs::File, io::{ self, BufRead, BufReader, }, path::Path, }, }; /// A regex for searching in file content #[derive(Debug, Clone)] pub struct ContentRegexPattern { rex: regex::Regex, flags: String, max_file_size: usize, } impl fmt::Display for ContentRegexPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { write!(f, "cr/{}/{}", self.rex, self.flags) } } impl ContentRegexPattern { pub fn new( pat: &str, flags: &str, max_file_size: usize, ) -> Result { Ok(Self { rex: super::build_regex(pat, flags)?, flags: flags.to_string(), max_file_size, }) } pub fn is_empty(&self) -> bool { self.rex.as_str().is_empty() } pub fn to_regex_parts(&self) -> (String, String) { (self.rex.to_string(), self.flags.clone()) } // TODO optimize with regex::bytes ? fn has_match( &self, path: &Path, ) -> io::Result { for line in BufReader::new(File::open(path)?).lines() { if self.rex.is_match(line?.as_str()) { return Ok(true); } } Ok(false) } pub fn score_of( &self, candidate: Candidate, ) -> Option { if candidate.path.is_dir() || !is_path_suitable(candidate.path, self.max_file_size) { return None; } match self.has_match(candidate.path) { Ok(true) => Some(1), Ok(false) => None, Err(e) => { debug!("error while scanning {:?} : {:?}", candidate.path, e); None } } } pub fn try_get_content_match( &self, path: &Path, desired_len: usize, ) -> io::Result> { for line in BufReader::new(File::open(path)?).lines() { let line = line?; if let Some(regex_match) = self.rex.find(line.as_str()) { return Ok(Some(ContentMatch::build( line.as_bytes(), regex_match.start(), regex_match.as_str(), desired_len, ))); } } Ok(None) } /// get the line of the first match, if any pub fn try_get_match_line_count( &self, path: &Path, ) -> io::Result> { let mut line_count = 1; for line in BufReader::new(File::open(path)?).lines() { let line = line?; if self.rex.is_match(line.as_str()) { return Ok(Some(line_count)); } line_count += 1; } Ok(None) } /// get the line of the first match, if any pub fn get_match_line_count( &self, path: &Path, ) -> Option { self.try_get_match_line_count(path).unwrap_or(None) } pub fn get_content_match( &self, path: &Path, desired_len: usize, ) -> Option { self.try_get_content_match(path, desired_len).ok().flatten() } } ================================================ FILE: src/pattern/exact_pattern.rs ================================================ //! a simple exact pattern matcher for filename filtering / sorting. //! It's not meant for file contents but for small strings (less than 1000 chars) //! such as file names. use { super::NameMatch, smallvec::SmallVec, std::{ fmt, fs::File, io::{ self, BufRead, BufReader, }, path::Path, }, }; // weights used in match score computing // (but we always take the leftist match) const BONUS_MATCH: i32 = 50_000; const BONUS_EXACT: i32 = 1_000; const BONUS_START: i32 = 10; const BONUS_START_WORD: i32 = 5; const BONUS_CANDIDATE_LENGTH: i32 = -1; // per byte const BONUS_DISTANCE_FROM_START: i32 = -1; // per byte /// A pattern for exact matching #[derive(Debug, Clone)] pub struct ExactPattern { pattern: String, chars_count: usize, } impl fmt::Display for ExactPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { self.pattern.fmt(f) } } fn is_word_separator(c: u8) -> bool { matches!(c, b'_' | b' ' | b'-' | b'/') } impl ExactPattern { /// build a pattern which will later be usable for fuzzy search. /// A pattern should be reused pub fn from(pattern: &str) -> Self { Self { pattern: pattern.to_string(), chars_count: pattern.chars().count(), } } pub fn is_empty(&self) -> bool { self.chars_count == 0 } fn score( &self, start: usize, candidate: &str, ) -> i32 { // start is the byte index let mut score = BONUS_MATCH + BONUS_CANDIDATE_LENGTH * candidate.len() as i32; if start == 0 { score += BONUS_START; if candidate.len() == self.pattern.len() { score += BONUS_EXACT; } } else { if is_word_separator(candidate.as_bytes()[start - 1]) { score += BONUS_START_WORD; } score += BONUS_DISTANCE_FROM_START * start as i32; } score } /// return a match if the pattern can be found in the candidate string. pub fn find( &self, candidate: &str, ) -> Option { candidate.find(&self.pattern).map(|start| { let score = self.score(start, candidate); // we must find the start in chars, not bytes for (char_idx, (byte_idx, _)) in candidate.char_indices().enumerate() { if byte_idx == start { let mut pos = SmallVec::with_capacity(self.chars_count); for i in 0..self.chars_count { pos.push(i + char_idx); } return NameMatch { score, pos }; } } unreachable!(); // if there was a match, pos should have been reached }) } /// get the line of the first match, if any /// (not used today, we use content_pattern to search in files) pub fn try_get_match_line_count( &self, path: &Path, ) -> io::Result> { let mut line_count = 1; // first line in text editors is 1 for line in BufReader::new(File::open(path)?).lines() { let line = line?; if line.contains(&self.pattern) { return Ok(Some(line_count)); } line_count = 1; } Ok(None) } /// get the line of the first match, if any /// (not used today, we use content_pattern to search in files) pub fn get_match_line_count( &self, path: &Path, ) -> Option { self.try_get_match_line_count(path).unwrap_or(None) } /// compute the score of the best match pub fn score_of( &self, candidate: &str, ) -> Option { candidate .find(&self.pattern) .map(|start| self.score(start, candidate)) } } ================================================ FILE: src/pattern/fuzzy_pattern.rs ================================================ //! a fuzzy pattern matcher for filename filtering / sorting. //! It's not meant for file contents but for small strings (less than 1000 chars) //! such as file names. use { super::NameMatch, secular, smallvec::{ SmallVec, smallvec, }, std::fmt::{ self, Write, }, }; type CandChars = SmallVec<[char; 32]>; // weights used in match score computing const BONUS_MATCH: i32 = 50_000; const BONUS_EXACT: i32 = 1_000; const BONUS_START: i32 = 10; const BONUS_START_WORD: i32 = 5; const BONUS_CANDIDATE_LENGTH: i32 = -1; // per char const BONUS_MATCH_LENGTH: i32 = -10; // per char of length of the match const BONUS_NB_HOLES: i32 = -30; // there's also a max on that number const BONUS_SINGLED_CHAR: i32 = -15; // when there's a char, neither first not last, isolated /// A pattern for fuzzy matching #[derive(Debug, Clone)] pub struct FuzzyPattern { chars: Box<[char]>, // secularized characters max_nb_holes: usize, } impl fmt::Display for FuzzyPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { for &c in &self.chars { f.write_char(c)? } Ok(()) } } enum MatchSearchResult { Perfect(NameMatch), // no need to test other positions Some(NameMatch), None, } fn is_word_separator(c: char) -> bool { matches!(c, '_' | ' ' | '-') } impl FuzzyPattern { /// build a pattern which will later be usable for fuzzy search. /// A pattern should be reused pub fn from(pat: &str) -> Self { let chars = secular::normalized_lower_lay_string(pat) .chars() .collect::>() .into_boxed_slice(); let max_nb_holes = match chars.len() { 1 => 0, 2 => 1, 3 => 2, 4 => 2, 5 => 2, 6 => 3, 7 => 3, 8 => 4, _ => chars.len() * 4 / 7, }; FuzzyPattern { chars, max_nb_holes, } } /// an "empty" pattern is one which accepts everything because /// it has no discriminant pub fn is_empty(&self) -> bool { self.chars.is_empty() } fn tight_match_from_index( &self, cand_chars: &CandChars, start_idx: usize, // start index in candidate, in chars ) -> MatchSearchResult { let mut pos = smallvec![0; self.chars.len()]; // positions of matching chars in candidate let mut cand_idx = start_idx; let mut pat_idx = 0; // index both in self.chars and pos let mut in_hole = false; loop { if cand_chars[cand_idx] == self.chars[pat_idx] { pos[pat_idx] = cand_idx; if in_hole { // We're no more in a hole. // Let's look if we can bring back the chars before the hole let mut rev_idx = 1; loop { if pat_idx < rev_idx { break; } if cand_chars[cand_idx - rev_idx] == self.chars[pat_idx - rev_idx] { // we move the pos forward pos[pat_idx - rev_idx] = cand_idx - rev_idx; } else { break; } rev_idx += 1; } in_hole = false; } pat_idx += 1; if pat_idx == self.chars.len() { break; // match, finished } } else { // there's a hole if cand_chars.len() - cand_idx <= self.chars.len() - pat_idx { return MatchSearchResult::None; } in_hole = true; } cand_idx += 1; } let mut nb_holes = 0; let mut nb_singled_chars = 0; for idx in 1..pos.len() { if pos[idx] > 1 + pos[idx - 1] { nb_holes += 1; if idx > 1 && pos[idx - 1] > 1 + pos[idx - 2] { // we improve a simple case: the one of a singleton which was created // by pushing forward a char if cand_chars[pos[idx - 2] + 1] == cand_chars[pos[idx - 1]] { // in some cases we're really removing another singletons but // let's forget this pos[idx - 1] = pos[idx - 2] + 1; nb_holes -= 1; } else { nb_singled_chars += 1; } } } } if nb_holes > self.max_nb_holes { return MatchSearchResult::None; } let match_len = 1 + cand_idx - pos[0]; let mut score = BONUS_MATCH; score += BONUS_CANDIDATE_LENGTH * (cand_chars.len() as i32); score += BONUS_SINGLED_CHAR * nb_singled_chars; score += BONUS_NB_HOLES * (nb_holes as i32); score += match_len as i32 * BONUS_MATCH_LENGTH; if pos[0] == 0 { score += BONUS_START + BONUS_START_WORD; if cand_chars.len() == self.chars.len() { score += BONUS_EXACT; return MatchSearchResult::Perfect(NameMatch { score, pos }); } } else { let previous = cand_chars[pos[0] - 1]; if is_word_separator(previous) { score += BONUS_START_WORD; if cand_chars.len() - pos[0] == self.chars.len() { return MatchSearchResult::Perfect(NameMatch { score, pos }); } } } MatchSearchResult::Some(NameMatch { score, pos }) } /// return a match if the pattern can be found in the candidate string. /// The algorithm tries to return the best one. For example if you search /// "abc" in "ababca-abc", the returned match would be at the end. pub fn find( &self, candidate: &str, ) -> Option { if candidate.len() < self.chars.len() { return None; } let mut cand_chars: CandChars = SmallVec::with_capacity(candidate.len()); cand_chars.extend(candidate.chars().map(secular::lower_lay_char)); if cand_chars.len() < self.chars.len() { return None; } let mut best_score = 0; let mut best_match: Option = None; let n = cand_chars.len() + 1 - self.chars.len(); for start_idx in 0..n { if cand_chars[start_idx] == self.chars[0] { match self.tight_match_from_index(&cand_chars, start_idx) { MatchSearchResult::Perfect(m) => { return Some(m); } MatchSearchResult::Some(m) => { if m.score > best_score { best_score = m.score; best_match = Some(m); } // we could make start_idx jump to pos[0] here // but it doesn't improve the perfs (it's rare // anyway to have pos[0] much greater than the // start of the search) } _ => {} } } } best_match } /// compute the score of the best match pub fn score_of( &self, candidate: &str, ) -> Option { self.find(candidate).map(|nm| nm.score) } } #[cfg(test)] mod fuzzy_pattern_tests { use { super::*, crate::pattern::Pos, }; /// check position of the match of the pattern in name fn check_pos( pattern: &str, name: &str, pos: &str, ) { let pat = FuzzyPattern::from(pattern); let match_pos = pat.find(name).unwrap().pos; let target_pos: Pos = pos .chars() .enumerate() .filter(|(_, c)| *c == '^') .map(|(i, _)| i) .collect(); assert_eq!(match_pos, target_pos); } /// test the quality of match length and groups number minimization #[test] fn check_match_pos() { check_pos( "ba", "babababaaa", "^^ ", ); check_pos( "baa", "babababaaa", " ^^^ ", ); check_pos( "bbabaa", "babababaaa", " ^ ^^^^^ ", ); check_pos( "aoml", "bacon.coml", " ^ ^^^", ); check_pos( "broot", "ab br bro oxt ", " ^^^ ^ ^ ", ); check_pos( "broot", "b_broooooot_broot", " ^^^^^" ); check_pos( "buityp", "builder-styles-less-typing.d.ts", "^^^ ^^^ ", ); check_pos( "broot", "ветер br x o vent ootot", " ^^ ^^^ ", ); check_pos( "broot", "brbroorrbbbbbrrooorobrototooooot.txt", " ^^^ ^^ ", ); check_pos( "besh", "benches/shared", "^^ ^^ ", ); } /// check that the scores of all names are strictly decreasing /// (pattern is first tested against itself). /// We verify this property with both computation functions. fn check_ordering_for( pattern: &str, names: &[&str], ) { let fp = FuzzyPattern::from(pattern); let mut last_score = fp.find(pattern).map(|m| m.score); let mut last_name = pattern; for name in names { let score = fp.find(name).map(|m| m.score); assert!( score < last_score, "score({name:?}) should be lower than score({last_name:?}) (using find)" ); last_name = name; last_score = score; } } #[test] fn check_orderings() { check_ordering_for( "broot", &[ "a broot", "abbroot", "abcbroot", " abdbroot", "1234broot1", "12345brrrroooottt", "12345brrr roooottt", "brot", ], ); check_ordering_for( "Abc", &["abCd", "aBdc", " abdc", " abdbccccc", " a b c", "nothing"], ); check_ordering_for( "réveil", &[ "Réveillon", "Réveillons", " réveils", "πréveil", "déréveil", "Rêve-t-il ?", " rêves", ], ); } /// check that we don't fail to find strings differing by diacritics /// or unicode normalization. /// /// Note that we can't go past the limits of unicode normalization /// and for example I don't know how to make 'B́' one char (help welcome). /// This should normally not cause any problem for the user as searching /// `"ab"` in `"aB́"` will still match. #[test] fn check_equivalences() { fn check_equivalences_in(arr: &[&str]) { for pattern in arr.iter() { let fp = FuzzyPattern::from(pattern); for name in arr.iter() { println!("looking for pattern {pattern:?} in name {name:?}"); assert!(fp.find(name).unwrap().score > 0); } } } check_equivalences_in(&["aB", "ab", "àb", "âB"]); let c12 = "Comunicações"; assert_eq!(c12.len(), 14); assert_eq!(c12.chars().count(), 12); let c14 = "Comunicações"; assert_eq!(c14.len(), 16); assert_eq!(c14.chars().count(), 14); check_equivalences_in(&["comunicacoes", c12, c14]); check_equivalences_in(&["у", "У"]); } /// check that there's no problem with ignoring case on cyrillic. /// This problem arises when secular was compiled without the "bmp" feature. /// See https://github.com/Canop/broot/issues/746 #[test] fn issue_746() { let fp = FuzzyPattern::from("устр"); assert!(fp.find("Устройства").is_some()); } } ================================================ FILE: src/pattern/input_pattern.rs ================================================ use { super::*, crate::{ app::AppContext, errors::PatternError, pattern::{ Pattern, PatternParts, }, }, bet::BeTree, lazy_regex::*, }; /// wraps both /// - the "pattern" (which may be used to filter and rank file entries) /// - the source raw string which was used to build it and which may /// be put back in the input. #[derive(Debug, Clone)] pub struct InputPattern { pub raw: String, pub pattern: Pattern, } impl PartialEq for InputPattern { fn eq( &self, other: &Self, ) -> bool { self.raw == other.raw } } impl InputPattern { pub fn none() -> Self { Self { raw: String::new(), pattern: Pattern::None, } } pub fn new( raw: String, parts_expr: &BeTree, con: &AppContext, ) -> Result { let pattern = Pattern::new( parts_expr, &con.search_modes, con.content_search_max_file_size, )?; Ok(Self { raw, pattern }) } pub fn is_none(&self) -> bool { self.pattern.is_empty() } pub fn is_some(&self) -> bool { self.pattern.is_some() } /// empties the pattern and return it /// Similar to Option::take pub fn take(&mut self) -> Self { std::mem::replace(self, Self::none()) } pub fn as_option(self) -> Option { if self.is_some() { Some(self) } else { None } } /// from a pattern used to filter a tree, build a pattern /// which would make sense to filter a previewed file pub fn tree_to_preview(&self) -> Self { let regex_parts: Option<(String, String)> = match &self.pattern { Pattern::ContentExact(cp) => Some(cp.to_regex_parts()), Pattern::ContentRegex(rp) => Some(rp.to_regex_parts()), Pattern::Composite(cp) => { cp.expr.paths_to_atoms() .into_iter() .filter(|(path, _p)| { !(path.contains(&PatternOperator::Or) || path.contains(&PatternOperator::Not)) }) .find_map(|(_path, p)| match p { Pattern::ContentExact(ce) => Some(ce.to_regex_parts()), Pattern::ContentRegex(cp) => Some(cp.to_regex_parts()), _ => None, }) } _ => None, }; regex_parts .map(|(core, modifiers)| // The regex part is missing the escaping which prevents it from // ending the pattern in the input. We need to restore it // See https://github.com/Canop/broot/issues/778 (regex_replace_all!("[ :]", &core, "\\$0").to_string(), modifiers)) .and_then(|(core, modifiers)| RegexPattern::from(&core, &modifiers).ok()) .map(|rp| InputPattern { raw: rp.to_string(), // this adds the initial / pattern: Pattern::NameRegex(rp), }) .unwrap_or_else(InputPattern::none) } } #[test] fn test_tree_to_preview() { fn make_pat(s: &str) -> InputPattern { let cp = crate::command::CommandParts::from(s); let search_modes = SearchModeMap::default(); InputPattern { raw: s.to_string(), pattern: Pattern::new( &cp.pattern, &search_modes, 0, // we don't do content search here ) .unwrap(), } } assert_eq!(make_pat("c/test").tree_to_preview(), make_pat("/test")); assert_eq!( make_pat("/java$/&c/test").tree_to_preview(), make_pat("/test") ); assert_eq!( make_pat("!c/test").tree_to_preview(), make_pat("") ); assert_eq!( make_pat(".java&!c/test").tree_to_preview(), make_pat("") ); assert_eq!( make_pat(".java|c/test").tree_to_preview(), make_pat("") ); assert_eq!( make_pat("!.java&c/test").tree_to_preview(), make_pat("/test") ); assert_eq!( make_pat("(.java|.rs)&c/test").tree_to_preview(), make_pat("/test") ); assert_eq!( make_pat(".java&(c/foo/|c/bar/)").tree_to_preview(), make_pat("") ); // not ideal handling: we'd like "c/foo&c/bar" to give "/foo/|/bar/" } ================================================ FILE: src/pattern/mod.rs ================================================ mod candidate; mod composite_pattern; mod content_pattern; mod content_regex_pattern; mod exact_pattern; mod fuzzy_pattern; mod input_pattern; mod name_match; mod operator; mod pattern; mod pattern_object; mod pattern_parts; mod pos; mod regex_pattern; mod search_mode; mod tok_pattern; pub use { candidate::Candidate, composite_pattern::CompositePattern, content_pattern::ContentExactPattern, content_regex_pattern::ContentRegexPattern, exact_pattern::ExactPattern, fuzzy_pattern::FuzzyPattern, input_pattern::InputPattern, name_match::NameMatch, operator::PatternOperator, pattern::Pattern, pattern_object::PatternObject, pattern_parts::PatternParts, pos::*, regex_pattern::RegexPattern, search_mode::*, tok_pattern::*, }; use { crate::errors::PatternError, lazy_regex::regex, }; pub fn build_regex( pat: &str, flags: &str, ) -> Result { let mut builder = regex::RegexBuilder::new(pat); for c in flags.chars() { match c { 'i' => { builder.case_insensitive(true); } 'U' => { builder.swap_greed(true); } _ => { return Err(PatternError::UnknownRegexFlag { bad: c }); } } } Ok(builder.build()?) } ================================================ FILE: src/pattern/name_match.rs ================================================ use { super::Pos, smallvec::SmallVec, }; /// A NameMatch is a positive result of pattern matching inside /// a filename or subpath #[derive(Debug, Clone)] pub struct NameMatch { pub score: i32, // score of the match, guaranteed strictly positive, bigger is better pub pos: Pos, // positions of the matching chars } impl NameMatch { pub fn merge_with(self, other: Self) -> Self{ let mut merged = SmallVec::new(); let mut a_pos = self.pos.into_iter().peekable(); let mut b_pos = other.pos.into_iter().peekable(); loop { let (val, winning_iter) = match (a_pos.peek(), b_pos.peek()){ (None, None) => break, (Some(a), None) => (*a, &mut a_pos), (None, Some(b)) => (*b, &mut b_pos), (Some(a), Some(b)) => { if a < b { (*a, &mut a_pos) } else { (*b, &mut b_pos) } } }; winning_iter.next(); if let Some(last) = merged.last() { if val <= *last { continue } } merged.push(val); } Self{ score: self.score + other.score, pos: merged, } } /// wraps any group of matching characters with match_start and match_end pub fn wrap( &self, name: &str, match_start: &str, match_end: &str, ) -> String { let mut result = String::new(); let mut index_in_pos = 0; let mut wrapped = false; for (idx, c) in name.chars().enumerate() { if index_in_pos < self.pos.len() && self.pos[index_in_pos] == idx { index_in_pos += 1; if !wrapped { result.push_str(match_start); wrapped = true; } } else if wrapped { result.push_str(match_end); wrapped = false; } result.push(c); } if wrapped { result.push_str(match_end); } result } // cut the name match in two parts by recomputing the pos // arrays pub fn cut_after( &mut self, chars_count: usize, ) -> Self { let mut tail = Self { score: self.score, pos: SmallVec::new(), }; let idx = self.pos.iter().position(|&p| p >= chars_count); if let Some(idx) = idx { for p in self.pos.drain(idx..) { tail.pos.push(p - chars_count); } } tail } } ================================================ FILE: src/pattern/operator.rs ================================================ /// operators combining patterns #[derive(Debug, Clone, Copy, PartialEq)] pub enum PatternOperator { And, Or, Not, } ================================================ FILE: src/pattern/pattern.rs ================================================ use { super::*, crate::{ content_search::ContentMatch, errors::PatternError, }, bet::BeTree, std::path::Path, }; /// a pattern for filtering and sorting files. #[derive(Debug, Clone)] pub enum Pattern { None, NameExact(ExactPattern), NameFuzzy(FuzzyPattern), NameRegex(RegexPattern), NameTokens(TokPattern), PathExact(ExactPattern), PathFuzzy(FuzzyPattern), PathRegex(RegexPattern), PathTokens(TokPattern), ContentExact(ContentExactPattern), ContentRegex(ContentRegexPattern), Composite(CompositePattern), } impl Pattern { pub fn new( raw_expr: &BeTree, search_modes: &SearchModeMap, content_search_max_file_size: usize, ) -> Result { let expr: BeTree = raw_expr.try_map_atoms::<_, PatternError, _>(|pattern_parts| { let core = pattern_parts.core(); Ok(if core.is_empty() { Pattern::None } else { let parts_mode = pattern_parts.mode(); let mode = search_modes.search_mode(parts_mode)?; let flags = pattern_parts.flags(); match mode { SearchMode::NameExact => Self::NameExact(ExactPattern::from(core)), SearchMode::NameFuzzy => Self::NameFuzzy(FuzzyPattern::from(core)), SearchMode::NameRegex => { Self::NameRegex(RegexPattern::from(core, flags.unwrap_or(""))?) } SearchMode::NameTokens => Self::NameTokens(TokPattern::new(core)), SearchMode::PathExact => Self::PathExact(ExactPattern::from(core)), SearchMode::PathFuzzy => Self::PathFuzzy(FuzzyPattern::from(core)), SearchMode::PathRegex => { Self::PathRegex(RegexPattern::from(core, flags.unwrap_or(""))?) } SearchMode::PathTokens => Self::PathTokens(TokPattern::new(core)), SearchMode::ContentExact => Self::ContentExact(ContentExactPattern::new( core, content_search_max_file_size, )), SearchMode::ContentRegex => Self::ContentRegex(ContentRegexPattern::new( core, flags.unwrap_or(""), content_search_max_file_size, )?), } }) })?; Ok(if expr.is_empty() { Pattern::None } else if expr.is_atomic() { expr.atoms().pop().unwrap() } else { Self::Composite(CompositePattern::new(expr)) }) } pub fn object(&self) -> PatternObject { let mut object = PatternObject::default(); match self { Self::None => {} Self::NameExact(_) | Self::NameFuzzy(_) | Self::NameRegex(_) | Self::NameTokens(_) => { object.name = true; } Self::PathExact(_) | Self::PathFuzzy(_) | Self::PathRegex(_) | Self::PathTokens(_) => { object.subpath = true; } Self::ContentExact(_) | Self::ContentRegex(_) => { object.content = true; } Self::Composite(cp) => { for atom in cp.expr.iter_atoms() { object |= atom.object(); } } } object } pub fn search_string( &self, candidate: &str, ) -> Option { match self { Self::NameExact(ep) | Self::PathExact(ep) => ep.find(candidate), Self::NameFuzzy(fp) | Self::PathFuzzy(fp) => fp.find(candidate), Self::NameRegex(rp) | Self::PathRegex(rp) => rp.find(candidate), Self::NameTokens(tp) | Self::PathTokens(tp) => tp.find(candidate), Self::Composite(cp) => cp.search_string(candidate), _ => None, } } pub fn find_string( &self, candidate: &str, ) -> Option { match self { Self::NameExact(ep) | Self::PathExact(ep) => ep.find(candidate), Self::NameFuzzy(fp) | Self::PathFuzzy(fp) => fp.find(candidate), Self::NameRegex(rp) | Self::PathRegex(rp) => rp.find(candidate), Self::NameTokens(tp) | Self::PathTokens(tp) => tp.find(candidate), Self::Composite(cp) => cp.find_string(candidate), _ => None, } } /// find the content to show next to the name of the file /// when the search involved a content filtering pub fn search_content( &self, candidate: &Path, desired_len: usize, // available space for content match display ) -> Option { match self { Self::ContentExact(cp) => cp.get_content_match(candidate, desired_len), Self::ContentRegex(cp) => cp.get_content_match(candidate, desired_len), Self::Composite(cp) => cp.search_content(candidate, desired_len), _ => None, } } pub fn is_content_search(&self) -> bool { match self { Self::ContentExact(_) | Self::ContentRegex(_) => true, Self::Composite(cp) => cp.is_content_search(), _ => false, } } /// find the content to show next to the name of the file /// when the search involved a content filtering and you already /// know the content is there so you don't want to filter by name/path pub fn find_content( &self, candidate: &Path, desired_len: usize, // available space for content match display ) -> Option { match self { Self::ContentExact(cp) => cp.get_content_match(candidate, desired_len), Self::ContentRegex(cp) => cp.get_content_match(candidate, desired_len), Self::Composite(cp) => cp.find_content(candidate, desired_len), _ => None, } } /// get the line of the first match, if any pub fn get_match_line_count( &self, path: &Path, ) -> Option { match self { Self::ContentExact(cp) => cp.get_match_line_count(path), Self::ContentRegex(cp) => cp.get_match_line_count(path), Self::Composite(cp) => cp.get_match_line_count(path), _ => None, } } pub fn score_of( &self, candidate: Candidate, ) -> Option { match self { Self::NameExact(ep) => ep.score_of(candidate.name), Self::NameFuzzy(fp) => fp.score_of(candidate.name), Self::NameRegex(rp) => rp.find(candidate.name).map(|m| m.score), Self::NameTokens(tp) => tp.score_of(candidate.name), Self::PathExact(ep) => ep.score_of(candidate.subpath), Self::PathFuzzy(fp) => fp.score_of(candidate.subpath), Self::PathRegex(rp) => rp.find(candidate.subpath).map(|m| m.score), Self::PathTokens(tp) => tp.score_of(candidate.subpath), Self::ContentExact(cp) => cp.score_of(candidate), Self::ContentRegex(cp) => cp.score_of(candidate), Self::Composite(cp) => cp.score_of(candidate), Self::None => Some(1), } } pub fn score_of_string( &self, candidate: &str, ) -> Option { match self { Self::NameExact(ep) => ep.score_of(candidate), Self::NameFuzzy(fp) => fp.score_of(candidate), Self::NameRegex(rp) => rp.find(candidate).map(|m| m.score), Self::NameTokens(tp) => tp.score_of(candidate), Self::PathExact(ep) => ep.score_of(candidate), Self::PathFuzzy(fp) => fp.score_of(candidate), Self::PathRegex(rp) => rp.find(candidate).map(|m| m.score), Self::PathTokens(tp) => tp.score_of(candidate), Self::ContentExact(_) => None, // this isn't suitable Self::ContentRegex(_) => None, // this isn't suitable Self::Composite(cp) => cp.score_of_string(candidate), Self::None => Some(1), } } pub fn is_some(&self) -> bool { !self.is_empty() } /// an empty pattern is one which doesn't discriminate /// (it accepts everything) pub fn is_empty(&self) -> bool { match self { Self::NameExact(ep) | Self::PathExact(ep) => ep.is_empty(), Self::ContentExact(ep) => ep.is_empty(), Self::NameFuzzy(fp) | Self::PathFuzzy(fp) => fp.is_empty(), Self::NameRegex(rp) | Self::PathRegex(rp) => rp.is_empty(), Self::ContentRegex(rp) => rp.is_empty(), Self::NameTokens(tp) | Self::PathTokens(tp) => tp.is_empty(), Self::Composite(cp) => cp.is_empty(), Self::None => true, } } /// whether the scores are more than just 0 or 1. /// When it's the case, the tree builder will look for more matching results /// in order to select the best ones. pub fn has_real_scores(&self) -> bool { match self { Self::NameExact(_) | Self::NameFuzzy(_) => true, Self::PathExact(_) | Self::PathFuzzy(_) => true, Self::Composite(cp) => cp.has_real_scores(), _ => false, } } } ================================================ FILE: src/pattern/pattern_object.rs ================================================ use std::ops; /// on what the search applies /// (a composite pattern may apply to several topic /// hence the bools) #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct PatternObject { pub name: bool, pub subpath: bool, pub content: bool, } impl ops::BitOr for PatternObject { type Output = Self; fn bitor( self, o: Self, ) -> Self::Output { Self { name: self.name | o.name, subpath: self.subpath | o.subpath, content: self.content | o.content, } } } impl ops::BitOrAssign for PatternObject { fn bitor_assign( &mut self, rhs: Self, ) { self.name |= rhs.name; self.subpath |= rhs.subpath; self.content |= rhs.content; } } ================================================ FILE: src/pattern/pattern_parts.rs ================================================ use std::fmt; /// An intermediate parsed representation of the raw string making /// a pattern, with up to 3 parts (search mode, core pattern, modifiers) #[derive(Debug, Clone, PartialEq)] pub struct PatternParts { /// can't be empty by construct parts: Vec, } impl fmt::Display for PatternParts { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { match self.parts.len() { 1 => write!(f, "{}", &self.parts[0]), 2 => write!(f, "{}/{}", &self.parts[0], &self.parts[1]), _ => write!( f, "{}/{}/{}", &self.parts[0], &self.parts[1], &self.parts[2] ), } } } impl Default for PatternParts { fn default() -> Self { Self { parts: vec![String::new()], } } } #[cfg(test)] impl TryFrom<&[&str]> for PatternParts { type Error = &'static str; fn try_from(a: &[&str]) -> Result { if a.is_empty() { return Err("invalid empty parts array"); } let parts = a.iter().map(|s| (*s).into()).collect(); Ok(Self { parts }) } } impl PatternParts { pub fn push( &mut self, c: char, ) { // self.parts can't be empty, by construct self.parts.last_mut().unwrap().push(c); } pub fn is_between_slashes(&self) -> bool { self.parts.len() == 2 } pub fn add_part(&mut self) { self.parts.push(String::new()); } pub fn is_empty(&self) -> bool { self.core().is_empty() } pub fn core(&self) -> &str { if self.parts.len() > 1 { &self.parts[1] } else { &self.parts[0] } } pub fn mode(&self) -> Option<&String> { if self.parts.len() > 1 { self.parts.first() } else { None } } pub fn flags(&self) -> Option<&str> { if self.parts.len() > 2 { self.parts.get(2).map(|s| s.as_str()) } else { None } } } ================================================ FILE: src/pattern/pos.rs ================================================ use smallvec::SmallVec; /// a vector of indexes of the matching characters (not bytes) pub type Pos = SmallVec<[usize; 8]>; ================================================ FILE: src/pattern/regex_pattern.rs ================================================ //! a filtering pattern using a regular expression use { super::NameMatch, crate::errors::PatternError, lazy_regex::regex, smallvec::SmallVec, std::fmt, }; #[derive(Debug, Clone)] pub struct RegexPattern { rex: regex::Regex, flags: String, } impl fmt::Display for RegexPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { if self.flags.is_empty() { write!(f, "/{}", self.rex) } else { write!(f, "/{}/{}", self.rex, self.flags) } } } impl RegexPattern { pub fn from( pat: &str, flags: &str, ) -> Result { Ok(RegexPattern { rex: super::build_regex(pat, flags)?, flags: flags.to_string(), }) } /// return a match if the pattern can be found in the candidate string pub fn find( &self, candidate: &str, ) -> Option { // note that there's no significative cost related to using // find over is_match self.rex.find(candidate).map(|rm| { let chars_before = candidate[..rm.start()].chars().count(); let rm_chars = rm.as_str().chars().count(); let mut pos = SmallVec::with_capacity(rm_chars); for i in 0..rm_chars { pos.push(chars_before + i); } super::NameMatch { score: 1, pos } }) } pub fn is_empty(&self) -> bool { self.rex.as_str().is_empty() } } ================================================ FILE: src/pattern/search_mode.rs ================================================ use { crate::{ app::AppContext, errors::{ ConfError, PatternError, }, }, lazy_regex::regex_is_match, rustc_hash::FxHashMap, std::convert::TryFrom, }; /// where to search #[derive(Debug, Clone, Copy, PartialEq)] pub enum SearchObject { Name, Path, Content, } /// how to search #[derive(Debug, Clone, Copy, PartialEq)] pub enum SearchKind { Exact, Fuzzy, Regex, Tokens, } /// a valid combination of SearchObject and SearchKind, /// determine how a pattern will be used #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SearchMode { NameExact, NameFuzzy, NameRegex, NameTokens, PathExact, PathFuzzy, PathRegex, PathTokens, ContentExact, ContentRegex, } pub static SEARCH_MODES: &[SearchMode] = &[ SearchMode::NameFuzzy, SearchMode::NameRegex, SearchMode::NameExact, SearchMode::NameTokens, SearchMode::PathExact, SearchMode::PathFuzzy, SearchMode::PathRegex, SearchMode::PathTokens, SearchMode::ContentExact, SearchMode::ContentRegex, ]; impl SearchMode { fn new( search_object: SearchObject, search_kind: SearchKind, ) -> Option { use { SearchKind::*, SearchObject::*, }; match (search_object, search_kind) { (Name, Exact) => Some(Self::NameExact), (Name, Fuzzy) => Some(Self::NameFuzzy), (Name, Regex) => Some(Self::NameRegex), (Name, Tokens) => Some(Self::NameTokens), (Path, Exact) => Some(Self::PathExact), (Path, Fuzzy) => Some(Self::PathFuzzy), (Path, Regex) => Some(Self::PathRegex), (Path, Tokens) => Some(Self::PathTokens), (Content, Exact) => Some(Self::ContentExact), (Content, Fuzzy) => None, // unsupported for now - could be but why ? (Content, Regex) => Some(Self::ContentRegex), (Content, Tokens) => None, // unsupported for now - could be but need bench } } /// Return the prefix to type, eg "/" in standard for a name-regex, /// "" for a name-fuzzy, and "ep" for a path-exact pub fn prefix( self, con: &AppContext, ) -> String { con.search_modes .key(self) .map_or_else(|| "".to_string(), |k| format!("{k}/")) } pub fn object(self) -> SearchObject { match self { Self::NameExact | Self::NameFuzzy | Self::NameRegex | Self::NameTokens => { SearchObject::Name } Self::PathExact | Self::PathFuzzy | Self::PathRegex | Self::PathTokens => { SearchObject::Path } Self::ContentExact | Self::ContentRegex => SearchObject::Content, } } pub fn kind(self) -> SearchKind { match self { Self::NameExact => SearchKind::Exact, Self::NameFuzzy => SearchKind::Fuzzy, Self::NameRegex => SearchKind::Regex, Self::NameTokens => SearchKind::Tokens, Self::PathExact => SearchKind::Exact, Self::PathFuzzy => SearchKind::Fuzzy, Self::PathRegex => SearchKind::Regex, Self::PathTokens => SearchKind::Tokens, Self::ContentExact => SearchKind::Exact, Self::ContentRegex => SearchKind::Regex, } } } /// define a mapping from a search mode which can be typed in /// the input to a SearchMode value #[derive(Debug, Clone)] pub struct SearchModeMapEntry { pub key: Option, pub mode: SearchMode, } /// manage how to find the search mode to apply to a /// pattern taking the config in account. #[derive(Debug, Clone)] pub struct SearchModeMap { pub entries: Vec, } impl SearchModeMapEntry { pub fn parse( conf_key: &str, conf_mode: &str, ) -> Result { let mut search_kinds = Vec::new(); let mut search_objects = Vec::new(); let s = conf_mode.to_lowercase(); for t in s.split_whitespace() { match t { "exact" => search_kinds.push(SearchKind::Exact), "fuzzy" => search_kinds.push(SearchKind::Fuzzy), "regex" => search_kinds.push(SearchKind::Regex), "tokens" => search_kinds.push(SearchKind::Tokens), "name" => search_objects.push(SearchObject::Name), "content" => search_objects.push(SearchObject::Content), "path" => search_objects.push(SearchObject::Path), _ => { return Err(ConfError::InvalidSearchMode { details: format!("{t:?} not understood in search mode definition"), }); } } } if search_kinds.is_empty() { return Err(ConfError::InvalidSearchMode { details: "missing search kind in search mode definition\ (the search kind must be one of 'exact', 'fuzzy', 'regex', 'tokens')" .to_string(), }); } if search_kinds.len() > 1 { return Err(ConfError::InvalidSearchMode { details: "only one search kind can be specified in a search mode".to_string(), }); } if search_objects.is_empty() { return Err(ConfError::InvalidSearchMode { details: "missing search object in search mode definition\ (the search object must be one of 'name', 'path', 'content')" .to_string(), }); } if search_objects.len() > 1 { return Err(ConfError::InvalidSearchMode { details: "only one search object can be specified in a search mode".to_string(), }); } let mode = match SearchMode::new(search_objects[0], search_kinds[0]) { Some(mode) => mode, None => { return Err(ConfError::InvalidSearchMode { details: "Unsupported combination of search object and kind".to_string(), }); } }; let key = if conf_key.is_empty() || conf_key == "" { // serde toml parser doesn't handle correctly empty keys so we accept as // alternative the `"" = "fuzzy name"` solution. // TODO look at issues and/or code in serde-toml None } else if regex_is_match!(r"^\w*/$", conf_key) { Some(conf_key[0..conf_key.len() - 1].to_string()) } else { return Err(ConfError::InvalidKey { raw: conf_key.to_string(), }); }; Ok(SearchModeMapEntry { key, mode }) } } impl Default for SearchModeMap { fn default() -> Self { let mut smm = SearchModeMap { entries: Vec::new(), }; // the last keys are preferred smm.setm(&["ne", "en", "e"], SearchMode::NameExact); smm.setm(&["nf", "fn", "n", "f"], SearchMode::NameFuzzy); smm.setm(&["r", "nr", "rn", ""], SearchMode::NameRegex); smm.setm(&["pe", "ep"], SearchMode::PathExact); smm.setm(&["pf", "fp", "p"], SearchMode::PathFuzzy); smm.setm(&["pr", "rp"], SearchMode::PathRegex); smm.setm(&["ce", "ec", "c"], SearchMode::ContentExact); smm.setm(&["rx", "cr"], SearchMode::ContentRegex); smm.setm(&["pt", "tp", "t"], SearchMode::PathTokens); smm.setm(&["tn", "nt"], SearchMode::NameTokens); smm.set(SearchModeMapEntry { key: None, mode: SearchMode::PathFuzzy, }); smm } } impl TryFrom<&FxHashMap> for SearchModeMap { type Error = ConfError; fn try_from(map: &FxHashMap) -> Result { let mut smm = Self::default(); for (k, v) in map { smm.entries.push(SearchModeMapEntry::parse(k, v)?); } Ok(smm) } } impl SearchModeMap { pub fn setm( &mut self, keys: &[&str], mode: SearchMode, ) { for key in keys { self.set(SearchModeMapEntry { key: Some(key.to_string()), mode, }); } } /// we don't remove existing entries to ensure there's always a matching entry in /// mode->key (but search iterations will be done in reverse) pub fn set( &mut self, entry: SearchModeMapEntry, ) { self.entries.push(entry); } pub fn search_mode( &self, key: Option<&String>, ) -> Result { for entry in self.entries.iter().rev() { if entry.key.as_ref() == key { return Ok(entry.mode); } } Err(PatternError::InvalidMode { mode: if let Some(key) = key { format!("{key}/") } else { "".to_string() }, }) } pub fn key( &self, search_mode: SearchMode, ) -> Option<&String> { for entry in self.entries.iter().rev() { if entry.mode == search_mode { return entry.key.as_ref(); } } warn!("search mode key not found for {:?}", search_mode); // should not happen None } } ================================================ FILE: src/pattern/tok_pattern.rs ================================================ use { super::NameMatch, secular, smallvec::{ SmallVec, smallvec, }, std::{ cmp::Reverse, ops::Range, }, }; type CandChars = SmallVec<[char; 32]>; static SEPARATORS: &[char] = &[',', ';']; // weights used in match score computing const BONUS_MATCH: i32 = 50_000; const BONUS_CANDIDATE_LENGTH: i32 = -1; // per char pub fn norm_chars(s: &str) -> Box<[char]> { secular::normalized_lower_lay_string(s) .chars() .collect::>() .into_boxed_slice() } /// a list of tokens we want to find, non overlapping /// and in any order, in strings #[derive(Debug, Clone, PartialEq)] pub struct TokPattern { toks: Vec>, sum_len: usize, } // scoring basis ? // - number of parts of the candidates (separated by / for example) // that are touched by a tok ? // - malus for adjacent ranges // - bonus for ranges starting just after a separator // - bonus for order ? impl TokPattern { pub fn new(pattern: &str) -> Self { // we accept several separators. The first one // we encounter among the possible ones is the // separator of the whole. This allows using the // other char: In ";ab,er", the comma isn't seen // as a separator but as part of a tok let sep = pattern.chars().find(|c| SEPARATORS.contains(c)); let mut toks: Vec> = if let Some(sep) = sep { pattern .split(sep) .filter(|s| !s.is_empty()) .map(norm_chars) .collect() } else if pattern.is_empty() { Vec::new() } else { vec![norm_chars(pattern)] }; // we sort the tokens from biggest to smallest // because the current algorithm stops at the // first match for any tok. Thus it would fail // to find "abc,b" in "abcdb" if it looked first // at the "b" token toks.sort_by_key(|t| Reverse(t.len())); let sum_len = toks.iter().map(|s| s.len()).sum(); Self { toks, sum_len } } /// an "empty" pattern is one which accepts everything because /// it has no discriminant pub fn is_empty(&self) -> bool { self.sum_len == 0 } /// return either None (no match) or a vec whose size is the number /// of tokens fn find_ranges( &self, candidate: &str, ) -> Option>> { let mut cand_chars: CandChars = SmallVec::with_capacity(candidate.len()); cand_chars.extend(candidate.chars().map(secular::lower_lay_char)); if cand_chars.len() < self.sum_len || self.sum_len == 0 { return None; } // we first look for the first tok, it's simpler let first_tok = &self.toks[0]; let l = first_tok.len(); let first_matching_range = (0..cand_chars.len() + 1 - l) .map(|idx| idx..idx + l) .find(|r| &cand_chars[r.start..r.end] == first_tok.as_ref()); // we initialize the vec only when the first tok is found first_matching_range.and_then(|first_matching_range| { let mut matching_ranges = vec![first_matching_range]; for tok in self.toks.iter().skip(1) { let l = tok.len(); let matching_range = (0..cand_chars.len() + 1 - l) .map(|idx| idx..idx + l) .filter(|r| &cand_chars[r.start..r.end] == tok.as_ref()) .find(|r| { // check we're not intersecting a previous range for pr in &matching_ranges { if pr.contains(&r.start) || pr.contains(&(r.end - 1)) { return false; } } true }); if let Some(r) = matching_range { matching_ranges.push(r); } else { return None; } } Some(matching_ranges) }) } fn score_of_matching( &self, candidate: &str, ) -> i32 { BONUS_MATCH + BONUS_CANDIDATE_LENGTH * candidate.len() as i32 } /// note that it should not be called on empty patterns pub fn find( &self, candidate: &str, ) -> Option { self.find_ranges(candidate).map(|matching_ranges| { let mut pos = smallvec![0; self.sum_len]; let mut i = 0; for r in matching_ranges { for p in r { pos[i] = p; i += 1; } } pos.sort_unstable(); let score = self.score_of_matching(candidate); NameMatch { score, pos } }) } /// compute the score of the best match /// Note that it should not be called on empty patterns pub fn score_of( &self, candidate: &str, ) -> Option { self.find_ranges(candidate) .map(|_| self.score_of_matching(candidate)) } } #[cfg(test)] mod tok_pattern_tests { use { super::*, crate::pattern::Pos, }; /// check position of the match of the pattern in name fn check_pos( pattern: &str, name: &str, pos: &str, ) { println!("checking pattern={pattern:?} name={name:?}"); let pat = TokPattern::new(pattern); let match_pos = pat.find(name).unwrap().pos; let target_pos: Pos = pos .chars() .enumerate() .filter(|(_, c)| *c == '^') .map(|(i, _)| i) .collect(); assert_eq!(match_pos, target_pos); } #[test] fn check_match_pos() { check_pos("m,", "miaou", "^ "); check_pos("bat", "cabat", " ^^^"); check_pos(";ba", "babababaaa", "^^ "); check_pos("ba,ca", "bababacaa", "^^ ^^ "); check_pos( "sub,doc,2", "/home/user/path2/subpath/Documents/", " ^ ^^^ ^^^", ); check_pos("ab,abc", "0123/abc/ab/cdg", " ^^^ ^^ "); } fn check_match( pattern: &str, name: &str, do_match: bool, ) { assert_eq!(TokPattern::new(pattern).find(name).is_some(), do_match,); } #[test] fn test_separators() { let a = TokPattern::new("ab;cd;ef"); let b = TokPattern::new("ab,cd,ef"); assert_eq!(a, b); let a = TokPattern::new(",ab;cd;ef"); assert_eq!(a.toks.len(), 1); assert_eq!(a.toks[0].len(), 8); let a = TokPattern::new(";ab,cd,ef;"); assert_eq!(a.toks.len(), 1); assert_eq!(a.toks[0].len(), 8); } #[test] fn test_match() { check_match("mia", "android/phonegap", false); check_match("mi", "a", false); check_match("mi", "π", false); check_match("mi", "miaou/a", true); check_match("imm", "😍", false); } #[test] fn test_tok_repetitions() { check_match("sub", "rasub", true); check_match("sub,sub", "rasub", false); check_match("sub,sub", "rasubandsub", true); check_match("sub,sub,sub", "rasubandsub", false); check_match("ghi,abc,def,ccc", "abccc/Defghi", false); check_match("ghi,abc,def,ccc", "abcccc/Defghi", true); } } ================================================ FILE: src/permissions/mod.rs ================================================ //////////////////// UNIX #[cfg(not(any(target_family = "windows", target_os = "android")))] pub mod permissions_unix; #[cfg(not(any(target_family = "windows", target_os = "android")))] pub use permissions_unix::*; //////////////////// WINDOWS #[cfg(windows)] pub fn supported() -> bool { false } ================================================ FILE: src/permissions/permissions_unix.rs ================================================ use { once_cell::sync::Lazy, rustc_hash::FxHashMap, std::sync::Mutex, }; pub fn supported() -> bool { true } pub fn user_name(uid: u32) -> String { static USERS_CACHE_MUTEX: Lazy>> = Lazy::new(|| Mutex::new(FxHashMap::default())); let mut users_cache = USERS_CACHE_MUTEX.lock().unwrap(); let name = users_cache.entry(uid).or_insert_with(|| { uzers::get_user_by_uid(uid).map_or_else( || "????".to_string(), |u| u.name().to_string_lossy().to_string(), ) }); (*name).to_string() } pub fn group_name(gid: u32) -> String { static GROUPS_CACHE_MUTEX: Lazy>> = Lazy::new(|| Mutex::new(FxHashMap::default())); let mut groups_cache = GROUPS_CACHE_MUTEX.lock().unwrap(); let name = groups_cache.entry(gid).or_insert_with(|| { uzers::get_group_by_gid(gid).map_or_else( || "????".to_string(), |u| u.name().to_string_lossy().to_string(), ) }); (*name).to_string() } ================================================ FILE: src/preview/dir_view.rs ================================================ use { crate::{ app::{ AppContext, DisplayContext, }, command::ScrollCommand, display::{ DisplayableTree, Screen, W, }, errors::ProgramError, pattern::InputPattern, skin::PanelSkin, task_sync::Dam, tree::{ Tree, TreeOptions, }, tree_build::TreeBuilder, }, crokey::crossterm::{ QueueableCommand, cursor, }, std::{ io, path::PathBuf, }, termimad::Area, }; pub struct DirView { pub tree: Tree, page_height: Option, } impl DirView { pub fn new( dir: PathBuf, pattern: InputPattern, dam: &Dam, con: &AppContext, ) -> Result { let options = TreeOptions { show_hidden: true, respect_git_ignore: false, pattern, ..Default::default() }; let mut builder = TreeBuilder::from(dir, options, 100, con).map_err(io::Error::other)?; builder.deep = false; let tree = builder .build_tree( false, // on refresh we always do a non total search dam, ) .map_err(io::Error::other)?; Ok(Self { tree, page_height: None, }) } pub fn display( &mut self, w: &mut W, disc: &DisplayContext, area: &Area, ) -> Result<(), ProgramError> { let page_height = area.height as usize; if Some(page_height) != self.page_height { self.page_height = Some(page_height); } let dp = DisplayableTree { app_state: None, tree: &self.tree, skin: &disc.panel_skin.styles, ext_colors: &disc.con.ext_colors, area: area.clone(), in_app: true, }; dp.write_on(w)?; Ok(()) } pub fn display_info( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let width = area.width as usize; let mut s = format!("{}", self.tree.lines.len()); if s.len() > width { return Ok(()); } if s.len() + "lines: ".len() < width { s = format!("entries: {s}"); } w.queue(cursor::MoveTo( area.left + area.width - s.len() as u16, area.top, ))?; panel_skin.styles.default.queue(w, s)?; Ok(()) } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { let Some(page_height) = self.page_height else { return false; }; let dy = cmd.to_lines(page_height); self.tree.try_scroll(dy, page_height) } pub fn try_select_y( &mut self, y: u16, ) -> bool { self.tree.try_select_y(y as usize) } pub fn move_selection( &mut self, dy: i32, cycle: bool, ) { if let Some(page_height) = self.page_height { self.tree.move_selection(dy, page_height, cycle); } } pub fn select_first(&mut self) { self.tree.try_select_first(); } pub fn select_last(&mut self) { if let Some(page_height) = self.page_height { self.tree.try_select_last(page_height); } } } ================================================ FILE: src/preview/mod.rs ================================================ mod dir_view; mod preview; mod preview_state; mod preview_transformer; mod zero_len_file_view; pub use { dir_view::DirView, preview::Preview, preview_state::PreviewState, preview_transformer::*, zero_len_file_view::ZeroLenFileView, }; #[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum PreviewMode { /// image Image, /// show the content as text, with syntax coloring if /// it makes sense. Fails if the file isn't in UTF8 Text, /// show the content of the file as hex Hex, /// Show the content with ANSI escape codes Tty, } ================================================ FILE: src/preview/preview.rs ================================================ use { super::*, crate::{ app::*, command::ScrollCommand, display::*, errors::ProgramError, hex::HexView, image::ImageView, pattern::InputPattern, skin::PanelSkin, syntactic::TextView, task_sync::Dam, tty::TtyView, }, crokey::crossterm::{ QueueableCommand, cursor, }, std::{ io, path::Path, }, termimad::{ Area, CropWriter, SPACE_FILLING, }, }; #[allow(clippy::large_enum_variant)] pub enum Preview { Dir(DirView), Image(ImageView), Text(TextView), Hex(HexView), Tty(TtyView), ZeroLen(ZeroLenFileView), IoError(io::Error), } impl Preview { /// build a preview, never failing (but the preview can be Preview::IOError). /// If the preferred mode can't be applied, an other mode is chosen. pub fn new( path: &Path, preferred_mode: Option, con: &AppContext, ) -> Self { if path.is_file() { match preferred_mode { Some(PreviewMode::Hex) => Self::hex(path), Some(PreviewMode::Image) => Self::image(path), Some(PreviewMode::Text) => Self::unfiltered_text(path, con), Some(PreviewMode::Tty) => Self::tty(path), None => { // automatic behavior: image, text, hex ImageView::new(path) .map(Self::Image) .unwrap_or_else(|_| Self::unfiltered_text(path, con)) } } } else { Self::dir(path, InputPattern::none(), &Dam::unlimited(), con) } } /// try to build a preview with the designed mode, return an error /// if that wasn't possible pub fn with_mode( path: &Path, mode: PreviewMode, con: &AppContext, ) -> Result { if path.is_file() { match mode { PreviewMode::Hex => Ok(HexView::new(path.to_path_buf()).map(Self::Hex)?), PreviewMode::Image => ImageView::new(path).map(Self::Image), PreviewMode::Tty => TtyView::new(path) .map(Self::Tty) .map_err(ProgramError::from), PreviewMode::Text => Ok(TextView::new( path, InputPattern::none(), &mut Dam::unlimited(), con, false, ) .transpose() .expect("syntactic view without pattern shouldn't be none") .map(Self::Text)?), } } else { Ok(Self::dir( path, InputPattern::none(), &Dam::unlimited(), con, )) } } /// build a dir preview pub fn dir( path: &Path, pattern: InputPattern, dam: &Dam, con: &AppContext, ) -> Self { match DirView::new(path.to_path_buf(), pattern, dam, con) { Ok(dv) => Self::Dir(dv), Err(e) => Self::IoError(e), } } /// build an image view, unless the file can't be interpreted /// as an image, in which case a hex view is used pub fn image(path: &Path) -> Self { ImageView::new(path) .ok() .map(Self::Image) .unwrap_or_else(|| Self::hex(path)) } /// build an tty view, unless there's an IO error pub fn tty(path: &Path) -> Self { match TtyView::new(path) { Ok(tv) => Self::Tty(tv), Err(e) => Self::IoError(e), } } /// build a text preview (maybe with syntaxic coloring) if possible, /// a hex (binary) view if content isnt't UTF8, a ZeroLen file if there's /// no length (it's probably a linux pseudofile) or a IOError when /// there's a IO problem pub fn unfiltered_text( path: &Path, con: &AppContext, ) -> Self { match TextView::new( path, InputPattern::none(), &mut Dam::unlimited(), con, false, ) { Ok(Some(sv)) => Self::Text(sv), Err(ProgramError::ZeroLenFile | ProgramError::UnmappableFile) => { debug!("zero len or unmappable file - check if system file"); Self::ZeroLen(ZeroLenFileView::new(path.to_path_buf())) } Err(ProgramError::SyntectCrashed { details }) => { warn!("syntect crashed with message : {details:?}"); Self::unstyled_text(path, con) } // not previewable as UTF8 text // we'll try reading it as binary Err(ProgramError::UnprintableFile) => Self::hex(path), _ => Self::hex(path), } } /// build a text preview with no syntax highlighting, if possible pub fn unstyled_text( path: &Path, con: &AppContext, ) -> Self { match TextView::new(path, InputPattern::none(), &mut Dam::unlimited(), con, true) { Ok(Some(sv)) => Self::Text(sv), Err(ProgramError::ZeroLenFile | ProgramError::UnmappableFile) => { debug!("zero len or unmappable file - check if system file"); Self::ZeroLen(ZeroLenFileView::new(path.to_path_buf())) } // not previewable as UTF8 text - we'll try reading it as binary Err(ProgramError::UnprintableFile) => Self::hex(path), _ => Self::hex(path), } } /// try to build a filtered view. Will return None if /// the dam gets an event before it's built pub fn filtered( &self, path: &Path, pattern: InputPattern, dam: &mut Dam, con: &AppContext, ) -> Option { if path.is_file() { match self { Self::Text(_) => { match TextView::new(path, pattern, dam, con, false) { // normal finished loading Ok(Some(sv)) => Some(Self::Text(sv)), // interrupted search Ok(None) => None, // not previewable as UTF8 text // we'll try reading it as binary Err(_) => Some(Self::hex(path)), // FIXME try as unstyled if syntect crashed } } _ => None, // not filterable } } else { Some(Self::dir(path, pattern, dam, con)) } } /// return a hex_view, suitable for binary, or Self::IOError /// if there was an error pub fn hex(path: &Path) -> Self { match HexView::new(path.to_path_buf()) { Ok(reader) => Self::Hex(reader), Err(e) => { // it's unlikely as the file isn't open at this point warn!("error while previewing {:?} : {:?}", path, e); Self::IoError(e) } } } /// Return true when the preview is based on a temporarily incomplete /// loading or computing pub fn is_partial(&self) -> bool { match self { Self::Text(sv) => sv.is_partial(), _ => false, } } pub fn complete_loading( &mut self, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { match self { Self::Text(sv) => sv.complete_loading(con, dam), _ => Ok(()), } } /// return the preview_mode, or None if we're on IOError or Directory pub fn get_mode(&self) -> Option { match self { Self::Image(_) => Some(PreviewMode::Image), Self::Text(_) => Some(PreviewMode::Text), Self::ZeroLen(_) => Some(PreviewMode::Text), Self::Hex(_) => Some(PreviewMode::Hex), Self::Tty(_) => Some(PreviewMode::Tty), Self::IoError(_) => None, Self::Dir(_) => None, } } pub fn pattern(&self) -> InputPattern { match self { Self::Dir(dv) => dv.tree.options.pattern.clone(), Self::Text(sv) => sv.pattern.clone(), _ => InputPattern::none(), } } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { match self { Self::Dir(dv) => dv.try_scroll(cmd), Self::Text(sv) => sv.try_scroll(cmd), Self::Hex(hv) => hv.try_scroll(cmd), Self::Tty(v) => v.try_scroll(cmd), _ => false, } } pub fn is_filterable(&self) -> bool { matches!(self, Self::Text(_) | Self::Dir(_)) } pub fn get_selected_line(&self) -> Option { match self { Self::Text(sv) => sv.get_selected_line(), _ => None, } } pub fn get_selected_line_number(&self) -> Option { match self { Self::Text(sv) => sv.get_selected_line_number(), _ => None, } } pub fn try_select_line_number( &mut self, number: usize, ) -> bool { match self { Self::Text(sv) => sv.try_select_line_number(number), _ => false, } } pub fn unselect(&mut self) { match self { Self::Text(sv) => sv.unselect(), Self::Tty(tv) => tv.unselect(), _ => {} } } pub fn try_select_y( &mut self, y: u16, ) -> bool { match self { Self::Dir(dv) => dv.try_select_y(y), Self::Text(sv) => sv.try_select_y(y), Self::Tty(v) => v.try_select_y(y), _ => false, } } pub fn move_selection( &mut self, dy: i32, cycle: bool, ) { match self { Self::Dir(dv) => dv.move_selection(dy, cycle), Self::Text(sv) => sv.move_selection(dy, cycle), Self::Tty(v) => v.move_selection(dy, cycle), Self::Hex(hv) => { hv.try_scroll(ScrollCommand::Lines(dy)); } _ => {} } } pub fn previous_match(&mut self) { if let Self::Text(sv) = self { sv.previous_match(); } else { self.move_selection(-1, true); } } pub fn next_match(&mut self) { if let Self::Text(sv) = self { sv.next_match(); } else { self.move_selection(1, true); } } pub fn select_first(&mut self) { match self { Self::Dir(dv) => dv.select_first(), Self::Text(sv) => sv.select_first(), Self::Hex(hv) => hv.select_first(), Self::Tty(v) => v.select_first(), _ => {} } } pub fn select_last(&mut self) { match self { Self::Text(sv) => sv.select_last(), Self::Hex(hv) => hv.select_last(), Self::Tty(v) => v.select_last(), _ => {} } } pub fn display( &mut self, w: &mut W, disc: &DisplayContext, area: &Area, ) -> Result<(), ProgramError> { let panel_skin = &disc.panel_skin; let screen = disc.screen; let con = &disc.con; match self { Self::Dir(dv) => dv.display(w, disc, area), Self::Image(iv) => time!(iv.display(w, disc, area)), Self::Text(sv) => sv.display(w, screen, panel_skin, area, con), Self::ZeroLen(zlv) => zlv.display(w, screen, panel_skin, area), Self::Hex(hv) => hv.display(w, screen, panel_skin, area), Self::Tty(v) => v.display(w, screen, panel_skin, area), Self::IoError(err) => { let mut y = area.top; w.queue(cursor::MoveTo(area.left, y))?; let mut cw = CropWriter::new(w, area.width as usize); cw.queue_str(&panel_skin.styles.default, "An error prevents the preview:")?; cw.fill(&panel_skin.styles.default, &SPACE_FILLING)?; y += 1; w.queue(cursor::MoveTo(area.left, y))?; let mut cw = CropWriter::new(w, area.width as usize); cw.queue_g_string(&panel_skin.styles.status_error, err.to_string())?; cw.fill(&panel_skin.styles.default, &SPACE_FILLING)?; y += 1; while y < area.top + area.height { w.queue(cursor::MoveTo(area.left, y))?; let mut cw = CropWriter::new(w, area.width as usize); cw.fill(&panel_skin.styles.default, &SPACE_FILLING)?; y += 1; } Ok(()) } } } pub fn display_info( &mut self, w: &mut W, screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { match self { Self::Dir(dv) => dv.display_info(w, screen, panel_skin, area), Self::Image(iv) => iv.display_info(w, screen, panel_skin, area), Self::Text(sv) => sv.display_info(w, screen, panel_skin, area), Self::Hex(hv) => hv.display_info(w, screen, panel_skin, area), _ => Ok(()), } } } ================================================ FILE: src/preview/preview_state.rs ================================================ use { super::*, crate::{ app::*, command::{ Command, ScrollCommand, TriggerType, }, display::{ Screen, W, }, errors::ProgramError, flag::Flag, pattern::InputPattern, task_sync::Dam, tree::TreeOptions, verb::*, }, crokey::crossterm::{ QueueableCommand, cursor, }, std::path::{ Path, PathBuf, }, termimad::{ Area, CropWriter, SPACE_FILLING, }, }; /// an application state dedicated to previewing files. /// /// It's usually the only state in its panel and is kept when the /// selection changes (other panels indirectly call `set_selected_path`). pub struct PreviewState { pub preview_area: Area, dirty: bool, // true when background must be cleared source_path: PathBuf, // path to the file whose preview is requested transform: Option, preview: Preview, pending_pattern: InputPattern, // a pattern (or not) which has not yet be applied filtered_preview: Option, removed_pattern: InputPattern, preferred_mode: Option, tree_options: TreeOptions, mode: Mode, } impl PreviewState { pub fn new( source_path: PathBuf, pending_pattern: InputPattern, preferred_mode: Option, tree_options: TreeOptions, con: &AppContext, ) -> PreviewState { let preview_area = Area::uninitialized(); // will be fixed at drawing time let transform = con .preview_transformers .transform(&source_path, preferred_mode); let preview_path = transform .as_ref() .map(|c| &c.output_path) .unwrap_or(&source_path); let preview = Preview::new(preview_path, preferred_mode, con); PreviewState { preview_area, dirty: true, source_path, transform, preview, pending_pattern, filtered_preview: None, removed_pattern: InputPattern::none(), preferred_mode, tree_options, mode: con.initial_mode(), } } pub fn preview_path(&self) -> &Path { self.transform .as_ref() .map(|c| &c.output_path) .unwrap_or(&self.source_path) } fn vis_preview(&self) -> &Preview { self.filtered_preview.as_ref().unwrap_or(&self.preview) } fn mut_preview(&mut self) -> &mut Preview { self.filtered_preview.as_mut().unwrap_or(&mut self.preview) } fn set_mode( &mut self, mode: PreviewMode, con: &AppContext, ) -> Result { if self.preview.get_mode() == Some(mode) { return Ok(CmdResult::Keep); } Ok(match Preview::with_mode(self.preview_path(), mode, con) { Ok(preview) => { self.preview = preview; self.preferred_mode = Some(mode); CmdResult::Keep } Err(e) => CmdResult::DisplayError(format!("Can't display as {mode:?} : {e:?}")), }) } fn no_opt_selection(&self) -> Selection<'_> { match self.transform.as_ref() { // When there's a transform, we can't assume the line number makes sense Some(transform) => Selection { path: &transform.output_path, stype: SelectionType::File, is_exe: false, line: 0, }, None => Selection { path: &self.source_path, stype: SelectionType::File, is_exe: false, line: self.vis_preview().get_selected_line_number().unwrap_or(0), }, } } /// do the preview filtering if required and not yet done fn do_pending_search( &mut self, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { let old_selection = self .filtered_preview .as_ref() .and_then(|p| p.get_selected_line_number()) .or_else(|| self.preview.get_selected_line_number()); let pattern = self.pending_pattern.take(); self.filtered_preview = time!( Info, "preview filtering", self.preview .filtered(self.preview_path(), pattern, dam, con), ); // can be None if a cancellation was required if let Some(ref mut filtered_preview) = self.filtered_preview { if let Some(number) = old_selection { filtered_preview.try_select_line_number(number); } } Ok(()) } } impl PanelState for PreviewState { fn get_type(&self) -> PanelStateType { PanelStateType::Preview } fn set_mode( &mut self, mode: Mode, ) { self.mode = mode; } fn get_mode(&self) -> Mode { self.mode } fn get_pending_task(&self) -> Option<&'static str> { if self.preview.is_partial() { Some("loading") } else if self.pending_pattern.is_some() { Some("searching") } else { None } } fn on_pattern( &mut self, pat: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { if pat.is_none() { if let Some(filtered_preview) = self.filtered_preview.take() { let old_selection = filtered_preview.get_selected_line_number(); if let Some(number) = old_selection { self.preview.try_select_line_number(number); } self.removed_pattern = filtered_preview.pattern(); } } else if !self.preview.is_filterable() { return Ok(CmdResult::error("this preview can't be searched")); } self.pending_pattern = pat; Ok(CmdResult::Keep) } fn do_pending_task( &mut self, _app_state: &mut AppState, _screen: Screen, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { if self.preview.is_partial() { self.preview.complete_loading(con, dam)?; } else if self.pending_pattern.is_some() { self.do_pending_search(con, dam)?; } Ok(()) } fn selected_path(&self) -> Option<&Path> { Some(&self.source_path) } fn set_selected_path( &mut self, path: PathBuf, con: &AppContext, ) { let selected_line_number = if self.preview_path() == path { self.preview.get_selected_line_number() } else { None }; if let Some(fp) = &self.filtered_preview { self.pending_pattern = fp.pattern(); }; self.transform = con .preview_transformers .transform(&path, self.preferred_mode); let preview_path = self.transform.as_ref().map_or(&path, |c| &c.output_path); self.preview = Preview::new(preview_path, self.preferred_mode, con); if let Some(number) = selected_line_number { self.preview.try_select_line_number(number); } self.source_path = path; } fn selection(&self) -> Option> { Some(self.no_opt_selection()) } 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 } fn refresh( &mut self, _screen: Screen, con: &AppContext, ) -> Command { self.dirty = true; self.set_selected_path(self.source_path.clone(), con); Command::empty() } fn on_click( &mut self, _x: u16, y: u16, _screen: Screen, _con: &AppContext, ) -> Result { if y >= self.preview_area.top && y < self.preview_area.top + self.preview_area.height { let y = y - self.preview_area.top; self.mut_preview().try_select_y(y); } Ok(CmdResult::Keep) } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError> { let state_area = &disc.state_area; if state_area.height < 3 { warn!("area too small for preview"); return Ok(()); } let mut preview_area = state_area.clone(); preview_area.height -= 1; preview_area.top += 1; if preview_area != self.preview_area { self.dirty = true; self.preview_area = preview_area; } if self.dirty { disc.panel_skin.styles.default.queue_bg(w)?; disc.screen.clear_area_to_right(w, state_area)?; self.dirty = false; } let styles = &disc.panel_skin.styles; w.queue(cursor::MoveTo(state_area.left, 0))?; let mut cw = CropWriter::new(w, state_area.width as usize); let file_name = self .source_path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "???".to_string()); cw.queue_str(&styles.preview_title, &file_name)?; let info_area = Area::new( state_area.left + state_area.width - cw.allowed as u16, state_area.top, cw.allowed as u16, 1, ); cw.fill(&styles.preview_title, &SPACE_FILLING)?; let preview = self.filtered_preview.as_mut().unwrap_or(&mut self.preview); preview.display_info(w, disc.screen, disc.panel_skin, &info_area)?; if let Err(err) = preview.display(w, disc, &self.preview_area) { warn!("error while displaying file: {:?}", &err); if preview.get_mode().is_some() { // means it's not an error already if let ProgramError::Io { source } = err { // we mutate the preview to Preview::IOError self.preview = Preview::IoError(source); return self.display(w, disc); } } return Err(err); } Ok(()) } fn no_verb_status( &self, has_previous_state: bool, con: &AppContext, width: usize, // available width ) -> Status { let mut ssb = con.standard_status .builder(PanelStateType::Preview, self.no_opt_selection(), width); ssb.has_previous_state = has_previous_state; ssb.is_filtered = self.filtered_preview.is_some(); ssb.has_removed_pattern = self.removed_pattern.is_some(); ssb.status() } 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 { let con = &cc.app.con; match internal_exec.internal { Internal::back => { if self.filtered_preview.is_some() { self.on_pattern(InputPattern::none(), app_state, con) } else { Ok(CmdResult::PopState) } } Internal::copy_line => { #[cfg(not(feature = "clipboard"))] { Ok(CmdResult::error( "Clipboard feature not enabled at compilation", )) } #[cfg(feature = "clipboard")] { Ok(match self.mut_preview().get_selected_line() { Some(line) => match terminal_clipboard::set_string(line) { Ok(()) => CmdResult::Keep, Err(_) => CmdResult::error("Clipboard error while copying path"), }, None => CmdResult::error("No selected line in preview"), }) } } Internal::line_down => { let count = get_arg(input_invocation, internal_exec, 1); self.mut_preview().move_selection(count, true); Ok(CmdResult::Keep) } Internal::line_up => { let count = get_arg(input_invocation, internal_exec, 1); self.mut_preview().move_selection(-count, true); Ok(CmdResult::Keep) } Internal::line_down_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.mut_preview().move_selection(count, false); Ok(CmdResult::Keep) } Internal::line_up_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.mut_preview().move_selection(-count, false); Ok(CmdResult::Keep) } Internal::page_down => { self.mut_preview().try_scroll(ScrollCommand::Pages(1)); Ok(CmdResult::Keep) } Internal::page_up => { self.mut_preview().try_scroll(ScrollCommand::Pages(-1)); Ok(CmdResult::Keep) } //Internal::restore_pattern => { // debug!("restore_pattern"); // self.pending_pattern = self.removed_pattern.take(); // Ok(CmdResult::Keep) //} Internal::panel_left if self.removed_pattern.is_some() => { self.pending_pattern = self.removed_pattern.take(); Ok(CmdResult::Keep) } Internal::panel_left_no_open if self.removed_pattern.is_some() => { self.pending_pattern = self.removed_pattern.take(); Ok(CmdResult::Keep) } Internal::panel_right if self.filtered_preview.is_some() => { self.on_pattern(InputPattern::none(), app_state, con) } Internal::panel_right_no_open if self.filtered_preview.is_some() => { self.on_pattern(InputPattern::none(), app_state, con) } Internal::select_first => { self.mut_preview().select_first(); Ok(CmdResult::Keep) } Internal::select_last => { self.mut_preview().select_last(); Ok(CmdResult::Keep) } Internal::previous_match => { self.mut_preview().previous_match(); Ok(CmdResult::Keep) } Internal::next_match => { self.mut_preview().next_match(); Ok(CmdResult::Keep) } Internal::preview_image => self.set_mode(PreviewMode::Image, con), Internal::preview_text => self.set_mode(PreviewMode::Text, con), Internal::preview_tty => self.set_mode(PreviewMode::Tty, con), Internal::preview_binary => self.set_mode(PreviewMode::Hex, con), _ => self.on_internal_generic( w, invocation_parser, internal_exec, input_invocation, trigger_type, app_state, cc, ), } } fn get_flags(&self) -> Vec { vec![] } fn get_starting_input(&self) -> String { if let Some(preview) = &self.filtered_preview { preview.pattern().raw } else { self.pending_pattern.raw.clone() } } } ================================================ FILE: src/preview/preview_transformer.rs ================================================ use { crate::{ errors::*, preview::PreviewMode, }, serde::Deserialize, std::{ fs, hash::{ DefaultHasher, Hash, Hasher, }, path::{ Path, PathBuf, }, process::Command, }, tempfile::TempDir, }; #[derive(Debug, Clone, Copy)] pub struct TransformerId { idx: usize, } pub struct PreviewTransformers { transformers: Vec, /// Where the output files are temporarily stored temp_dir: TempDir, } #[derive(Debug, Clone, Deserialize)] pub struct PreviewTransformerConf { pub input_extensions: Vec, pub output_extension: String, /// The command generating an output file from an input file /// eg "mutool draw -o {output-path} {input-path}" pub command: Vec, pub mode: PreviewMode, } #[derive(Debug, Clone)] pub struct PreviewTransformer { pub input_extensions: Vec, pub output_extension: String, /// The command generating an output file from an input file /// eg "mutool draw -o {output-path} {input-path}" pub command: Vec, pub mode: PreviewMode, pub input_kind: ProcessInputKind, pub output_kind: ProcessOutputKind, } /// Specified how the input of the transformation is provided to the /// external process. #[derive(Debug, Clone, Copy)] pub enum ProcessInputKind { File, Stdin, } /// Specifies how the output of the transformation is read: /// - read from {output-path} if it's in the command, or /// - read from the first file found in {output-dir} if it's in the command, or /// - read from stdout if neither is in the command #[derive(Debug, Clone, Copy)] pub enum ProcessOutputKind { File, Dir, Stdout, } pub struct PreviewTransform { pub transformer_id: TransformerId, /// Path to the generated file pub output_path: PathBuf, } impl PreviewTransformers { pub fn new(transformer_confs: &[PreviewTransformerConf]) -> Result { let mut transformers = Vec::with_capacity(transformer_confs.len()); for transformer_conf in transformer_confs { transformers.push(PreviewTransformer::from_conf(transformer_conf)?); } let temp_dir = tempfile::Builder::new() .prefix("broot-conversions") .tempdir()?; Ok(Self { transformers, temp_dir, }) } pub fn transformer( &self, id: TransformerId, ) -> &PreviewTransformer { &self.transformers[id.idx] } pub fn transform( &self, input_path: &Path, mode: Option, ) -> Option { let transformer_id = self.find_transformer_for(input_path, mode)?; let temp_dir = self.temp_dir.path(); match self.transformers[transformer_id.idx].transform(input_path, temp_dir) { Ok(output_path) => Some(PreviewTransform { transformer_id, output_path, }), Err(e) => { error!( "conversion failed using {:?}", self.transformers[transformer_id.idx].command ); error!("conversion error: {:?}", e); None } } } pub fn find_transformer_for( &self, path: &Path, mode: Option, ) -> Option { let extension = path.extension().and_then(|ext| ext.to_str())?; for (idx, transformer) in self.transformers.iter().enumerate() { if !transformer .input_extensions .iter() .any(|ext| ext.eq_ignore_ascii_case(extension)) { continue; } if let Some(mode) = mode { if transformer.mode != mode { continue; } } return Some(TransformerId { idx }); } None } } impl PreviewTransformer { pub fn from_conf(conf: &PreviewTransformerConf) -> Result { if conf.command.is_empty() { return Err(ConfError::MissingField { txt: "empty command in preview transformer".to_string(), }); } let has_input_path = conf.command.iter().any(|c| c.contains("{input-path}")); let has_output_path = conf.command.iter().any(|c| c.contains("{output-path}")); let has_output_dir = conf.command.iter().any(|c| c.contains("{output-dir}")); let input_kind = if has_input_path { ProcessInputKind::File } else { ProcessInputKind::Stdin }; let output_kind = if has_output_path { ProcessOutputKind::File } else if has_output_dir { ProcessOutputKind::Dir } else { ProcessOutputKind::Stdout }; Ok(Self { input_extensions: conf.input_extensions.clone(), output_extension: conf.output_extension.clone(), command: conf.command.clone(), mode: conf.mode, input_kind, output_kind, }) } /// Call the external process to transform the input file into an output file /// /// Input is given to the process either as a file or as stdin, depending on /// whether the command contains "{input-path}". /// /// Output is /// - read from {output-path} if it's in the command, or /// - read from the first file found in {output-dir} if it's in the command, or /// - read from stdout if neither is in the command pub fn transform( &self, input_path: &Path, temp_dir: &Path, ) -> Result { let hash = { let mut hasher = DefaultHasher::new(); input_path.hash(&mut hasher); hasher.finish() }; let input_stem = input_path .file_stem() .ok_or(PreviewTransformerError::InvalidInput)? .to_string_lossy(); let output_dir = temp_dir.join(format!("{:x}", hash)); if output_dir.exists() { // if there's a file in the output directory, it's the result of a previous // transformation of the same input file if let Some(path) = first_file_in_dir(&output_dir)? { // we check that the transformed file isn't older than the file // to preview (or changes would be ignored) let input_modified = input_path.metadata().and_then(|m| m.modified()); let transformed_modified = path.metadata().and_then(|m| m.modified()); match (input_modified, transformed_modified) { (Ok(input_date), Ok(transformed_date)) if input_date <= transformed_date => { // the transformed file is up to date debug!("preview transform {:?} up to date", path); return Ok(path); } _ => { // the transformed file is obsolete debug!("preview transform {:?} obsolete", path); fs::remove_file(&path)?; } } } } else { fs::create_dir(&output_dir)?; } let mut output_path = output_dir.join(format!("{}.{}", input_stem, self.output_extension)); let mut command = self.command.iter().map(|part| { part.replace("{input-path}", &input_path.to_string_lossy()) .replace("{output-dir}", &output_dir.to_string_lossy()) .replace("{output-path}", &output_path.to_string_lossy()) }); info!("transforming {:?} to {:?}", input_path, output_path); let executable = command.next().unwrap(); let mut process = Command::new(executable); process.stderr(std::process::Stdio::null()); process.args(command); match self.input_kind { ProcessInputKind::File => { process.stdin(std::process::Stdio::null()); } ProcessInputKind::Stdin => { process.stdin(std::fs::File::open(input_path)?); } } match self.output_kind { ProcessOutputKind::File | ProcessOutputKind::Dir => { process.stdout(std::process::Stdio::null()); } ProcessOutputKind::Stdout => { process.stdout(std::fs::File::create(&output_path)?); } } let exit_status = process.spawn().and_then(|mut p| p.wait())?; output_path = first_file_in_dir(&output_dir)?.ok_or(PreviewTransformerError::NoOutput)?; if exit_status.success() { Ok(output_path) } else { // we remove the output file if the process failed, so that // it's not returned on the next call let _ = std::fs::remove_file(&output_path); match exit_status.code() { Some(code) => Err(PreviewTransformerError::ProcessFailed { code }), None => Err(PreviewTransformerError::ProcessInterrupted), } } } } fn first_file_in_dir(dir: &Path) -> Result, PreviewTransformerError> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_file() { return Ok(Some(path)); } } Ok(None) } ================================================ FILE: src/preview/zero_len_file_view.rs ================================================ use { crate::{ display::{ Screen, W, }, errors::ProgramError, skin::PanelSkin, }, char_reader::CharReader, crokey::crossterm::{ QueueableCommand, cursor, }, std::{ fs::File, path::PathBuf, }, termimad::{ Area, CropWriter, SPACE_FILLING, }, }; /// a (light) display for a file declaring a size 0, /// as happens for many system "files", for example in /proc pub struct ZeroLenFileView { path: PathBuf, } impl ZeroLenFileView { pub fn new(path: PathBuf) -> Self { Self { path } } pub fn display( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let styles = &panel_skin.styles; let line_count = area.height as usize; let file = File::open(&self.path)?; let mut reader = CharReader::new(file); // line_len here is in chars, and we crop in cols, but it's OK because both // are usually identical for system files and we crop later anyway let line_len = area.width as usize; for y in 0..line_count { w.queue(cursor::MoveTo(area.left, y as u16 + area.top))?; let mut cw = CropWriter::new(w, area.width as usize); let cw = &mut cw; if let Some(line) = reader.next_line(line_len, 15_000)? { cw.queue_str(&styles.default, &line)?; } cw.fill(&styles.default, &SPACE_FILLING)?; } Ok(()) } } ================================================ FILE: src/print.rs ================================================ //! functions printing a tree or a path use { crate::{ app::*, display::Screen, errors::ProgramError, launchable::Launchable, skin::{ PanelSkin, StyleMap, }, tree::Tree, }, crokey::crossterm::tty::IsTty, pathdiff, std::{ io::{ self, stdout, }, path::Path, }, }; fn print_string( string: String, _con: &AppContext, ) -> io::Result { Ok( // We write on stdout, but we must do it after app closing // to have the desired stdout (it may be the normal terminal // or a file, or other output) CmdResult::from(Launchable::printer(string)), ) } pub fn print_paths( sel_info: SelInfo, con: &AppContext, ) -> io::Result { let string = match sel_info { SelInfo::None => "".to_string(), // better idea ? SelInfo::One(sel) => sel.path.to_string_lossy().to_string(), SelInfo::More(stage) => { let mut string = String::new(); for path in stage.paths() { string.push_str(&path.to_string_lossy()); string.push('\n'); } string } }; print_string(string, con) } fn relativize_path( path: &Path, con: &AppContext, ) -> io::Result { let relative_path = match pathdiff::diff_paths(path, &con.initial_root) { None => { return Err(io::Error::other(format!("Cannot relativize {path:?}"))); } Some(p) => p, }; Ok(if relative_path.components().next().is_some() { relative_path.to_string_lossy().to_string() } else { ".".to_string() }) } pub fn print_relative_paths( sel_info: SelInfo, con: &AppContext, ) -> io::Result { let string = match sel_info { SelInfo::None => "".to_string(), SelInfo::One(sel) => relativize_path(sel.path, con)?, SelInfo::More(stage) => { let mut string = String::new(); for path in stage.paths() { string.push_str(&relativize_path(path, con)?); string.push('\n'); } string } }; print_string(string, con) } pub fn print_tree( tree: &Tree, screen: Screen, panel_skin: &PanelSkin, con: &AppContext, ) -> Result { // We write on stdout, but we must do it after app closing to have the normal terminal let show_color = con.launch_args.color.unwrap_or_else(|| stdout().is_tty()); let styles = if show_color { panel_skin.styles.clone() } else { StyleMap::no_term() }; Ok(CmdResult::from(Launchable::tree_printer( tree, screen, styles, con.ext_colors.clone(), ))) } ================================================ FILE: src/shell_install/bash.rs ================================================ //! The goal of this mod is to ensure the launcher shell function //! is available for bash and zsh i.e. the `br` shell function can //! be used to launch broot (and thus make it possible to execute //! some commands, like `cd`, from the starting shell. //! //! In a correct installation, we have: //! - a function declaration script in ~/.local/share/broot/launcher/bash/br/1 //! - a link to that script in ~/.config/broot/launcher/bash/br/1 //! - a line to source the link in ~/.bashrc and ~/.zshrc //! //! (exact paths depend on XDG variables) use { super::{ ShellInstall, util, }, crate::{ conf, errors::*, }, directories::UserDirs, lazy_regex::regex, regex::Captures, std::{ env, path::PathBuf, }, termimad::mad_print_inline, }; const NAME: &str = "bash"; const SOURCING_FILES: &[&str] = &[".bashrc", ".bash_profile", ".zshrc", "$ZDOTDIR/.zshrc"]; const VERSION: &str = "1"; // This script has been tested on bash and zsh. // It's installed under the bash name (~/.config/broot // but linked from both the .bashrc and the .zshrc files const BASH_FUNC: &str = r#" # This script was automatically generated by the broot program # More information can be found in https://github.com/Canop/broot # This function starts broot and executes the command # it produces, if any. # It's needed because some shell commands, like `cd`, # have no useful effect if executed in a subshell. function br { local cmd cmd_file code cmd_file=$(mktemp) if broot --outcmd "$cmd_file" "$@"; then cmd=$(<"$cmd_file") command rm -f "$cmd_file" eval "$cmd" else code=$? command rm -f "$cmd_file" return "$code" fi } "#; const MD_NO_SOURCING: &str = r" I found no sourcing file for the bash/zsh family. If you're using bash or zsh, then installation isn't complete: the br function initialization script won't be sourced unless you source it yourself. "; pub fn get_script() -> &'static str { BASH_FUNC } /// return the path to the link to the function script fn get_link_path() -> PathBuf { conf::dir().join("launcher").join(NAME).join("br") } /// return the path to the script containing the function. /// /// At version 0.10.4 we change the location of the script: /// It was previously with the link, but it's now in /// XDG_DATA_HOME (typically ~/.local/share on linux) fn get_script_path() -> PathBuf { conf::app_dirs() .data_dir() .join("launcher") .join(NAME) .join(VERSION) } /// return the paths to the files in which the br function is sourced. /// Paths in SOURCING_FILES can be absolute or relative to the home /// directory. Environment variables designed as $NAME are interpolated. fn get_sourcing_paths() -> Vec { let homedir_path = UserDirs::new() .expect("no home directory!") .home_dir() .to_path_buf(); SOURCING_FILES .iter() .map(|name| { regex!(r#"\$(\w+)"#) .replace(name, |c: &Captures<'_>| { env::var(&c[1]).unwrap_or_else(|_| (*name).to_string()) }) .to_string() }) .map(PathBuf::from) .map(|path| { if path.is_absolute() { path } else { homedir_path.join(path) } }) .filter(|path| { debug!("considering path: {:?}", &path); path.exists() }) .collect() } /// check for bash and zsh shells. /// check whether the shell function is installed, install /// it if it wasn't refused before or if broot is launched /// with --install. pub fn install(si: &mut ShellInstall) -> Result<(), ShellInstallError> { let script_path = get_script_path(); si.write_script(&script_path, BASH_FUNC)?; let link_path = get_link_path(); si.create_link(&link_path, &script_path)?; let sourcing_paths = get_sourcing_paths(); if sourcing_paths.is_empty() { warn!("no sourcing path for bash/zsh!"); si.skin.print_text(MD_NO_SOURCING); return Ok(()); } let escaped_path = link_path.to_string_lossy().replace(' ', "\\ "); let source_line = format!("source {}", &escaped_path); for sourcing_path in &sourcing_paths { let sourcing_path_str = sourcing_path.to_string_lossy(); if util::file_contains_line(sourcing_path, &source_line)? { mad_print_inline!( &si.skin, "`$0` already patched, no change made.\n", &sourcing_path_str, ); } else { util::append_to_file(sourcing_path, format!("\n{source_line}\n"))?; let is_zsh = sourcing_path_str.contains(".zshrc"); if is_zsh { mad_print_inline!( &si.skin, "`$0` successfully patched, you can make the function immediately available with `exec zsh`\n", &sourcing_path_str, ); } else { mad_print_inline!( &si.skin, "`$0` successfully patched, you can make the function immediately available with `source $0`\n", &sourcing_path_str, ); } } } si.done = true; Ok(()) } ================================================ FILE: src/shell_install/fish.rs ================================================ //! The goal of this mod is to ensure the launcher shell function //! is available for fish i.e. the `br` shell function can //! be used to launch broot (and thus make it possible to execute //! some commands, like `cd`, from the starting shell. //! //! //! In a correct installation, we have: //! - a function declaration script in ~/.local/share/broot/launcher/fish/br.fish //! - a link to that script in ~/.config/fish/functions/br.fish //! //! (exact paths depend on XDG variables) //! //! fish stores functions in FISH_CONFIG_DIR/functions (for example, //! ~/.config/fish/functions) and lazily loads (or reloads) them as //! needed. use { super::ShellInstall, crate::{ conf, errors::*, }, directories::{ BaseDirs, ProjectDirs, }, std::path::PathBuf, }; const NAME: &str = "fish"; const SCRIPT_FILENAME: &str = "br.fish"; const FISH_FUNC: &str = r" # This script was automatically generated by the broot program # More information can be found in https://github.com/Canop/broot # This function starts broot and executes the command # it produces, if any. # It's needed because some shell commands, like `cd`, # have no useful effect if executed in a subshell. function br --wraps=broot set -l cmd_file (mktemp) if broot --outcmd $cmd_file $argv source $cmd_file rm -f $cmd_file else set -l code $status rm -f $cmd_file return $code end end "; pub fn get_script() -> &'static str { FISH_FUNC } /// return the root of fish's config fn get_fish_dir() -> PathBuf { if let Some(base_dirs) = BaseDirs::new() { let fish_dir = base_dirs.home_dir().join(".config/fish"); if fish_dir.exists() { return fish_dir; } } ProjectDirs::from("fish", "fish", "fish") // hem... .expect("Unable to find configuration directories") .config_dir() .to_path_buf() } /// return the fish functions directory fn get_fish_functions_dir() -> PathBuf { get_fish_dir().join("functions") } /// return the path to the link to the function script /// /// At version 0.10.4 we change the location of the script: /// It was previously with the link, but it's now in /// ~/.config/fish/functions/br.fish fn get_link_path() -> PathBuf { get_fish_functions_dir().join("br.fish") } /// return the path to the script containing the function. /// /// At version 0.10.4 we change the location of the script: /// It was previously with the link, but it's now in /// ~/.local/share/broot/launcher/fish/br.fish fn get_script_path() -> PathBuf { conf::app_dirs() .data_dir() .join("launcher") .join(NAME) .join(SCRIPT_FILENAME) } /// check for fish shell /// /// As fish isn't frequently used, we first check that it seems /// to be installed. If not, we just do nothing. pub fn install(si: &mut ShellInstall) -> Result<(), ShellInstallError> { let fish_dir = get_fish_dir(); if !fish_dir.exists() { debug!("no fish config directory. Assuming fish isn't used."); return Ok(()); } info!("fish seems to be installed"); let script_path = get_script_path(); si.write_script(&script_path, FISH_FUNC)?; let link_path = get_link_path(); // creating the link may create the fish/conf.d directory si.create_link(&link_path, &script_path)?; si.done = true; Ok(()) } ================================================ FILE: src/shell_install/mod.rs ================================================ mod bash; mod fish; mod nushell; mod powershell; mod state; mod util; use { crate::{ cli, errors::*, skin, }, std::{ fs, os, path::Path, }, termimad::{ MadSkin, mad_print_inline, }, }; pub use state::ShellInstallState; const MD_INSTALL_REQUEST: &str = r" **Broot** should be launched using a shell function. This function most notably makes it possible to `cd` from inside broot (see *https://dystroy.org/broot/install-br/* for explanations). Can I install it now? [**Y**/n] "; const MD_UPGRADE_REQUEST: &str = r" Broot's shell function should be upgraded. Can I proceed? [**Y**/n] "; const MD_INSTALL_CANCELLED: &str = r" You refused the installation (for now). You can still used `broot` but some features won't be available. If you want the `br` shell function, you may either * do `broot --install` * install the various pieces yourself (see *https://dystroy.org/broot/install-br/* for details). "; const MD_PERMISSION_DENIED: &str = r" Installation check resulted in **Permission Denied**. Please relaunch with elevated privilege. This is typically only needed once. Error details: "; const MD_INSTALL_DONE: &str = r" The **br** function has been successfully installed. You may have to restart your shell or source your shell init files. Afterwards, you should start broot with `br` in order to use its full power. "; pub struct ShellInstall { force_install: bool, // when the program was launched with --install skin: MadSkin, pub should_quit: bool, authorization: Option, done: bool, // true if the installation was just made } impl ShellInstall { pub fn new(force_install: bool) -> Self { Self { force_install, skin: skin::make_cli_mad_skin(), should_quit: false, authorization: if force_install { Some(true) } else { None }, done: false, } } /// write on stdout the script building the function for /// the given shell pub fn print(shell: &str) -> Result<(), ProgramError> { match shell { "bash" | "zsh" => println!("{}", bash::get_script()), "fish" => println!("{}", fish::get_script()), "nushell" => println!("{}", nushell::get_script()), "powershell" => println!("{}", powershell::get_script()), _ => { return Err(ProgramError::UnknownShell { shell: shell.to_string(), }); } } Ok(()) } /// check whether the shell function is installed an up to date, /// install it if it wasn't refused before or if broot is launched /// with --install. pub fn check(&mut self) -> Result<(), ShellInstallError> { let install_state = ShellInstallState::detect(); info!("Shell installation state: {install_state:?}"); if self.force_install { self.skin.print_text("You requested a clean (re)install."); ShellInstallState::remove(self)?; } else { match install_state { ShellInstallState::Refused => { return Ok(()); } ShellInstallState::UpToDate => { return Ok(()); } ShellInstallState::Obsolete => { if !self.can_upgrade()? { debug!("User refuses the upgrade. Doing nothing."); return Ok(()); } } ShellInstallState::NotInstalled => { if !self.can_install()? { debug!("User refuses the installation. Doing nothing."); return Ok(()); } } } } // even if the installation isn't really complete (for example // when no bash file was found), we don't want to ask the user // again, we'll assume it's done ShellInstallState::UpToDate.write(self)?; debug!("Starting install"); bash::install(self)?; fish::install(self)?; nushell::install(self)?; powershell::install(self)?; self.should_quit = true; if self.done { self.skin.print_text(MD_INSTALL_DONE); } Ok(()) } /// print some additional information on the error (typically before /// the error itself is dumped) pub fn comment_error( &self, err: &ShellInstallError, ) { if err.is_permission_denied() { self.skin.print_text(MD_PERMISSION_DENIED); } } pub fn remove( &self, path: &Path, ) -> Result<(), ShellInstallError> { // path.exists() doesn't work when the file is a link (it checks whether // the link destination exists instead of checking the link exists // so we first check whether the link exists if fs::read_link(path).is_ok() || path.exists() { mad_print_inline!(self.skin, "Removing `$0`.\n", path.to_string_lossy()); fs::remove_file(path).context(&|| format!("removing {path:?}"))?; } Ok(()) } /// check whether we're allowed to install. fn can_install(&mut self) -> Result { self.can_do(false) } fn can_upgrade(&mut self) -> Result { self.can_do(true) } fn can_do( &mut self, upgrade: bool, ) -> Result { if let Some(authorization) = self.authorization { return Ok(authorization); } let refused_path = ShellInstallState::get_refused_path(); if refused_path.exists() { debug!("User already refused the installation"); return Ok(false); } self.skin.print_text(if upgrade { MD_UPGRADE_REQUEST } else { MD_INSTALL_REQUEST }); let proceed = cli::ask_authorization().context(&|| "asking user".to_string())?; // read_line failure debug!("proceed: {:?}", proceed); self.authorization = Some(proceed); if !proceed { ShellInstallState::Refused.write(self)?; self.skin.print_text(MD_INSTALL_CANCELLED); } Ok(proceed) } /// write the script at the given path fn write_script( &self, script_path: &Path, content: &str, ) -> Result<(), ShellInstallError> { self.remove(script_path)?; info!("Writing `br` shell function in `{:?}`", &script_path); mad_print_inline!( &self.skin, "Writing *br* shell function in `$0`.\n", script_path.to_string_lossy(), ); fs::create_dir_all(script_path.parent().unwrap()) .context(&|| format!("creating parent dirs to {script_path:?}"))?; fs::write(script_path, content) .context(&|| format!("writing script in {script_path:?}"))?; Ok(()) } /// create a link fn create_link( &self, link_path: &Path, script_path: &Path, ) -> Result<(), ShellInstallError> { info!("Creating link from {:?} to {:?}", &link_path, &script_path); self.remove(link_path)?; let link_path_str = link_path.to_string_lossy(); let script_path_str = script_path.to_string_lossy(); mad_print_inline!( &self.skin, "Creating link from `$0` to `$1`.\n", &link_path_str, &script_path_str, ); let parent = link_path.parent().unwrap(); fs::create_dir_all(parent).context(&|| format!("creating directory {parent:?}"))?; #[cfg(unix)] os::unix::fs::symlink(script_path, link_path) .context(&|| format!("linking from {link_path:?} to {script_path:?}"))?; #[cfg(windows)] os::windows::fs::symlink_file(&script_path, &link_path) .context(&|| format!("linking from {link_path:?} to {script_path:?}"))?; Ok(()) } } ================================================ FILE: src/shell_install/nushell.rs ================================================ //! The goal of this mod is to ensure the launcher shell function //! is available for nushell i.e. the `br` shell function can //! be used to launch broot (and thus make it possible to execute //! some commands, like `cd`, from the starting shell. //! //! In a correct installation, we have: //! - a function declaration script in ~/.local/share/broot/launcher/nushell/br/1 //! - a link to that script in ~/.config/broot/launcher/nushell/br/1 //! - a line to use the link in ~/.config/nushell/config.nu //! //! (exact paths depend on XDG variables) //! //! Please note that this function doesn't allow other commands than cd, //! contrary to the similar function of other shells. use { super::{ ShellInstall, util, }, crate::{ conf, errors::*, }, directories::BaseDirs, std::path::PathBuf, termimad::mad_print_inline, }; const NAME: &str = "nushell"; const VERSION: &str = "7"; const NU_FUNC: &str = r#" # Launch broot # # Examples: # > br -hi some/path # > br # > br -sdp # > br -hi -c "vacheblan.svg;:open_preview" .. # # See https://dystroy.org/broot/install-br/ export def --env br [ --cmd(-c): string # Semicolon separated commands to execute --color: string = "auto" # Whether to have styles and colors (auto is default and usually OK) [possible values: auto, yes, no] --conf: string # Semicolon separated paths to specific config files"), --dates(-d) # Show the last modified date of files and directories" --no-dates(-D) # Don't show the last modified date" --only-folders(-f) # Only show folders --no-only-folders(-F) # Show folders and files alike --show-git-info(-g) # Show git statuses on files and stats on repo --no-show-git-info(-G) # Don't show git statuses on files and stats on repo --git-status # Only show files having an interesting git status, including hidden ones --hidden(-h) # Show hidden files --listen: string # Listen for commands on a given linux socket --listen-auto # Listen for commands on a random linux socket --no-hidden(-H) # Don't show hidden files --height: int # Height (if you don't want to fill the screen or for file export) --help # Print help information --git-ignored(-i) # Show git ignored files --no-git-ignored(-I) # Don't show git ignored files --install # Install or reinstall the br shell function --no-sort # Don't sort --permissions(-p) # Show permissions --no-permissions(-P) # Don't show permissions --print-shell-function: string # Print to stdout the br function for a given shell --sizes(-s) # Show the size of files and directories --no-sizes(-S) # Don't show sizes --set-install-state: path # Where to write the produced cmd (if any) [possible values: undefined, refused, installed] --show-root-fs # Show filesystem info on top --max-depth: int # Only show trees up to a certain depth --sort-by-count # Sort by count (only show one level of the tree) --sort-by-date # Sort by date (only show one level of the tree) --sort-by-size # Sort by size (only show one level of the tree) --sort-by-type # Same as sort-by-type-dirs-first --sort-by-type-dirs-first # Sort by type, directories first (only show one level of the tree) --sort-by-type-dirs-last # Sort by type, directories last (only show one level of the tree) --trim-root(-t) # Trim the root too and don't show a scrollbar --no-trim-root(-T) # Don't trim the root level, show a scrollbar --version(-V) # Print version information --whale-spotting(-w) # Sort by size, show ignored and hidden files --write-default-conf: path # Write default conf files in given directory file?: path # Root Directory ] { mut args = [] if $cmd != null { $args = ($args | append $'--cmd=($cmd)') } if $color != null { $args = ($args | append $'--color=($color)') } if $conf != null { $args = ($args | append $'--conf=($conf)') } if $dates { $args = ($args | append $'--dates') } if $no_dates { $args = ($args | append $'--no-dates') } if $only_folders { $args = ($args | append $'--only-folders') } if $no_only_folders { $args = ($args | append $'--no-only-folders') } if $show_git_info { $args = ($args | append $'--show-git-info') } if $no_show_git_info { $args = ($args | append $'--no-show-git-info') } if $git_status { $args = ($args | append $'--git-status') } if $hidden { $args = ($args | append $'--hidden') } if $no_hidden { $args = ($args | append $'--no-hidden') } if $height != null { $args = ($args | append $'--height=($height)') } if $help { $args = ($args | append $'--help') } if $git_ignored { $args = ($args | append $'--git-ignored') } if $no_git_ignored { $args = ($args | append $'--no-git-ignored') } if $install { $args = ($args | append $'--install') } if $listen != null { $args = ($args | append $'--listen=($listen)') } if $listen_auto { $args = ($args | append $'--listen-auto') } if $no_sort { $args = ($args | append $'--no-sort') } if $permissions { $args = ($args | append $'--permissions') } if $no_permissions { $args = ($args | append $'--no-permissions') } if $print_shell_function != null { $args = ($args | append $'--print-shell-function=($print_shell_function)') } if $sizes { $args = ($args | append $'--sizes') } if $no_sizes { $args = ($args | append $'--no-sizes') } if $set_install_state != null { $args = ($args | append $'--set-install-state=($set_install_state)') } if $show_root_fs { $args = ($args | append $'--show-root-fs') } if $max_depth != null { $args = ($args | append $'--max-depth=($max_depth)') } if $sort_by_count { $args = ($args | append $'--sort-by-count') } if $sort_by_date { $args = ($args | append $'--sort-by-date') } if $sort_by_size { $args = ($args | append $'--sort-by-size') } if $sort_by_type { $args = ($args | append $'--sort-by-type') } if $sort_by_type_dirs_first { $args = ($args | append $'--sort-by-type-dirs-first') } if $sort_by_type_dirs_last { $args = ($args | append $'--sort-by-type-dirs-last') } if $trim_root { $args = ($args | append $'--trim-root') } if $no_trim_root { $args = ($args | append $'--no-trim-root') } if $version { $args = ($args | append $'--version') } if $whale_spotting { $args = ($args | append $'--whale-spotting') } if $write_default_conf != null { $args = ($args | append $'--write-default-conf=($write_default_conf)') } let cmd_file = ( if ($env.XDG_RUNTIME_DIR? | is-not-empty) { $env.XDG_RUNTIME_DIR } else { if (version).minor >= 110 or (version).major > 0 { $nu.temp-dir } else { $nu.temp-path } } | path join $"broot-(random chars).tmp" ) touch $cmd_file if ($file == null) { ^broot --outcmd $cmd_file ...$args } else { ^broot --outcmd $cmd_file ...$args $file } let $cmd = (open $cmd_file) rm -p -f $cmd_file if (not ($cmd | lines | is-empty)) { cd ($cmd | parse -r `^cd\s+(?"|'|)(?.+)\k[\s\r\n]*$` | get path | to text) } } export extern broot [ --cmd(-c): string # Semicolon separated commands to execute --color: string = "auto" # Whether to have styles and colors (auto is default and usually OK) [possible values: auto, yes, no] --conf: string # Semicolon separated paths to specific config files"), --dates(-d) # Show the last modified date of files and directories" --no-dates(-D) # Don't show the last modified date" --only-folders(-f) # Only show folders --no-only-folders(-F) # Show folders and files alike --show-git-info(-g) # Show git statuses on files and stats on repo --no-show-git-info(-G) # Don't show git statuses on files and stats on repo --git-status # Only show files having an interesting git status, including hidden ones --hidden(-h) # Show hidden files --no-hidden(-H) # Don't show hidden files --height: int # Height (if you don't want to fill the screen or for file export) --help # Print help information --git-ignored(-i) # Show git ignored files --no-git-ignored(-I) # Don't show git ignored files --install # Install or reinstall the br shell function --listen: string # Listen for commands on a given linux socket --listen-auto # Listen for commands on a random linux socket --no-sort # Don't sort --outcmd: path # Write cd command in given path --permissions(-p) # Show permissions --no-permissions(-P) # Don't show permissions --print-shell-function: string # Print to stdout the br function for a given shell --sizes(-s) # Show the size of files and directories --no-sizes(-S) # Don't show sizes --set-install-state: path # Where to write the produced cmd (if any) [possible values: undefined, refused, installed] --show-root-fs # Show filesystem info on top --max-depth: int # Only show trees up to a certain depth --sort-by-count # Sort by count (only show one level of the tree) --sort-by-date # Sort by date (only show one level of the tree) --sort-by-size # Sort by size (only show one level of the tree) --sort-by-type # Same as sort-by-type-dirs-first --sort-by-type-dirs-first # Sort by type, directories first (only show one level of the tree) --sort-by-type-dirs-last # Sort by type, directories last (only show one level of the tree) --trim-root(-t) # Trim the root too and don't show a scrollbar --no-trim-root(-T) # Don't trim the root level, show a scrollbar --version(-V) # Print version information --whale-spotting(-w) # Sort by size, show ignored and hidden files --write-default-conf: path # Write default conf files in given directory file?: path # Root Directory ] "#; pub fn get_script() -> &'static str { NU_FUNC } /// return the path to the link to the function script fn get_link_path() -> PathBuf { conf::dir().join("launcher").join(NAME).join("br") } /// return the root of fn get_nushell_dir() -> Option { BaseDirs::new() .map(|base_dirs| base_dirs.config_dir().join("nushell")) .filter(|dir| dir.exists()) } /// return the path to the script containing the function. /// /// In XDG_DATA_HOME (typically ~/.local/share on linux) fn get_script_path() -> PathBuf { conf::app_dirs() .data_dir() .join("launcher") .join(NAME) .join(VERSION) } /// Check for nushell. /// /// Check whether the shell function is installed, install /// it if it wasn't refused before or if broot is launched /// with --install. pub fn install(si: &mut ShellInstall) -> Result<(), ShellInstallError> { info!("install {NAME}"); let Some(nushell_dir) = get_nushell_dir() else { debug!("no nushell config directory. Assuming nushell isn't used."); return Ok(()); }; info!("nushell seems to be installed"); let script_path = get_script_path(); si.write_script(&script_path, NU_FUNC)?; let link_path = get_link_path(); si.create_link(&link_path, &script_path)?; let escaped_path = link_path.to_string_lossy().replace(' ', "\\ "); let source_line = format!("use '{}' *", &escaped_path); let sourcing_path = nushell_dir.join("config.nu"); if !sourcing_path.exists() { warn!("Unexpected lack of config.nu file"); return Ok(()); } if sourcing_path.is_dir() { warn!("config.nu file"); return Ok(()); } let sourcing_path_str = sourcing_path.to_string_lossy(); if util::file_contains_line(&sourcing_path, &source_line)? { mad_print_inline!( &si.skin, "`$0` already patched, no change made.\n", &sourcing_path_str, ); } else { util::append_to_file(&sourcing_path, format!("\n{source_line}\n"))?; mad_print_inline!( &si.skin, "`$0` successfully patched, you can make the function immediately available with `use '$0' *`\n", &sourcing_path_str, ); } si.done = true; Ok(()) } ================================================ FILE: src/shell_install/powershell.rs ================================================ //! The goal of this mod is to ensure the launcher shell function //! is available for PowerShell i.e. the `br` shell function can //! be used to launch broot (and thus make it possible to execute //! some commands, like `cd`, from the starting shell. //! //! In a correct installation, we have: //! - a function declaration script in %APPDATA%/dystroy/broot/data/launcher/powershell/1 //! - a link to that script in %APPDATA%/dystroy/broot/config/launcher/powershell/br.ps1 //! - a line to source the link in the PowerShell profile (detected dynamically) //! //! The profile is detected by running pwsh.exe first, then //! powershell.exe if pwsh is not found. If neither is found, it defaults to //! %USERPROFILE%/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1 use { super::{ ShellInstall, util, }, crate::{ conf, errors::*, }, directories::UserDirs, std::{ fs, path::PathBuf, process::Command, }, termimad::mad_print_inline, }; const NAME: &str = "powershell"; const VERSION: &str = "1"; const PS_FUNC: &str = r#" # https://github.com/Canop/broot/issues/460#issuecomment-1303005689 Function br { $args = $args -join ' ' $cmd_file = New-TemporaryFile try { $process = Start-Process -FilePath 'broot.exe' ` -ArgumentList "--outcmd $($cmd_file.FullName) $args" ` -NoNewWindow -PassThru -WorkingDirectory $PWD Wait-Process -InputObject $process #Faster than Start-Process -Wait If ($process.ExitCode -eq 0) { $cmd = Get-Content $cmd_file If ($cmd -ne $null) { Invoke-Expression -Command $cmd } } Else { Write-Host "`n" # Newline to tidy up broot unexpected termination Write-Error "broot.exe exited with error code $($process.ExitCode)" } } finally { Remove-Item $cmd_file } } "#; pub fn get_script() -> &'static str { PS_FUNC } /// return the path to the link to the function script fn get_link_path() -> PathBuf { conf::dir().join("launcher").join(NAME).join("br.ps1") } /// return the path to the script containing the function. /// /// In XDG_DATA_HOME (typically ~/.local/share on linux) fn get_script_path() -> PathBuf { conf::app_dirs() .data_dir() .join("launcher") .join(NAME) .join(VERSION) } /// Get PowerShell's $profile by invoking pwsh or powershell. /// Returns None if the executable isn't present in environment /// path or the call fails fn get_profile(exe: &str) -> Option { let output = Command::new(exe) .args(["-NoProfile", "-NoLogo", "-Command", "Write-Output", "$profile"]) .output() .ok()?; if !output.status.success() { return None; } let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); if s.is_empty() { None } else { Some(PathBuf::from(s)) } } /// Check whether the shell function is installed, install /// it if it wasn't refused before or if broot is launched /// with --install. #[allow(unreachable_code, unused_variables)] pub fn install(si: &mut ShellInstall) -> Result<(), ShellInstallError> { info!("install {NAME}"); #[cfg(unix)] { debug!("Shell install not supported for PowerShell on unix-based systems."); return Ok(()); } let Some(user_dir) = UserDirs::new() else { warn!("Could not find user directory."); return Ok(()); }; let Some(document_dir) = user_dir.document_dir() else { warn!("Could not find user documents directory."); return Ok(()); }; let script_path = get_script_path(); si.write_script(&script_path, PS_FUNC)?; let link_path = get_link_path(); si.create_link(&link_path, &script_path)?; let escaped_path = link_path.to_string_lossy().replace('\'', "''"); let source_line = format!(". '{}'", escaped_path); let sourcing_path = get_profile("pwsh") .or_else(|| get_profile("powershell")) .unwrap_or_else(|| document_dir.join("WindowsPowerShell").join("Microsoft.PowerShell_profile.ps1")); if !sourcing_path.exists() { debug!("Creating missing PowerShell profile file."); if let Some(parent) = sourcing_path.parent() { fs::create_dir_all(parent).context(&|| format!("creating {parent:?} directory"))?; } fs::File::create(&sourcing_path).context(&|| format!("creating {sourcing_path:?}"))?; } let sourcing_path_str = sourcing_path.to_string_lossy(); if util::file_contains_line(&sourcing_path, &source_line)? { mad_print_inline!( &si.skin, "`$0` already patched, no change made.\n", &sourcing_path_str, ); } else { util::append_to_file(&sourcing_path, format!("\n{source_line}\n"))?; mad_print_inline!(&si.skin, "`$0` successfully patched.\n", &sourcing_path_str,); } si.done = true; Ok(()) } ================================================ FILE: src/shell_install/state.rs ================================================ use { super::ShellInstall, crate::{ cli, conf, errors::*, }, std::{ fs, path::PathBuf, }, }; /// must be incremented when the architecture changes or one of the shell /// specific scripts is upgraded to a new version const CURRENT_VERSION: usize = 4; const REFUSED_FILE_CONTENT: &str = r" This file tells broot you refused the installation of the companion shell function. If you want to install it run broot -- install "; const INSTALLED_FILE_CONTENT: &str = r" This file tells broot the installation of the br function was done. If there's a problem and you want to install it again run broot -- install "; #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum ShellInstallState { NotInstalled, // before any install, this is the initial state Refused, // user doesn't want anything to be installed Obsolete, UpToDate, } impl From for ShellInstallState { fn from(cs: cli::CliShellInstallState) -> Self { match cs { cli::CliShellInstallState::Undefined => Self::NotInstalled, cli::CliShellInstallState::Refused => Self::Refused, cli::CliShellInstallState::Installed => Self::UpToDate, } } } impl ShellInstallState { pub fn get_refused_path() -> PathBuf { conf::dir().join("launcher").join("refused") } pub fn get_installed_path(version: usize) -> PathBuf { conf::dir() .join("launcher") .join(format!("installed-v{version}")) } pub fn detect() -> Self { let current = Self::get_installed_path(CURRENT_VERSION); if current.exists() { return Self::UpToDate; } if Self::get_refused_path().exists() { return Self::Refused; } for version in 0..CURRENT_VERSION { let installed = Self::get_installed_path(version); if installed.exists() { return Self::Obsolete; } } Self::NotInstalled } pub fn remove(si: &ShellInstall) -> Result<(), ShellInstallError> { si.remove(&Self::get_refused_path())?; for version in 0..=CURRENT_VERSION { let installed = Self::get_installed_path(version); si.remove(&installed)?; } Ok(()) } /// write either the "installed" or the "refused" file, or remove /// those files. /// /// This is useful in installation /// or test scripts when we don't want the user to be prompted /// to install the function, or in case something doesn't properly /// work in shell detections pub fn write( self, si: &ShellInstall, ) -> Result<(), ShellInstallError> { Self::remove(si)?; match self { ShellInstallState::Refused => { let refused_path = Self::get_refused_path(); fs::create_dir_all(refused_path.parent().unwrap()) .context(&|| format!("creating parents of {refused_path:?}"))?; fs::write(&refused_path, REFUSED_FILE_CONTENT) .context(&|| format!("writing in {refused_path:?}"))?; } ShellInstallState::UpToDate => { let installed_path = Self::get_installed_path(CURRENT_VERSION); fs::create_dir_all(installed_path.parent().unwrap()) .context(&|| format!("creating parents of {installed_path:?}"))?; fs::write(&installed_path, INSTALLED_FILE_CONTENT) .context(&|| format!("writing in {installed_path:?}"))?; } _ => { warn!("not writing state {self:?}"); } } Ok(()) } } ================================================ FILE: src/shell_install/util.rs ================================================ use { crate::errors::*, std::{ fs::{ self, OpenOptions, }, io::{ BufRead, BufReader, Write, }, path::Path, }, }; pub fn file_contains_line( path: &Path, searched_line: &str, ) -> Result { let file = fs::File::open(path).context(&|| format!("opening {path:?}"))?; for line in BufReader::new(file).lines() { let line = line.context(&|| format!("reading line in {path:?}"))?; if line == searched_line { return Ok(true); } } Ok(false) } pub fn append_to_file>( path: &Path, content: S, ) -> Result<(), ShellInstallError> { let mut shellrc = OpenOptions::new() .append(true) .open(path) .context(&|| format!("opening {path:?} for append"))?; shellrc .write_all(content.as_ref().as_bytes()) .context(&|| format!("writing in {path:?}"))?; Ok(()) } ================================================ FILE: src/skin/app_skin.rs ================================================ use { super::*, crate::conf::Conf, rustc_hash::FxHashMap, }; /// all the skin things used by the broot application /// during running pub struct AppSkin { /// the skin used in the focused panel pub focused: PanelSkin, /// the skin used in unfocused panels pub unfocused: PanelSkin, } impl AppSkin { pub fn new( conf: &Conf, no_style: bool, ) -> Self { if no_style { Self { focused: PanelSkin::new(StyleMap::no_term()), unfocused: PanelSkin::new(StyleMap::no_term()), } } else { let def_skin; let skin = if let Some(skin) = &conf.skin { skin } else { def_skin = FxHashMap::default(); &def_skin }; let StyleMaps { focused, unfocused } = StyleMaps::create(skin); Self { focused: PanelSkin::new(focused), unfocused: PanelSkin::new(unfocused), } } } } ================================================ FILE: src/skin/cli_mad_skin.rs ================================================ use { crokey::crossterm::style::Color, termimad::{ MadSkin, gray, }, }; /// build a termimad skin for cli output (mostly /// for the install process) pub fn make_cli_mad_skin() -> MadSkin { let mut skin = MadSkin::default(); skin.set_headers_fg(Color::AnsiValue(178)); skin.inline_code.set_bg(gray(2)); skin.inline_code.set_fg(gray(18)); skin.code_block.set_bg(gray(2)); skin.code_block.set_fg(gray(18)); skin.italic.set_fg(Color::Magenta); skin } ================================================ FILE: src/skin/ext_colors.rs ================================================ use { crate::errors::InvalidSkinError, crokey::crossterm::style::Color, lazy_regex::*, rustc_hash::FxHashMap, std::convert::TryFrom, termimad::parse_color, }; /// a map from file extension to the foreground /// color to use when drawing the tree #[derive(Debug, Clone, Default)] pub struct ExtColorMap { map: FxHashMap, } impl ExtColorMap { /// return the color to use, or None when the default color /// of files should apply pub fn get( &self, ext: &str, ) -> Option { self.map.get(ext).copied() } pub fn set( &mut self, ext: String, raw_color: &str, ) -> Result<(), InvalidSkinError> { if !regex_is_match!("^none$"i, raw_color) { let color = parse_color(raw_color)?; self.map.insert(ext, color); } Ok(()) } } impl TryFrom<&FxHashMap> for ExtColorMap { type Error = InvalidSkinError; fn try_from(raw_map: &FxHashMap) -> Result { let mut map = ExtColorMap::default(); for (k, v) in raw_map { map.set(k.to_string(), v)?; } Ok(map) } } ================================================ FILE: src/skin/help_mad_skin.rs ================================================ use { super::StyleMap, termimad::{ Alignment, LineStyle, MadSkin, }, }; /// build a MadSkin, which will be used for markdown formatting /// for the help screen by applying the `help_*` entries /// of the skin. pub fn make_help_mad_skin(skin: &StyleMap) -> MadSkin { let mut ms = MadSkin::default(); ms.paragraph.compound_style = skin.help_paragraph.clone(); ms.inline_code = skin.help_code.clone(); ms.code_block.compound_style = ms.inline_code.clone(); ms.bold = skin.help_bold.clone(); ms.italic = skin.help_italic.clone(); ms.table = LineStyle::new(skin.help_table_border.clone(), Alignment::Center); if let Some(c) = skin.help_headers.get_fg() { ms.set_headers_fg(c); } if let Some(c) = skin.help_headers.get_bg() { ms.set_headers_bg(c); } ms.bullet .set_compound_style(ms.paragraph.compound_style.clone()); ms.scrollbar .track .set_compound_style(skin.scrollbar_track.clone()); ms.scrollbar .thumb .set_compound_style(skin.scrollbar_thumb.clone()); ms } ================================================ FILE: src/skin/mod.rs ================================================ mod app_skin; mod cli_mad_skin; mod ext_colors; mod help_mad_skin; mod panel_skin; mod purpose_mad_skin; mod skin_entry; mod status_mad_skin; mod style_map; pub use { app_skin::AppSkin, cli_mad_skin::*, ext_colors::ExtColorMap, help_mad_skin::*, panel_skin::PanelSkin, purpose_mad_skin::*, skin_entry::SkinEntry, status_mad_skin::StatusMadSkinSet, style_map::{ StyleMap, StyleMaps, }, }; use crokey::crossterm::style::Color::{ self, *, }; pub fn gray(mut level: u8) -> Option { if level > 23 { // this only happens when I mess the literals in style_map.rs warn!("fixed invalid gray level: {}", level); level = 23 } Some(AnsiValue(0xE8 + level)) } pub fn rgb( r: u8, g: u8, b: u8, ) -> Option { Some(Rgb { r, g, b }) } pub fn ansi(v: u8) -> Option { Some(AnsiValue(v)) } ================================================ FILE: src/skin/panel_skin.rs ================================================ use { super::*, termimad::MadSkin, }; /// the various skin things used in a panel. /// /// There are normally two instances of this struct in /// a broot application: one is used for the focused panel /// and one is used for the other panels. pub struct PanelSkin { pub styles: StyleMap, pub purpose_skin: MadSkin, pub status_skin: StatusMadSkinSet, pub help_skin: MadSkin, } impl PanelSkin { pub fn new(styles: StyleMap) -> Self { let purpose_skin = make_purpose_mad_skin(&styles); let status_skin = StatusMadSkinSet::from_skin(&styles); let help_skin = make_help_mad_skin(&styles); Self { styles, purpose_skin, status_skin, help_skin, } } } ================================================ FILE: src/skin/purpose_mad_skin.rs ================================================ use { super::StyleMap, termimad::{ Alignment, LineStyle, MadSkin, }, }; /// build a MadSkin which will be used to display the status /// when there's no error pub fn make_purpose_mad_skin(skin: &StyleMap) -> MadSkin { MadSkin { paragraph: LineStyle::new(skin.purpose_normal.clone(), Alignment::Left), italic: skin.purpose_italic.clone(), bold: skin.purpose_bold.clone(), ellipsis: skin.purpose_ellipsis.clone(), ..Default::default() } } ================================================ FILE: src/skin/skin_entry.rs ================================================ //! Manage conversion of a user provided string //! defining foreground and background colors into //! a string with TTY colors use { crate::errors::InvalidSkinError, serde::{ Deserialize, Deserializer, de::Error, }, termimad::{ CompoundStyle, parse_compound_style, }, }; /// Parsed content of a [skin] line of the conf.toml file #[derive(Clone, Debug)] pub struct SkinEntry { focused: CompoundStyle, unfocused: Option, } impl SkinEntry { pub fn new( focused: CompoundStyle, unfocused: Option, ) -> Self { Self { focused, unfocused } } pub fn get_focused(&self) -> &CompoundStyle { &self.focused } pub fn get_unfocused(&self) -> &CompoundStyle { self.unfocused.as_ref().unwrap_or(&self.focused) } /// Parse a string representation of a skin entry. /// /// The general form is either "" or " / ": /// It may be just the focused compound_style, or both /// the focused and the unfocused ones, in which case there's /// a '/' as separator. /// /// Each part is " " /// where the attributes list may be empty. pub fn parse(s: &str) -> Result { let mut parts = s.split('/'); let focused = parse_compound_style(parts.next().unwrap())?; let unfocused = parts.next().map(parse_compound_style).transpose()?; Ok(Self { focused, unfocused }) } } impl<'de> Deserialize<'de> for SkinEntry { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; SkinEntry::parse(&s).map_err(|e| D::Error::custom(e.to_string())) } } ================================================ FILE: src/skin/status_mad_skin.rs ================================================ use { super::StyleMap, termimad::{ Alignment, LineStyle, MadSkin, }, }; /// the mad skin applying to the status depending whether it's an /// error or not pub struct StatusMadSkinSet { pub normal: MadSkin, pub error: MadSkin, } /// build a MadSkin which will be used to display the status /// when there's no error fn make_normal_status_mad_skin(skin: &StyleMap) -> MadSkin { MadSkin { paragraph: LineStyle::new(skin.status_normal.clone(), Alignment::Left), italic: skin.status_italic.clone(), bold: skin.status_bold.clone(), inline_code: skin.status_code.clone(), ellipsis: skin.status_ellipsis.clone(), ..Default::default() } } /// build a MadSkin which will be used to display the status /// when there's a error fn make_error_status_mad_skin(skin: &StyleMap) -> MadSkin { MadSkin { paragraph: LineStyle::new(skin.status_error.clone(), Alignment::Left), ellipsis: skin.status_ellipsis.clone(), ..Default::default() } } impl StatusMadSkinSet { pub fn from_skin(skin: &StyleMap) -> Self { Self { normal: make_normal_status_mad_skin(skin), error: make_error_status_mad_skin(skin), } } } ================================================ FILE: src/skin/style_map.rs ================================================ /// Defines the StyleMap structure with its default value. /// /// A style_map is a collection of termimad compound_style. It's /// either defined for the focused panel state or the unfocused /// one (there are thus two instances in the application) use { super::*, crate::errors::ProgramError, crokey::crossterm::{ QueueableCommand, style::{ Attribute::*, Attributes, Color::*, SetBackgroundColor, }, }, rustc_hash::FxHashMap, std::{ fmt, io::Write, }, termimad::CompoundStyle, }; // this macro, which must be called once, creates // the StyleMap struct with its creation functions handling // both default values defined in the macro call and // overriding values defined in TOML macro_rules! StyleMap { ( $( $name:ident: $fg:expr, $bg:expr, [$($attr:expr)*] $( / $fgu:expr, $bgu:expr , [$($attru:expr)*] )* )* ) => { /// a struct whose fields are /// - a boolean telling whether it's a no-style map /// - the styles to apply to various parts/cases pub struct StyleMap { styled: bool, $(pub $name: CompoundStyle,)* } /// a set of two style_maps: one for the focused panel and one for the other panels /// /// This struct is just a vessel for the skin initialization process. pub struct StyleMaps { pub focused: StyleMap, pub unfocused: StyleMap, } impl StyleMap { /// build a skin without any terminal control character (for file output) pub fn no_term() -> Self { Self { styled: false, $($name: CompoundStyle::default(),)* } } /// ensures the "default" skin entry is used as base for all other /// entries (this processus is part of the skin initialization) fn diffuse_default(&mut self) { $( let mut base = self.default.clone(); base.overwrite_with(&self.$name); self.$name = base; )* } } impl StyleMaps { pub fn create(skin_conf: &FxHashMap) -> Self { let mut focused = StyleMap { styled: true, $($name: skin_conf .get(stringify!($name)) .map(|sec| sec.get_focused().clone()) .unwrap_or( CompoundStyle::new( $fg, $bg, Attributes::from(vec![$($attr),*].as_slice()), ) ) ,)* }; focused.diffuse_default(); let mut unfocused = StyleMap { styled: true, $($name: CompoundStyle::default(),)* }; $( unfocused.$name = CompoundStyle::new( $fg, $bg, Attributes::from(vec![$($attr),*].as_slice()), ); $( unfocused.$name = CompoundStyle::new( $fgu, $bgu, Attributes::from(vec![$($attru),*].as_slice()), ); )* if let Some(sec) = skin_conf.get(stringify!($name)) { unfocused.$name = sec.get_unfocused().clone(); } )* unfocused.diffuse_default(); Self { focused, unfocused, } } } impl Clone for StyleMap { fn clone(&self) -> Self { Self { styled: self.styled, $($name: self.$name.clone(),)* } } } } } impl StyleMap { pub fn queue_reset( &self, f: &mut W, ) -> Result<(), ProgramError> { if self.styled { f.queue(SetBackgroundColor(Color::Reset))?; } Ok(()) } pub fn good_to_bad_color( &self, value: f64, ) -> Color { debug_assert!((0.0..=1.0).contains(&value)); const N: usize = 10; let idx = (value * N as f64) as usize; let cs = match idx { 0 => &self.good_to_bad_0, 1 => &self.good_to_bad_1, 2 => &self.good_to_bad_2, 3 => &self.good_to_bad_3, 4 => &self.good_to_bad_4, 5 => &self.good_to_bad_5, 6 => &self.good_to_bad_6, 7 => &self.good_to_bad_7, 8 => &self.good_to_bad_8, _ => &self.good_to_bad_9, }; cs.object_style.foreground_color.unwrap_or(Color::Blue) } } // Default styles defined as // name: forecolor, backcolor, [attributes] // The optional part after a '/' is the style for unfocused panels // (if missing the style is the same than for focused panels) StyleMap! { default: gray(22), gray(2), [] / gray(20), gray(2), [] tree: gray(8), None, [] / gray(4), None, [] parent: gray(18), None, [] / gray(13), None, [] file: gray(22), None, [] / gray(15), None, [] directory: ansi(110), None, [Bold] / ansi(110), None, [] exe: Some(Cyan), None, [] link: Some(Magenta), None, [] pruning: gray(12), None, [Italic] perm__: gray(5), None, [] perm_r: ansi(94), None, [] perm_w: ansi(132), None, [] perm_x: ansi(65), None, [] owner: ansi(138), None, [] group: ansi(131), None, [] count: ansi(138), gray(4), [] dates: ansi(66), None, [] sparse: ansi(214), None, [] content_extract: ansi(29), None, [] content_match: ansi(34), None, [] device_id_major: ansi(138), None, [] device_id_sep: ansi(102), None, [] device_id_minor: ansi(138), None, [] git_branch: ansi(178), None, [] git_insertions: ansi(28), None, [] git_deletions: ansi(160), None, [] git_status_current: gray(5), None, [] git_status_modified: ansi(28), None, [] git_status_new: ansi(94), None, [Bold] git_status_ignored: gray(17), None, [] git_status_conflicted: ansi(88), None, [] git_status_other: ansi(88), None, [] selected_line: None, gray(6), [] / None, gray(4), [] char_match: Some(Green), None, [] file_error: Some(Red), None, [] flag_label: gray(15), gray(2), [] flag_value: ansi(178), gray(2), [Bold] input: Some(White), gray(2), [] / gray(15), None, [] status_error: gray(22), ansi(124), [] status_job: ansi(220), gray(5), [] status_normal: gray(20), gray(4), [] / gray(2), gray(2), [] status_italic: ansi(178), gray(4), [] / gray(2), gray(2), [] status_bold: ansi(178), gray(4), [Bold] / gray(2), gray(2), [] status_code: ansi(229), gray(4), [] / gray(2), gray(2), [] status_ellipsis: gray(19), gray(1), [] / gray(2), gray(2), [] purpose_normal: gray(20), gray(2), [] purpose_italic: ansi(178), gray(2), [] purpose_bold: ansi(178), gray(2), [Bold] purpose_ellipsis: gray(20), gray(2), [] scrollbar_track: gray(7), None, [] / gray(4), None, [] scrollbar_thumb: gray(22), None, [] / gray(14), None, [] help_paragraph: gray(20), None, [] help_bold: ansi(178), None, [Bold] help_italic: ansi(229), None, [] help_code: gray(21), gray(3), [] help_headers: ansi(178), None, [] help_table_border: ansi(239), None, [] preview: gray(20), gray(1), [] / gray(18), gray(2), [] preview_title: gray(23), gray(2), [] / gray(21), gray(2), [] preview_line_number: gray(12), gray(3), [] preview_separator: gray(7), None, [] preview_match: None, ansi(29), [] hex_null: gray(8), None, [] hex_ascii_graphic: gray(18), None, [] hex_ascii_whitespace: ansi(143), None, [] hex_ascii_other: ansi(215), None, [] hex_non_ascii: ansi(167), None, [] staging_area_title: gray(22), gray(2), [] / gray(20), gray(3), [] mode_command_mark: gray(5), ansi(204), [Bold] good_to_bad_0: ansi(28), None, [] good_to_bad_1: ansi(29), None, [] good_to_bad_2: ansi(29), None, [] good_to_bad_3: ansi(29), None, [] good_to_bad_4: ansi(29), None, [] good_to_bad_5: ansi(100), None, [] good_to_bad_6: ansi(136), None, [] good_to_bad_7: ansi(172), None, [] good_to_bad_8: ansi(166), None, [] good_to_bad_9: ansi(196), None, [] } impl fmt::Debug for StyleMap { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { write!(f, "Skin") } } ================================================ FILE: src/stage/filtered_stage.rs ================================================ use { super::*, crate::pattern::*, std::{ convert::TryFrom, path::{ Path, PathBuf, }, }, }; #[derive(Clone)] pub struct FilteredStage { stage_version: usize, paths_idx: Vec, // indexes of the matching paths in the stage pattern: InputPattern, // an optional filtering pattern selection: Option, // index in paths_idx, always in [0, paths_idx.len()[ } impl FilteredStage { pub fn unfiltered(stage: &Stage) -> Self { Self::filtered(stage, InputPattern::none()) } /// compute the paths_idx and maybe change the selection fn compute( &mut self, stage: &Stage, ) { if self.pattern.is_none() { self.paths_idx = stage .paths() .iter() .enumerate() .map(|(idx, _)| idx) .collect(); } else { let mut best_score = None; self.paths_idx.clear(); for (idx, path) in stage.paths().iter().enumerate() { if let Some(file_name) = path.file_name() { let subpath = path.to_string_lossy().to_string(); let name = file_name.to_string_lossy().to_string(); let candidate = Candidate { path, subpath: &subpath, name: &name, }; if let Some(score) = self.pattern.pattern.score_of(candidate) { let is_best = match best_score { Some(old_score) if old_score < score => true, None => true, _ => false, }; if is_best { self.selection = Some(self.paths_idx.len()); best_score = Some(score); } self.paths_idx.push(idx); } } } } } pub fn filtered( stage: &Stage, pattern: InputPattern, ) -> Self { let mut fs = Self { stage_version: stage.version(), paths_idx: Vec::new(), pattern, selection: None, }; fs.compute(stage); fs } /// check whether the stage has changed, and update the /// filtered list if necessary pub fn update( &mut self, stage: &Stage, ) -> bool { if stage.version() == self.stage_version { false } else { self.compute(stage); true } } /// change the pattern, keeping the selection if possible /// Assumes the stage didn't change (if it changed, we lose the /// selection) pub fn set_pattern( &mut self, stage: &Stage, pattern: InputPattern, ) { self.stage_version = stage.version(); // in case it changed self.pattern = pattern; self.compute(stage); } pub fn len(&self) -> usize { self.paths_idx.len() } pub fn path<'s>( &self, stage: &'s Stage, idx: usize, ) -> Option<&'s Path> { self.paths_idx .get(idx) .and_then(|&idx| stage.paths().get(idx)) .map(PathBuf::as_path) } pub fn path_sel<'s>( &self, stage: &'s Stage, idx: usize, ) -> Option<(&'s Path, bool)> { self.path(stage, idx) .map(|p| (p, self.selection.map_or(false, |si| idx == si))) } pub fn pattern(&self) -> &InputPattern { &self.pattern } pub fn selection(&self) -> Option { self.selection } pub fn has_selection(&self) -> bool { self.selection.is_some() } pub fn try_select_idx( &mut self, idx: usize, ) -> bool { if idx < self.paths_idx.len() { self.selection = Some(idx); true } else { false } } pub fn selected_path<'s>( &self, stage: &'s Stage, ) -> Option<&'s Path> { self.selection .and_then(|pi| self.paths_idx.get(pi)) .and_then(|&idx| stage.paths().get(idx)) .map(|p| p.as_path()) } pub fn unselect(&mut self) { self.selection = None } /// unstage the selection, if any, or return false. /// If possible we select the item below so that the user /// may easily remove a few items pub fn unstage_selection( &mut self, stage: &mut Stage, ) -> bool { if let Some(spi) = self.selection { stage.remove_idx(self.paths_idx[spi]); self.stage_version = stage.version(); self.compute(stage); if spi >= self.paths_idx.len() { self.selection = None; }; true } else { false } } pub fn move_selection( &mut self, dy: i32, cycle: bool, ) { self.selection = if self.paths_idx.is_empty() { None } else if let Some(sel_idx) = self.selection.and_then(|i| i32::try_from(i).ok()) { let new_sel_idx = sel_idx + dy; Some(if new_sel_idx < 0 { if cycle && sel_idx == 0 { self.paths_idx.len() - 1 } else { 0 } } else if new_sel_idx as usize >= self.paths_idx.len() { if cycle && sel_idx == self.paths_idx.len() as i32 - 1 { 0 } else { self.paths_idx.len() - 1 } } else { new_sel_idx as usize }) } else if dy < 0 { Some(self.paths_idx.len() - 1) } else { Some(0) }; } } ================================================ FILE: src/stage/mod.rs ================================================ mod filtered_stage; mod stage; mod stage_state; mod stage_sum; pub use { filtered_stage::*, stage::*, stage_state::*, stage_sum::*, }; ================================================ FILE: src/stage/stage.rs ================================================ use { crate::{ app::*, file_sum::FileSum, task_sync::Dam, }, std::path::{ Path, PathBuf, }, }; /// a staging area: selection of several paths /// for later user /// /// The structure is versioned to allow caching /// of derived structs (filtered list mainly). This /// scheme implies the stage isn't cloned, and that /// it exists in only one instance #[derive(Default, Debug)] pub struct Stage { version: usize, paths: Vec, } impl Stage { pub fn contains( &self, path: &Path, ) -> bool { self.paths.iter().any(|p| p == path) } pub fn is_empty(&self) -> bool { self.paths.is_empty() } /// return true when there's a change pub fn add( &mut self, path: PathBuf, ) -> bool { if self.contains(&path) { false } else { self.version += 1; self.paths.push(path); true } } /// return true when there's a change pub fn remove( &mut self, path: &Path, ) -> bool { if let Some(pos) = self.paths.iter().position(|p| p == path) { self.version += 1; self.paths.remove(pos); true } else { false } } pub fn remove_idx( &mut self, idx: usize, ) { if idx < self.paths.len() { self.version += 1; self.paths.remove(idx); } } pub fn clear(&mut self) { self.version += 1; self.paths.clear(); } pub fn paths(&self) -> &[PathBuf] { &self.paths } /// removes paths to non existing files pub fn refresh(&mut self) { let len_before = self.paths.len(); self.paths.retain(|p| p.exists()); if self.paths.len() != len_before { self.version += 1; } } pub fn len(&self) -> usize { self.paths.len() } pub fn version(&self) -> usize { self.version } pub fn compute_sum( &self, dam: &Dam, con: &AppContext, ) -> Option { let mut sum = FileSum::zero(); for path in &self.paths { if path.is_dir() { let dir_sum = FileSum::from_dir(path, dam, con); if let Some(dir_sum) = dir_sum { sum += dir_sum; } else { return None; // computation was interrupted } } else { sum += FileSum::from_file(path); } } Some(sum) } pub fn to_selections(&self) -> Vec> { self.paths .iter() .map(|path| Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, }) .collect() } } ================================================ FILE: src/stage/stage_state.rs ================================================ use { super::*, crate::{ app::*, command::*, display::{MatchedString, Screen, W}, errors::ProgramError, pattern::*, skin::*, task_sync::Dam, tree::*, verb::*, }, crokey::crossterm::{QueueableCommand, cursor}, std::path::Path, termimad::{Area, CropWriter, SPACE_FILLING}, unicode_width::{UnicodeWidthChar, UnicodeWidthStr}, }; static TITLE: &str = "Staging Area"; // no wide char allowed here static COUNT_LABEL: &str = " count: "; static SIZE_LABEL: &str = " size: "; static ELLIPSIS: char = '…'; pub struct StageState { filtered_stage: FilteredStage, scroll: usize, tree_options: TreeOptions, /// the 'modal' mode mode: Mode, page_height: usize, stage_sum: StageSum, } impl StageState { pub fn new( app_state: &AppState, tree_options: TreeOptions, con: &AppContext, ) -> StageState { let filtered_stage = FilteredStage::filtered(&app_state.stage, tree_options.pattern.clone()); Self { filtered_stage, scroll: 0, tree_options, mode: con.initial_mode(), page_height: 0, stage_sum: StageSum::default(), } } fn need_sum_computation(&self) -> bool { self.tree_options.show_sizes && !self.stage_sum.is_up_to_date() } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { let old_scroll = self.scroll; self.scroll = cmd.apply(self.scroll, self.filtered_stage.len(), self.page_height); self.scroll != old_scroll } pub fn fix_scroll(&mut self) { let len = self.filtered_stage.len(); if self.scroll + self.page_height > len { self.scroll = len.saturating_sub(self.page_height); } } fn write_title_line( &self, stage: &Stage, cw: &mut CropWriter<'_, W>, styles: &StyleMap, ) -> Result<(), ProgramError> { let total_count = format!("{}", stage.len()); let mut count_len = total_count.len(); if self.filtered_stage.pattern().is_some() { count_len += total_count.len() + 1; // 1 for '/' } if cw.allowed < count_len { return Ok(()); } if TITLE.len() + 1 + count_len <= cw.allowed { cw.queue_str(&styles.staging_area_title, TITLE)?; } let mut show_count_label = false; let mut rem = cw.allowed - count_len; if COUNT_LABEL.len() < rem { rem -= COUNT_LABEL.len(); show_count_label = true; if self.tree_options.show_sizes { if let Some(sum) = self.stage_sum.computed() { let size = file_size::fit_4(sum.to_size()); let size_len = SIZE_LABEL.len() + size.len(); if size_len < rem { rem -= size_len; // we display the size in the middle, so we cut rem in two let left_rem = rem / 2; rem -= left_rem; cw.repeat(&styles.staging_area_title, &SPACE_FILLING, left_rem)?; cw.queue_g_string(&styles.staging_area_title, SIZE_LABEL.to_string())?; cw.queue_g_string(&styles.staging_area_title, size)?; } } } } cw.repeat(&styles.staging_area_title, &SPACE_FILLING, rem)?; if show_count_label { cw.queue_g_string(&styles.staging_area_title, COUNT_LABEL.to_string())?; } if self.filtered_stage.pattern().is_some() { cw.queue_g_string(&styles.char_match, format!("{}", self.filtered_stage.len()))?; cw.queue_char(&styles.staging_area_title, '/')?; } cw.queue_g_string(&styles.staging_area_title, total_count)?; cw.fill(&styles.staging_area_title, &SPACE_FILLING)?; Ok(()) } fn move_selection( &mut self, dy: i32, cycle: bool, ) -> CmdResult { self.filtered_stage.move_selection(dy, cycle); if let Some(sel) = self.filtered_stage.selection() { if sel < self.scroll + 5 { self.scroll = (sel as i32 - 5).max(0) as usize; } else if sel > self.scroll + self.page_height - 5 { self.scroll = (sel + 5 - self.page_height).min(self.filtered_stage.len() - self.page_height); } } CmdResult::Keep } } impl PanelState for StageState { fn get_type(&self) -> PanelStateType { PanelStateType::Stage } fn selected_path(&self) -> Option<&Path> { None } fn selection(&self) -> Option> { None } fn clear_pending(&mut self) { self.stage_sum.clear(); } fn do_pending_task( &mut self, app_state: &mut AppState, _screen: Screen, con: &AppContext, dam: &mut Dam, // need the stage here ) -> Result<(), ProgramError> { if self.need_sum_computation() { self.stage_sum.compute(&app_state.stage, dam, con); } Ok(()) } fn get_pending_task(&self) -> Option<&'static str> { if self.need_sum_computation() { Some("stage size summing") } else { None } } fn sel_info<'c>( &'c self, app_state: &'c AppState, ) -> SelInfo<'c> { match app_state.stage.len() { 0 => SelInfo::None, 1 => SelInfo::One(Selection { path: &app_state.stage.paths()[0], stype: SelectionType::File, is_exe: false, line: 0, }), _ => SelInfo::More(&app_state.stage), } } fn has_at_least_one_selection( &self, app_state: &AppState, ) -> bool { !app_state.stage.is_empty() } fn tree_options(&self) -> TreeOptions { self.tree_options.clone() } /// option changing is unlikely to be done on this state, but /// we'll still do it in case a future scenario makes it possible /// to open a different state from this state fn with_new_options( &mut self, _screen: Screen, change_options: &dyn Fn(&mut TreeOptions) -> &'static str, in_new_panel: bool, con: &AppContext, ) -> CmdResult { if in_new_panel { CmdResult::error("stage can't be displayed in two panels") } else { let mut new_options = self.tree_options(); let message = change_options(&mut new_options); let state = Box::new(StageState { filtered_stage: self.filtered_stage.clone(), scroll: self.scroll, mode: con.initial_mode(), tree_options: new_options, page_height: self.page_height, stage_sum: self.stage_sum, }); CmdResult::NewState { state, message: Some(message), } } } fn on_click( &mut self, _x: u16, y: u16, _screen: Screen, _con: &AppContext, ) -> Result { if y > 0 { // the list starts on the second row self.filtered_stage .try_select_idx(y as usize - 1 + self.scroll); } Ok(CmdResult::Keep) } fn on_pattern( &mut self, pat: InputPattern, app_state: &AppState, _con: &AppContext, ) -> Result { self.filtered_stage.set_pattern(&app_state.stage, pat); self.fix_scroll(); Ok(CmdResult::Keep) } fn display( &mut self, w: &mut W, disc: &DisplayContext, ) -> Result<(), ProgramError> { let stage = &disc.app_state.stage; self.stage_sum.see_stage(stage); // this may invalidate the sum if self.filtered_stage.update(stage) { self.fix_scroll(); } let area = &disc.state_area; let styles = &disc.panel_skin.styles; let width = area.width as usize; w.queue(cursor::MoveTo(area.left, 0))?; let mut cw = CropWriter::new(w, width); self.write_title_line(stage, &mut cw, styles)?; let list_area = Area::new(area.left, area.top + 1, area.width, area.height - 1); self.page_height = list_area.height as usize; let pattern = &self.filtered_stage.pattern().pattern; let pattern_object = pattern.object(); let scrollbar = list_area.scrollbar(self.scroll, self.filtered_stage.len()); for idx in 0..self.page_height { let y = list_area.top + idx as u16; let stage_idx = idx + self.scroll; w.queue(cursor::MoveTo(area.left, y))?; let mut cw = CropWriter::new(w, width - 1); let cw = &mut cw; if let Some((path, selected)) = self.filtered_stage.path_sel(stage, stage_idx) { let mut style = if path.is_dir() { &styles.directory } else { &styles.file }; let mut bg_style; if selected { bg_style = style.clone(); if let Some(c) = styles.selected_line.get_bg() { bg_style.set_bg(c); } style = &bg_style; } let mut bg_style_match; let mut style_match = &styles.char_match; if selected { bg_style_match = style_match.clone(); if let Some(c) = styles.selected_line.get_bg() { bg_style_match.set_bg(c); } style_match = &bg_style_match; } if disc.con.show_selection_mark && self.filtered_stage.has_selection() { cw.queue_char(style, if selected { '▶' } else { ' ' })?; } if pattern_object.subpath { let label = path.to_string_lossy(); // we must display the matching on the whole path // (subpath is the path for the staging area) let name_match = pattern.search_string(&label); let matched_string = MatchedString::new(name_match, &label, style, style_match); matched_string.queue_on(cw)?; } else if let Some(file_name) = path.file_name() { let label = file_name.to_string_lossy(); let label_cols = label.width(); if label_cols + 2 < cw.allowed { if let Some(parent_path) = path.parent() { let mut parent_style = &styles.parent; let mut bg_style; if selected { bg_style = parent_style.clone(); if let Some(c) = styles.selected_line.get_bg() { bg_style.set_bg(c); } parent_style = &bg_style; } let cols_max = cw.allowed - label_cols - 3; let parent_path = parent_path.to_string_lossy(); let parent_cols = parent_path.width(); if parent_cols <= cols_max { cw.queue_str(parent_style, &parent_path)?; } else { // TODO move to (crop_writer ? termimad ?) // we'll compute the size of the tail fitting // the width minus one (for the ellipsis) let mut bytes_count = 0; let mut cols_count = 0; for c in parent_path.chars().rev() { let char_width = UnicodeWidthChar::width(c).unwrap_or(0); let next_str_width = cols_count + char_width; if next_str_width > cols_max { break; } cols_count = next_str_width; bytes_count += c.len_utf8(); } cw.queue_char(parent_style, ELLIPSIS)?; cw.queue_str( parent_style, &parent_path[parent_path.len() - bytes_count..], )?; } cw.queue_char(parent_style, '/')?; } } let name_match = pattern.search_string(&label); let matched_string = MatchedString::new(name_match, &label, style, style_match); matched_string.queue_on(cw)?; } else { // this should not happen warn!("how did we fall on a path without filename?"); } cw.fill(style, &SPACE_FILLING)?; } cw.fill(&styles.default, &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 refresh( &mut self, _screen: Screen, _con: &AppContext, ) -> Command { Command::empty() } fn set_mode( &mut self, mode: Mode, ) { self.mode = mode; } fn get_mode(&self) -> Mode { self.mode } 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 { Ok(match internal_exec.internal { Internal::back if self.filtered_stage.pattern().is_some() => { self.filtered_stage = FilteredStage::unfiltered(&app_state.stage); CmdResult::Keep } Internal::back if self.filtered_stage.has_selection() => { self.filtered_stage.unselect(); CmdResult::Keep } Internal::line_down => { let count = get_arg(input_invocation, internal_exec, 1); self.move_selection(count, true) } Internal::line_up => { let count = get_arg(input_invocation, internal_exec, 1); self.move_selection(-count, true) } Internal::line_down_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.move_selection(count, false) } Internal::line_up_no_cycle => { let count = get_arg(input_invocation, internal_exec, 1); self.move_selection(-count, false) } Internal::page_down => { self.try_scroll(ScrollCommand::Pages(1)); CmdResult::Keep } Internal::page_up => { self.try_scroll(ScrollCommand::Pages(-1)); CmdResult::Keep } Internal::stage => { // shall we restage what we just unstaged ? CmdResult::error("nothing to stage here") } Internal::unstage | Internal::toggle_stage => { if self.filtered_stage.unstage_selection(&mut app_state.stage) { CmdResult::Keep } else { CmdResult::error("you must select a path to unstage") } } Internal::trash => { info!("trash {} staged files", app_state.stage.len()); #[cfg(any(target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android")))))] match trash::delete_all(app_state.stage.paths()) { Ok(()) => { debug!("trash success"); CmdResult::RefreshState { clear_cache: true } } Err(e) => { warn!("trash error: {:?}", &e); CmdResult::DisplayError(format!("trash error: {:?}", &e)) } } #[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android"))))))] CmdResult::DisplayError("trash not supported on this platform".into()) } _ => self.on_internal_generic( w, invocation_parser, internal_exec, input_invocation, trigger_type, app_state, cc, )?, }) } } ================================================ FILE: src/stage/stage_sum.rs ================================================ use { super::*, crate::{ app::AppContext, file_sum::FileSum, task_sync::Dam, }, }; #[derive(Clone, Copy, Default)] pub struct StageSum { stage_version: usize, sum: Option, } impl StageSum { /// invalidates the computed sum if the version at compilation /// time is older than the current one pub fn see_stage( &mut self, stage: &Stage, ) { if stage.version() != self.stage_version { self.sum = None; } } pub fn is_up_to_date(&self) -> bool { self.sum.is_some() } pub fn clear(&mut self) { self.sum = None; } pub fn compute( &mut self, stage: &Stage, dam: &Dam, con: &AppContext, ) -> Option { if self.stage_version != stage.version() { self.sum = None; } self.stage_version = stage.version(); if self.sum.is_none() { // produces None in case of interruption self.sum = stage.compute_sum(dam, con); } self.sum } pub fn computed(&self) -> Option { self.sum } } ================================================ FILE: src/syntactic/mod.rs ================================================ mod text_view; mod syntax_theme; mod syntaxer; pub use { text_view::TextView, syntax_theme::*, syntaxer::{ SYNTAXER, Syntaxer, }, }; ================================================ FILE: src/syntactic/syntax_theme.rs ================================================ //! The supported syntax themes coming from syntect. //! //! This enumeration may change but right now the values are the ones from //! https://docs.rs/syntect/latest/syntect/highlighting/struct.ThemeSet.html use { crate::errors::ConfError, serde::{ Deserialize, Deserializer, }, std::str::FromStr, }; macro_rules! Themes { ( $($enum_name:ident: $syntect_name: literal,)* ) => { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyntaxTheme { $($enum_name,)* } impl FromStr for SyntaxTheme { type Err = ConfError; fn from_str(s: &str) -> Result { use crate::syntactic::SyntaxTheme::*; let s = s.to_lowercase(); $( if s == stringify!($enum_name).to_lowercase() { return Ok($enum_name); } if s == $syntect_name.to_lowercase() { return Ok($enum_name); } )* Err(ConfError::InvalidSyntaxTheme { name: s.to_string() }) } } impl SyntaxTheme { pub fn name(self) -> &'static str { use crate::syntactic::SyntaxTheme::*; match self { $($enum_name => stringify!($enum_name),)* } } pub fn syntect_name(self) -> &'static str { use crate::syntactic::SyntaxTheme::*; match self { $($enum_name => $syntect_name,)* } } } impl Default for SyntaxTheme { fn default() -> Self { Self::MochaDark } } pub static SYNTAX_THEMES: &[SyntaxTheme] = &[ $(crate::syntactic::SyntaxTheme::$enum_name,)* ]; } } Themes! { GitHub: "InspiredGitHub", SolarizedDark: "Solarized (dark)", SolarizedLight: "Solarized (light)", EightiesDark: "base16-eighties.dark", MochaDark: "base16-mocha.dark", OceanDark: "base16-ocean.dark", OceanLight: "base16-ocean.light", } impl<'de> Deserialize<'de> for SyntaxTheme { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; FromStr::from_str(&s).map_err(serde::de::Error::custom) } } ================================================ FILE: src/syntactic/syntaxer.rs ================================================ use { crate::app::AppContext, once_cell::sync::Lazy, std::path::Path, syntect::{ easy::{ HighlightLines, HighlightOptions, }, highlighting::{ Theme, ThemeSet, }, parsing::SyntaxSet, }, }; static SYNTAXES: &[u8] = include_bytes!("../../resources/syntect/syntaxes.bin"); pub static SYNTAXER: Lazy = Lazy::new(Syntaxer::default); /// wrap heavy to initialize syntect things pub struct Syntaxer { pub syntax_set: SyntaxSet, pub theme_set: ThemeSet, } impl Default for Syntaxer { fn default() -> Self { Self { syntax_set: time!( Debug, syntect::dumps::from_uncompressed_data(SYNTAXES).unwrap() ), theme_set: ThemeSet::load_defaults(), } } } impl Syntaxer { pub fn available_themes(&self) -> std::collections::btree_map::Keys<'_, String, Theme> { self.theme_set.themes.keys() } pub fn highlighter_for( &self, path: &Path, con: &AppContext, ) -> Option> { path.extension() .and_then(|e| e.to_str()) .and_then(|ext| self.syntax_set.find_syntax_by_extension(ext)) .map(|syntax| { let theme = con.syntax_theme.unwrap_or_default(); let theme = self .theme_set .themes .get(theme.syntect_name()) .unwrap_or_else(|| self.theme_set.themes.iter().next().unwrap().1); let options = HighlightOptions { ignore_errors: true, }; HighlightLines::new(syntax, theme, options) }) } } ================================================ FILE: src/syntactic/text_view.rs ================================================ use { super::*, crate::{ app::{ AppContext, LineNumber, }, command::{ ScrollCommand, move_sel, }, display::{ Screen, W, }, errors::*, pattern::{ InputPattern, NameMatch, }, skin::PanelSkin, task_sync::Dam, }, crokey::crossterm::{ QueueableCommand, cursor, style::{ Color, Print, SetBackgroundColor, SetForegroundColor, }, }, memmap2::Mmap, once_cell::sync::Lazy, std::{ borrow::Cow, fs::File, io::{ BufRead, BufReader, }, path::{ Path, PathBuf, }, str, }, syntect::highlighting::Style, termimad::{ Area, CropWriter, Filling, SPACE_FILLING, }, }; pub static SEPARATOR_FILLING: Lazy = Lazy::new(|| Filling::from_char('─')); /// Homogeneously colored piece of a line #[derive(Debug)] pub struct Region { pub fg: Color, pub string: String, } /// when the file is bigger, we don't style it and we don't keep /// it in memory: we just keep the offsets of the lines in the file. const MAX_SIZE_FOR_STYLING: u64 = 2_000_000; /// Size of what's initially loaded (rest is loaded when user in background) /// Must be greater than MAX_SIZE_FOR_STYLING const INITIAL_LOAD: usize = 4_000_000; impl Region { pub fn from_syntect(region: &(Style, &str)) -> Self { let fg = Color::Rgb { r: region.0.foreground.r, g: region.0.foreground.g, b: region.0.foreground.b, }; let string = region.1.to_string(); Self { fg, string } } } #[derive(Debug)] pub enum DisplayLine { Content(Line), Separator, } #[derive(Debug)] pub struct Line { pub number: LineNumber, // starting at 1 pub start: usize, // offset in the file, in bytes pub len: usize, // len in bytes pub regions: Vec, // not always computed pub name_match: Option, } /// A text viewer, which can display a text file with syntax coloring if it's not too big. /// /// In some cases, only the beginning of the file is read at first, and the rest is read /// in background. pub struct TextView { pub path: PathBuf, pub pattern: InputPattern, lines: Vec, scroll: usize, page_height: usize, selection_idx: Option, // index in lines of the selection, if any content_lines_count: usize, // number of lines excluding separators total_lines_count: usize, // including lines not filtered out partial: bool, } impl DisplayLine { pub fn line_number(&self) -> Option { match self { DisplayLine::Content(line) => Some(line.number), DisplayLine::Separator => None, } } pub fn is_match(&self) -> bool { match self { DisplayLine::Content(line) => line.name_match.is_some(), DisplayLine::Separator => false, } } } impl TextView { /// Return a prepared text view with syntax coloring if possible. /// May return Ok(None) only when a pattern is given and there /// was an event before the end of filtering. pub fn new( path: &Path, pattern: InputPattern, dam: &mut Dam, con: &AppContext, no_style: bool, ) -> Result, ProgramError> { let allow_partial = pattern.is_none(); let mut sv = Self { path: path.to_path_buf(), pattern, lines: Vec::new(), scroll: 0, page_height: 0, selection_idx: None, content_lines_count: 0, total_lines_count: 0, partial: false, }; if sv.read_lines(dam, con, no_style, allow_partial)? { sv.select_first(); Ok(Some(sv)) } else { Ok(None) } } pub fn is_partial(&self) -> bool { self.partial } /// If the load was partial, complete it now pub fn complete_loading( &mut self, con: &AppContext, dam: &mut Dam, ) -> Result<(), ProgramError> { if self.partial { self.partial = false; self.read_lines(dam, con, true, false)?; } Ok(()) } /// Return true when there was no interruption fn read_lines( &mut self, dam: &mut Dam, con: &AppContext, no_style: bool, initial_load: bool, ) -> Result { let f = File::open(&self.path)?; { // if we detect the file isn't mappable, we'll // let the ZeroLenFilePreview try to read it let mmap = unsafe { Mmap::map(&f) }; if mmap.is_err() { return Err(ProgramError::UnmappableFile); } } let md = f.metadata()?; if md.len() == 0 { return Err(ProgramError::ZeroLenFile); } let with_style = !no_style && md.len() < MAX_SIZE_FOR_STYLING; let mut reader = BufReader::new(f); let mut content_lines = Vec::new(); let mut line = String::new(); self.total_lines_count = 0; let mut offset = 0; let mut number = 0; static SYNTAXER: Lazy = Lazy::new(Syntaxer::default); let mut highlighter = if with_style { SYNTAXER.highlighter_for(&self.path, con) } else { None }; let pattern = &self.pattern.pattern; while reader.read_line(&mut line)? > 0 { number += 1; self.total_lines_count += 1; let start = offset; offset += line.len(); // We clean the line to prevent TTY rendering from being broken. // We don't remove '\n' or '\r' at this point because some syntax sets // need them for correct detection of comments. See #477 // Those chars are removed on printing, later on. let clean_line = printable_line(&line); let name_match = pattern.search_string(&clean_line); let regions = if let Some(highlighter) = highlighter.as_mut() { highlighter .highlight_line(&clean_line, &SYNTAXER.syntax_set) .map_err(|e| ProgramError::SyntectCrashed { details: e.to_string(), })? .iter() .map(Region::from_syntect) .collect() } else { Vec::new() }; content_lines.push(Line { regions, start, len: clean_line.len(), name_match, number, }); line.clear(); if dam.has_event() { info!("event interrupted preview filtering"); self.partial = true; return Ok(false); } if initial_load && offset > INITIAL_LOAD { info!("partial load"); self.partial = true; break; } } let mut must_add_separators = false; if !pattern.is_empty() { let lines_before = con.lines_before_match_in_preview; let lines_after = con.lines_after_match_in_preview; if lines_before + lines_after > 0 { let mut kept = vec![false; content_lines.len()]; for (i, line) in content_lines.iter().enumerate() { if line.name_match.is_some() { for j in i.saturating_sub(lines_before) ..(i + lines_after + 1).min(content_lines.len()) { kept[j] = true; } } } for i in 1..kept.len() - 1 { if !kept[i] && kept[i - 1] && kept[i + 1] { kept[i] = true; } } content_lines.retain(|line| kept[line.number - 1]); must_add_separators = true; } else { content_lines.retain(|line| line.name_match.is_some()); } } self.lines.clear(); self.content_lines_count = content_lines.len(); for line in content_lines { if must_add_separators { if let Some(last_number) = self.lines.last().and_then(|l| l.line_number()) { if line.number > last_number + 1 { self.lines.push(DisplayLine::Separator); } } } self.lines.push(DisplayLine::Content(line)); } Ok(true) } /// Give the count of lines which can be seen when scrolling, /// total count including filtered ones pub fn line_counts(&self) -> (usize, usize) { (self.lines.len(), self.total_lines_count) } fn ensure_selection_is_visible(&mut self) { if self.page_height >= self.lines.len() { self.scroll = 0; } else if let Some(idx) = self.selection_idx { let padding = self.padding(); if idx < self.scroll + padding || idx + padding > self.scroll + self.page_height { if idx <= padding { self.scroll = 0; } else if idx + padding > self.lines.len() { self.scroll = self.lines.len() - self.page_height; } else if idx < self.scroll + self.page_height / 2 { self.scroll = idx - padding; } else { self.scroll = idx + padding - self.page_height; } } } } fn padding(&self) -> usize { (self.page_height / 4).min(4) } pub fn get_selected_line(&self) -> Option { self.selection_idx .and_then(|idx| self.lines.get(idx)) .and_then(|line| match line { DisplayLine::Content(line) => Some(line), DisplayLine::Separator => None, }) .and_then(|line| { File::open(&self.path) .and_then(|file| unsafe { Mmap::map(&file) }) .ok() .filter(|mmap| mmap.len() >= line.start + line.len) .and_then(|mmap| { String::from_utf8((mmap[line.start..line.start + line.len]).to_vec()).ok() }) }) } pub fn get_selected_line_number(&self) -> Option { self.selection_idx .and_then(|idx| self.lines[idx].line_number()) } pub fn unselect(&mut self) { self.selection_idx = None; } pub fn try_select_y( &mut self, y: u16, ) -> bool { let idx = y as usize + self.scroll; if idx < self.lines.len() { self.selection_idx = Some(idx); true } else { false } } pub fn select_first(&mut self) { if !self.lines.is_empty() { self.selection_idx = Some(0); self.scroll = 0; } } pub fn select_last(&mut self) { self.selection_idx = Some(self.lines.len() - 1); if self.page_height < self.lines.len() { self.scroll = self.lines.len() - self.page_height; } } pub fn try_select_line_number( &mut self, number: LineNumber, ) -> bool { // this could obviously be optimized for (idx, line) in self.lines.iter().enumerate() { if line.line_number() == Some(number) { self.selection_idx = Some(idx); self.ensure_selection_is_visible(); return true; } } false } pub fn move_selection( &mut self, dy: i32, cycle: bool, ) { if let Some(idx) = self.selection_idx { self.selection_idx = Some(move_sel(idx, self.lines.len(), dy, cycle)); } else if !self.lines.is_empty() { self.selection_idx = Some(0) } self.ensure_selection_is_visible(); } pub fn previous_match(&mut self) { let s = self.selection_idx.unwrap_or(0); for d in 1..self.lines.len() { let idx = (self.lines.len() + s - d) % self.lines.len(); if self.lines[idx].is_match() { self.selection_idx = Some(idx); self.ensure_selection_is_visible(); return; } } } pub fn next_match(&mut self) { let s = self.selection_idx.unwrap_or(0); for d in 1..self.lines.len() { let idx = (s + d) % self.lines.len(); if self.lines[idx].is_match() { self.selection_idx = Some(idx); self.ensure_selection_is_visible(); return; } } } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { let old_scroll = self.scroll; self.scroll = cmd.apply(self.scroll, self.lines.len(), self.page_height); if let Some(idx) = self.selection_idx { if self.scroll == old_scroll { let old_selection = self.selection_idx; if cmd.is_up() { self.selection_idx = Some(0); } else { self.selection_idx = Some(self.lines.len() - 1); } return self.selection_idx == old_selection; } else if idx >= old_scroll && idx < old_scroll + self.page_height { if idx + self.scroll < old_scroll { self.selection_idx = Some(0); } else if idx + self.scroll - old_scroll >= self.lines.len() { self.selection_idx = Some(self.lines.len() - 1); } else { self.selection_idx = Some(idx + self.scroll - old_scroll); } } } self.scroll != old_scroll } pub fn max_line_number(&self) -> Option { for line in self.lines.iter().rev() { if let Some(n) = line.line_number() { return Some(n); } } None } pub fn get_content_line( &self, idx: usize, ) -> Option<&Line> { self.lines.get(idx).and_then(|line| match line { DisplayLine::Content(line) => Some(line), DisplayLine::Separator => None, }) } pub fn display( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, con: &AppContext, ) -> Result<(), ProgramError> { if area.height as usize != self.page_height { self.page_height = area.height as usize; self.ensure_selection_is_visible(); } let max_number_len = self.max_line_number().unwrap_or(0).to_string().len(); let show_line_number = area.width > 55 || (self.pattern.is_some() && area.width > 8); let line_count = area.height as usize; let styles = &panel_skin.styles; let normal_fg = styles .preview .get_fg() .or_else(|| styles.default.get_fg()) .unwrap_or(Color::Reset); let normal_bg = styles .preview .get_bg() .or_else(|| styles.default.get_bg()) .unwrap_or(Color::Reset); let selection_bg = styles .selected_line .get_bg() .unwrap_or(Color::AnsiValue(240)); let match_bg = styles .preview_match .get_bg() .unwrap_or(Color::AnsiValue(28)); let code_width = area.width as usize - 1; // 1 char left for scrollbar let scrollbar = area.scrollbar(self.scroll, self.lines.len()); let scrollbar_fg = styles .scrollbar_thumb .get_fg() .or_else(|| styles.preview.get_fg()) .unwrap_or(Color::White); for y in 0..line_count { w.queue(cursor::MoveTo(area.left, y as u16 + area.top))?; let mut cw = CropWriter::new(w, code_width); let line_idx = self.scroll + y; let selected = self.selection_idx == Some(line_idx); let bg = if selected { selection_bg } else { normal_bg }; let mut op_mmap: Option = None; match self.lines.get(line_idx) { Some(DisplayLine::Separator) => { cw.w.queue(SetBackgroundColor(bg))?; cw.queue_unstyled_str(" ")?; cw.fill(&styles.preview_separator, &SEPARATOR_FILLING)?; } Some(DisplayLine::Content(line)) => { let mut regions = &line.regions; let regions_ur; if regions.is_empty() && line.len > 0 { if op_mmap.is_none() { let file = File::open(&self.path)?; let mmap = unsafe { Mmap::map(&file)? }; op_mmap = Some(mmap); } if op_mmap.as_ref().unwrap().len() < line.start + line.len { warn!("file truncated since parsing"); } else { // an UTF8 error can only happen if file modified during display let string = String::from_utf8( // we copy the memmap slice, as it's not immutable (op_mmap.unwrap()[line.start..line.start + line.len]).to_vec(), ) .unwrap_or_else(|_| "Bad UTF8".to_string()); regions_ur = vec![Region { fg: normal_fg, string, }]; regions = ®ions_ur; } } cw.w.queue(SetBackgroundColor(bg))?; if show_line_number { cw.queue_g_string( &styles.preview_line_number, format!(" {:w$} ", line.number, w = max_number_len), )?; } else { cw.queue_unstyled_str(" ")?; } cw.w.queue(SetBackgroundColor(bg))?; if con.show_selection_mark { cw.queue_unstyled_char(if selected { '▶' } else { ' ' })?; } if let Some(nm) = &line.name_match { let mut dec = 0; let pos = &nm.pos; let mut pos_idx: usize = 0; for content in regions { let s = content.string.trim_end_matches(is_char_end_of_line); cw.w.queue(SetForegroundColor(content.fg))?; if pos_idx < pos.len() { for (cand_idx, cand_char) in s.chars().enumerate() { if pos_idx < pos.len() && pos[pos_idx] == cand_idx + dec { cw.w.queue(SetBackgroundColor(match_bg))?; cw.queue_unstyled_char(cand_char)?; cw.w.queue(SetBackgroundColor(bg))?; pos_idx += 1; } else { cw.queue_unstyled_char(cand_char)?; } } dec += s.chars().count(); } else { cw.queue_unstyled_str(s)?; } } } else { for content in regions { cw.w.queue(SetForegroundColor(content.fg))?; cw.queue_unstyled_str( content.string.trim_end_matches(is_char_end_of_line), )?; } } } None => {} } cw.fill( if selected { &styles.selected_line } else { &styles.preview }, &SPACE_FILLING, )?; w.queue(SetBackgroundColor(bg))?; if is_thumb(y + area.top as usize, scrollbar) { w.queue(SetForegroundColor(scrollbar_fg))?; w.queue(Print('▐'))?; } else { w.queue(Print(' '))?; } } Ok(()) } fn info( &self, width: usize, ) -> String { if self.is_partial() { let s = "loading..."; let s = if s.len() > width { "" } else { s }; return s.to_string(); } let mut s = if self.pattern.is_some() { format!("{}/{}", self.content_lines_count, self.total_lines_count) } else { format!("{}", self.total_lines_count) }; if s.len() > width { return "".to_string(); } if s.len() + "lines: ".len() < width { s = format!("lines: {s}"); } s } pub fn display_info( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let width = area.width as usize; let s = self.info(width); w.queue(cursor::MoveTo( area.left + area.width - s.len() as u16, area.top, ))?; panel_skin.styles.default.queue(w, s)?; Ok(()) } } fn is_thumb( y: usize, scrollbar: Option<(u16, u16)>, ) -> bool { scrollbar.map_or(false, |(sctop, scbottom)| { let y = y as u16; sctop <= y && y <= scbottom }) } /// Tell whether the character must be replaced to prevent rendering from being broken fn is_char_unprintable(c: char) -> bool { match c { '\u{8}' => true, // backspace '\u{b}'..='\u{e}' => true, '\u{84}'..='\u{85}' => true, '\u{1a}'..'\u{1c}' => true, '\u{89}'..='\u{9f}' => true, _ => false, } } fn printable_line(line: &str) -> Cow<'_, str> { if line.chars().any(is_char_unprintable) { let replacement = line.replace(is_char_unprintable, "�"); Cow::Owned(replacement) } else { Cow::Borrowed(line) } } fn is_char_end_of_line(c: char) -> bool { c == '\n' || c == '\r' } ================================================ FILE: src/task_sync.rs ================================================ use { std::thread, termimad::{ TimedEvent, crossbeam::channel::{ self, Receiver, bounded, select, }, }, }; pub enum Either { First(A), Second(B), } #[derive(Debug, Clone)] pub enum ComputationResult { NotComputed, // not computed but will probably be Done(V), None, // nothing to compute, cancelled, failed, etc. } impl ComputationResult { pub fn is_done(&self) -> bool { matches!(&self, Self::Done(_)) } pub fn is_not_computed(&self) -> bool { matches!(&self, Self::NotComputed) } pub fn is_some(&self) -> bool { !matches!(&self, Self::None) } pub fn is_none(&self) -> bool { matches!(&self, Self::None) } } /// The dam controls the flow of events. /// A dam is used in broot to manage long computations and, /// when the user presses a key, either tell the computation /// to stop (the computation function checking `has_event`) /// or drop the computation. pub struct Dam { receiver: Receiver, in_dam: Option, } impl Dam { pub fn from(receiver: Receiver) -> Self { Self { receiver, in_dam: None, } } pub fn unlimited() -> Self { Self::from(channel::never()) } /// provide an observer which can be used for periodic /// check a task can be used. /// The observer can safely be moved to another thread /// but Be careful not to use it /// after the event listener started again. In any case /// using try_compute should be preferred for immediate /// return to the ui thread. pub fn observer(&self) -> DamObserver { DamObserver::from(self) } /// launch the computation on a new thread and return /// when it finishes or when a new event appears on /// the channel. /// Note that the task itself isn't interrupted so that /// this should not be used when many tasks are expected /// to be launched (or it would result in many working /// threads uselessly working in the background) : use /// dam.has_event from inside the task whenever possible. pub fn try_compute ComputationResult>( &mut self, f: F, ) -> ComputationResult { let (comp_sender, comp_receiver) = bounded(1); thread::spawn(move || { let comp_res = time!("comp in dam", f()); if comp_sender.send(comp_res).is_err() { debug!("no channel at end of computation"); } }); self.select(comp_receiver) } pub fn select( &mut self, comp_receiver: Receiver>, ) -> ComputationResult { if self.in_dam.is_some() { // should probably not happen debug!("There's already an event in dam"); ComputationResult::None } else { select! { recv(self.receiver) -> event => { // interruption debug!("dam interrupts computation"); self.in_dam = event.ok(); ComputationResult::None } recv(comp_receiver) -> comp_res => { // computation finished comp_res.unwrap_or(ComputationResult::None) } } } } /// non blocking pub fn has_event(&self) -> bool { !self.receiver.is_empty() } /// drop all events, returns the count of removed events pub fn clear(&mut self) -> usize { let mut n = 0; while self.has_event() { n += 1; self.next_event(); } n } /// block until next event (including the one which /// may have been pushed back into the dam). /// no event means the source is dead (i.e. we /// must quit broot) /// There's no event kept in dam after this call. pub fn next_event(&mut self) -> Option { if self.in_dam.is_some() { self.in_dam.take() } else { match self.receiver.recv() { Ok(event) => Some(event), Err(_) => { debug!("dead dam"); // should be logged once None } } } } pub fn next( &mut self, other: &Receiver, ) -> Either, Option> { if self.in_dam.is_some() { Either::First(self.in_dam.take()) } else { select! { recv(self.receiver) -> event => Either::First(match event { Ok(event) => Some(event), Err(_) => { debug!("dead dam"); // should be logged once None } }), recv(other) -> o => Either::Second(match o { Ok(o) => Some(o), Err(_) => { debug!("dead other"); None } }), } } } } pub struct DamObserver { receiver: Receiver, } impl DamObserver { pub fn from(dam: &Dam) -> Self { Self { receiver: dam.receiver.clone(), } } /// be careful that this can be used as a thread /// stop condition only before the event receiver /// start being active to avoid a race condition. pub fn has_event(&self) -> bool { !self.receiver.is_empty() } } /// wraps either a computation in progress, or a finished /// one (even a failed or useless one). /// This can be stored in a map to avoid starting computations /// more than once. #[derive(Debug, Clone)] pub enum Computation { InProgress(Receiver>), Finished(ComputationResult), } ================================================ FILE: src/terminal.rs ================================================ use { crate::{ app::*, display::W, verb::*, }, std::io::Write, }; /// Change the terminal's title if broot was configured with /// a `terminal_title` entry #[inline] pub fn update_title( w: &mut W, app_state: &AppState, con: &AppContext, ) { if let Some(pattern) = &con.terminal_title_pattern { set_title(w, pattern, app_state, con); } } /// Reset the terminal's title to its default value (which may be the one /// just before broot was launched, but may also be different) pub fn reset_title( w: &mut W, con: &AppContext, ) { if con.terminal_title_pattern.is_some() && con.reset_terminal_title_on_exit { let _ = write!(w, "\u{1b}]2;\u{07}"); let _ = w.flush(); } } fn set_title( w: &mut W, pattern: &ExecPattern, app_state: &AppState, con: &AppContext, ) { let mut builder = ExecutionBuilder::without_invocation(SelInfo::from_path(&app_state.root), app_state); let title = builder.shell_exec_string(pattern, con); set_title_str(w, &title) } #[inline] fn set_title_str( w: &mut W, title: &str, ) { let _ = write!(w, "\u{1b}]0;{title}\u{07}"); let _ = w.flush(); } ================================================ FILE: src/trash/mod.rs ================================================ mod trash_sort; mod trash_state; mod trash_state_cols; pub use trash_state::*; use trash::{ self as trash_crate, TrashItem, TrashItemSize, }; /// Determine whether an item in the trash is a directory. /// /// There's probably a simpler solution in the trash crate, but I didn't found it. fn item_is_dir(item: &TrashItem) -> bool { match trash_crate::os_limited::metadata(item) { Ok(metadata) => match metadata.size { TrashItemSize::Bytes(_) => false, TrashItemSize::Entries(_) => true, }, Err(_) => false, } } /// Return either the byte size or the number of entries of a trash item. /// /// Return None when it couldn't be determined. fn item_unified_size(item: &TrashItem) -> Option { match trash_crate::os_limited::metadata(item).ok()?.size { TrashItemSize::Bytes(v) => Some(v), TrashItemSize::Entries(v) => v.try_into().ok(), } } ================================================ FILE: src/trash/trash_sort.rs ================================================ use { super::*, crate::tree::*, trash::TrashItem, }; /// Sort trash items according to the current tree options. pub fn sort( items: &mut [TrashItem], tree_options: &TreeOptions, ) { info!("sorting itemsi by {:?}", tree_options.sort); match tree_options.sort { Sort::Date => items.sort_by_key(|item| std::cmp::Reverse(item.time_deleted)), Sort::Size => { items.sort_by_key(|item| std::cmp::Reverse(item_unified_size(item).unwrap_or(0))) } _ => items.sort_by_key(|item| (item.name.clone(), item.original_parent.clone())), } } ================================================ FILE: src/trash/trash_state.rs ================================================ use { super::{ item_is_dir, trash_sort::*, trash_state_cols::*, }, crate::{ app::*, command::*, display::*, errors::ProgramError, pattern::*, tree::TreeOptions, verb::*, }, crokey::crossterm::{ QueueableCommand, cursor, style::Color, }, std::{ ffi::OsString, path::Path, }, termimad::{ minimad::Alignment, *, }, trash::{ self as trash_crate, TrashItem, }, unicode_width::UnicodeWidthStr, }; struct FilteredContent { pattern: Pattern, items: Vec, selection_idx: Option, } /// an application state showing the content of the trash pub struct TrashState { items: Vec, selection_idx: Option, scroll: usize, page_height: usize, tree_options: TreeOptions, filtered: Option, mode: Mode, } impl TrashState { /// create a state listing the content of the system's trash pub fn new( tree_options: TreeOptions, con: &AppContext, ) -> Result { let mut items = trash::os_limited::list().map_err(|e| ProgramError::Trash { message: e.to_string(), })?; sort(&mut items, &tree_options); let selection_idx = None; Ok(TrashState { items, selection_idx, scroll: 0, page_height: 0, tree_options, filtered: None, mode: con.initial_mode(), }) } fn modified( &self, options: TreeOptions, message: Option<&'static str>, in_new_panel: bool, con: &AppContext, ) -> CmdResult { match Self::new(options, con) { Ok(mut ts) => { let old_selection = self.selected_item_id(); ts.select_item_by_id(old_selection.as_ref()); if in_new_panel { CmdResult::NewPanel { state: Box::new(ts), purpose: PanelPurpose::None, direction: HDir::Right, } } else { CmdResult::NewState { state: Box::new(ts), message, } } } Err(e) => CmdResult::error(e.to_string()), } } pub fn count(&self) -> usize { self.filtered .as_ref() .map(|f| f.items.len()) .unwrap_or_else(|| self.items.len()) } 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 selected_item_id(&self) -> Option { self.selected_item().map(|i| i.id.clone()) } fn select_item_by_id( &mut self, id: Option<&OsString>, ) { self.selection_idx = id.and_then(|id| self.items.iter().position(|i| &i.id == id)); } fn take_selected_item(&mut self) -> Option { 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, con: &AppContext, ) -> CmdResult { let mut options = self.tree_options.clone(); let message = change_options(&mut options); let message = Some(message); self.modified(options, message, in_new_panel, con) } /// We don't want to expose path to verbs because you can't /// normally access files in the trash fn selection(&self) -> Option> { None } fn refresh( &mut self, _screen: Screen, _con: &AppContext, ) -> Command { let old_selection = self.selected_item_id(); if let Ok(mut items) = trash::os_limited::list() { sort(&mut items, &self.tree_options); self.items = items; self.scroll = 0; self.select_item_by_id(old_selection.as_ref()); } Command::empty() } fn on_pattern( &mut self, pattern: InputPattern, _app_state: &AppState, _con: &AppContext, ) -> Result { 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 Some(name) = item.name.to_str() else { continue; }; let score = pattern.score_of_string(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 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 available_width = if con.show_selection_mark { width - 1 } else { width }; let cols = get_cols(items, available_width, &self.tree_options); let first_col_width = cols.iter().filter_map(|c| c.size()).next().unwrap_or(0); //- 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 mut added = false; for col in &cols { let Some(size) = col.size() else { continue; }; if added { cw.queue_char(border_style, '│')?; } else { added = true; } let title = col.content().title(); let title = if title.len() > size { &title[..size] } else { title }; cw.queue_g_string(&styles.default, format!("{:^size$}", 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, ' ')?; } let mut added = false; for col in &cols { let Some(size) = col.size() else { continue; }; if added { cw.queue_char(border_style, '┼')?; } else { added = true; } cw.queue_g_string(border_style, format!("{:─>width$}", "", width = size))?; } 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 is_dir = item_is_dir(item); 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 { ' ' })?; } let mut added = false; for col in &cols { let Some(size) = col.size() else { continue; }; if added { cw.queue_char(border_style, '│')?; } else { added = true; } let value = col.content().value_of(item, &self.tree_options); let style = col.content().style(is_dir, styles); let mut cloned_style; let style = if selected { cloned_style = style.clone(); if let Some(c) = styles.selected_line.get_bg() { cloned_style.set_bg(c); } &cloned_style } else { style }; let mut matched_string = MatchedString::new( self.filtered .as_ref() .and_then(|f| f.pattern.search_string(&value)), &value, style, match_style, ); if value.width() > size { cw.queue_char(txt_style, '…')?; matched_string.cut_left_to_fit(size - 1); matched_string.queue_on(&mut cw)?; } else { matched_string.fill(size, 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 = first_col_width + 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 { 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)) } } } 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)) } } } 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 { if y >= 2 { let y = y as usize - 2 + self.scroll; let len: usize = self.items.len(); if y < len { self.selection_idx = Some(y); } } Ok(CmdResult::Keep) } } ================================================ FILE: src/trash/trash_state_cols.rs ================================================ use { super::*, crate::{ skin::StyleMap, tree::TreeOptions, }, chrono::{ Local, LocalResult, TimeZone, }, termimad::CompoundStyle, trash::TrashItem, unicode_width::UnicodeWidthStr, }; /// A displayable column, related to properties of the TrashItem #[derive(Debug, Clone, Copy)] pub enum TrashItemProperty { OriginalParent, Name, DeletionDate, Size, } impl TrashItemProperty { pub fn title(self) -> &'static str { // only single byte characters allowed here match self { Self::OriginalParent => "Original parent", Self::Name => "Deleted file name", Self::DeletionDate => "Deletion", Self::Size => "Size", } } pub fn style( self, is_dir: bool, styles: &StyleMap, ) -> &CompoundStyle { match self { Self::DeletionDate => &styles.dates, _ => { if is_dir { &styles.directory } else { &styles.file } } } } pub fn value_of( self, item: &TrashItem, options: &TreeOptions, ) -> String { match self { Self::OriginalParent => item.original_parent.to_string_lossy().to_string(), Self::Name => item.name.to_string_lossy().to_string(), Self::DeletionDate => { let seconds = item.time_deleted; if let LocalResult::Single(date_time) = Local.timestamp_opt(seconds, 0) { date_time.format(options.date_time_format).to_string() } else { "???".to_string() } } Self::Size => match item_unified_size(item) { Some(size) => format!("{:>4}", file_size::fit_4(size)), None => "????".to_string(), }, } } pub fn const_width(self) -> bool { match self { Self::OriginalParent => false, Self::Name => false, Self::DeletionDate => true, Self::Size => true, } } pub fn optimal_width( self, items: &[TrashItem], options: &TreeOptions, ) -> usize { match self { Self::Size => 4, _ => items .iter() .map(|m| self.value_of(m, options).width()) .max() .unwrap_or(0), } } pub fn column_constraints( self, items: &[TrashItem], options: &TreeOptions, ) -> flex_grow::Child { let optimal_width = self.optimal_width(items, options); let child = flex_grow::Child::new(self); if self.const_width() { child.with_size(optimal_width) } else { child.with_max(optimal_width) } } } pub fn get_cols( items: &[TrashItem], available_width: usize, tree_options: &TreeOptions, ) -> Vec> { let mut cols_builder = flex_grow::Container::builder_in(available_width).with_margin_between(1); if tree_options.show_sizes { cols_builder.add( TrashItemProperty::Size .column_constraints(items, tree_options) .optional(), ); } if tree_options.show_dates { cols_builder.add( TrashItemProperty::DeletionDate .column_constraints(items, tree_options) .optional(), ); } cols_builder.add( TrashItemProperty::OriginalParent .column_constraints(items, tree_options) .with_min(10) .optional(), ); cols_builder.add( TrashItemProperty::Name .column_constraints(items, tree_options) .with_min(10) .with_grow(2.0), ); let Ok(cols) = cols_builder.build() else { return Vec::new(); // should not happen }; debug!("trash_state cols: {:?}", cols.sizes()); cols.to_children() } ================================================ FILE: src/tree/mod.rs ================================================ mod sort; mod tree; mod tree_line; mod tree_line_type; mod tree_options; pub use { sort::Sort, tree::Tree, tree_line::*, tree_line_type::TreeLineType, tree_options::TreeOptions, }; ================================================ FILE: src/tree/sort.rs ================================================ /// A sort key. /// A non None sort mode implies only one level of the tree /// is displayed. /// When in None mode, paths are alpha sorted #[derive(Debug, Clone, Copy, PartialEq)] pub enum Sort { None, Count, Date, Size, TypeDirsFirst, TypeDirsLast, } impl Sort { pub fn prevent_deep_display(self) -> bool { match self { Self::None => false, Self::Count => true, Self::Date => true, Self::Size => true, Self::TypeDirsFirst => false, Self::TypeDirsLast => false, } } } ================================================ FILE: src/tree/tree.rs ================================================ use { super::*, crate::{ app::AppContext, errors::TreeBuildError, file_sum::FileSum, git::TreeGitStatus, task_sync::{ ComputationResult, Dam, }, tree_build::{ BuildReport, TreeBuilder, }, }, rustc_hash::FxHashMap, std::{ cmp::Ord, mem, path::{ Path, PathBuf, }, }, }; /// The tree which may be displayed, with one line per visible line of the panel. /// /// In the tree structure, every "node" is just a line, there's /// no link from a child to its parent or from a parent to its children. #[derive(Debug, Clone)] pub struct Tree { pub lines: Vec, pub next_line_id: usize, pub selection: usize, // there's always a selection (starts with root, which is 0) pub options: TreeOptions, pub scroll: usize, // the number of lines at the top hidden because of scrolling pub total_search: bool, // whether the search was made on all children pub git_status: ComputationResult, pub build_report: BuildReport, } impl Tree { /// rebuild the tree with the same root, height, and options pub fn refresh( &mut self, page_height: usize, con: &AppContext, ) -> Result<(), TreeBuildError> { let builder = TreeBuilder::from( self.root().to_path_buf(), self.options.clone(), page_height, con, )?; self.total_search = false; // on refresh we always do a non total search let mut tree = builder .build_tree(self.total_search, &Dam::unlimited()) .unwrap(); // should not fail let selected_path = self.selected_line().path.to_path_buf(); mem::swap(&mut self.lines, &mut tree.lines); self.scroll = 0; if !self.try_select_path(&selected_path) && self.selection >= self.lines.len() { self.selection = 0; } self.make_selection_visible(page_height); Ok(()) } /// do what must be done after line additions or removals: /// - sort the lines /// - compute left branches pub fn after_lines_changed(&mut self) { // we need to order the lines to build the tree. // It's a little complicated because // - we want a case insensitive sort // - we still don't want to confuse the children of AA and Aa // - a node can come from a not parent node, when we followed a link let mut id_parents: FxHashMap = FxHashMap::default(); let mut id_lines: FxHashMap = FxHashMap::default(); for line in &self.lines[..] { if let Some(parent_id) = line.parent_id { id_parents.insert(line.id, parent_id); } id_lines.insert(line.id, line); } let mut sort_paths: FxHashMap = FxHashMap::default(); for line in &self.lines[1..] { let mut sort_path = String::new(); let mut id = line.id; while let Some(l) = id_lines.get(&id) { let lower_name = l .path .file_name() .map_or("".to_string(), |name| name.to_string_lossy().to_lowercase()); let sort_prefix = match self.options.sort { Sort::TypeDirsFirst => { if l.is_dir() { " " } else { l.path.extension().and_then(|s| s.to_str()).unwrap_or("") } } Sort::TypeDirsLast => { if l.is_dir() { "~~~~~~~~~~~~~~" } else { l.path.extension().and_then(|s| s.to_str()).unwrap_or("") } } _ => "", }; sort_path = format!( "{}{}-{}/{}", sort_prefix, lower_name, id, // to be sure to separate paths having the same lowercase sort_path, ); if let Some(&parent_id) = id_parents.get(&id) { id = parent_id; } else { break; } } sort_paths.insert(line.id, sort_path); } self.lines[1..].sort_by_key(|line| sort_paths.get(&line.id).unwrap()); let mut best_index = 0; // index of the line with the best score for i in 1..self.lines.len() { if self.lines[i].score > self.lines[best_index].score { best_index = i; } for d in 0..self.lines[i].left_branches.len() { self.lines[i].left_branches[d] = false; } } // then we discover the branches (for the drawing) // and we mark the last children as pruning, if they have unlisted brothers let mut last_parent_index: usize = self.lines.len() + 1; for end_index in (1..self.lines.len()).rev() { let depth = (self.lines[end_index].depth - 1) as usize; let start_index = { let parent_index = match self.lines[end_index].parent_id { Some(parent_id) => { let mut index = end_index; loop { index -= 1; if self.lines[index].id == parent_id { break; } if index == 0 { break; } } index } None => end_index, // Should not happen }; if parent_index != last_parent_index { // the line at end_index is the last listed child of the line at parent_index let unlisted = self.lines[parent_index].unlisted; if unlisted > 0 && self.lines[end_index].nb_kept_children == 0 { if best_index == end_index { //debug!("Avoiding to prune the line with best score"); } else { //debug!("turning {:?} into Pruning", self.lines[end_index].path); self.lines[end_index].line_type = TreeLineType::Pruning; self.lines[end_index].unlisted = unlisted + 1; self.lines[end_index].name = format!("{} unlisted", unlisted + 1); self.lines[parent_index].unlisted = 0; } } last_parent_index = parent_index; } parent_index + 1 }; for i in start_index..=end_index { self.lines[i].left_branches[depth] = true; } } if self.options.needs_sum() { time!("fetch_file_sum", self.fetch_regular_file_sums()); // not the dirs, only simple files self.sort_siblings(); // does nothing when sort mode is None } } pub fn is_empty(&self) -> bool { self.lines.len() == 1 } pub fn has_branch( &self, line_index: usize, depth: usize, ) -> bool { if line_index >= self.lines.len() { return false; } let line = &self.lines[line_index]; depth < usize::from(line.depth) && line.left_branches[depth] } /// select another line /// /// For example the following one if dy is 1. pub fn move_selection( &mut self, dy: i32, page_height: usize, cycle: bool, ) { let l = self.lines.len(); // we find the new line to select loop { if dy < 0 { let ady = (-dy) as usize; if !cycle && self.selection < ady { break; } self.selection = (self.selection + l - ady) % l; } else { let dy = dy as usize; if !cycle && self.selection + dy >= l { break; } self.selection = (self.selection + dy) % l; } if self.lines[self.selection].is_selectable() { break; } } // we adjust the scroll if l > page_height { if self.selection < 3 { self.scroll = 0; } else if self.selection < self.scroll + 3 { self.scroll = self.selection - 3; } else if self.selection + 3 > l { self.scroll = l - page_height; } else if self.selection + 3 > self.scroll + page_height { self.scroll = self.selection + 3 - page_height; } } } /// Scroll the desired amount and return true, or return false if it's /// already at end or the tree fits the page pub fn try_scroll( &mut self, dy: i32, page_height: usize, ) -> bool { if self.lines.len() <= page_height { return false; } if dy < 0 { // scroll up if self.scroll == 0 { return false; } let ady = -dy as usize; if ady < self.scroll { self.scroll -= ady; } else { self.scroll = 0; } } else { // scroll down let max = self.lines.len() - page_height; if self.scroll >= max { return false; } self.scroll = (self.scroll + dy as usize).min(max); } self.select_visible_line(page_height); true } /// try to select a line by index of visible line /// (works if y+scroll falls on a selectable line) pub fn try_select_y( &mut self, y: usize, ) -> bool { let y = y + self.scroll; if y < self.lines.len() && self.lines[y].is_selectable() { self.selection = y; return true; } false } /// fix the selection so that it's a selectable visible line fn select_visible_line( &mut self, page_height: usize, ) { if self.selection < self.scroll || self.selection >= self.scroll + page_height { self.selection = self.scroll; let l = self.lines.len(); loop { self.selection = (self.selection + l + 1) % l; if self.lines[self.selection].is_selectable() { break; } } } } pub fn make_selection_visible( &mut self, page_height: usize, ) { if page_height >= self.lines.len() || self.selection < 3 { self.scroll = 0; } else if self.selection <= self.scroll { self.scroll = self.selection - 2; } else if self.selection > self.lines.len() - 2 { self.scroll = self.lines.len() - page_height; } else if self.selection >= self.scroll + page_height { self.scroll = self.selection + 2 - page_height; } } pub fn selected_line(&self) -> &TreeLine { &self.lines[self.selection] } pub fn root(&self) -> &PathBuf { &self.lines[0].path } pub fn is_root_selected(&self) -> bool { self.selection == 0 } /// select the line with the best matching score pub fn try_select_best_match(&mut self) { let mut best_score = 0; for (idx, line) in self.lines.iter().enumerate() { if !line.is_selectable() { continue; } if best_score > line.score { continue; } if line.score == best_score { // in case of equal scores, we prefer the shortest path if self.lines[idx].depth >= self.lines[self.selection].depth { continue; } } best_score = line.score; self.selection = idx; } } /// return true when we could select the given path pub fn try_select_path( &mut self, path: &Path, ) -> bool { for (idx, line) in self.lines.iter().enumerate() { if !line.is_selectable() { continue; } if path == line.path { self.selection = idx; return true; } } false } pub fn try_select_first(&mut self) -> bool { for idx in 0..self.lines.len() { let line = &self.lines[idx]; if line.is_selectable() { self.selection = idx; self.scroll = 0; return true; } } false } pub fn try_select_last( &mut self, page_height: usize, ) -> bool { for idx in (0..self.lines.len()).rev() { let line = &self.lines[idx]; if line.is_selectable() { self.selection = idx; self.make_selection_visible(page_height); return true; } } false } pub fn try_select_previous_same_depth( &mut self, page_height: usize, ) -> bool { let depth = self.lines[self.selection].depth; for di in (0..self.lines.len()).rev() { let idx = (self.selection + di) % self.lines.len(); let line = &self.lines[idx]; if !line.is_selectable() || line.depth != depth { continue; } self.selection = idx; self.make_selection_visible(page_height); return true; } false } pub fn try_select_next_same_depth( &mut self, page_height: usize, ) -> bool { let depth = self.lines[self.selection].depth; for di in 0..self.lines.len() { let idx = (self.selection + di + 1) % self.lines.len(); let line = &self.lines[idx]; if !line.is_selectable() || line.depth != depth { continue; } self.selection = idx; self.make_selection_visible(page_height); return true; } false } pub fn try_select_previous_filtered( &mut self, filter: F, page_height: usize, ) -> bool where F: Fn(&TreeLine) -> bool, { for di in (0..self.lines.len()).rev() { let idx = (self.selection + di) % self.lines.len(); let line = &self.lines[idx]; if !line.is_selectable() { continue; } if !filter(line) { continue; } if line.score > 0 { self.selection = idx; self.make_selection_visible(page_height); return true; } } false } pub fn try_select_next_filtered( &mut self, filter: F, page_height: usize, ) -> bool where F: Fn(&TreeLine) -> bool, { for di in 0..self.lines.len() { let idx = (self.selection + di + 1) % self.lines.len(); let line = &self.lines[idx]; if !line.is_selectable() { continue; } if !filter(line) { continue; } if line.score > 0 { self.selection = idx; self.make_selection_visible(page_height); return true; } } false } pub fn has_dir_missing_sum(&self) -> bool { self.options.needs_sum() && self .lines .iter() .any(|line| line.line_type == TreeLineType::Dir && line.sum.is_none()) } pub fn is_missing_git_status_computation(&self) -> bool { self.git_status.is_not_computed() } /// fetch the file_sums of regular files (thus avoiding the /// long computation which is needed for directories) pub fn fetch_regular_file_sums(&mut self) { for i in 1..self.lines.len() { match self.lines[i].line_type { TreeLineType::Dir | TreeLineType::Pruning => {} _ => { self.lines[i].sum = Some(FileSum::from_file(&self.lines[i].path)); } } } self.sort_siblings(); } /// compute the file_sum of one directory /// /// To compute the size of all of them, this should be called until /// has_dir_missing_sum returns false pub fn fetch_some_missing_dir_sum( &mut self, dam: &Dam, con: &AppContext, ) { // we prefer to compute the root directory last: its computation // is faster when its first level children are already computed for i in (0..self.lines.len()).rev() { if self.lines[i].sum.is_none() && self.lines[i].line_type == TreeLineType::Dir { self.lines[i].sum = FileSum::from_dir(&self.lines[i].path, dam, con); self.sort_siblings(); return; } } } /// Sort files according to the sort option /// /// (does nothing if it's None) fn sort_siblings(&mut self) { match self.options.sort { Sort::Count => { // we'll try to keep the same path selected let selected_path = self.selected_line().path.to_path_buf(); self.lines[1..].sort_by(|a, b| { let account = a.sum.map_or(0, |s| s.to_count()); let bcount = b.sum.map_or(0, |s| s.to_count()); bcount.cmp(&account) }); self.try_select_path(&selected_path); } Sort::Date => { let selected_path = self.selected_line().path.to_path_buf(); self.lines[1..].sort_by(|a, b| { let adate = a.sum.map_or(0, |s| s.to_seconds()); let bdate = b.sum.map_or(0, |s| s.to_seconds()); bdate.cmp(&adate) }); self.try_select_path(&selected_path); } Sort::Size => { let selected_path = self.selected_line().path.to_path_buf(); self.lines[1..].sort_by(|a, b| { let asize = a.sum.map_or(0, |s| s.to_size()); let bsize = b.sum.map_or(0, |s| s.to_size()); bsize.cmp(&asize) }); self.try_select_path(&selected_path); } _ => {} } } /// compute and return the size of the root pub fn total_sum(&self) -> FileSum { if let Some(sum) = self.lines[0].sum { // if the real total sum is computed, it's in the root line sum } else { // if we don't have the sum in root, the nearest estimate is // the sum of sums of lines at depth 1 let mut sum = FileSum::zero(); for i in 1..self.lines.len() { if self.lines[i].depth == 1 { if let Some(line_sum) = self.lines[i].sum { sum += line_sum; } } } sum } } /// Add to the tree the lines which are in the given path but not already in the tree. /// /// Fail if the path is not a descendant of the tree root. fn add_lines_to_path( &mut self, target_path: &Path, con: &AppContext, ) -> Result<(), TreeBuildError> { let mut path = target_path; let mut paths_to_add = Vec::new(); // find the closest parent already in the tree let mut present_ancestor_idx = loop { let idx = self.lines.iter().position(|line| line.path == path); if let Some(idx) = idx { break idx; } paths_to_add.push(path); let Some(parent) = path.parent() else { warn!("no ancestor in the tree for {:?}", path); return Err(TreeBuildError::NotARootDescendant { path: path.display().to_string(), }); }; path = parent; }; let present_ancestor = &mut self.lines[present_ancestor_idx]; //debug!("present ancestor: {:#?}", &present_ancestor); if present_ancestor.line_type.is_pruning() { info!("unpruning {:?}", &present_ancestor.path); present_ancestor.unprune(); // we should in exchange prune another one ? } debug!("show -> paths to add: {:?}", paths_to_add); if paths_to_add.is_empty() { return Ok(()); } present_ancestor.nb_kept_children += 1; // adding the new lines while let Some(path_to_add) = paths_to_add.pop() { info!("adding {:?}", path_to_add); let new_line_id = self.next_line_id; self.next_line_id += 1; let parent = &self.lines[present_ancestor_idx]; let depth = parent.depth + 1; // The 1 kept_children here might be a trick to avoid the file // being changed to Pruning in the after_lines_changed method... let nb_kept_children = 1; let subpath = path_to_add .strip_prefix(self.root()) .map_err(|_| { // not supposed to happen at this point as we're adding a descendant TreeBuildError::NotARootDescendant { path: path.display().to_string(), } })? .to_string_lossy() .to_string(); let line = TreeLineBuilder { id: new_line_id, path: path_to_add.to_path_buf(), subpath, parent_id: Some(parent.id), depth, unlisted: 0, nb_kept_children, has_error: false, score: 1, direct_match: true, } .build(con)?; present_ancestor_idx = self.lines.len(); self.lines.push(line); } self.after_lines_changed(); Ok(()) } pub fn show_path( &mut self, path: &Path, con: &AppContext, ) -> Result<(), TreeBuildError> { self.add_lines_to_path(path, con)?; let selected = self.try_select_path(path); if !selected { warn!("failed to select {:?}", path); } Ok(()) } } ================================================ FILE: src/tree/tree_line.rs ================================================ use { super::*, crate::{ app::{ AppContext, Selection, SelectionType, }, errors::TreeBuildError, file_sum::FileSum, git::LineGitStatus, }, lazy_regex::regex_captures, std::{ fs, path::{ Path, PathBuf, }, }, }; #[cfg(unix)] use { std::os::unix::fs::MetadataExt, umask::Mode, }; #[cfg(windows)] use is_executable::IsExecutable; pub type TreeLineId = usize; /// a line in the representation of the file hierarchy #[derive(Debug, Clone)] pub struct TreeLine { pub id: TreeLineId, pub parent_id: Option, pub left_branches: Box<[bool]>, // a depth-sized array telling whether a branch pass pub depth: u16, pub path: PathBuf, pub subpath: String, pub icon: Option, pub name: String, // a displayable name - some chars may have been stripped pub line_type: TreeLineType, pub has_error: bool, pub nb_kept_children: usize, pub unlisted: usize, // number of not listed children (Dir) or brothers (Pruning) pub score: i32, // 0 if there's no pattern pub direct_match: bool, pub sum: Option, // None when not measured pub metadata: fs::Metadata, pub git_status: Option, } pub struct TreeLineBuilder { pub path: PathBuf, pub subpath: String, pub id: TreeLineId, pub parent_id: Option, pub depth: u16, pub unlisted: usize, pub nb_kept_children: usize, pub has_error: bool, pub score: i32, pub direct_match: bool, } impl TreeLineBuilder { pub fn build( self, con: &AppContext, ) -> Result { let Self { path, subpath, id, parent_id, depth, unlisted, nb_kept_children, has_error, score, direct_match, } = self; let metadata = fs::symlink_metadata(&path).map_err(|_| TreeBuildError::FileNotFound { path: path.to_string_lossy().to_string(), })?; let line_type = TreeLineType::new(&path, metadata.file_type()); let name = path .file_name() .map(|os_str| os_str.to_string_lossy().replace('\n', "␤")) .unwrap_or_else(String::new); let icon = con.icons.as_ref().map(|icon_plugin| { let extension = TreeLine::extension_from_name(&name); let double_extension = extension.and_then(|_| TreeLine::double_extension_from_name(&name)); icon_plugin.get_icon(&line_type, &name, double_extension, extension) }); Ok(TreeLine { id, parent_id, left_branches: vec![false; depth as usize].into_boxed_slice(), depth, icon, name, subpath, path, line_type, has_error, nb_kept_children, unlisted, score, direct_match, sum: None, metadata, git_status: None, }) } } impl TreeLine { pub fn double_extension_from_name(name: &str) -> Option<&str> { regex_captures!(r"\.([^.]+\.[^.]+)", name).map(|(_, de)| de) } pub fn extension_from_name(name: &str) -> Option<&str> { regex_captures!(r"\.([^.]+)$", name).map(|(_, ext)| ext) } pub fn is_selectable(&self) -> bool { !matches!(&self.line_type, TreeLineType::Pruning) } pub fn is_dir(&self) -> bool { match &self.line_type { TreeLineType::Dir => true, TreeLineType::SymLink { final_is_dir, .. } if *final_is_dir => true, _ => false, } } pub fn is_file(&self) -> bool { matches!(&self.line_type, TreeLineType::File) } pub fn is_of( &self, selection_type: SelectionType, ) -> bool { match selection_type { SelectionType::Any => true, SelectionType::File => self.is_file(), SelectionType::Directory => self.is_dir(), } } pub fn extension(&self) -> Option<&str> { Self::extension_from_name(&self.name) } pub fn selection_type(&self) -> SelectionType { use TreeLineType::*; match &self.line_type { File => SelectionType::File, Dir | BrokenSymLink(_) => SelectionType::Directory, SymLink { final_is_dir, .. } => { if *final_is_dir { SelectionType::Directory } else { SelectionType::File } } Pruning => SelectionType::Any, // should not happen today } } pub fn as_selection(&self) -> Selection<'_> { Selection { path: &self.path, stype: self.selection_type(), is_exe: self.is_exe(), line: 0, } } #[cfg(unix)] pub fn mode(&self) -> Mode { Mode::from(self.metadata.mode()) } /// Return the unix device id /// /// (the equivalent for windows isn't unfailliblely implementable today) #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn device_id(&self) -> lfs_core::DeviceId { self.metadata.dev().into() } #[cfg(target_os = "windows")] pub fn device_id(&self) -> Option { lfs_core::DeviceId::of_path(&self.path).ok() } #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub fn mount(&self) -> Option { use crate::filesystems::*; let mut mount_list = MOUNTS.lock().unwrap(); if mount_list.load().is_ok() { let device_id = lfs_core::DeviceId::of_path(&self.path).ok()?; mount_list .get_by_device_id(device_id) .cloned() } else { None } } pub fn is_exe(&self) -> bool { #[cfg(unix)] return self.mode().is_exe(); #[cfg(windows)] return self.path.is_executable(); } /// build and return the absolute targeted path: either self.path or the /// solved canonicalized symlink pub fn target(&self) -> &Path { match &self.line_type { TreeLineType::SymLink { final_target, .. } => final_target, _ => &self.path, } } pub fn unprune(&mut self) { self.line_type = TreeLineType::new(&self.path, self.metadata.file_type()); self.name = self .path .file_name() .map_or_else(|| "???".to_string(), |n| n.to_string_lossy().to_string()); } } ================================================ FILE: src/tree/tree_line_type.rs ================================================ use { rustc_hash::FxHashSet, std::{ fs, io, path::{ Path, PathBuf, }, }, }; /// The maximum number of symlink hops before giving up. const MAX_LINK_CHAIN_LENGTH: usize = 128; /// The type of a line which can be displayed as /// part of a tree #[derive(Debug, Clone, PartialEq)] pub enum TreeLineType { File, Dir, BrokenSymLink(String), SymLink { direct_target: String, final_is_dir: bool, final_target: PathBuf, }, Pruning, // a "xxx unlisted" line } pub fn read_link(path: &Path) -> io::Result { let mut target = fs::read_link(path)?; if target.is_relative() { target = path.parent().unwrap().join(&target); } Ok(target) } impl TreeLineType { pub fn is_pruning(&self) -> bool { matches!(self, Self::Pruning) } fn resolve(direct_target: &Path) -> io::Result { let mut final_target = direct_target.to_path_buf(); let mut final_metadata = fs::symlink_metadata(&final_target)?; let mut final_ft = final_metadata.file_type(); let mut final_is_dir = final_ft.is_dir(); let mut link_chain_length = 0; let mut visited = FxHashSet::default(); while final_ft.is_symlink() { final_target = read_link(&final_target)?; if visited.contains(&final_target) { info!( "circular symlink opened by {} and closed by {}", direct_target.display(), final_target.display(), ); return Ok(Self::BrokenSymLink( direct_target.to_string_lossy().into_owned(), )); } visited.insert(final_target.clone()); final_metadata = fs::symlink_metadata(&final_target)?; final_ft = final_metadata.file_type(); final_is_dir = final_ft.is_dir(); link_chain_length += 1; if link_chain_length > MAX_LINK_CHAIN_LENGTH { info!("too long link chain at {}", direct_target.display()); return Ok(Self::BrokenSymLink( direct_target.to_string_lossy().into_owned(), )); } } let direct_target = direct_target.to_string_lossy().into_owned(); Ok(Self::SymLink { direct_target, final_is_dir, final_target, }) } pub fn new( path: &Path, ft: fs::FileType, ) -> Self { if ft.is_dir() { Self::Dir } else if ft.is_symlink() { if let Ok(direct_target) = read_link(path) { Self::resolve(&direct_target).unwrap_or_else(|_| { Self::BrokenSymLink(direct_target.to_string_lossy().to_string()) }) } else { Self::BrokenSymLink("???".to_string()) } } else { Self::File } } } ================================================ FILE: src/tree/tree_options.rs ================================================ use { super::Sort, crate::{ cli::Args, conf::Conf, display::{ Cols, DEFAULT_COLS, }, errors::ConfError, pattern::*, }, clap::Parser, lazy_regex::regex_is_match, std::convert::TryFrom, }; /// Options defining how the tree should be build and|or displayed #[derive(Debug, Clone)] pub struct TreeOptions { pub show_selection_mark: bool, // whether to have a triangle left of selected line pub show_hidden: bool, // whether files whose name starts with a dot should be shown pub only_folders: bool, // whether to hide normal files and links pub show_counts: bool, // whether to show the number of files (> 1 only for dirs) pub show_dates: bool, // whether to show the last modified date pub show_sizes: bool, // whether to show sizes of files and dirs pub max_depth: Option, // the maximum directory depth to recurse to pub show_git_file_info: bool, pub show_device_id: bool, pub show_root_fs: bool, // show information relative to the fs of the root pub trim_root: bool, // whether to cut out direct children of root pub show_permissions: bool, // show classic rwx unix permissions (only on unix) pub respect_git_ignore: bool, // hide files as requested by .gitignore ? pub filter_by_git_status: bool, // only show files whose git status is not nul pub pattern: InputPattern, // an optional filtering/scoring pattern pub date_time_format: &'static str, pub sort: Sort, pub show_tree: bool, // whether to show the tree pub cols_order: Cols, // order of columns pub show_matching_characters_on_path_searches: bool, } impl TreeOptions { /// clone self but without the pattern (if any) pub fn without_pattern(&self) -> Self { TreeOptions { show_selection_mark: self.show_selection_mark, show_hidden: self.show_hidden, only_folders: self.only_folders, max_depth: self.max_depth, show_counts: self.show_counts, show_dates: self.show_dates, show_sizes: self.show_sizes, show_permissions: self.show_permissions, respect_git_ignore: self.respect_git_ignore, filter_by_git_status: self.filter_by_git_status, show_git_file_info: self.show_git_file_info, show_device_id: self.show_device_id, show_root_fs: self.show_root_fs, trim_root: self.trim_root, pattern: InputPattern::none(), date_time_format: self.date_time_format, sort: self.sort, show_tree: self.show_tree, cols_order: self.cols_order, show_matching_characters_on_path_searches: self .show_matching_characters_on_path_searches, } } /// counts must be computed, either for sorting or just for display pub fn needs_counts(&self) -> bool { self.show_counts || self.sort == Sort::Count } /// dates must be computed, either for sorting or just for display pub fn needs_dates(&self) -> bool { self.show_dates || self.sort == Sort::Date } /// sizes must be computed, either for sorting or just for display pub fn needs_sizes(&self) -> bool { self.show_sizes || self.sort == Sort::Size } pub fn needs_sum(&self) -> bool { self.needs_counts() || self.needs_dates() || self.needs_sizes() } /// this method does not exist, you saw nothing /// (at least don't call it other than with the config, once) pub fn set_date_time_format( &mut self, format: String, ) { self.date_time_format = Box::leak(format.into_boxed_str()); } /// change tree options according to configuration /// (but not the default_flags part, which is handled separately) pub fn apply_config( &mut self, config: &Conf, ) -> Result<(), ConfError> { if let Some(b) = config.show_selection_mark { self.show_selection_mark = b; } if let Some(format) = &config.date_time_format { self.set_date_time_format(format.clone()); } if let Some(b) = config.show_matching_characters_on_path_searches { self.show_matching_characters_on_path_searches = b; } self.cols_order = config .cols_order .as_ref() .map(Cols::try_from) .transpose()? .unwrap_or(DEFAULT_COLS); Ok(()) } /// apply flags like "sdp" pub fn apply_flags( &mut self, flags: &str, ) -> Result<(), &'static str> { if !regex_is_match!("^[a-zA-Z]+$", flags) { return Err("Flags must be a sequence of letters"); } let prefixed = format!("-{flags}"); let tokens = vec!["broot", &prefixed]; let args = Args::try_parse_from(tokens).map_err(|_| { warn!("invalid flags: {:?}", flags); "invalid flag (valid flags are -dDfFgGhHiIpPsSwWtT)" })?; self.apply_launch_args(&args); Ok(()) } /// change tree options according to broot launch arguments pub fn apply_launch_args( &mut self, cli_args: &Args, ) { if cli_args.sizes { self.show_sizes = true; self.show_root_fs = true; } else if cli_args.no_sizes { self.show_sizes = false; } if cli_args.whale_spotting { self.show_hidden = true; self.respect_git_ignore = false; self.sort = Sort::Size; self.show_sizes = true; self.show_root_fs = true; } if cli_args.no_whale_spotting { self.show_hidden = false; self.respect_git_ignore = true; self.sort = Sort::None; self.show_sizes = false; self.show_root_fs = false; } if cli_args.only_folders { self.only_folders = true; } else if cli_args.no_only_folders { self.only_folders = false; } if let Some(max_depth) = cli_args.max_depth { self.max_depth = Some(max_depth); } if cli_args.git_status { self.filter_by_git_status = true; self.show_hidden = true; } if cli_args.hidden { self.show_hidden = true; } else if cli_args.no_hidden { self.show_hidden = false; } if cli_args.dates { self.show_dates = true; } else if cli_args.no_dates { self.show_dates = false; } if cli_args.permissions { self.show_permissions = true; } else if cli_args.no_permissions { self.show_permissions = false; } if cli_args.show_root_fs { self.show_root_fs = true; } if cli_args.git_ignored { self.respect_git_ignore = false; } else if cli_args.no_git_ignored { self.respect_git_ignore = true; } if cli_args.show_git_info { self.show_git_file_info = true; } else if cli_args.no_show_git_info { self.show_git_file_info = false; } if cli_args.sort_by_count { self.sort = Sort::Count; self.show_counts = true; } if cli_args.sort_by_date { self.sort = Sort::Date; self.show_dates = true; } if cli_args.sort_by_size { self.sort = Sort::Size; self.show_sizes = true; } if cli_args.tree { self.show_tree = true; } else if cli_args.no_tree { self.show_tree = false; } if cli_args.sort_by_type_dirs_first || cli_args.sort_by_type { self.sort = Sort::TypeDirsFirst; } if cli_args.sort_by_type_dirs_last { self.sort = Sort::TypeDirsLast; } if cli_args.no_sort { self.sort = Sort::None; } if cli_args.trim_root { self.trim_root = true; } else if cli_args.no_trim_root { self.trim_root = false; } } } impl Default for TreeOptions { fn default() -> Self { Self { show_selection_mark: false, show_hidden: false, only_folders: false, max_depth: None, show_counts: false, show_dates: false, show_sizes: false, show_git_file_info: false, show_device_id: false, show_root_fs: false, trim_root: false, show_permissions: false, respect_git_ignore: true, filter_by_git_status: false, pattern: InputPattern::none(), date_time_format: "%Y/%m/%d %R", sort: Sort::None, show_tree: true, cols_order: DEFAULT_COLS, show_matching_characters_on_path_searches: true, } } } ================================================ FILE: src/tree_build/bid.rs ================================================ use { super::bline::BLine, id_arena::Id, std::cmp::Ordering, }; pub type BId = Id; /// a structure making it possible to keep bline references /// sorted in a binary heap with the line with the smallest /// score at the top pub struct SortableBId { pub id: BId, pub score: i32, } impl Eq for SortableBId {} impl PartialEq for SortableBId { fn eq( &self, other: &SortableBId, ) -> bool { self.score == other.score // unused but required by spec of Ord } } impl Ord for SortableBId { fn cmp( &self, other: &SortableBId, ) -> Ordering { other.score.cmp(&self.score) } } impl PartialOrd for SortableBId { fn partial_cmp( &self, other: &SortableBId, ) -> Option { Some(self.cmp(other)) } } ================================================ FILE: src/tree_build/bline.rs ================================================ use { super::bid::BId, crate::{ errors::TreeBuildError, git::IgnoreChain, path::{ Directive, SpecialHandling, normalize_path, }, tree::*, }, id_arena::Arena, std::{ fs, io, path::PathBuf, result::Result, }, }; /// like a tree line, but with the info needed during the build /// This structure isn't usable independently from the tree builder pub struct BLine { pub parent_id: Option, pub path: PathBuf, pub depth: u16, pub file_type: fs::FileType, pub children: Option>, // sorted and filtered pub next_child_idx: usize, // index for iteration, among the children pub has_error: bool, pub has_match: bool, pub direct_match: bool, pub score: i32, pub nb_kept_children: i32, // used during the trimming step pub git_ignore_chain: IgnoreChain, pub special_handling: SpecialHandling, } impl BLine { pub fn name(&self) -> &str { self.path .file_name() .and_then(|os_str| os_str.to_str()) .unwrap_or("") } /// a special constructor, checking nothing pub fn from_root( blines: &mut Arena, path: PathBuf, git_ignore_chain: IgnoreChain, _options: &TreeOptions, ) -> Result { if let Ok(md) = fs::metadata(&path) { let file_type = md.file_type(); Ok(blines.alloc(BLine { parent_id: None, path, depth: 0, children: None, next_child_idx: 0, file_type, has_error: false, has_match: true, direct_match: false, score: 0, nb_kept_children: 0, git_ignore_chain, special_handling: Default::default(), })) } else { Err(TreeBuildError::FileNotFound { path: format!("{path:?}"), }) } } /// execute read_dir either on the link if we're a link, /// or on the path otherwise. /// /// Assume the can_enter check has already be done. pub(crate) fn read_dir(&self) -> io::Result { if self.file_type.is_symlink() { if let Ok(target) = fs::read_link(&self.path) { let mut target_path = PathBuf::from(&target); if target_path.is_relative() { target_path = self.path.parent().unwrap().join(target_path); target_path = normalize_path(target_path); } return fs::read_dir(&target_path); } } fs::read_dir(&self.path) } /// tell whether we should list the children of the present line pub fn can_enter(&self) -> bool { if self.file_type.is_dir() && self.special_handling.list != Directive::Never { return true; } if self.special_handling.list == Directive::Always { // we must check we're a link to a directory if self.file_type.is_symlink() { if let Ok(target) = fs::read_link(&self.path) { let mut target_path = PathBuf::from(&target); if target_path.is_relative() { target_path = self.path.parent().unwrap().join(target_path); } if let Ok(target_metadata) = fs::symlink_metadata(&target_path) { if target_metadata.file_type().is_dir() { if self.path.starts_with(target_path) { debug!("not entering link because it's a parent"); // lets's not cycle } else { debug!("entering {:?} because of special path rule", &self.path); return true; } } } } } } false } } ================================================ FILE: src/tree_build/build_report.rs ================================================ /// Information from the builder about the /// tree operation /// /// A file is counted at most once here #[derive(Debug, Clone, Copy, Default)] pub struct BuildReport { /// number of times a gitignore pattern excluded a file pub gitignored_count: usize, /// number of times a file was excluded because hidden /// (this count stays at zero if hidden files are displayed) pub hidden_count: usize, /// number of errors excluding a file pub error_count: usize, } ================================================ FILE: src/tree_build/builder.rs ================================================ use { super::{ BuildReport, bid::{ BId, SortableBId, }, bline::BLine, }, crate::{ app::AppContext, errors::TreeBuildError, git::{ IgnoreChain, Ignorer, LineStatusComputer, }, path::Directive, pattern::Candidate, task_sync::{ ComputationResult, Dam, }, tree::*, }, git2::Repository, id_arena::Arena, std::{ collections::{ BinaryHeap, VecDeque, }, fs, path::PathBuf, result::Result, time::{ Duration, Instant, }, }, }; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; #[cfg(target_os = "windows")] use std::ffi::OsStr; #[cfg(target_os = "windows")] trait OsStrWin { fn as_bytes(&self) -> &[u8]; } #[cfg(target_os = "windows")] impl OsStrWin for OsStr { fn as_bytes(&self) -> &[u8] { static INVALID_UTF8: &[u8] = b"invalid utf8"; self.to_str().map(|s| s.as_bytes()).unwrap_or(INVALID_UTF8) } } /// If a search found enough results to fill the screen but didn't scan /// everything, we search a little more in case we find better matches /// but not after the NOT_LONG duration. static NOT_LONG: Duration = Duration::from_millis(900); /// The TreeBuilder builds a Tree according to options (including an optional search pattern) /// Instead of the final TreeLine, the builder uses an internal structure: BLine. /// All BLines used during build are stored in the blines arena and kept until the end. /// Most operations and temporary data structures just deal with the ids of lines /// the blines arena. pub struct TreeBuilder<'c> { pub options: TreeOptions, targeted_size: usize, // the number of lines we should fill (height of the screen) blines: Arena, root_id: BId, subpath_offset: usize, total_search: bool, git_ignorer: Ignorer, line_status_computer: Option, con: &'c AppContext, pub matches_max: Option, // optional hard limit trim_root: bool, pub deep: bool, report: BuildReport, } impl<'c> TreeBuilder<'c> { pub fn from( path: PathBuf, options: TreeOptions, targeted_size: usize, con: &'c AppContext, ) -> Result, TreeBuildError> { let mut blines = Arena::new(); let subpath_offset = path.components().count(); let mut git_ignorer = time!(Ignorer::default()); let root_ignore_chain = git_ignorer.root_chain(&path); let line_status_computer = if options.filter_by_git_status || options.show_git_file_info { time!( "init line_status_computer", Repository::discover(&path) .ok() .as_ref() .and_then(LineStatusComputer::from), ) } else { None }; let root_id = BLine::from_root(&mut blines, path, root_ignore_chain, &options)?; let trim_root = match ( options.trim_root, options.pattern.is_some(), options.sort.prevent_deep_display(), ) { // we never want to trim the root if there's a sort (_, _, true) => false, // if the user don't want root trimming, we don't trim (false, _, _) => false, // if there's a pattern, we try to show at least root matches (_, true, _) => false, // in other cases, as the user wants trimming, we trim _ => true, }; Ok(TreeBuilder { options, targeted_size, blines, root_id, subpath_offset, total_search: true, // we'll set it to false if we don't look at all children git_ignorer, line_status_computer, con, trim_root, matches_max: None, deep: true, report: BuildReport::default(), }) } /// Return a bline if the dir_entry directly matches the options and there's no error fn make_bline( &mut self, parent_id: BId, e: &fs::DirEntry, depth: u16, ) -> Option { let name = e.file_name(); if name.is_empty() { // this should not really happen as the only path with an empty name is the root // and we don't call this function for the tree root self.report.error_count += 1; return None; } let path = e.path(); let special_handling = self.con.special_paths.find(&path); if special_handling.show == Directive::Never { return None; } if !self.options.show_hidden && name.as_bytes()[0] == b'.' && special_handling.show != Directive::Always { self.report.hidden_count += 1; return None; } let name = name.to_string_lossy(); let mut has_match = true; let mut score = 10000 - i32::from(depth); // we dope less deep entries let file_type = match e.file_type() { Ok(ft) => ft, Err(_) => { self.report.error_count += 1; return None; } }; let subpath = path .components() .skip(self.subpath_offset) .collect::(); let candidate = Candidate { name: &name, subpath: &subpath.to_string_lossy(), path: &path, }; let direct_match = if let Some(pattern_score) = self.options.pattern.pattern.score_of(candidate) { // we dope direct matches to compensate for depth doping of parent folders score += pattern_score + 10; true } else { has_match = false; false }; if has_match && self.options.filter_by_git_status { if let Some(line_status_computer) = &self.line_status_computer { if !line_status_computer.is_interesting(&path) { has_match = false; } } } if file_type.is_file() && !has_match { return None; } if self.options.only_folders && !file_type.is_dir() { if !file_type.is_symlink() { return None; } let Ok(target_metadata) = fs::metadata(&path) else { return None; }; if !target_metadata.is_dir() { return None; } } if self.options.respect_git_ignore { let parent_chain = &self.blines[parent_id].git_ignore_chain; if !self .git_ignorer .accepts(parent_chain, &path, &name, file_type.is_dir()) { if special_handling.show != Directive::Always { return None; } } }; Some(BLine { parent_id: Some(parent_id), path, depth, file_type, children: None, next_child_idx: 0, has_error: false, has_match, direct_match, score, nb_kept_children: 0, git_ignore_chain: IgnoreChain::default(), special_handling, }) } /// Fill the bline's children vec of blines. /// /// Return true when there are direct matches among children fn load_children( &mut self, bid: BId, ) -> bool { let mut has_child_match = false; match self.blines[bid].read_dir() { Ok(entries) => { let mut children: Vec = Vec::new(); let child_depth = self.blines[bid].depth + 1; let mut lines = Vec::new(); for e in entries.flatten() { if let Some(line) = self.make_bline(bid, &e, child_depth) { lines.push(line); } } for mut bl in lines { if self.options.respect_git_ignore { let parent_chain = &self.blines[bid].git_ignore_chain; bl.git_ignore_chain = if bl.file_type.is_dir() { self.git_ignorer.deeper_chain(parent_chain, &bl.path) } else { parent_chain.clone() }; } if bl.has_match { self.blines[bid].has_match = true; has_child_match = true; } let child_id = self.blines.alloc(bl); children.push(child_id); } children.sort_by(|&a, &b| { self.blines[a] .name() .to_lowercase() .cmp(&self.blines[b].name().to_lowercase()) }); self.blines[bid].children = Some(children); } Err(_err) => { self.blines[bid].has_error = true; self.blines[bid].children = Some(Vec::new()); } } has_child_match } /// return the next child. /// load_children must have been called before on parent_id fn next_child( &mut self, parent_id: BId, ) -> Option { let bline = &mut self.blines[parent_id]; if let Some(children) = &bline.children { if bline.next_child_idx < children.len() { let next_child = children[bline.next_child_idx]; bline.next_child_idx += 1; Some(next_child) } else { Option::None } } else { unreachable!(); } } /// first step of the build: we explore the directories and gather lines. /// If there's no search pattern we stop when we have enough lines to fill the screen. /// If there's a pattern, we try to gather more lines that will be sorted afterwards. fn gather_lines( &mut self, total_search: bool, dam: &Dam, ) -> Result, TreeBuildError> { let start = Instant::now(); let mut out_blines: Vec = Vec::new(); // the blines we want to display let optimal_size = if self.options.pattern.pattern.has_real_scores() { 10 * self.targeted_size } else { self.targeted_size }; out_blines.push(self.root_id); let mut nb_lines_ok = 1; // in out_blines let mut open_dirs: VecDeque = VecDeque::new(); let mut next_level_dirs: Vec = Vec::new(); self.load_children(self.root_id); open_dirs.push_back(self.root_id); let deep = self.deep && self.options.show_tree && !self.options.sort.prevent_deep_display(); loop { if !total_search && ((nb_lines_ok > optimal_size) || (nb_lines_ok >= self.targeted_size && start.elapsed() > NOT_LONG)) { self.total_search = false; break; } if let Some(max) = self.matches_max { if nb_lines_ok > max { return Err(TreeBuildError::TooManyMatches { max }); } } if let Some(open_dir_id) = open_dirs.pop_front() { if let Some(child_id) = self.next_child(open_dir_id) { open_dirs.push_back(open_dir_id); let child = &self.blines[child_id]; if child.has_match { nb_lines_ok += 1; } if self .options .max_depth .map_or(false, |max| child.depth > max) { break; } if child.can_enter() { next_level_dirs.push(child_id); } out_blines.push(child_id); } } else { // this depth is finished, we must go deeper if !deep { break; } if next_level_dirs.is_empty() { // except there's nothing deeper break; } for next_level_dir_id in &next_level_dirs { if dam.has_event() { info!("task expired (core build - inner loop)"); return Err(TreeBuildError::Interrupted); } let has_child_match = self.load_children(*next_level_dir_id); if has_child_match { // we must ensure the ancestors are made Ok let mut id = *next_level_dir_id; loop { let bline = &mut self.blines[id]; if !bline.has_match { bline.has_match = true; nb_lines_ok += 1; } if let Some(pid) = bline.parent_id { id = pid; } else { break; } } } open_dirs.push_back(*next_level_dir_id); } next_level_dirs.clear(); } } if let Some(max) = self.matches_max { if nb_lines_ok > max { return Err(TreeBuildError::TooManyMatches { max }); } } if !self.trim_root { // if the root directory isn't totally read, we finished it even // it it goes past the bottom of the screen while let Some(child_id) = self.next_child(self.root_id) { out_blines.push(child_id); } } Ok(out_blines) } /// Post search trimming /// When there's a pattern, gathering normally brings many more lines than /// strictly necessary to fill the screen. /// This function keeps only the best ones while taking care of not /// removing a parent before its children. fn trim_excess( &mut self, out_blines: &[BId], ) { let mut count = 1; for id in &out_blines[1..] { if self.blines[*id].has_match { //debug!("bline before trimming: {:?}", &self.blines[*idx].path); count += 1; let parent_id = self.blines[*id].parent_id.unwrap(); // (we can unwrap because only the root can have a None parent) self.blines[parent_id].nb_kept_children += 1; } } let mut remove_queue: BinaryHeap = BinaryHeap::new(); for id in &out_blines[1..] { let bline = &self.blines[*id]; if bline.has_match && bline.nb_kept_children == 0 && (bline.depth > 1 || self.trim_root) { remove_queue.push(SortableBId { id: *id, score: bline.score, }); } } while count > self.targeted_size { if let Some(sli) = remove_queue.pop() { self.blines[sli.id].has_match = false; let parent_id = self.blines[sli.id].parent_id.unwrap(); let parent = &mut self.blines[parent_id]; parent.nb_kept_children -= 1; parent.next_child_idx -= 1; // to fix the number of "unlisted" if parent.nb_kept_children == 0 { remove_queue.push(SortableBId { id: parent_id, score: parent.score, }); } count -= 1; } else { debug!("trimming prematurely interrupted"); break; } } } fn make_tree_line( &self, bid: BId, ) -> Result { let bline = &self.blines[bid]; let path = bline.path.clone(); let subpath = if bline.depth == 0 { bline.path.to_string_lossy().to_string() } else { bline .path .components() .skip(self.subpath_offset) .map(|c| c.as_os_str()) .collect::() .to_string_lossy() .to_string() }; let unlisted = bline .children .as_ref() .map_or(0, |children| children.len() - bline.next_child_idx); TreeLineBuilder { path, subpath, id: bid.index(), parent_id: bline.parent_id.map(|bid| bid.index()), depth: bline.depth, unlisted, nb_kept_children: bline.nb_kept_children as usize, has_error: bline.has_error, score: bline.score, direct_match: bline.direct_match, } .build(self.con) } /// make a tree from the builder's specific structure fn take_as_tree( mut self, out_blines: &[BId], ) -> Tree { let mut lines: Vec = Vec::new(); for id in out_blines { if self.blines[*id].has_match { // we need to count the children, so we load them if self.blines[*id].can_enter() && self.blines[*id].children.is_none() { self.load_children(*id); } if let Ok(tree_line) = self.make_tree_line(*id) { lines.push(tree_line); } else { // I guess the file went missing during tree computation warn!( "Error while builind treeline for {:?}", self.blines[*id].path, ); } } } let next_line_id = lines.iter().map(|line| line.id).max().unwrap_or(0) + 1; let mut tree = Tree { lines, next_line_id, selection: 0, options: self.options.clone(), scroll: 0, total_search: self.total_search, git_status: ComputationResult::None, build_report: self.report, }; tree.after_lines_changed(); if let Some(computer) = self.line_status_computer { // tree git status is slow to compute, we just mark it should be // done (later on) tree.git_status = ComputationResult::NotComputed; // it would make no sense to keep only files having a git status and // not display that type for line in &mut tree.lines { line.git_status = computer.line_status(&line.path); } } tree } /// build a tree. Can be called only once per builder. /// /// Return None if the lifetime expires before end of computation /// (usually because the user hit a key) pub fn build_tree( mut self, total_search: bool, dam: &Dam, ) -> Result { let blines_ids = self.gather_lines(total_search, dam)?; debug!("blines before trimming: {}", blines_ids.len()); if !self.total_search { self.trim_excess(&blines_ids); } Ok(self.take_as_tree(&blines_ids)) } pub fn build_paths( mut self, total_search: bool, dam: &Dam, filter: F, ) -> Result, TreeBuildError> where F: Fn(&BLine) -> bool, { self.gather_lines(total_search, dam).map(|mut blines_ids| { blines_ids .drain(..) .filter(|&bid| self.blines[bid].direct_match && filter(&self.blines[bid])) .map(|id| self.blines[id].path.clone()) .collect() }) } } ================================================ FILE: src/tree_build/mod.rs ================================================ mod bid; mod bline; mod build_report; mod builder; pub use { bid::BId, build_report::BuildReport, builder::TreeBuilder, }; ================================================ FILE: src/tty/mod.rs ================================================ mod tline; mod tline_builder; mod trange; mod tstring; mod tty_view; pub const CSI_RESET: &str = "\u{1b}[0m"; pub const CSI_BOLD: &str = "\u{1b}[1m"; pub const CSI_ITALIC: &str = "\u{1b}[3m"; static TAB_REPLACEMENT: &str = " "; use { crate::{ display::W, errors::ProgramError, }, std::io::Write, }; pub use { tline::*, tline_builder::*, trange::*, tstring::*, tty_view::*, }; fn draw( w: &mut W, csi: &str, raw: &str, ) -> Result<(), ProgramError> { if csi.is_empty() { write!(w, "{}", raw)?; } else { write!(w, "{}{}{}", csi, raw, CSI_RESET,)?; } Ok(()) } ================================================ FILE: src/tty/tline.rs ================================================ use { super::*, crate::display::W, serde::{ Deserialize, Serialize, }, }; /// a simple representation of a line made of homogeneous parts. /// /// Note that this only manages CSI and SGR components /// and isn't a suitable representation for an arbitrary /// terminal input or output. /// I recommend you to NOT try to reuse this hack in another /// project unless you perfectly understand it. #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TLine { pub strings: Vec, } impl TLine { pub fn change_range_style( &mut self, trange: TRange, new_csi: String, ) { let TRange { mut string_idx, start_byte_in_string, end_byte_in_string, } = trange; if string_idx >= self.strings.len() { return; } let has_before = start_byte_in_string > 0; let has_after = end_byte_in_string < self.strings[string_idx].raw.len(); if has_after { self.strings.insert( string_idx + 1, TString { csi: self.strings[string_idx].csi.clone(), raw: self.strings[string_idx].raw[end_byte_in_string..].to_string(), }, ); } if has_before { self.strings.insert( string_idx, TString { csi: self.strings[string_idx].csi.clone(), raw: self.strings[string_idx].raw[..start_byte_in_string].to_string(), }, ); string_idx += 1; } self.strings[string_idx].csi = new_csi; if has_before { self.strings[string_idx].raw = self.strings[string_idx].raw[start_byte_in_string..end_byte_in_string].to_string(); } else { // we can just truncate the string self.strings[string_idx].raw.truncate(end_byte_in_string); } } pub fn from_tty(tty: &str) -> Self { let tty_str: String; let tty = if tty.contains('\t') { tty_str = tty.replace('\t', TAB_REPLACEMENT); &tty_str } else { tty }; let mut builder = TLineBuilder::default(); builder.read(tty); builder.build() } pub fn from_raw(raw: String) -> Self { Self { strings: vec![TString { csi: " ".to_string(), raw, }], } } pub fn to_raw(&self) -> String { let mut s = String::new(); for ts in &self.strings { s.push_str(&ts.raw); } s } pub fn bold(raw: String) -> Self { Self { strings: vec![TString { csi: CSI_BOLD.to_string(), raw, }], } } pub fn italic(raw: String) -> Self { Self { strings: vec![TString { csi: CSI_ITALIC.to_string(), raw, }], } } pub fn add_tstring, R: Into>( &mut self, csi: C, raw: R, ) { self.strings.push(TString { csi: csi.into(), raw: raw.into(), }); } pub fn draw( &self, w: &mut W, ) -> Result<(), ProgramError> { for ts in &self.strings { ts.draw(w)?; } Ok(()) } /// draw the line but without taking more than cols_max cols. /// Return the number of cols written pub fn draw_in( &self, w: &mut W, cols_max: usize, ) -> Result { let mut cols = 0; for ts in &self.strings { if cols >= cols_max { break; } cols += ts.draw_in(w, cols_max - cols)?; } Ok(cols) } pub fn is_blank(&self) -> bool { self.strings.iter().all(|s| s.raw.trim().is_empty()) } // if this line has no style, return its content pub fn if_unstyled(&self) -> Option<&str> { if self.strings.len() == 1 { self.strings .first() .filter(|s| s.csi.is_empty()) .map(|s| s.raw.as_str()) } else { None } } pub fn has( &self, part: &str, ) -> bool { self.strings.iter().any(|s| s.raw.contains(part)) } } ================================================ FILE: src/tty/tline_builder.rs ================================================ use super::*; /// A builder consuming a string assumed to contain TTY sequences and building a TLine. #[derive(Debug, Default)] pub struct TLineBuilder { cur: Option, strings: Vec, } impl TLineBuilder { pub fn read( &mut self, s: &str, ) { let mut parser = vte::Parser::new(); parser.advance(self, s.as_bytes()); } pub fn build(mut self) -> TLine { self.take_tstring(); TLine { strings: self.strings, } } fn take_tstring(&mut self) { if let Some(cur) = self.cur.take() { self.push_tstring(cur); } } fn push_tstring( &mut self, tstring: TString, ) { if let Some(last) = self.strings.last_mut() { if last.csi == tstring.csi { last.raw.push_str(&tstring.raw); return; } } self.strings.push(tstring); } } impl vte::Perform for TLineBuilder { fn print( &mut self, c: char, ) { self.cur.get_or_insert_with(TString::default).raw.push(c); } fn csi_dispatch( &mut self, params: &vte::Params, _intermediates: &[u8], _ignore: bool, action: char, ) { if params.len() == 1 && params.iter().next() == Some(&[0]) { self.take_tstring(); return; } if let Some(cur) = self.cur.as_mut() { if cur.raw.is_empty() { cur.push_csi(params, action); return; } } self.take_tstring(); let mut cur = TString::default(); cur.push_csi(params, action); self.cur = Some(cur); } fn execute( &mut self, _byte: u8, ) { } fn hook( &mut self, _params: &vte::Params, _intermediates: &[u8], _ignore: bool, _action: char, ) { } fn put( &mut self, _byte: u8, ) { } fn unhook(&mut self) {} fn osc_dispatch( &mut self, _params: &[&[u8]], _bell_terminated: bool, ) { } fn esc_dispatch( &mut self, _intermediates: &[u8], _ignore: bool, _byte: u8, ) { } } ================================================ FILE: src/tty/trange.rs ================================================ /// A position in a tline #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TRange { pub string_idx: usize, pub start_byte_in_string: usize, pub end_byte_in_string: usize, } ================================================ FILE: src/tty/tstring.rs ================================================ use { super::*, crate::display::W, serde::{ Deserialize, Serialize, }, std::{ fmt::Write as _, io::Write, }, termimad::StrFit, }; /// a simple representation of a colored and styled string. #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TString { pub csi: String, pub raw: String, } impl TString { pub fn new, S2: Into>( csi: S1, raw: S2, ) -> Self { Self { csi: csi.into(), raw: raw.into(), } } pub fn push_csi( &mut self, params: &vte::Params, action: char, ) { self.csi.push('\u{1b}'); self.csi.push('['); for (idx, param) in params.iter().enumerate() { for p in param { let _ = write!(self.csi, "{}", p); } if idx < params.len() - 1 { self.csi.push(';'); } } self.csi.push(action); } pub fn draw( &self, w: &mut W, ) -> Result<(), ProgramError> { draw(w, &self.csi, &self.raw) } /// draw the string but without taking more than cols_max cols. /// Return the number of cols written pub fn draw_in( &self, w: &mut W, cols_max: usize, ) -> Result { let fit = StrFit::make_cow(&self.raw, cols_max); if self.csi.is_empty() { write!(w, "{}", &fit.0)?; } else { write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?; } Ok(fit.1) } pub fn starts_with( &self, csi: &str, raw: &str, ) -> bool { self.csi == csi && self.raw.starts_with(raw) } pub fn split_off( &mut self, at: usize, ) -> Self { Self { csi: self.csi.clone(), raw: self.raw.split_off(at), } } pub fn is_blank(&self) -> bool { self.raw.chars().all(char::is_whitespace) } pub fn is_styled(&self) -> bool { !self.csi.is_empty() } pub fn is_unstyled(&self) -> bool { self.csi.is_empty() } } ================================================ FILE: src/tty/tty_view.rs ================================================ use { super::*, crate::{ command::{ ScrollCommand, move_sel, }, display::{ Screen, W, }, errors::*, skin::PanelSkin, }, crokey::crossterm::{ QueueableCommand, cursor, style::{ Color, Print, SetBackgroundColor, SetForegroundColor, }, }, memmap2::Mmap, std::{ fs::File, io::{ self, BufRead, BufReader, }, path::{ Path, PathBuf, }, }, termimad::Area, }; pub struct TtyView { pub path: PathBuf, lines: Vec, scroll: usize, page_height: usize, selection_idx: Option, // index in lines of the selection, if any total_lines_count: usize, } impl TtyView { pub fn new(path: &Path) -> Result { let mut sv = Self { path: path.to_path_buf(), lines: Vec::new(), scroll: 0, page_height: 0, selection_idx: None, total_lines_count: 0, }; sv.read_lines()?; sv.select_first(); Ok(sv) } fn read_lines(&mut self) -> Result<(), io::Error> { let f = File::open(&self.path)?; { // if we detect the file isn't mappable, we'll // let the ZeroLenFilePreview try to read it let mmap = unsafe { Mmap::map(&f) }; if mmap.is_err() { return Err(io::Error::other("unmappable file")); } } let md = f.metadata()?; if md.len() == 0 { return Err(io::Error::other("zero length file")); } let mut reader = BufReader::new(f); self.lines.clear(); let mut line = String::new(); self.total_lines_count = 0; while reader.read_line(&mut line)? > 0 { self.total_lines_count += 1; let tline = TLine::from_tty(&line); self.lines.push(tline); line.clear(); } Ok(()) } fn ensure_selection_is_visible(&mut self) { if self.page_height >= self.lines.len() { self.scroll = 0; } else if let Some(idx) = self.selection_idx { let padding = self.padding(); if idx < self.scroll + padding || idx + padding > self.scroll + self.page_height { if idx <= padding { self.scroll = 0; } else if idx + padding > self.lines.len() { self.scroll = self.lines.len() - self.page_height; } else if idx < self.scroll + self.page_height / 2 { self.scroll = idx - padding; } else { self.scroll = idx + padding - self.page_height; } } } } fn padding(&self) -> usize { (self.page_height / 4).min(4) } pub fn unselect(&mut self) { self.selection_idx = None; } pub fn try_select_y( &mut self, y: u16, ) -> bool { let idx = y as usize + self.scroll; if idx < self.lines.len() { self.selection_idx = Some(idx); true } else { false } } pub fn select_first(&mut self) { if !self.lines.is_empty() { self.selection_idx = Some(0); self.scroll = 0; } } pub fn select_last(&mut self) { self.selection_idx = Some(self.lines.len() - 1); if self.page_height < self.lines.len() { self.scroll = self.lines.len() - self.page_height; } } pub fn move_selection( &mut self, dy: i32, cycle: bool, ) { if let Some(idx) = self.selection_idx { self.selection_idx = Some(move_sel(idx, self.lines.len(), dy, cycle)); } else if !self.lines.is_empty() { self.selection_idx = Some(0) } self.ensure_selection_is_visible(); } pub fn try_scroll( &mut self, cmd: ScrollCommand, ) -> bool { let old_scroll = self.scroll; self.scroll = cmd.apply(self.scroll, self.lines.len(), self.page_height); if let Some(idx) = self.selection_idx { if self.scroll == old_scroll { let old_selection = self.selection_idx; if cmd.is_up() { self.selection_idx = Some(0); } else { self.selection_idx = Some(self.lines.len() - 1); } return self.selection_idx == old_selection; } else if idx >= old_scroll && idx < old_scroll + self.page_height { if idx + self.scroll < old_scroll { self.selection_idx = Some(0); } else if idx + self.scroll - old_scroll >= self.lines.len() { self.selection_idx = Some(self.lines.len() - 1); } else { self.selection_idx = Some(idx + self.scroll - old_scroll); } } } self.scroll != old_scroll } pub fn display( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { if area.height as usize != self.page_height { self.page_height = area.height as usize; self.ensure_selection_is_visible(); } let line_count = area.height as usize; let styles = &panel_skin.styles; let bg = styles .preview .get_bg() .or_else(|| styles.default.get_bg()) .unwrap_or(Color::AnsiValue(238)); let content_width = area.width as usize - 1; // 1 char left for scrollbar let scrollbar = area.scrollbar(self.scroll, self.lines.len()); let scrollbar_fg = styles .scrollbar_thumb .get_fg() .or_else(|| styles.preview.get_fg()) .unwrap_or(Color::White); for y in 0..line_count { let line_idx = self.scroll + y; let mut allowed = content_width; w.queue(cursor::MoveTo(area.left, y as u16 + area.top))?; if let Some(tline) = self.lines.get(line_idx) { w.queue(SetBackgroundColor(bg))?; allowed -= tline.draw_in(w, allowed)?; } w.queue(SetBackgroundColor(bg))?; for _ in 0..allowed { w.queue(Print(' '))?; } if is_thumb(y + area.top as usize, scrollbar) { w.queue(SetForegroundColor(scrollbar_fg))?; w.queue(Print('▐'))?; } else { w.queue(Print(' '))?; } } Ok(()) } pub fn display_info( &mut self, w: &mut W, _screen: Screen, panel_skin: &PanelSkin, area: &Area, ) -> Result<(), ProgramError> { let width = area.width as usize; let mut s = format!("{}", self.total_lines_count); if s.len() > width { return Ok(()); } if s.len() + "lines: ".len() < width { s = format!("lines: {s}"); } w.queue(cursor::MoveTo( area.left + area.width - s.len() as u16, area.top, ))?; panel_skin.styles.default.queue(w, s)?; Ok(()) } } fn is_thumb( y: usize, scrollbar: Option<(u16, u16)>, ) -> bool { scrollbar.map_or(false, |(sctop, scbottom)| { let y = y as u16; sctop <= y && y <= scbottom }) } ================================================ FILE: src/verb/coarity.rs ================================================ /// Describe whether a command should be executed once per selection or /// once per command invocation. #[derive(Debug, Clone, Copy, PartialEq)] pub enum CommandCoarity { // one command per selection PerSelection, // One command integrating all selections, levaraging "repeated" or "repeating" patterns to // specify how selections are integrated in the command. Merged, } ================================================ FILE: src/verb/exec_pattern.rs ================================================ use { crate::verb::*, serde::{ Deserialize, Deserializer, Serialize, Serializer, }, std::fmt, }; /// A pattern which can be expanded into an executable #[derive(Debug, Clone)] pub struct ExecPattern { tokens: Vec, } impl ExecPattern { pub fn from_string(s: &str) -> Self { Self { tokens: splitty::split_unquoted_whitespace(s) .unwrap_quotes(true) .map(String::from) .collect(), } } pub fn from_tokens(tokens: Vec) -> Self { Self { tokens } } pub fn tokens(&self) -> &[String] { &self.tokens } pub fn into_tokens(self) -> Vec { self.tokens } pub fn is_empty(&self) -> bool { self.tokens.is_empty() } pub fn has_selection_group(&self) -> bool { self.tokens.iter().any(|s| str_has_selection_group(s)) } pub fn has_other_panel_group(&self) -> bool { self.tokens.iter().any(|s| str_has_other_panel_group(s)) } pub fn to_internal_pattern(&self) -> Option { let first_token = self.tokens.first()?; if first_token.starts_with(':') || first_token.starts_with(' ') { let mut ip = String::from(&first_token[1..]); for token in self.tokens.iter().skip(1) { ip.push(' '); ip.push_str(token); } Some(ip) } else { None } } pub fn visit_arg_defs( &self, f: &mut dyn FnMut(&VerbArgDef), ) { for token in &self.tokens { for capture in ARG_DEF_GROUP.captures_iter(token) { let arg_def = VerbArgDef::from_capture(&capture); f(&arg_def); } } } /// Tell whether, in case of a multiple selection, the command should be executed once per /// selection or once for all selections together (meaning the selections will be merged). pub fn coarity(&self) -> CommandCoarity { let mut has_repeated = false; self.visit_arg_defs(&mut |arg_def| { for flag in &arg_def.flags { debug!("arg {} has flag {:?}", arg_def.name, flag); if flag.is_merging() { has_repeated = true; } } }); if has_repeated { CommandCoarity::Merged } else { CommandCoarity::PerSelection } } } // This implementation builds a string used for description (eg in help) impl fmt::Display for ExecPattern { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { for (idx, s) in self.tokens.iter().enumerate() { if idx > 0 { write!(f, " ")?; } write!(f, "{s}")?; } Ok(()) } } impl Serialize for ExecPattern { fn serialize( &self, serializer: S, ) -> Result { self.tokens.serialize(serializer) } } impl<'de> Deserialize<'de> for ExecPattern { fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Raw { Single(String), Multiple(Vec), } let tokens = match Raw::deserialize(deserializer)? { Raw::Single(s) => splitty::split_unquoted_whitespace(&s) .map(String::from) .collect(), Raw::Multiple(v) => v, }; Ok(ExecPattern { tokens }) } } ================================================ FILE: src/verb/execution_builder.rs ================================================ use { super::*, crate::{ app::*, command::*, path::{ self, PathAnchor, }, }, regex::Captures, rustc_hash::FxHashMap, std::path::{ Path, PathBuf, }, }; /// a temporary structure gathering selection and invocation /// parameters and able to generate an executable string from /// a verb's execution pattern pub struct ExecutionBuilder<'b> { /// the current file selection pub sel_info: SelInfo<'b>, /// the current root of the app root: &'b Path, /// the selection in the other panel, when there are exactly two other_file: Option<&'b PathBuf>, /// parsed arguments invocation_values: Option>, /// whether to keep groups which can't be solved or remove them keep_groups: bool, target: Target, } /// Whether we're trying to build the command as a string or as a vec of tokens (in /// which case we don't want to do the same escaping, for example) #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Target { String, Tokens, } impl<'b> ExecutionBuilder<'b> { /// constructor to use when there's no invocation string /// (because we're in the process of building one, for example /// when a verb is triggered from a key shortcut) pub fn without_invocation( sel_info: SelInfo<'b>, app_state: &'b AppState, ) -> Self { Self { sel_info, root: &app_state.root, other_file: app_state.other_panel_path.as_ref(), invocation_values: None, keep_groups: false, target: Target::Tokens, } } pub fn with_invocation( invocation_parser: Option<&InvocationParser>, sel_info: SelInfo<'b>, app_state: &'b AppState, invocation_args: Option<&String>, ) -> Self { let invocation_values = invocation_parser .as_ref() .zip(invocation_args.as_ref()) .and_then(|(parser, args)| parser.parse(args)); Self { sel_info, root: &app_state.root, other_file: app_state.other_panel_path.as_ref(), invocation_values, keep_groups: false, target: Target::Tokens, } } /// Return the replacing value for the whole sel_info /// /// When you have a multiselection and no merging flag, don't call this function /// but get_sel_capture_replacement while building a command per selection. fn get_arg_replacement( &self, arg_def: &VerbArgDef, con: &AppContext, ) -> Option { let merging_flag = arg_def.merging_flag(); match self.sel_info { SelInfo::None => self.get_sel_arg_replacement(arg_def, None, con), SelInfo::One(sel) => self.get_sel_arg_replacement(arg_def, Some(sel), con), SelInfo::More(stage) => { let mut sels = stage.to_selections(); if let Some(merging_flag) = merging_flag { let mut values = Vec::new(); for sel in sels { let rcr = self.get_sel_arg_replacement(arg_def, Some(sel), con); if let Some(rcr) = rcr { values.push(rcr); } } merging_flag.merge_values(values) } else { // we're called with no specific selection and there's no merging // strategy, this should probably not really happen, we'll take // the first selection let sel = if sels.is_empty() { None } else { Some(sels.swap_remove(0)) }; self.get_sel_arg_replacement(arg_def, sel, con) } } } } /// return the standard replacement (ie not one from the invocation) fn get_sel_name_standard_replacement( &self, name: &str, sel: Option>, con: &AppContext, ) -> Option { match name { "root" => Some(self.path_to_string(self.root)), "initial-root" => Some(self.path_to_string(&con.initial_root)), "line" => sel.map(|s| s.line.to_string()), "file" => sel.map(|s| s.path).map(|p| self.path_to_string(p)), "file-name" => sel .map(|s| s.path) .and_then(|path| path.file_name()) .and_then(|oss| oss.to_str()) .map(|s| s.to_string()), "file-stem" => sel .map(|s| s.path) .and_then(|path| path.file_stem()) .and_then(|oss| oss.to_str()) .map(|s| s.to_string()), "file-extension" => { debug!("expending file extension"); sel.map(|s| s.path) .and_then(|path| path.extension()) .and_then(|oss| oss.to_str()) .map(|s| s.to_string()) } "file-dot-extension" => { debug!("expending file dot extension"); sel.map(|s| s.path) .and_then(|path| path.extension()) .and_then(|oss| oss.to_str()) .map(|ext| format!(".{ext}")) .or_else(|| Some("".to_string())) } "directory" => sel .map(|s| path::closest_dir(s.path)) .map(|p| self.path_to_string(p)), "parent" => sel .and_then(|s| s.path.parent()) .map(|p| self.path_to_string(p)), "other-panel-file" => self.other_file.map(|p| self.path_to_string(p)), "other-panel-filename" => self .other_file .and_then(|path| path.file_name()) .and_then(|oss| oss.to_str()) .map(|s| s.to_string()), "other-panel-directory" => self .other_file .map(|p| path::closest_dir(p)) .as_ref() .map(|p| self.path_to_string(p)), "other-panel-parent" => self .other_file .and_then(|p| p.parent()) .map(|p| self.path_to_string(p)), "git-root" => { // path to git repo workdir debug!("finding git root"); sel.and_then(|s| git2::Repository::discover(s.path).ok()) .and_then(|repo| repo.workdir().map(|p| self.path_to_string(p))) } "git-name" => { // name of the git repo workdir sel.and_then(|s| git2::Repository::discover(s.path).ok()) .and_then(|repo| { repo.workdir().and_then(|path| { path.file_name() .and_then(|oss| oss.to_str()) .map(|s| s.to_string()) }) }) } "file-git-relative" => { // file path relative to git repo workdir let sel = sel?; let path = git2::Repository::discover(self.root) .ok() .and_then(|repo| repo.workdir().map(|p| self.path_to_string(p))) .and_then(|gitroot| sel.path.strip_prefix(gitroot).ok()) .filter(|p| { // it's empty when the file is both the tree root and the git root !p.as_os_str().is_empty() }) .unwrap_or(sel.path); Some(self.path_to_string(path)) } #[cfg(unix)] "server-name" => con.server_name.clone(), _ => None, } } fn get_sel_arg_replacement( &self, arg_def: &VerbArgDef, sel: Option>, con: &AppContext, ) -> Option { let name = &arg_def.name; self.get_sel_name_standard_replacement(name, sel, con) .or_else(|| { // it's not one of the standard group names, so we'll look // into the ones provided by the invocation pattern self.invocation_values .as_ref() .and_then(|map| map.get(name)) .and_then(|value| { if arg_def.has_flag(VerbArgFlag::PathFromDirectory) { sel.map(|s| path::closest_dir(s.path)) .map(|dir| path::path_from(dir, PathAnchor::Unspecified, value)) .map(|pb| self.path_to_string(pb)) } else if arg_def.has_flag(VerbArgFlag::PathFromParent) { sel.and_then(|s| s.path.parent()) .map(|dir| path::path_from(dir, PathAnchor::Unspecified, value)) .map(|pb| self.path_to_string(pb)) } else { Some(value.to_string()) } }) }) } fn replace_args( &self, s: &str, replacer: &mut dyn FnMut(&VerbArgDef) -> Option, ) -> String { ARG_DEF_GROUP .replace_all(s, |ec: &Captures<'_>| { let arg_def = VerbArgDef::from_capture(ec); replacer(&arg_def).unwrap_or_else(|| { if self.keep_groups { ec[0].to_string() } else { "".to_string() } }) }) .to_string() } /// fills groups having a default value (after the colon) /// /// This is used to fill the input in case on non auto_exec /// verb triggered with a key. /// /// In invocation pattern, the part after the colon isn't handled /// as a 'flag' but as a default value pub fn invocation_with_default( &self, verb_invocation: &VerbInvocation, con: &AppContext, ) -> VerbInvocation { VerbInvocation { name: verb_invocation.name.clone(), args: verb_invocation.args.as_ref().map(|a| { ARG_DEF_GROUP .replace_all(a.as_str(), |ec: &Captures<'_>| { ec.get(2) .map(|default_name| default_name.as_str()) .and_then(|default_name| { self.get_sel_name_standard_replacement( default_name, self.sel_info.first_sel(), con, ) }) .unwrap_or_default() }) .to_string() }), bang: verb_invocation.bang, } } fn base_dir(&self) -> &Path { self.sel_info.one_sel().map_or(self.root, |sel| sel.path) } /// replace groups in a sequence /// /// Replacing escapes for the shell for externals, and without /// escaping for internals. /// /// Note that this is *before* asking the (local or remote) panel /// state the sequential execution of the different commands. In /// this secondary execution, new replacements are expected too, /// depending on the verbs. pub fn sequence( &mut self, sequence: &Sequence, verb_store: &VerbStore, con: &AppContext, panel_state_type: Option, ) -> Sequence { let mut inputs = Vec::new(); for input in sequence.raw.split(&sequence.separator) { let raw_parts = CommandParts::from(input.to_string()); let (_, verb_invocation) = raw_parts.split(); let verb_is_external = verb_invocation .and_then(|vi| { let command = Command::from_parts(vi, true); if let Command::VerbInvocate(invocation) = &command { let search = verb_store.search_prefix(&invocation.name, panel_state_type); if let PrefixSearchResult::Match(_, verb) = search { return Some(verb); } } None }) .map_or(false, |verb| verb.get_internal().is_none()); let input = if verb_is_external { self.shell_exec_string(&ExecPattern::from_string(input), con) } else { self.string(input, con) }; inputs.push(input); } Sequence { raw: inputs.join(&sequence.separator), separator: sequence.separator.clone(), } } fn string( &self, pattern: &str, con: &AppContext, ) -> String { self.replace_args(pattern, &mut |arg_def| { self.get_arg_replacement(arg_def, con) }) } /// build a path from a pattern (eg the `working_dir` parameter of a verb definition) pub fn path( &self, pattern: &str, con: &AppContext, ) -> PathBuf { path::path_from( self.base_dir(), path::PathAnchor::Unspecified, &self.replace_args(pattern, &mut |arg_def| { self.get_arg_replacement(arg_def, con) }), ) } /// build a shell compatible command, with escapings pub fn shell_exec_string( &mut self, exec_pattern: &ExecPattern, con: &AppContext, ) -> String { self.target = Target::String; // this ensures proper escaping let tokens = self.exec_token(exec_pattern, con); tokens.join(" ") } /// build a shell compatible command, with escapings, for a specific /// selection (this is intended for execution on all selections of a /// stage) pub fn sel_shell_exec_string( &mut self, exec_pattern: &ExecPattern, sel: Option>, con: &AppContext, ) -> String { self.target = Target::String; // this ensures proper escaping let tokens = self.sel_exec_token(exec_pattern, sel, con); tokens.join(" ") } /// build a vec of tokens which can be passed to Command to /// launch an executable. pub fn exec_token( &self, exec_pattern: &ExecPattern, con: &AppContext, ) -> Vec { // When a token is a space-separated arg, and the selection is multiple, // we want to build several tokens so that it's received as several args by the // executed program, and not as a single arg with spaces. // This complex work is needed only when the selection is multiple and there's // a "space-separated" flag in the capture let mut output = Vec::new(); for token in exec_pattern.tokens() { if let Some(ec) = capture_if_total(&ARG_DEF_GROUP, token) { let arg_def = VerbArgDef::from_capture(&ec); let space_separated = arg_def.has_flag(VerbArgFlag::SpaceSeparated); if space_separated { if let SelInfo::More(stage) = &self.sel_info { let sels = stage.to_selections(); for sel in sels { if let Some(s) = self.get_sel_arg_replacement(&arg_def, Some(sel), con) { output.push(s); } } continue; // we did the replacement } } } // as we won't be able to build several tokens from this one, we do the // standard replacement let replaced = self.replace_args(token, &mut |arg_def| self.get_arg_replacement(arg_def, con)); output.push(fix_token_path(replaced)); } output } /// build a vec of tokens which can be passed to Command to /// launch an executable. /// This is intended for execution on all selections of a stage /// when the exec pattern isn't merging. pub fn sel_exec_token( &mut self, exec_pattern: &ExecPattern, sel: Option>, con: &AppContext, ) -> Vec { exec_pattern .tokens() .iter() .map(|s| { self.replace_args(s, &mut |arg_def| { self.get_sel_arg_replacement(arg_def, sel, con) }) }) .map(fix_token_path) .collect() } /// Convert a path (or part of a path) to a string, with escaping if needed (depending on the target) fn path_to_string>( &self, path: P, ) -> String { let s = path.as_ref().to_string_lossy(); if self.target == Target::Tokens { // when building tokens, we don't want to do any escaping, // even if there are special characters return s.to_string(); } if !regex_is_match!(r#"[\s"']"#, &s) { // if there's no special character, we don't need to escape or wrap return s.to_string(); } // first we replace single quotes by `'"'"'` (close the single quote, add an escaped // single quote, and reopen the single quote) let s = s.replace('\'', r#"'"'"#); // then we wrap the whole thing in single quotes let s = format!("'{}'", s); s } } fn capture_if_total<'h>( regex: &Regex, s: &'h str, ) -> Option> { let captures = regex.captures(s)?; let overall_match = captures.get(0)?; if overall_match.start() == 0 && overall_match.end() == s.len() { Some(captures) } else { None } } fn fix_token_path + AsRef>(token: T) -> String { let path = Path::new(token.as_ref()); if path.exists() { if let Some(path) = path.to_str() { return path.to_string(); } } else if path::TILDE_REGEX.is_match(token.as_ref()) { let path = path::untilde(token.as_ref()); if path.exists() { if let Some(path) = path.to_str() { return path.to_string(); } } } token.into() } #[cfg(test)] mod execution_builder_test { // allows writing vo!["a", "b"] to build a vec of strings macro_rules! vo { ($($item:literal),* $(,)?) => {{ let mut vec = Vec::new(); $( vec.push($item.to_owned()); )* vec }} } use super::*; fn check_build_execution_from_sel( exec_patterns: Vec, path: &str, replacements: Vec<(&str, &str)>, chk_exec_token: Vec<&str>, ) { let path = PathBuf::from(path); let sel = Selection { path: &path, line: 0, stype: SelectionType::File, is_exe: false, }; let app_state = AppState::new(PathBuf::from("/".to_owned())); let mut builder = ExecutionBuilder::without_invocation(SelInfo::One(sel), &app_state); let mut map = FxHashMap::default(); for (k, v) in replacements { map.insert(k.to_owned(), v.to_owned()); } builder.invocation_values = Some(map); let con = AppContext::default(); for exec_pattern in exec_patterns { dbg!("checking pattern: {:#?}", &exec_pattern); let exec_token = builder.exec_token(&exec_pattern, &con); assert_eq!(exec_token, chk_exec_token); } } #[test] fn test_build_execution() { check_build_execution_from_sel( vec![ExecPattern::from_string("vi {file}")], "/home/dys/dev", vec![], vec!["vi", "/home/dys/dev"], ); check_build_execution_from_sel( vec![ ExecPattern::from_string("/bin/e.exe -a {arg} -e {file}"), ExecPattern::from_tokens(vo!["/bin/e.exe", "-a", "{arg}", "-e", "{file}"]), ], "expérimental & 试验性", vec![("arg", "deux mots")], vec![ "/bin/e.exe", "-a", "deux mots", "-e", "expérimental & 试验性", ], ); check_build_execution_from_sel( vec![ ExecPattern::from_string("xterm -e \"kak {file}\""), ExecPattern::from_tokens(vo!["xterm", "-e", "kak {file}"]), ], "/path/to/file", vec![], vec!["xterm", "-e", "kak /path/to/file"], ); } } ================================================ FILE: src/verb/external_execution.rs ================================================ use { super::*, crate::{ app::*, display::W, errors::ProgramError, launchable::Launchable, }, std::{ fs::OpenOptions, io::Write, path::PathBuf, }, }; pub static MULTI_SELECTION_ERROR: &str = "Only verbs returning to broot on end or merging selections can be executed on multi-selection"; /// Definition of how the user input should be interpreted /// to be executed in an external command. #[derive(Debug, Clone)] pub struct ExternalExecution { /// the pattern which will result in an executable string when /// completed with the args. /// This pattern may include names coming from the invocation /// pattern (like {my-arg}) and special names automatically filled by /// broot from the selection and application state: /// * {file} /// * {directory} /// * {parent} /// * {other-panel-file} /// * {other-panel-directory} /// * {other-panel-parent} pub exec_pattern: ExecPattern, /// how the external process must be launched pub exec_mode: ExternalExecutionMode, /// the working directory of the new process, or none if we don't /// want to set it pub working_dir: Option, /// whether we need to switch to the normal terminal for /// the duration of the execution of the process pub switch_terminal: bool, /// whether the tree must be refreshed after the verb is executed pub refresh_after: bool, } impl ExternalExecution { pub fn new( exec_pattern: ExecPattern, exec_mode: ExternalExecutionMode, ) -> Self { Self { exec_pattern, exec_mode, working_dir: None, switch_terminal: true, // by default we switch refresh_after: true, // by default we refresh } } pub fn with_working_dir( mut self, b: Option, ) -> Self { self.working_dir = b; self } /// goes from the external execution command to the CmdResult: /// - by executing the command if it can be executed from a subprocess /// - by building a command to be executed in parent shell in other cases pub fn to_cmd_result( &self, w: &mut W, builder: ExecutionBuilder<'_>, con: &AppContext, ) -> Result { match self.exec_mode { ExternalExecutionMode::FromParentShell => { self.cmd_result_exec_from_parent_shell(builder, con) } ExternalExecutionMode::LeaveBroot => self.cmd_result_exec_leave_broot(builder, con), ExternalExecutionMode::StayInBroot => { self.cmd_result_exec_stay_in_broot(w, builder, con) } } } fn working_dir_path( &self, builder: &ExecutionBuilder<'_>, con: &AppContext, ) -> Option { self.working_dir .as_ref() .map(|pattern| builder.path(pattern, con)) .filter(|pb| { if pb.exists() { true } else { warn!("workding dir doesn't exist: {:?}", pb); false } }) } /// build the cmd result as an executable which will be called /// from the parent shell (meaning broot must quit) fn cmd_result_exec_from_parent_shell( &self, mut builder: ExecutionBuilder<'_>, con: &AppContext, ) -> Result { if builder.sel_info.count_paths() > 1 { let coarity = self.exec_pattern.coarity(); debug!("coarity of the command is {:?}", coarity); if coarity == CommandCoarity::PerSelection { return Ok(CmdResult::error(MULTI_SELECTION_ERROR)); } } if let Some(ref export_path) = con.launch_args.outcmd { // Broot was probably launched as br. // the whole command is exported in the passed file let f = OpenOptions::new().append(true).open(export_path)?; writeln!(&f, "{}", builder.shell_exec_string(&self.exec_pattern, con))?; Ok(CmdResult::Quit) } else { Ok(CmdResult::error( "This verb needs broot to be launched as `br`. Try `broot --install` if necessary.", )) } } /// build the cmd result as an executable which will be called in a process /// launched by broot at end of broot fn cmd_result_exec_leave_broot( &self, builder: ExecutionBuilder<'_>, con: &AppContext, ) -> Result { if builder.sel_info.count_paths() > 1 { if self.exec_pattern.coarity() == CommandCoarity::PerSelection { return Ok(CmdResult::error(MULTI_SELECTION_ERROR)); } } let launchable = Launchable::program( builder.exec_token(&self.exec_pattern, con), self.working_dir_path(&builder, con), self.switch_terminal, con, )?; Ok(CmdResult::from(launchable)) } /// build the cmd result as an executable which will be called in a process /// launched by broot fn cmd_result_exec_stay_in_broot( &self, w: &mut W, mut builder: ExecutionBuilder<'_>, con: &AppContext, ) -> Result { let working_dir_path = self.working_dir_path(&builder, con); match &builder.sel_info { SelInfo::None | SelInfo::One(_) => { // zero or one selection -> only one execution let launchable = Launchable::program( builder.exec_token(&self.exec_pattern, con), working_dir_path, self.switch_terminal, con, )?; info!("Executing not leaving, launchable {:#?}", launchable); if let Err(e) = launchable.execute(Some(w)) { warn!("launchable failed : {:#?}", e); return Ok(CmdResult::error(e.to_string())); } } SelInfo::More(stage) => { // multiselection -> what we do depends on the coarity of the command let coarity = self.exec_pattern.coarity(); info!("coarity of the command is {:#?}", coarity); match coarity { CommandCoarity::PerSelection => { // we execute once per selection let sels = stage.paths().iter().map(|path| Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, }); let n = sels.len(); for (i, sel) in sels.enumerate() { let launchable = Launchable::program( builder.sel_exec_token(&self.exec_pattern, Some(sel), con), working_dir_path.clone(), self.switch_terminal, con, )?; let i = i + 1; info!("Executing not leaving launchable {i}/{n}: {launchable:#?}"); if let Err(e) = launchable.execute(Some(w)) { warn!("launchable failed : {:#?}", e); return Ok(CmdResult::error(e.to_string())); } } } CommandCoarity::Merged => { // we execute once as the arguments are merging the selection let launchable = Launchable::program( builder.exec_token(&self.exec_pattern, con), working_dir_path.clone(), self.switch_terminal, con, )?; info!("Executing not leaving, merged launchable {:#?}", launchable); if let Err(e) = launchable.execute(Some(w)) { warn!("launchable failed : {:?}", e); return Ok(CmdResult::error(e.to_string())); } } } } } if self.refresh_after { Ok(CmdResult::RefreshState { clear_cache: true }) } else { Ok(CmdResult::Keep) } } } ================================================ FILE: src/verb/external_execution_mode.rs ================================================ #[derive(Debug, Clone, Copy, PartialEq)] pub enum ExternalExecutionMode { /// executed in the parent shell, on broot leaving, using the `br` function FromParentShell, /// executed on broot leaving, not necessarily in the parent shell LeaveBroot, /// executed in a sub process without quitting broot StayInBroot, } impl ExternalExecutionMode { pub fn is_from_shell(self) -> bool { matches!(self, Self::FromParentShell) } pub fn is_leave_broot(self) -> bool { !matches!(self, Self::StayInBroot) } pub fn from_conf( from_shell: Option, // default is false leave_broot: Option, // default is true ) -> Self { if from_shell.unwrap_or(false) { Self::FromParentShell } else if leave_broot.unwrap_or(true) { Self::LeaveBroot } else { Self::StayInBroot } } } ================================================ FILE: src/verb/file_type_condition.rs ================================================ use { crate::{ app::SelectionType, content_type, tree::{ TreeLine, TreeLineType, }, }, serde::{ Deserialize, Serialize, }, std::path::Path, }; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum FileTypeCondition { #[default] Any, // directory or link to a directory Directory, File, TextFile, BinaryFile, } impl FileTypeCondition { pub fn is_default(&self) -> bool { self == &Self::default() } pub fn accepts_path( self, path: &Path, ) -> bool { match self { Self::Any => true, Self::Directory => path.is_dir(), Self::File => path.is_file(), Self::TextFile => { path.is_file() && matches!(content_type::is_file_text(path), Ok(true)) } Self::BinaryFile => { path.is_file() && matches!(content_type::is_file_binary(path), Ok(true)) } } } pub fn accepts_line( self, line: &TreeLine, ) -> bool { match self { Self::Any => true, Self::Directory => line.is_dir(), Self::File => matches!(line.line_type, TreeLineType::File), Self::TextFile => { line.is_file() && matches!(content_type::is_file_text(&line.path), Ok(true)) } Self::BinaryFile => { line.is_file() && matches!(content_type::is_file_binary(&line.path), Ok(true)) } } } /// a little clunky, should be used only on well defined cases, like documenting /// internals pub fn accepts_selection_type( self, stype: SelectionType, ) -> bool { match (self, stype) { (Self::Any, _) => true, (Self::Directory, SelectionType::Directory) => true, (Self::File, SelectionType::File) => true, _ => false, } } } ================================================ FILE: src/verb/internal.rs ================================================ //! declare the internal functions which may be used in verbs. //! They don't take any user argument other than the selection //! (this may change if the needs arise). //! They can be called as ":some_name" from builtin verbs and //! from configured verbs. use crate::errors::ConfError; macro_rules! Internals { ( $($name:ident: $description:literal $need_path:literal,)* ) => { #[derive(Debug, Clone, Copy, PartialEq)] #[allow(non_camel_case_types)] pub enum Internal { $($name,)* } impl Internal { pub fn try_from(verb: &str) -> Result { use Internal::*; match verb { $(stringify!($name) => Ok($name),)* _ => Err(ConfError::UnknownInternal{ verb: verb.to_string() }), } } } impl Internal { pub fn name(self) -> &'static str { use Internal::*; match self { $($name => stringify!($name),)* } } pub fn description(self) -> &'static str { use Internal::*; match self { $($name => $description,)* } } pub fn need_path(self) -> bool { use Internal::*; match self { $($name => $need_path,)* } } } } } // internals: // name: "description" needs_a_path Internals! { apply_flags: "apply flags (eg `-sd` to show sizes and dates)" false, back: "revert to the previous state (mapped to *esc*)" false, default_layout: "restore default panel sizes" false, clear_output: "clear the --verb-output file" false, clear_stage: "empty the staging area" false, close_panel_cancel: "close the panel, not using the selected path" false, close_panel_ok: "close the panel, validating the selected path" false, close_preview: "close the preview panel" false, close_staging_area: "close the staging area panel" false, copy_line: "copy selected line (in tree or preview)" true, copy_path: "copy path to system clipboard" true, escape: "escape from edition, completion, page, etc." false, filesystems: "list mounted filesystems" false, focus: "display the directory (mapped to *enter*)" true, focus_staging_area_no_open: "focus the staging area if already open" false, help: "display broot's help" false, input_clear: "empty the input" false, input_del_char_below: "delete the char left at the cursor's position" false, input_del_char_left: "delete the char left of the cursor" false, input_del_word_left: "delete the word left of the cursor" false, input_del_word_right: "delete the word right of the cursor" false, input_go_left: "move the cursor to the left" false, input_go_right: "move the cursor to the right" false, input_go_to_end: "move the cursor to the end of input" false, input_go_to_start: "move the cursor to the start of input" false, input_go_word_left: "move the cursor one word to the left" false, input_go_word_right: "move the cursor one word to the right" false, input_paste: "paste the clipboard content into the input" false, input_selection_copy: "copy the selected part of the input into the selection" false, input_selection_cut: "cut the selected part of the input into the selection" false, line_down: "move one line down" false, line_down_no_cycle: "move one line down" false, line_up: "move one line up" false, line_up_no_cycle: "move one line up" false, mode_command: "enter the command mode" false, mode_input: "enter the input mode" false, move_panel_divider: "move a panel divider" false, next_dir: "select the next directory" false, next_match: "select the next match" false, next_same_depth: "select the next file at the same depth" false, no_sort: "don't sort" false, open_leave: "open file or directory according to OS (quit broot)" true, open_preview: "open the preview panel" true, 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, focus_panel_left: "cycle panel focus to left" false, focus_panel_right: "cycle panel focus to right" false, panel_left: "focus or open panel on left" false, panel_left_no_open: "either focus panel on left or close right one" false, panel_right: "focus or open panel on right" false, panel_right_no_open: "either focus panel on right or close left one" false, parent: "move to the parent directory" false, preview_binary: "preview the selection as binary" true, preview_image: "preview the selection as image" true, preview_text: "preview the selection as text" true, preview_tty: "preview the selection as tty" true, previous_dir: "select the previous directory" false, previous_match: "select the previous match" false, previous_same_depth: "select the previous file at the same depth" false, print_path: "print path and leaves broot" true, print_relative_path: "print relative path and leaves broot" true, 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, show: "reveal and select a file by path" true, select_first: "select the first item" false, select_last: "select the last item" false, set_panel_width: "set the width of a panel" false, set_syntax_theme: "set the theme of code preview" false, sort_by_count: "sort by count" false, sort_by_date: "sort by date" false, sort_by_size: "sort by size" false, sort_by_type: "sort by type" false, sort_by_type_dirs_first: "sort by type, dirs first" false, sort_by_type_dirs_last: "sort by type, dirs last" false, stage: "add selection to staging area" true, stage_all_directories: "stage all matching directories" true, stage_all_files: "stage all matching files" true, start_end_panel: "either open or close an additional panel" true, toggle_counts: "toggle showing number of files in directories" false, toggle_dates: "toggle showing last modified dates" false, toggle_device_id: "toggle showing device id" false, toggle_files: "toggle showing files (or just folders)" false, toggle_git_file_info: "toggle display of git file information" false, toggle_git_ignore: "toggle use of .gitignore and .ignore" false, toggle_git_status: "toggle showing only files relevant for git status" false, toggle_hidden: "toggle showing hidden files" false, toggle_ignore: "toggle use of .gitignore and .ignore" false, toggle_perm: "toggle showing file permissions" false, toggle_preview: "open/close the preview panel" false, toggle_root_fs: "toggle showing filesystem info on top" false, set_max_depth: "set the maximum directory depth shown" false, unset_max_depth: "clear the max_depth" false, toggle_second_tree: "toggle display of a second tree panel" true, toggle_sizes: "toggle showing sizes" false, toggle_stage: "add or remove selection to staging area" true, toggle_staging_area: "open/close the staging area panel" false, toggle_tree: "toggle showing more than one level of the tree" true, toggle_trim_root: "toggle removing nodes at first level too" false, total_search: "search again but on all children" false, search_again: "either put back last search, or search deeper" false, trash: "move file to system trash" true, unstage: "remove selection from staging area" true, up_tree: "focus the parent of the current root" true, write_output: "write the argument to the --verb-output file" false, toggle_watch: "toggle watching the current root for changes" false, //restore_pattern: "restore a pattern which was just removed" false, } impl Internal { pub fn invocation_pattern(self) -> &'static str { match self { Self::apply_flags => r"-(?P\w+)?", Self::focus => r"focus (?P.*)?", Self::select => r"select (?P.*)?", Self::show => r"show (?P.*)?", Self::line_down => r"line_down (?P\d*)?", Self::line_up => r"line_up (?P\d*)?", Self::line_down_no_cycle => r"line_down_no_cycle (?P\d*)?", Self::line_up_no_cycle => r"line_up_no_cycle (?P\d*)?", Self::move_panel_divider => r"move_panel_divider (?P\d+) (?P-?\d+)", Self::set_panel_width => r"set_panel_width (?P\d+) (?P\d+)", Self::set_max_depth => r"set_max_depth (?P\d+)", Self::set_syntax_theme => r"set_syntax_theme {theme:theme}", Self::write_output => r"write_output (?P.*)", _ => self.name(), } } pub fn exec_pattern(self) -> &'static str { match self { Self::apply_flags => r"apply_flags {flags}", Self::focus => r"focus {path}", Self::line_down => r"line_down {count}", Self::line_up => r"line_up {count}", Self::line_down_no_cycle => r"line_down_no_cycle {count}", Self::line_up_no_cycle => r"line_up_no_cycle {count}", Self::move_panel_divider => r"move_panel_divider {idx} {dx}", Self::set_panel_width => r"set_panel_width {idx} {width}", Self::write_output => r"write_output {line}", _ => self.name(), } } pub fn needs_selection( self, arg: &Option, ) -> bool { match self { Internal::focus => arg.is_none(), _ => self.need_path(), } } pub fn is_input_related(self) -> bool { match self { Self::input_clear => true, Self::input_del_char_below => true, Self::input_del_char_left => true, Self::input_del_word_left => true, Self::input_del_word_right => true, Self::input_go_left => true, Self::input_go_right => true, Self::input_go_to_end => true, Self::input_go_to_start => true, Self::input_go_word_left => true, Self::input_go_word_right => true, Self::input_paste => true, Self::input_selection_copy => true, Self::input_selection_cut => true, _ => false, } } } ================================================ FILE: src/verb/internal_execution.rs ================================================ use { super::*, crate::errors::ConfError, std::fmt, }; /// A verb execution definition based on an internal #[derive(Debug, Clone)] pub struct InternalExecution { /// the internal to use pub internal: Internal, /// whether to open the resulting state in a new panel /// instead of the current ones pub bang: bool, /// arguments /// /// For example it's `"~"` when a verb execution is `":!focus ~"` /// and it's execution: `"OO {v} {file}\n"` when the verb execution is /// `":write_output OO {v} {file}\n"` pub arg: Option, } impl InternalExecution { pub fn from_internal(internal: Internal) -> Self { Self { internal, bang: false, arg: None, } } pub fn from_internal_bang( internal: Internal, bang: bool, ) -> Self { Self { internal, bang, arg: None, } } pub fn try_from(invocation_str: &str) -> Result { let invocation = VerbInvocation::from(invocation_str); let internal = Internal::try_from(&invocation.name)?; Ok(Self { internal, bang: invocation.bang, arg: invocation.args, }) } pub fn needs_selection(&self) -> bool { self.internal.needs_selection(&self.arg) } fn has_merging_arg(&self) -> bool { let Some(args) = &self.arg else { return false; }; for capture in ARG_DEF_GROUP.captures_iter(args) { let arg_def = VerbArgDef::from_capture(&capture); for flag in &arg_def.flags { if flag.is_merging() { return true; } } } false } /// Tell whether, in case of a multiple selection, the command should be executed once per /// selection or once for all selections together (meaning the selections will be merged). pub fn coarity(&self) -> CommandCoarity { if self.has_merging_arg() { CommandCoarity::Merged } else { CommandCoarity::PerSelection } } } impl fmt::Display for InternalExecution { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { write!(f, ":{}", self.internal.name())?; if self.bang { write!(f, "!")?; } if let Some(arg) = &self.arg { write!(f, " {arg}")?; } Ok(()) } } ================================================ FILE: src/verb/internal_focus.rs ================================================ //! utility functions to help handle the `:focus` internal use { super::*, crate::{ app::*, browser::BrowserState, command::TriggerType, display::Screen, path::{ self, PathAnchor, }, pattern::InputPattern, preview::PreviewState, task_sync::Dam, tree::TreeOptions, }, std::path::{ Path, PathBuf, }, }; pub fn on_path( path: PathBuf, screen: Screen, tree_options: TreeOptions, in_new_panel: bool, con: &AppContext, ) -> CmdResult { if in_new_panel { new_panel_on_path( path, screen, tree_options, PanelPurpose::None, con, HDir::Right, ) } else { new_state_on_path(path, screen, tree_options, con) } } pub fn new_state_on_path( path: PathBuf, screen: Screen, tree_options: TreeOptions, con: &AppContext, ) -> CmdResult { let path = path::closest_dir(&path); CmdResult::from_optional_browser_state( BrowserState::new(path, tree_options, screen, con, &Dam::unlimited()), None, false, ) } #[allow(unused_mut)] pub fn new_panel_on_path( mut path: PathBuf, screen: Screen, mut tree_options: TreeOptions, purpose: PanelPurpose, con: &AppContext, direction: HDir, ) -> CmdResult { #[cfg(not(windows))] // We try to canonicalize the path, mostly to resolve links // We don't do it on Windows due to issue #809 if let Ok(canonic) = std::fs::canonicalize(&path) { path = canonic; // If it can't be canonicalized, we'll let the panel state // deal with the original path } if purpose.is_preview() { let pattern = tree_options.pattern.tree_to_preview(); CmdResult::NewPanel { state: Box::new(PreviewState::new(path, pattern, None, tree_options, con)), purpose, direction, } } else { let path = path::closest_dir(&path); // We remove the pattern on opening another browser. This will probably // be configuratble with a clear_pattern verb option in the future tree_options.pattern = InputPattern::none(); match BrowserState::new(path, tree_options, screen, con, &Dam::unlimited()) { Ok(os) => CmdResult::NewPanel { state: Box::new(os), purpose, direction, }, Err(e) => CmdResult::DisplayError(e.to_string()), } } } /// Compute the path to go to in case of the internal being triggered from /// the input. /// /// This path depends on the verb (which may hardcore the path or have a /// pattern), from the selection, fn path_from_input( verb: &Verb, internal_exec: &InternalExecution, base_path: &Path, // either the selected path or the root path input_arg: Option<&String>, app_state: &AppState, con: &AppContext, ) -> PathBuf { match (input_arg, internal_exec.arg.as_ref()) { (Some(input_arg), Some(verb_arg)) => { // The verb probably defines some pattern which uses the input. // For example: // { // invocation: "gotar {path}" // execution: ":focus {path}/target" // } // (or that input is useless) let path_builder = ExecutionBuilder::with_invocation( verb.invocation_parser.as_ref(), SelInfo::from_path(base_path), app_state, Some(input_arg), ); path_builder.path(verb_arg, con) } (Some(input_arg), None) => { // the verb defines nothing // The :focus internal execution was triggered from the // input (which must be a kind of alias for :focus) // so we do exactly what the input asks for path::path_from(base_path, PathAnchor::Unspecified, input_arg) } (None, Some(verb_arg)) => { // the verb defines the path where to go.. // the internal_execution specifies the path to use // (it may come from a configured verb whose execution is // `:focus some/path`). // The given path may be relative hence the need for the // state's selection // (we assume a check before ensured it doesn't need an input) let path_builder = ExecutionBuilder::with_invocation( verb.invocation_parser.as_ref(), SelInfo::from_path(base_path), app_state, None, ); path_builder.path(verb_arg, con) } (None, None) => { // user only wants to open the selected path, either in the same panel or // in a new one base_path.to_path_buf() } } } pub fn get_status_markdown( verb: &Verb, internal_exec: &InternalExecution, sel_info: SelInfo<'_>, invocation: &VerbInvocation, app_state: &AppState, con: &AppContext, ) -> String { let base_path = sel_info.one_path().unwrap_or(&app_state.root); let path = path_from_input( verb, internal_exec, base_path, invocation.args.as_ref(), app_state, con, ); format!("Hit *enter* to focus `{}`", path.to_string_lossy()) } /// general implementation for verbs based on the :focus internal with optionally /// a bang or an argument. #[allow(clippy::too_many_arguments)] pub fn on_internal( internal_exec: &InternalExecution, input_invocation: Option<&VerbInvocation>, trigger_type: TriggerType, selected_path: &Path, is_root_selected: bool, tree_options: TreeOptions, app_state: &AppState, cc: &CmdContext, ) -> CmdResult { let con = &cc.app.con; let screen = cc.app.screen; let bang = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); let input_arg = input_invocation .as_ref() .and_then(|invocation| invocation.args.as_ref()); match trigger_type { TriggerType::Input(verb) => { let path = path_from_input( verb, internal_exec, selected_path, input_arg, app_state, cc.app.con, ); on_path(path, screen, tree_options, bang, con) } _ => { // the :focus internal was triggered by a key if let Some(arg) = &internal_exec.arg { // the internal_execution specifies the path to use // (it may come from a configured verb whose execution is // `:focus some/path` or `:focus {initial-root}̀). // The given path may be relative hence the need for the // state's selection let path_builder = ExecutionBuilder::without_invocation( SelInfo::from_path(selected_path), app_state, ); let path = path_builder.path(arg, con); let bang = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); on_path(path, screen, tree_options, bang, con) } else if let Some(input_arg) = input_arg { let base_dir = selected_path.to_string_lossy(); let path = path::path_from(&*base_dir, PathAnchor::Unspecified, input_arg); if bang { // Unsure this special behavior is really needed. It was based // on the assumption that the user wanted to edit an argument // of a verb, and that the trigering was a key (but it can also // be another medium, like a command sequence or with the server) let arg_type = SelectionType::Any; // We might do better later let purpose = PanelPurpose::ArgEdition { arg_type }; new_panel_on_path(path, screen, tree_options, purpose, con, HDir::Right) } else { on_path(path, screen, tree_options, bang, con) } } else { // user only wants to open the selected path, either in the same panel or // in a new one let mut path = selected_path.to_path_buf(); if !bang && is_root_selected { // the selected path is the root, focusing it would do nothing, so // we rather go up one level if let Some(parent_path) = selected_path.parent() { path = parent_path.to_path_buf(); } } on_path(path, screen, tree_options, bang, con) } } } } ================================================ FILE: src/verb/internal_path.rs ================================================ //! utility functions to help handle some internals which require a path use { super::*, crate::{ app::*, browser::BrowserState, command::TriggerType, display::Screen, path::{ self, PathAnchor, }, tree::Tree, }, std::path::{ Path, PathBuf, }, }; /// general implementation for verbs on the form ":some_internal path" pub fn determine_path( internal_exec: &InternalExecution, input_invocation: Option<&VerbInvocation>, trigger_type: TriggerType, tree: &Tree, app_state: &AppState, cc: &CmdContext, ) -> Option { info!( "internal_path::determine_path internal_exec={:?} input_invocation={:?} trygger_type={:?}", internal_exec, input_invocation, trigger_type, ); let input_arg = input_invocation .as_ref() .and_then(|invocation| invocation.args.as_ref()); match trigger_type { TriggerType::Input(verb) => { let path = path_from_input( verb, internal_exec, &tree.selected_line().path, input_arg, app_state, cc.app.con, ); Some(path) } _ => { // the :select internal was triggered by a key if let Some(arg) = &internal_exec.arg { // the internal_execution specifies the path to use // (it may come from a configured verb whose execution is // `:select some/path`). // The given path may be relative hence the need for the // state's selection let path = path::path_from(&tree.selected_line().path, PathAnchor::Unspecified, arg); Some(path) } else { // there's nothing really to do here None } } } } /// Compute the path to go to in case of the internal being triggered from /// the input. /// /// This path depends on the verb (which may hardcore the path or have a /// pattern), from the selection, fn path_from_input( verb: &Verb, internal_exec: &InternalExecution, base_path: &Path, // either the selected path or the root path input_arg: Option<&String>, app_state: &AppState, con: &AppContext, ) -> PathBuf { match (input_arg, internal_exec.arg.as_ref()) { (Some(input_arg), Some(verb_arg)) => { // The verb probably defines some pattern which uses the input. // For example: // { // invocation: "gotar {path}" // execution: ":select {path}/target" // } // (or that input is useless) let path_builder = ExecutionBuilder::with_invocation( verb.invocation_parser.as_ref(), SelInfo::from_path(base_path), app_state, Some(input_arg), ); path_builder.path(verb_arg, con) } (Some(input_arg), None) => { // the verb defines nothing // The :select internal execution was triggered from the // input (which must be a kind of alias for :select) // so we do exactly what the input asks for path::path_from(base_path, PathAnchor::Unspecified, input_arg) } (None, Some(verb_arg)) => { // the verb defines the path where to go.. // the internal_execution specifies the path to use // (it may come from a configured verb whose execution is // `:select some/path`). // The given path may be relative hence the need for the // state's selection // (we assume a check before ensured it doesn't need an input) path::path_from(base_path, PathAnchor::Unspecified, verb_arg) } (None, None) => { // This doesn't really make sense: we're selecting the currently // selected path base_path.to_path_buf() } } } pub fn on_path( path: PathBuf, tree: &mut Tree, screen: Screen, in_new_panel: bool, ) -> CmdResult { debug!("executing :select on path {:?}", &path); if in_new_panel { warn!("bang in :select isn't supported yet"); } if tree.try_select_path(&path) { tree.make_selection_visible(BrowserState::page_height(screen)); } CmdResult::Keep } ================================================ FILE: src/verb/internal_select.rs ================================================ //! utility functions to help handle the `:select` internal use { super::*, crate::{ app::*, browser::BrowserState, command::TriggerType, display::Screen, tree::Tree, }, std::path::PathBuf, }; /// general implementation for verbs based on the :select internal with optionally /// a bang or an argument. pub fn on_internal( internal_exec: &InternalExecution, input_invocation: Option<&VerbInvocation>, trigger_type: TriggerType, tree: &mut Tree, app_state: &AppState, cc: &CmdContext, ) -> CmdResult { let Some(path) = internal_path::determine_path( internal_exec, input_invocation, trigger_type, tree, app_state, cc, ) else { return CmdResult::Keep; }; let screen = cc.app.screen; let bang = input_invocation .map(|inv| inv.bang) .unwrap_or(internal_exec.bang); on_path(path, tree, screen, bang) } pub fn on_path( path: PathBuf, tree: &mut Tree, screen: Screen, in_new_panel: bool, ) -> CmdResult { debug!("executing :select on path {:?}", &path); if in_new_panel { warn!("bang in :select isn't supported yet"); } if tree.try_select_path(&path) { tree.make_selection_visible(BrowserState::page_height(screen)); } CmdResult::Keep } ================================================ FILE: src/verb/invocation_parser.rs ================================================ use { super::*, crate::{ errors::ConfError, path::PathAnchor, }, regex::Regex, rustc_hash::FxHashMap, std::path::PathBuf, }; /// Definition of how the user input should be checked /// and maybe parsed to provide the arguments used /// for execution or description. #[derive(Debug)] pub struct InvocationParser { /// pattern of how the command is supposed to be typed in the input pub invocation_pattern: VerbInvocation, /// a regex to read the arguments in the user input /// This regex declares named groups, with the name being the /// name of the replacement variable (this implies that an /// invocation name's characters are [_0-9a-zA-Z.\[\]]) args_parser: Option, pub arg_defs: Vec, } impl InvocationParser { pub fn new(invocation_str: &str) -> Result { let invocation_pattern = VerbInvocation::from(invocation_str); let mut args_parser = None; let mut arg_defs = Vec::new(); if let Some(args) = &invocation_pattern.args { let spec = ARG_DEF_GROUP.replace_all(args, r"(?P<$1>.+)"); let spec = format!("^{spec}$"); args_parser = match Regex::new(&spec) { Ok(regex) => Some(regex), Err(_) => { return Err(ConfError::InvalidVerbInvocation { invocation: spec }); } }; for group in ARG_DEF_GROUP.captures_iter(args) { arg_defs.push(VerbArgDef::from_capture(&group)); } } Ok(Self { invocation_pattern, args_parser, arg_defs, }) } pub fn name(&self) -> &str { &self.invocation_pattern.name } pub fn get_unique_arg_def(&self) -> Option { (self.arg_defs.len() == 1).then(|| self.arg_defs[0].clone()) } pub fn get_unique_arg_anchor(&self) -> PathAnchor { self.get_unique_arg_def() .map(|arg_def| arg_def.path_anchor()) .unwrap_or_default() } /// Assuming the verb has been matched, check whether the arguments /// are OK according to the regex. Return none when there's no problem /// and return the error to display if arguments don't match pub fn check_args( &self, invocation: &VerbInvocation, _other_path: &Option, ) -> Option { match (&invocation.args, &self.args_parser) { (None, None) => None, (None, Some(ref regex)) => { if regex.is_match("") { None } else { Some(self.invocation_pattern.to_string_for_name(&invocation.name)) } } (Some(ref s), Some(ref regex)) => { if regex.is_match(s) { None } else { Some(self.invocation_pattern.to_string_for_name(&invocation.name)) } } (Some(_), None) => Some(format!("{} doesn't take arguments", invocation.name)), } } pub fn parse( &self, args: &str, ) -> Option> { self.args_parser.as_ref().map(|r| { let mut map = FxHashMap::default(); if let Some(input_cap) = r.captures(args) { for name in r.capture_names().flatten() { if let Some(c) = input_cap.name(name) { map.insert(name.to_string(), c.as_str().to_string()); } } } map }) } } ================================================ FILE: src/verb/mod.rs ================================================ mod coarity; //mod arg_def; mod exec_pattern; mod execution_builder; mod external_execution; mod external_execution_mode; mod file_type_condition; mod internal; mod internal_execution; pub mod internal_focus; pub mod internal_path; pub mod internal_select; mod invocation_parser; mod sequence_execution; mod verb; mod verb_arg_def; mod verb_description; mod verb_execution; mod verb_invocation; mod verb_store; mod write; use lazy_regex::*; pub use { coarity::*, //arg_def::*, exec_pattern::*, execution_builder::*, external_execution::*, external_execution_mode::ExternalExecutionMode, file_type_condition::*, internal::Internal, internal_execution::InternalExecution, invocation_parser::InvocationParser, once_cell::sync::Lazy, sequence_execution::SequenceExecution, verb::Verb, verb_arg_def::*, verb_description::VerbDescription, verb_execution::VerbExecution, verb_invocation::*, verb_store::{ PrefixSearchResult, VerbStore, }, write::*, }; pub type VerbId = usize; pub fn str_has_selection_group(s: &str) -> bool { ARG_DEF_GROUP.find_iter(s).any(|group| { matches!( group.as_str(), "{file}" | "{file-name}" | "{parent}" | "{directory}", ) }) } pub fn str_has_other_panel_group(s: &str) -> bool { for group in ARG_DEF_GROUP.find_iter(s) { if group.as_str().starts_with("{other-panel-") { return true; } } false } ================================================ FILE: src/verb/sequence_execution.rs ================================================ use crate::command::Sequence; /// A verb execution definition based on a sequence /// of commands #[derive(Debug, Clone)] pub struct SequenceExecution { pub sequence: Sequence, } ================================================ FILE: src/verb/verb.rs ================================================ use { super::*, crate::{ app::*, errors::ConfError, path::PathAnchor, }, crokey::KeyCombination, std::{ cmp::PartialEq, path::PathBuf, ptr, }, }; /// what makes a verb. /// /// Verbs are the engines of broot commands, and apply /// - to the selected file (if user-defined, then must contain {file}, {parent} or {directory}) /// - to the current app state /// /// There are two types of verbs executions: /// - external programs or commands (cd, mkdir, user defined commands, etc.) /// - internal behaviors (focusing a path, going back, showing the help, etc.) /// /// Some verbs are builtins, some other ones are created by configuration. /// /// Both builtins and configured vers can be internal or external based. /// /// Verbs can't be cloned. Two verbs are equal if they have the same address /// in memory. #[derive(Debug)] pub struct Verb { pub id: VerbId, /// names (like "cd", "focus", "focus_tab", "c") by which /// a verb can be called. /// Can be empty if the verb is only called with a key shortcut. /// Right now there's no way for it to contain more than 2 elements /// but this may change. pub names: Vec, /// key shortcuts pub keys: Vec, /// how the input must be checked and interpreted /// Can be empty if the verb is only called with a key shortcut. pub invocation_parser: Option, /// how the verb will be executed pub execution: VerbExecution, /// a description pub description: VerbDescription, /// the type of selection this verb applies to pub selection_condition: FileTypeCondition, /// extension filtering. If empty, all extensions apply pub file_extensions: Vec, /// whether the verb needs a selection pub needs_selection: bool, /// whether we need to have a secondary panel for execution /// (which is the case when the execution pattern has {other-panel-file}) pub needs_another_panel: bool, /// if true (default) verbs are directly executed when /// triggered with a keyboard shortcut pub auto_exec: bool, /// whether to show the verb in help screen /// (if we show all input related actions, the doc is unusable) pub show_in_doc: bool, pub panels: Vec, /// The panel on which the verb applies (even if triggered from another panel) pub impacted_panel: PanelReference, } impl PartialEq for Verb { fn eq( &self, other: &Self, ) -> bool { ptr::eq(self, other) } } impl Verb { pub fn new( id: VerbId, invocation_str: Option<&str>, execution: VerbExecution, description: VerbDescription, ) -> Result { let invocation_parser = invocation_str.map(InvocationParser::new).transpose()?; let mut names = Vec::new(); if let Some(ref invocation_parser) = invocation_parser { let name = invocation_parser.name().to_string(); check_verb_name(&name)?; names.push(name); } let (needs_selection, needs_another_panel) = match &execution { VerbExecution::Internal(ie) => (ie.needs_selection(), false), VerbExecution::External(ee) => ( ee.exec_pattern.has_selection_group(), ee.exec_pattern.has_other_panel_group(), ), VerbExecution::Sequence(se) => ( se.sequence.has_selection_group(), se.sequence.has_other_panel_group(), ), }; Ok(Self { id, names, keys: Vec::new(), invocation_parser, execution, description, selection_condition: FileTypeCondition::Any, file_extensions: Vec::new(), needs_selection, needs_another_panel, auto_exec: true, show_in_doc: true, panels: Vec::new(), impacted_panel: PanelReference::default(), }) } pub fn with_key( &mut self, key: KeyCombination, ) -> &mut Self { self.keys.push(key); self } pub fn add_keys( &mut self, keys: Vec, ) { for key in keys { self.keys.push(key); } } pub fn no_doc(&mut self) -> &mut Self { self.show_in_doc = false; self } pub fn with_name( &mut self, name: &str, ) -> Result<&mut Self, ConfError> { check_verb_name(name)?; self.names.insert(0, name.to_string()); Ok(self) } pub fn with_description( &mut self, description: &str, ) -> &mut Self { self.description = VerbDescription::from_text(description.to_string()); self } pub fn with_shortcut( &mut self, shortcut: &str, ) -> &mut Self { self.names.push(shortcut.to_string()); self } pub fn with_condition( &mut self, selection_condition: FileTypeCondition, ) -> &mut Self { self.selection_condition = selection_condition; self } pub fn needing_another_panel(&mut self) -> &mut Self { self.needs_another_panel = true; self } pub fn with_auto_exec( &mut self, b: bool, ) -> &mut Self { self.auto_exec = b; self } pub fn has_name( &self, searched_name: &str, ) -> bool { self.names.iter().any(|name| name == searched_name) } /// Assuming the verb has been matched, check whether the arguments /// are OK according to the regex. Return none when there's no problem /// and return the error to display if arguments don't match. pub fn check_args( &self, sel_info: SelInfo<'_>, invocation: &VerbInvocation, other_path: &Option, ) -> Option { match sel_info { SelInfo::None => self.check_sel_args(None, invocation, other_path), SelInfo::One(sel) => self.check_sel_args(Some(sel), invocation, other_path), SelInfo::More(stage) => stage .paths() .iter() .filter_map(|path| { let sel = Selection { path, line: 0, stype: SelectionType::from(path), is_exe: false, }; self.check_sel_args(Some(sel), invocation, other_path) }) .next(), } } fn check_sel_args( &self, sel: Option>, invocation: &VerbInvocation, other_path: &Option, ) -> Option { if self.needs_selection && sel.is_none() { Some("This verb needs a selection".to_string()) } else if self.needs_another_panel && other_path.is_none() { Some("This verb needs exactly two panels".to_string()) } else if let Some(ref parser) = self.invocation_parser { parser.check_args(invocation, other_path) } else if invocation.args.is_some() { Some("This verb doesn't take arguments".to_string()) } else { None } } pub fn get_status_markdown( &self, sel_info: SelInfo<'_>, app_state: &AppState, invocation: &VerbInvocation, con: &AppContext, ) -> String { let name = self.names.first().unwrap_or(&invocation.name); // there's one special case: the ̀ :focus` internal. As long // as no other internal takes args, and no other verb can // have an optional argument, I don't try to build a // generic behavior for internal optionally taking args and // thus I hardcode the test here. if let VerbExecution::Internal(internal_exec) = &self.execution { if internal_exec.internal == Internal::focus { return internal_focus::get_status_markdown( self, internal_exec, sel_info, invocation, app_state, con, ); } } let builder = || { ExecutionBuilder::with_invocation( self.invocation_parser.as_ref(), sel_info, app_state, invocation.args.as_ref(), ) }; if let VerbExecution::Sequence(seq_ex) = &self.execution { // We can't determine before execution what will be the arguments, except // for the first item of the sequence. It's cleaner to just not try expand it format!("Hit *enter* to **{}**: `{}`", name, seq_ex.sequence.raw) } else if let VerbExecution::External(external_exec) = &self.execution { let exec_desc = builder().shell_exec_string(&external_exec.exec_pattern, con); format!("Hit *enter* to **{}**: `{}`", name, &exec_desc) } else if self.description.code { format!( "Hit *enter* to **{}**: `{}`", name, &self.description.content ) } else { format!("Hit *enter* to **{}**: {}", name, &self.description.content) } } pub fn get_unique_arg_anchor(&self) -> PathAnchor { self.invocation_parser.as_ref().map_or( PathAnchor::Unspecified, InvocationParser::get_unique_arg_anchor, ) } pub fn get_internal(&self) -> Option { match &self.execution { VerbExecution::Internal(internal_exec) => Some(internal_exec.internal), _ => None, } } pub fn is_internal( &self, internal: Internal, ) -> bool { self.get_internal() == Some(internal) } pub fn is_some_internal( v: Option<&Verb>, internal: Internal, ) -> bool { v.map_or(false, |v| v.is_internal(internal)) } pub fn is_sequence(&self) -> bool { matches!(self.execution, VerbExecution::Sequence(_)) } pub fn can_be_called_in_panel( &self, panel_state_type: PanelStateType, ) -> bool { self.panels.is_empty() || self.panels.contains(&panel_state_type) } pub fn accepts_extension( &self, extension: Option<&str>, ) -> bool { if self.file_extensions.is_empty() { true } else { extension.map_or(false, |ext| self.file_extensions.iter().any(|ve| ve == ext)) } } } pub fn check_verb_name(name: &str) -> Result<(), ConfError> { if regex_is_match!(r"^([@,#~&'%$\dù_-]+|[\w][\w_@,#~&'%$\dù_-]*)+$", name) { Ok(()) } else { Err(ConfError::InvalidVerbName { name: name.to_string(), }) } } ================================================ FILE: src/verb/verb_arg_def.rs ================================================ use { crate::{ errors::ConfError, path::PathAnchor, }, lazy_regex::*, std::str::FromStr, std::fmt, }; /// A `{name:flags}` group in a verb definition string, where `name` is the argument name and /// `flags` is a comma-separated list of flags that modify how the argument is processed. /// /// This pattern is also used slightly differently in verb invocations, where the flags part /// can be used to specify a default value. pub static ARG_DEF_GROUP: Lazy = lazy_regex!(r"\{([^{}:]+)(?::([^{}:]+))?\}"); #[derive(Debug, Clone, PartialEq)] pub struct VerbArgDef { pub name: String, pub flags: Vec, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum VerbArgFlag { CommaSeparated, SpaceSeparated, PathFromDirectory, PathFromParent, Theme, } impl VerbArgFlag { pub fn is_merging(&self) -> bool { matches!(self, Self::CommaSeparated | Self::SpaceSeparated) } pub fn merge_values( &self, args: Vec, ) -> Option { if args.is_empty() { return None; } match self { Self::CommaSeparated => Some(args.join(",")), Self::SpaceSeparated => Some(args.join(" ")), _ => None, } } pub fn path_anchor(&self) -> PathAnchor { match self { Self::PathFromDirectory => PathAnchor::Directory, Self::PathFromParent => PathAnchor::Parent, _ => crate::path::PathAnchor::Unspecified, } } } impl VerbArgDef { /// Assuming a valid capture from the GROUP regex, parse the argument definition pub fn from_capture(capture: &Captures<'_>) -> VerbArgDef { let name = capture .get(1) .map(|m| m.as_str()) .unwrap_or_else(|| { // internal error, the regex should guarantee this group exists error!("Invalid capture for argument definition"); "???" }) .to_string(); let flags = capture .get(2) .map(|m| { m.as_str() .split(',') .map(str::trim) .filter_map(|s| match s.parse() { Ok(flag) => Some(flag), Err(e) => { warn!("Invalid flag '{}' in argument definition: {}", s, e); None } }) .collect() }) .unwrap_or_default(); VerbArgDef { name: name.as_str().to_string(), flags, } } pub fn merging_flag(&self) -> Option { for flag in &self.flags { if flag.is_merging() { return Some(*flag); } } None } pub fn has_flag( &self, flag: VerbArgFlag, ) -> bool { self.flags.contains(&flag) } pub fn path_anchor(&self) -> PathAnchor { for flag in &self.flags { let anchor = flag.path_anchor(); if anchor != PathAnchor::Unspecified { return anchor; } } PathAnchor::Unspecified } } impl FromStr for VerbArgFlag { type Err = ConfError; fn from_str(s: &str) -> Result { match s { "comma-separated" => Ok(Self::CommaSeparated), "space-separated" => Ok(Self::SpaceSeparated), "path-from-directory" => Ok(Self::PathFromDirectory), "path-from-parent" => Ok(Self::PathFromParent), "theme" => Ok(Self::Theme), _ => Err(ConfError::UnknownVerbArgFlag { name: s.to_string(), }), } } } impl fmt::Display for VerbArgFlag { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { let s = match self { Self::CommaSeparated => "comma-separated", Self::SpaceSeparated => "space-separated", Self::PathFromDirectory => "path-from-directory", Self::PathFromParent => "path-from-parent", Self::Theme => "theme", }; write!(f, "{s}") } } ================================================ FILE: src/verb/verb_description.rs ================================================ /// how a verb is described in the help screen #[derive(Debug, Clone)] pub struct VerbDescription { pub code: bool, pub content: String, } impl VerbDescription { pub fn from_code(content: String) -> Self { Self { code: true, content, } } pub fn from_text(content: String) -> Self { Self { code: false, content, } } } ================================================ FILE: src/verb/verb_execution.rs ================================================ use { super::*, std::fmt, }; /// how a verb must be executed #[derive(Debug, Clone)] pub enum VerbExecution { /// the verb execution is based on a behavior defined in code in Broot. /// Executions in conf starting with ":" are of this type. Internal(InternalExecution), /// the verb execution refers to a command that will be executed by the system, /// outside of broot. External(ExternalExecution), /// the execution is a sequence similar to what can be given /// to broot with --cmd Sequence(SequenceExecution), } // This implementation builds a string used for description (eg in help) impl fmt::Display for VerbExecution { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { match self { Self::Internal(ie) => ie.fmt(f), Self::External(ee) => ee.exec_pattern.fmt(f), Self::Sequence(se) => se.sequence.raw.fmt(f), } } } ================================================ FILE: src/verb/verb_invocation.rs ================================================ use std::fmt; /// the verb and its arguments, making the invocation. /// When coming from parsing, the args is Some as soon /// as there's a separator (i.e. it's "" in "cp ") #[derive(Clone, Debug, PartialEq)] pub struct VerbInvocation { pub name: String, pub args: Option, pub bang: bool, } impl fmt::Display for VerbInvocation { fn fmt( &self, f: &mut fmt::Formatter<'_>, ) -> fmt::Result { write!(f, ":")?; if self.bang { write!(f, "!")?; } write!(f, "{}", &self.name)?; if let Some(args) = &self.args { write!(f, " {}", &args)?; } Ok(()) } } impl VerbInvocation { pub fn new>( name: T, args: Option, bang: bool, ) -> Self { Self { name: name.into(), args: args.map(|s| s.into()), bang, } } pub fn is_empty(&self) -> bool { self.name.is_empty() } /// build a new String pub fn complete_name(&self) -> String { if self.bang { format!("{}_tab", &self.name) } else { self.name.clone() } } /// basically return the invocation but allow another name (the shortcut /// or a variant) pub fn to_string_for_name( &self, name: &str, ) -> String { let mut s = String::new(); if self.bang { s.push('!'); } s.push_str(name); if let Some(args) = &self.args { s.push(' '); s.push_str(args); } s } } impl From<&str> for VerbInvocation { /// Parse a string being or describing the invocation of a verb with its /// arguments and optional bang. The leading space or colon must /// have been stripped before. /// /// Examples: /// "mv" -> name: "mv" /// "!mv" -> name: "mv", bang /// "mv a b" -> name: "mv", args: "a b" /// "mv!a b" -> name: "mv", args: "a b", bang /// "a-b c" -> name: "a-b", args: "c", bang /// "-sp" -> name: "-", args: "sp" /// "-a b" -> name: "-", args: "a b" /// "-a b" -> name: "-", args: "a b" /// "--a" -> name: "--", args: "a" /// /// Notes: /// 1. A name is either "special" (only made of non alpha characters) /// or normal (starting with an alpha character). Special names don't /// need a space afterwards, as the first alpha character will start /// the args. /// 2. The space or colon after the name is optional if there's a bang /// after the name: the bang is the separator. /// 3. Duplicate separators before args are ignored (they're usually typos) /// 4. An opening parenthesis starts args fn from(invocation: &str) -> Self { let mut bang_before = false; let mut name = String::new(); let mut bang_after = false; let mut args: Option = None; let mut name_is_special = false; for c in invocation.chars() { if let Some(args) = args.as_mut() { if args.is_empty() && (c == ' ' || c == ':') { // we don't want args starting with a space just because // they're doubled or are optional after a special name } else { args.push(c); } continue; } if c == ' ' || c == ':' { args = Some(String::new()); continue; } if c == '(' { args = Some(c.to_string()); continue; } if c == '!' { if !name.is_empty() { bang_after = true; args = Some(String::new()); } else { bang_before = true; } continue; } if name.is_empty() { name.push(c); if !c.is_alphabetic() { name_is_special = true; } continue; } if c.is_alphabetic() && name_is_special { // this isn't part of the name anymore, it's part of the args args = Some(c.to_string()); continue; } name.push(c); } let bang = bang_before || bang_after; VerbInvocation { name, args, bang } } } #[cfg(test)] mod verb_invocation_tests { use super::*; #[test] fn check_special_chars() { assert_eq!( VerbInvocation::from("-sdp"), VerbInvocation::new("-", Some("sdp"), false), ); assert_eq!( VerbInvocation::from("!-sdp"), VerbInvocation::new("-", Some("sdp"), true), ); assert_eq!( VerbInvocation::from("-!sdp"), VerbInvocation::new("-", Some("sdp"), true), ); assert_eq!( VerbInvocation::from("-! sdp"), VerbInvocation::new("-", Some("sdp"), true), ); assert_eq!( VerbInvocation::from("!@a b"), VerbInvocation::new("@", Some("a b"), true), ); assert_eq!( VerbInvocation::from("!@%a b"), VerbInvocation::new("@%", Some("a b"), true), ); assert_eq!( VerbInvocation::from("22a b"), VerbInvocation::new("22", Some("a b"), false), ); assert_eq!( VerbInvocation::from("22!a b"), VerbInvocation::new("22", Some("a b"), true), ); assert_eq!( VerbInvocation::from("22 !a b"), VerbInvocation::new("22", Some("!a b"), false), ); assert_eq!( VerbInvocation::from("a$b4!r"), VerbInvocation::new("a$b4", Some("r"), true), ); assert_eq!( VerbInvocation::from("a-b c"), VerbInvocation::new("a-b", Some("c"), false), ); } #[test] fn check_verb_invocation_parsing_empty_arg() { // those tests focus mainly on the distinction between // None and Some("") for the args, distinction which matters // for inline help assert_eq!( VerbInvocation::from("!mv"), VerbInvocation::new("mv", None, true), ); assert_eq!( VerbInvocation::from("mva!"), VerbInvocation::new("mva", Some(""), true), ); assert_eq!( VerbInvocation::from("cp "), VerbInvocation::new("cp", Some(""), false), ); assert_eq!( VerbInvocation::from("cp ../"), VerbInvocation::new("cp", Some("../"), false), ); } #[test] fn check_verb_invocation_parsing_post_bang() { // ignoring post_bang (see issue #326) assert_eq!( VerbInvocation::from("mva!a"), VerbInvocation::new("mva", Some("a"), true), ); assert_eq!( VerbInvocation::from("!!!"), VerbInvocation::new("", None, true), ); } #[test] fn check_verb_invocation_parsing_empty_verb() { // there's currently no meaning for the empty verb, it's "reserved" // and will probably not be used as it may need a distinction between // one and two initial spaces in the input assert_eq!( VerbInvocation::from(""), VerbInvocation::new("", None, false), ); assert_eq!( VerbInvocation::from("!"), VerbInvocation::new("", None, true), ); assert_eq!( VerbInvocation::from("!! "), VerbInvocation::new("", Some(""), true), ); assert_eq!( VerbInvocation::from("!! a"), VerbInvocation::new("", Some("a"), true), ); } #[test] fn check_verb_invocation_parsing_oddities() { // checking some corner cases assert_eq!( VerbInvocation::from("!!a"), // the second bang is ignored VerbInvocation::new("a", None, true), ); assert_eq!( VerbInvocation::from("!!"), // the second bang is ignored VerbInvocation::new("", None, true), ); assert_eq!( VerbInvocation::from("a ! !"), VerbInvocation::new("a", Some("! !"), false), ); assert_eq!( VerbInvocation::from("!a !a"), VerbInvocation::new("a", Some("!a"), true), ); assert_eq!( VerbInvocation::from("a! ! //"), VerbInvocation::new("a", Some("! //"), true), ); assert_eq!( VerbInvocation::from(".. .."), VerbInvocation::new("..", Some(".."), false), ); } } ================================================ FILE: src/verb/verb_store.rs ================================================ use { super::{ Internal, Verb, VerbId, }, crate::{ app::*, command::Sequence, conf::{ Conf, VerbConf, }, errors::ConfError, keys::{ self, KEY_FORMAT, }, verb::*, }, crokey::*, }; /// Provide access to the verbs: /// - the built-in ones /// - the user defined ones /// /// A user defined verb can replace a built-in. /// /// When the user types some keys, we select a verb /// - if the input exactly matches a shortcut or the name /// - if only one verb name starts with the input pub struct VerbStore { verbs: Vec, unbound_keys: Vec, } #[derive(Debug, Clone, PartialEq)] pub enum PrefixSearchResult<'v, T> { NoMatch, Match(&'v str, T), Matches(Vec<&'v str>), } impl VerbStore { pub fn new(conf: &mut Conf) -> Result { let mut store = Self { verbs: Vec::new(), unbound_keys: Vec::new(), }; for vc in &conf.verbs { if let Err(e) = store.add_from_conf(vc) { eprintln!("Invalid verb configuration: {}", e); warn!("Faulty parsed configuration: {:#?}", vc); if let Ok(toml) = toml::to_string(&vc) { eprintln!("Faulty configuration:\n{}", toml); } eprintln!("Configuration files:"); for path in &conf.files { eprintln!(" - {}", path.display()); } } } store.add_builtin_verbs()?; // at the end so that we can override them for key in store.unbound_keys.clone() { store.unbind_key(key)?; } Ok(store) } fn add_builtin_verbs(&mut self) -> Result<(), ConfError> { use super::{ ExternalExecutionMode::*, Internal::*, }; self.add_internal(escape).with_key(key!(esc)); // input actions, not visible in doc, but available for // example in remote control self.add_internal(input_clear).no_doc(); self.add_internal(input_del_char_left).no_doc(); self.add_internal(input_del_char_below).no_doc(); self.add_internal(input_del_word_left).no_doc(); self.add_internal(input_del_word_right).no_doc(); self.add_internal(input_go_to_end) .with_key(key!(end)) .no_doc(); self.add_internal(input_go_left).no_doc(); self.add_internal(input_go_right).no_doc(); self.add_internal(input_go_to_start) .with_key(key!(home)) .no_doc(); self.add_internal(input_go_word_left).no_doc(); self.add_internal(input_go_word_right).no_doc(); // arrow keys bindings self.add_internal(back); self.add_internal(open_stay); self.add_internal(line_down) .with_key(key!(down)) .with_key(key!('j')); self.add_internal(line_up) .with_key(key!(up)) .with_key(key!('k')); // changing display self.add_internal(set_syntax_theme); self.add_internal(apply_flags).with_name("apply_flags")?; self.add_internal(set_panel_width); self.add_internal(default_layout); // those two operations are mapped on ALT-ENTER, one // for directories and the other one for the other files self.add_internal(open_leave) // calls the system open .with_condition(FileTypeCondition::File) .with_key(key!(alt - enter)) .with_shortcut("ol"); self.add_external("cd", "cd {directory}", FromParentShell) .with_condition(FileTypeCondition::Directory) .with_key(key!(alt - enter)) .with_shortcut("ol") .with_description("change directory and quit"); #[cfg(unix)] self.add_external("chmod {args}", "chmod {args} {file}", StayInBroot) .with_condition(FileTypeCondition::File); #[cfg(unix)] self.add_external("chmod {args}", "chmod -R {args} {file}", StayInBroot) .with_condition(FileTypeCondition::Directory); self.add_internal(open_preview); self.add_internal(close_preview); self.add_internal(toggle_preview); self.add_internal(preview_image).with_shortcut("img"); self.add_internal(preview_text).with_shortcut("txt"); self.add_internal(preview_binary).with_shortcut("hex"); self.add_internal(preview_tty).with_shortcut("tty"); self.add_internal(close_panel_ok); self.add_internal(close_panel_cancel) .with_key(key!(ctrl - w)); #[cfg(unix)] self.add_external( "copy {newpath}", "cp -r {file} {newpath:path-from-parent}", StayInBroot, ) .with_shortcut("cp"); #[cfg(windows)] self.add_external( "copy {newpath}", "xcopy /Q /H /Y /I {file} {newpath:path-from-parent}", StayInBroot, ) .with_shortcut("cp"); #[cfg(feature = "clipboard")] self.add_internal(copy_line).with_key(key!(alt - c)); #[cfg(feature = "clipboard")] self.add_internal(copy_path); #[cfg(unix)] self.add_external( "copy_to_panel", "cp -r {file} {other-panel-directory}", StayInBroot, ) .with_shortcut("cpp"); #[cfg(windows)] self.add_external( "copy_to_panel", "xcopy /Q /H /Y /I {file} {other-panel-directory}", StayInBroot, ) .with_shortcut("cpp"); self.add_internal(trash); #[cfg(any( target_os = "windows", all(unix, not(any(target_os = "ios", target_os = "android"))) ))] { self.add_internal(open_trash).with_shortcut("ot"); self.add_internal(restore_trashed_file).with_shortcut("rt"); self.add_internal(delete_trashed_file).with_shortcut("dt"); self.add_internal(purge_trash).with_shortcut("et"); } #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] self.add_internal(filesystems).with_shortcut("fs"); self.add_internal(focus_staging_area_no_open); // :focus is also hardcoded on Enter on directories // but ctrl-f is useful for focusing on a file's parent // (and keep the filter) self.add_internal(focus) .with_key(key!(L)) // hum... why this one ? .with_key(key!(ctrl - f)); self.add_internal(help) .with_key(key!(F1)) .with_shortcut("?"); #[cfg(feature = "clipboard")] self.add_internal(input_paste).with_key(key!(ctrl - v)); #[cfg(unix)] self.add_external( "mkdir {subpath}", "mkdir -p {subpath:path-from-directory}", StayInBroot, ) .with_shortcut("md"); #[cfg(windows)] self.add_external( "mkdir {subpath}", "cmd /c mkdir {subpath:path-from-directory}", StayInBroot, ) .with_shortcut("md"); #[cfg(unix)] self.add_external( "move {newpath}", "mv {file} {newpath:path-from-parent}", StayInBroot, ) .with_shortcut("mv"); #[cfg(windows)] self.add_external( "move {newpath}", "cmd /c move /Y {file} {newpath:path-from-parent}", StayInBroot, ) .with_shortcut("mv"); #[cfg(unix)] self.add_external( "move_to_panel", "mv {file} {other-panel-directory}", StayInBroot, ) .with_shortcut("mvp"); #[cfg(windows)] self.add_external( "move_to_panel", "cmd /c move /Y {file} {other-panel-directory}", StayInBroot, ) .with_shortcut("mvp"); #[cfg(unix)] self.add_external( "rename {new_filename:file-name}", "mv {file} {parent}/{new_filename}", StayInBroot, ) .with_auto_exec(false) .with_key(key!(f2)); #[cfg(windows)] self.add_external( "rename {new_filename:file-name}", "cmd /c move /Y {file} {parent}/{new_filename}", StayInBroot, ) .with_auto_exec(false) .with_key(key!(f2)); self.add_internal_bang(start_end_panel) .with_key(key!(ctrl - p)); // the char keys for mode_input are handled differently as they're not // consumed by the command self.add_internal(mode_input) .with_key(key!(' ')) .with_key(key!(':')) .with_key(key!('/')); self.add_internal(previous_match) .with_key(key!(shift - backtab)) .with_key(key!(backtab)); self.add_internal(next_match).with_key(key!(tab)); self.add_internal(no_sort).with_shortcut("ns"); self.add_internal(open_stay) .with_key(key!(enter)) .with_shortcut("os"); self.add_internal(open_stay_filter).with_shortcut("osf"); self.add_internal(parent) .with_key(key!(h)) .with_shortcut("p"); self.add_internal(page_down) .with_key(key!(ctrl - d)) .with_key(key!(pagedown)); self.add_internal(page_up) .with_key(key!(ctrl - u)) .with_key(key!(pageup)); self.add_internal(focus_panel_left); self.add_internal(focus_panel_right); self.add_internal(panel_left_no_open) .with_key(key!(ctrl - left)); self.add_internal(panel_right).with_key(key!(ctrl - right)); self.add_internal(print_path).with_shortcut("pp"); self.add_internal(print_relative_path).with_shortcut("prp"); self.add_internal(print_tree).with_shortcut("pt"); self.add_internal(quit) .with_key(key!(ctrl - c)) .with_key(key!(ctrl - q)) .with_shortcut("q"); self.add_internal(refresh).with_key(key!(f5)); self.add_internal(root_up).with_key(key!(ctrl - up)); self.add_internal(root_down).with_key(key!(ctrl - down)); self.add_internal(select_first); self.add_internal(select_last); self.add_internal(select); self.add_internal(show); self.add_internal(clear_stage).with_shortcut("cls"); self.add_internal(stage).with_key(key!('+')); self.add_internal(unstage).with_key(key!('-')); self.add_internal(stage_all_directories); self.add_internal(stage_all_files).with_key(key!(ctrl - a)); self.add_internal(toggle_stage).with_key(key!(ctrl - g)); self.add_internal(open_staging_area).with_shortcut("osa"); self.add_internal(close_staging_area).with_shortcut("csa"); self.add_internal(toggle_staging_area).with_shortcut("tsa"); self.add_internal(toggle_tree).with_shortcut("tree"); self.add_internal(toggle_watch) .with_shortcut("watch") .with_key(key!(alt - w)); self.add_internal(sort_by_count).with_shortcut("sc"); self.add_internal(sort_by_date).with_shortcut("sd"); self.add_internal(sort_by_size).with_shortcut("ss"); self.add_internal(sort_by_type).with_shortcut("st"); #[cfg(unix)] self.add_external("rm", "rm -rf {file}", StayInBroot); #[cfg(windows)] self.add_external("rm", "cmd /c rmdir /Q /S {file}", StayInBroot) .with_condition(FileTypeCondition::Directory); #[cfg(windows)] self.add_external("rm", "cmd /c del /Q {file}", StayInBroot) .with_condition(FileTypeCondition::File); self.add_internal(toggle_counts).with_shortcut("counts"); self.add_internal(toggle_dates).with_shortcut("dates"); self.add_internal(toggle_device_id).with_shortcut("dev"); self.add_internal(toggle_files).with_shortcut("files"); self.add_internal(toggle_ignore) .with_key(key!(alt - i)) .with_shortcut("gi"); self.add_internal(toggle_git_file_info).with_shortcut("gf"); self.add_internal(toggle_git_status).with_shortcut("gs"); self.add_internal(toggle_root_fs).with_shortcut("rfs"); self.add_internal(set_max_depth); self.add_internal(unset_max_depth); self.add_internal(toggle_hidden) .with_key(key!(alt - h)) .with_shortcut("h"); #[cfg(unix)] 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(total_search); self.add_internal(search_again).with_key(key!(ctrl - s)); self.add_internal(up_tree).with_shortcut("up"); self.add_internal_with_args(move_panel_divider, "0 1") .with_key(key!(alt - '>')); self.add_internal_with_args(move_panel_divider, "0 -1") .with_key(key!(alt - '<')); self.add_internal(clear_output); self.add_internal(write_output); Ok(()) } fn build_add_internal( &mut self, internal: Internal, bang: bool, ) -> &mut Verb { let invocation = internal.invocation_pattern(); let execution = VerbExecution::Internal(InternalExecution::from_internal_bang(internal, bang)); let description = VerbDescription::from_text(internal.description().to_string()); self.add_verb(Some(invocation), execution, description) .unwrap() } fn add_internal( &mut self, internal: Internal, ) -> &mut Verb { self.build_add_internal(internal, false) } fn add_internal_with_args( &mut self, internal: Internal, args: &str, ) -> &mut Verb { let command = format!("{} {}", internal.name(), args); let execution = VerbExecution::Internal(InternalExecution { internal, bang: false, arg: Some(args.to_string()), }); let description = VerbDescription::from_text(command.clone()); self.add_verb(Some(&command), execution, description) .unwrap() } fn add_internal_bang( &mut self, internal: Internal, ) -> &mut Verb { self.build_add_internal(internal, true) } fn add_external( &mut self, invocation_str: &str, execution_str: &str, exec_mode: ExternalExecutionMode, ) -> &mut Verb { let execution = VerbExecution::External(ExternalExecution::new( ExecPattern::from_string(execution_str), exec_mode, )); self.add_verb( Some(invocation_str), execution, VerbDescription::from_code(execution_str.to_string()), ) .unwrap() } pub fn add_verb( &mut self, invocation_str: Option<&str>, execution: VerbExecution, description: VerbDescription, ) -> Result<&mut Verb, ConfError> { let id = self.verbs.len(); self.verbs .push(Verb::new(id, invocation_str, execution, description)?); Ok(&mut self.verbs[id]) } /// Create a verb from its configuration, adding it to its store pub fn add_from_conf( &mut self, vc: &VerbConf, ) -> Result<(), ConfError> { if vc.leave_broot == Some(false) && vc.from_shell == Some(true) { return Err(ConfError::InvalidVerbConf { details: "You can't simultaneously have leave_broot=false and from_shell=true" .to_string(), }); } // we accept both key and keys. We merge both here let mut unchecked_keys = vc.keys.clone(); if let Some(key) = &vc.key { unchecked_keys.push(key.clone()); } let mut checked_keys = Vec::new(); for key in &unchecked_keys { let key = crokey::parse(key)?; if keys::is_reserved(key) { return Err(ConfError::ReservedKey { key: keys::KEY_FORMAT.to_string(key), }); } checked_keys.push(key); } let invocation = vc.invocation.clone().filter(|i| !i.is_empty()); let internal = vc.internal.as_ref().filter(|i| !i.is_empty()); let external = vc.external.as_ref().filter(|i| !i.is_empty()); let cmd = vc.cmd.as_ref().filter(|i| !i.is_empty()); let cmd_separator = vc.cmd_separator.as_ref().filter(|i| !i.is_empty()); let execution = vc.execution.as_ref().filter(|i| !i.is_empty()); let make_external_execution = |s| { let working_dir = match (vc.set_working_dir, &vc.working_dir) { (Some(false), _) => None, (_, Some(s)) => Some(s.clone()), (Some(true), None) => Some("{directory}".to_owned()), (None, None) => None, }; let mut external_execution = ExternalExecution::new( s, ExternalExecutionMode::from_conf(vc.from_shell, vc.leave_broot), ) .with_working_dir(working_dir); if let Some(b) = vc.switch_terminal { external_execution.switch_terminal = b; } external_execution }; let mut execution = match (execution, internal, external, cmd) { // old definition with "execution": we guess whether it's an internal or // an external (Some(ep), None, None, None) => { if let Some(internal_pattern) = ep.to_internal_pattern() { if let Some(previous_verb) = self.verbs.iter().find(|&v| v.has_name(&internal_pattern)) { previous_verb.execution.clone() } else { VerbExecution::Internal(InternalExecution::try_from(&internal_pattern)?) } } else { VerbExecution::External(make_external_execution(ep.clone())) } } // "internal": the leading `:` or ` ` is optional (None, Some(s), None, None) => { VerbExecution::Internal(if s.starts_with(':') || s.starts_with(' ') { InternalExecution::try_from(&s[1..])? } else { InternalExecution::try_from(s)? }) } // "external": it can be about any form (None, None, Some(ep), None) => { VerbExecution::External(make_external_execution(ep.clone())) } // "cmd": it's a sequence (None, None, None, Some(s)) => VerbExecution::Sequence(SequenceExecution { sequence: Sequence::new(s, cmd_separator), }), _ => { // there's no execution, this 'verbconf' is supposed to be dedicated to // unbind keys for key in checked_keys { self.unbound_keys.push(key); } return Ok(()); } }; if let Some(refresh_after) = vc.refresh_after { if let VerbExecution::External(external_execution) = &mut execution { external_execution.refresh_after = refresh_after; } else { warn!("refresh_after is only relevant for external commands"); } } let description = vc .description .clone() .map(VerbDescription::from_text) .unwrap_or_else(|| VerbDescription::from_code(execution.to_string())); let verb = self.add_verb(invocation.as_deref(), execution, description)?; for extension in &vc.extensions { verb.file_extensions.push(extension.clone()); } if !checked_keys.is_empty() { verb.add_keys(checked_keys); } if let Some(shortcut) = &vc.shortcut { verb.names.push(shortcut.clone()); } if vc.auto_exec == Some(false) { verb.auto_exec = false; } if !vc.panels.is_empty() { verb.panels.clone_from(&vc.panels); } verb.impacted_panel = vc.impacted_panel; verb.selection_condition = vc.apply_to; Ok(()) } pub fn unbind_key( &mut self, key: KeyCombination, ) -> Result<(), ConfError> { debug!("unbinding key {:?}", key); for verb in &mut self.verbs { verb.keys.retain(|&k| k != key); } Ok(()) } pub fn unbind_name( &mut self, name: &str, ) -> Result<(), ConfError> { for verb in &mut self.verbs { verb.names.retain(|n| n != name); } Ok(()) } pub fn search_sel_info<'v>( &'v self, prefix: &str, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> PrefixSearchResult<'v, &'v Verb> { self.search(prefix, Some(sel_info), true, panel_state_type) } pub fn search_prefix<'v>( &'v self, prefix: &str, panel_state_type: Option, ) -> PrefixSearchResult<'v, &'v Verb> { self.search(prefix, None, true, panel_state_type) } /// Return either the only match, or None if there's not /// exactly one match pub fn search_sel_info_unique<'v>( &'v self, prefix: &str, sel_info: SelInfo<'_>, panel_state_type: Option, ) -> Option<&'v Verb> { match self.search_sel_info(prefix, sel_info, panel_state_type) { PrefixSearchResult::Match(_, verb) => Some(verb), _ => None, } } pub fn search<'v>( &'v self, prefix: &str, sel_info: Option, short_circuit: bool, panel_state_type: Option, ) -> PrefixSearchResult<'v, &'v Verb> { let mut found_index = 0; let mut nb_found = 0; let mut completions: Vec<&str> = Vec::new(); let extension = sel_info.as_ref().and_then(|si| si.extension()); let sel_count = sel_info.map(|si| si.count_paths()); for (index, verb) in self.verbs.iter().enumerate() { if let Some(sel_info) = sel_info { if !sel_info.is_accepted_by(verb.selection_condition) { continue; } } if let Some(panel_state_type) = panel_state_type { if !verb.can_be_called_in_panel(panel_state_type) { continue; } } if let Some(count) = sel_count { if count > 1 && verb.is_sequence() { continue; } if count == 0 && verb.needs_selection { continue; } } if !verb.accepts_extension(extension) { continue; } for name in &verb.names { if name.starts_with(prefix) { if short_circuit && name == prefix { return PrefixSearchResult::Match(name, verb); } found_index = index; nb_found += 1; completions.push(name); } } } match nb_found { 0 => PrefixSearchResult::NoMatch, 1 => PrefixSearchResult::Match(completions[0], &self.verbs[found_index]), _ => PrefixSearchResult::Matches(completions), } } pub fn key_desc_of_internal_stype( &self, internal: Internal, stype: SelectionType, ) -> Option { for verb in &self.verbs { if verb.get_internal() == Some(internal) && verb.selection_condition.accepts_selection_type(stype) { return verb.keys.first().map(|&k| KEY_FORMAT.to_string(k)); } } None } pub fn key_desc_of_internal( &self, internal: Internal, ) -> Option { for verb in &self.verbs { if verb.get_internal() == Some(internal) { return verb.keys.first().map(|&k| KEY_FORMAT.to_string(k)); } } None } pub fn verbs(&self) -> &[Verb] { &self.verbs } pub fn verb( &self, id: VerbId, ) -> &Verb { &self.verbs[id] } } #[test] fn check_builtin_verbs() { let mut conf = Conf::default(); let _store = VerbStore::new(&mut conf).unwrap(); } ================================================ FILE: src/verb/write.rs ================================================ use { crate::{ app::*, errors::ProgramError, }, std::{ fs::{ File, OpenOptions, }, io::Write, }, }; /// Intended to verbs, this function writes the passed string to the file /// provided to broot with `--verb-output`, creating a new line if the /// file is not empty. pub fn verb_write( con: &AppContext, content: &str, ) -> Result { let Some(path) = &con.launch_args.verb_output else { return Ok(CmdResult::error("No --verb-output provided".to_string())); }; let mut file = OpenOptions::new().create(true).append(true).open(path)?; let needs_new_line = file.metadata().map(|m| m.len() > 0).unwrap_or(false); if needs_new_line { writeln!(file)?; } write!(file, "{}", content)?; Ok(CmdResult::Keep) } /// Remove the content of the file provided to broot with `--verb-output`. pub fn verb_clear_output(con: &AppContext) -> Result { let Some(path) = &con.launch_args.verb_output else { return Ok(CmdResult::error("No --verb-output provided".to_string())); }; File::create(path)?; Ok(CmdResult::Keep) } ================================================ FILE: src/watcher.rs ================================================ use { crate::{ command::Sequence, errors::ProgramError, }, notify::{ RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher, event::{ AccessKind, AccessMode, DataChange, EventKind, ModifyKind, }, }, std::{ path::PathBuf, thread, }, termimad::crossbeam::channel, }; const DEBOUNCE_MAX_DELAY: std::time::Duration = std::time::Duration::from_millis(500); /// Watch for notify events on a path, and send a :refresh sequence when a change is detected /// /// inotify events are debounced: /// - an isolated event sends a refresh immediately /// - successive events after the first one will have to wait a little /// - there's at most one refrest sent every DEBOUNCE_MAX_DELAY /// - if there's a long sequence of events, it's guaranteed that there's one /// refresh sent every DEBOUNCE_MAX_DELAY /// - the last event of the sequence is always sent (with a delay of /// at most DEBOUNCE_MAX_DELAY), ensuring we don't miss any change pub struct Watcher { notify_sender: channel::Sender<()>, notify_watcher: Option, watched: Vec, } impl Watcher { pub fn new(tx_seqs: channel::Sender) -> Self { let (notify_sender, notify_receiver) = channel::unbounded(); thread::spawn(move || { let mut period_events = 0; loop { match notify_receiver.recv_timeout(DEBOUNCE_MAX_DELAY) { Ok(()) => { period_events += 1; if period_events > 1 { continue; } debug!("sending single event"); Self::send_refresh(&tx_seqs); } Err(channel::RecvTimeoutError::Timeout) => { if period_events <= 1 { continue; } debug!("sending aggregation of {} pending events", period_events - 1); Self::send_refresh(&tx_seqs); period_events = 0; } Err(channel::RecvTimeoutError::Disconnected) => { info!("notify sender disconnected, stopping notify watcher thread"); break; } } } }); Self { notify_sender, notify_watcher: None, watched: Default::default(), } } fn send_refresh( tx_seqs: &channel::Sender, ) { if !tx_seqs.is_empty() { // let's avoid accumulating refreshes when the tree is long to update debug!("skipping refresh, channel full"); return; } let sequence = Sequence::new_single(":refresh"); if let Err(e) = tx_seqs.send(sequence) { warn!("error when sending sequence from watcher: {}", e); } } /// stop watching the previous path, watch new one. /// /// In case of error, we try to stop watching the previous path anyway. pub fn watch( &mut self, paths: Vec, ) -> Result<(), ProgramError> { debug!("start watching new paths"); let notify_watcher = match self.notify_watcher.as_mut() { Some(nw) => { for path in self.watched.drain(..) { debug!("stop watching previous path {:?}", path); if let Err(e) = nw.unwatch(&path) { warn!("error when unwatching path {:?}: {}", path, e); } } nw } None => self .notify_watcher .insert(Self::make_notify_watcher(self.notify_sender.clone())?), }; let mut err = None; for path in &paths { if !path.exists() { warn!("watch path doesn't exist: {:?}", path); return Ok(()); } debug!("add watch {:?}", &path); if let Err(e) = notify_watcher.watch(path, RecursiveMode::NonRecursive) { warn!("error when watching path {:?}: {}", path, e); err = Some(e); break; } } if let Some(err) = err { // the RecommendedWatcher sometimes ends in an unconsistent state when failing // to watch a path, so we drop it self.notify_watcher = None; Err(err.into()) } else { self.watched = paths; Ok(()) } } fn make_notify_watcher(sender: channel::Sender<()>) -> Result { let mut notify_watcher = notify::recommended_watcher(move |res: notify::Result| match res { Ok(we) => { // Warning: don't log we, or a Modify::Any event, as this could cause infinite // loop while the logger writes to the file being watched (if the log file is // inside the watched directory) match we.kind { EventKind::Modify(ModifyKind::Metadata(_)) => { debug!("ignoring metadata change"); return; // useless event } EventKind::Modify(ModifyKind::Data(DataChange::Any)) => { // might be data append, we prefer to ignore it // as some cases (eg log files) are very noisy return; } EventKind::Access(AccessKind::Close(AccessMode::Write)) => { debug!("close write event: {we:?}"); } EventKind::Access(_) => { // we don't want to watch for reads return; } _ => { debug!("notify event: {we:?}"); } } if let Err(e) = sender.send(()) { info!("error when notifying on notify event: {}", e); } } Err(e) => warn!("watch error: {:?}", e), })?; notify_watcher.configure( notify::Config::default() .with_compare_contents(false) .with_follow_symlinks(false), )?; Ok(notify_watcher) } pub fn stop_watching(&mut self) -> Result<(), ProgramError> { for path in self.watched.drain(..) { if let Some(nw) = self.notify_watcher.as_mut() { debug!("stop watching previous path {:?}", path); if let Err(e) = nw.unwatch(&path) { warn!("error when unwatching path {:?}: {}", path, e); } } } Ok(()) } } ================================================ FILE: target.sh ================================================ # extract the target from the output of target=$(rustc -vV | sed 's/^host: \(.*\)/\1/ t d' | head -1) echo "$target" ================================================ FILE: tests/search_strings.rs ================================================ //! This checks some edge cases of pattern searches, especially composite patterns. //! Don't hesitate to suggest more tests for clarification or to prevent regressions. use { broot::{ command::CommandParts, pattern::*, }, }; fn build_pattern(s: &str) -> Pattern { let cp = CommandParts::from(s); let search_modes = SearchModeMap::default(); cp.pattern.print_tree(); Pattern::new( &cp.pattern, &search_modes, 0, // we don't do content search here ).unwrap() } fn check( pattern: &str, haystack: &str, expected: bool, ) { println!("applying pattern {:?} on {:?}", pattern, haystack); let pattern = build_pattern(pattern); //dbg!(&pattern); let found = pattern.search_string(haystack).is_some(); assert_eq!(found, expected); } #[test] fn simple_fuzzy() { check("toto", "toto", true); check("toto", "Toto", true); check("toto", "ToTuTo", true); check("tota", "ToTuTo", false); } #[test] fn simple_exact() { check("e/toto", "toto", true); check("e/toto", "Toto", false); check("e/toto", "Tototo", true); } #[test] fn simple_regex() { check("/toto", "toto", true); check("/to{3,5}to", "toto", false); check("/to{3,5}to", "tooooto", true); check("/to{3,5}to", "toooooooto", false); check("/to{3,5}to", "tooOoto", false); check("/to{3,5}to/i", "tooOoto", true); } #[test] fn one_operator() { check("a&b", "a", false); check("a&b", "ab", true); check("a|b", "ab", true); check("a|b", "b", true); check("a|b", "c", false); } #[test] fn negation() { check("!ab", "a", true); check("!ab", "aB", false); check("a&!b", "aB", false); check("a&!b", "aA", true); check("!a&!b", "ccc", true); check("!a|!b", "ccc", true); check("!a|!b", "cac", true); check("!a|!b", "cbc", true); check("!a|!b", "cbac", false); } // remember: it's left to right #[test] fn multiple_operators_no_parenthesis() { check("ab|ac|ad", "ab", true); check("ab|ac|ad", "ac", true); check("ab|ac|ad", "ad", true); check("ab|ac|ad|af|ag|er", "ad", true); check("ab&ac&ad", "ad", false); check("ab&ac&ad", "abcd", true); check("ab|ac|ad|ae", "ad", true); check("ab|ac|ad&ae", "ad", false); check("ab|ac|ad&ae", "axcd", false); check("ab|ac|ad&ae", "abe", true); check("ab|ac&ad|ae", "abd", true); check("ab|ac&ad|ae", "abc", false); } #[test] fn multiple_operators_with_parenthesis() { check("ab|(ac|ad)", "ab", true); check("(ab|ac)|ad", "ac", true); check("ab|(ac|ad)|ae", "ad", true); check("ab|ac|(ad&ae)", "ac", true); check("ab|ac|(ad&ae)", "ad", false); check("(ab|ac)&(ad|ae)", "ad", false); check("!(ab|ac)&(ad|ae)", "ad", true); check("ab|(ac&ad|ae)", "abc", true); check("(ab|ac)&(ad|ae)", "abd", true); } ================================================ FILE: version.sh ================================================ # extract the version from the main Cargo.toml file version=$(sed 's/^version = "\([^\"]*\)"/\1/ t d' Cargo.toml | head -1) echo "$version" ================================================ FILE: website/.gitignore ================================================ /site ================================================ FILE: website/README.md ================================================ Broot's website is live at https://dystroy.org/broot It's built using [ddoc](https://www.dystroy.org/ddoc) To test it locally, cd to the website directory then ddoc -- serve To build it, do ddoc ================================================ FILE: website/ddoc.hjson ================================================ # This is a configuration file for the ddoc static site generator. # For details and instruction, see https://dystroy.org/ddoc/ title: "broot" description: "Broot, a tree oriented file manager" favicon: "img/favicon.ico" ddoc-vesion: "0.16" // minimal version of ddoc site-map: { "Why": index.md "Install": { "Install broot": install.md "Install br": install-br.md "Common Problems": common-problems.md } "Usage": { "Launch": launch.md "Search and Navigate": navigation.md "Tree View": tree_view.md "Help screen": help.md "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 "Client - Server": remote.md "Reference - The input": input.md } "Conf": { "Config files": conf_file.md "Verbs and shortcuts": conf_verbs.md "Skins": skins.md "Icons": icons.md "Modal Mode": modal.md } "Community": community.md } body: { header: { nav.before-menu: { ddoc-link.external-nav-link: { img: img/dystroy-rust-white.svg href: https://dystroy.org alt: dystroy.org homepage } ddoc-link.broot-nav-logo: { img: img/vache-blanche.svg label: Broot href: /index.md alt: broot homepage } } ddoc-menu: { hamburger-checkbox: true } nav.after-menu: { ddoc-link.search-opener: { img: img/ddoc-search.svg href: --search alt: Search } ddoc-link.external-nav-link: { img: img/github-mark-white.svg alt: GitHub href: https://github.com/Canop/broot } } } article: { aside.page-nav: { ddoc-toc: { activate-visible-item: true } } div.main-content: { ddoc-main: {} nav.prev-next: { ddoc-link.previous-page-link: { inline: img/ddoc-left-arrow.svg href: --previous alt: Previous Page label: --previous-page-title } ddoc-link.next-page-link: { label: --next-page-title inline: img/ddoc-right-arrow.svg href: --next alt: Next Page } } } } } ================================================ FILE: website/src/README.md ================================================ This directory is the source of the web site which is at https://dystroy.org/broot ================================================ FILE: website/src/common-problems.md ================================================ This page lists a few problems which were observed more than once and the best current answer. Please come to [miaou](https://miaou.dystroy.org/3490?broot) if something isn't clear or if you want to propose a change or addition. # Compilation failure The common causes are * an outdated Rust installation. If you're using [rustup](https://rustup.rs), do `rustup update`. * some tools missing. On Debian like distributions, this can generally be solved with `sudo apt install build-essential libxcb-shape0-dev and libxcb-xfixes0-dev` # Colors Broot's initial colors ensure that everything is readable whatever your settings. But you may want to have something more similar to your usual terminal colors, or maybe to define the whole skin. * [changing the skin](../skins/) * [set a transparent background](../skins/#transparent-background) * [set file extension dependent colors](../conf_file/#colors-by-file-extension) # Weird chars in input Some users [reported](https://github.com/Canop/broot/issues/1031) the input being populated with ANSI escape sequences at launch. This may be due to the terminal being too slow to answer to ANSI queries. In such case, you may simplify importing in your conf.hjson to skip color testing. For example replace the whole `imports` with ```TOML imports: [ verbs.hjson skins/dark-gruvbox.hjson ] ``` # Tmux The first problem you might see is the presence of artifacts. This may happen in other terminal multiplexers too and it seems related to their bad proxying of some style related codes. * [relevant issue](https://github.com/Canop/broot/issues/248) A workaround is to create a skin (for example by uncommenting the one in `conf.toml`) and to remove all `Italic` and `Bold`. Additionally, if backgrounds can't be properly displayed, you may consider [marking selected lines](../conf_file/#selection-mark). Another problem is the fact the `br` function doesn't set a proper pane name (you'll probably see the name of your shell instead of broot). This may be [solved with a modified shell function](https://github.com/Canop/broot/issues/270). # Key combination problem Most terminals intercept a few keyboard shortcut for their own features. You may need to remap your terminal's default keyboard shortcuts. I've made a small program which tells you what key combinations are available: [print_key](https://github.com/Canop/print_key). **Windows Terminal** [Windows Terminal](https://docs.microsoft.com/en-us/windows/terminal/) binds `alt+enter` to the "toggle fullscreen" command by default. To reclaim `alt+enter` for Broot, [add an 'unbound' entry to the actions array in settings.json](https://docs.microsoft.com/en-us/windows/terminal/customize-settings/actions#unbind-keys): ```json {"command": "unbound", "keys": "alt+enter"} ``` **iTerm2** For Mac users, iTerm2 must also be configured to enable this shortcut: Go to *Preferences->Profiles->Default->Keys* and add a mapping that maps `⌥Return↩` to `Send Hex Codes: 0x1b 0x0d`. This can be done by clicking the + sign at the bottom to add a mapping, clicking the "Click to Set" area, pressing the desired key combination (⌥Enter a.k.a ⌥Return), choosing the "Send Hex Code" option from the drop-down menu and inserting the following string there: "0x1b 0x0d". Note that this will change the behavior of `alt+enter` for all terminal windows, and it will no longer send the `return` sequence. * [relevant issue](https://github.com/Canop/broot/issues/682) **Remap in Broot** If a shortcut isn't available for broot and you can't or don't want to remap the one of your terminal, the solution is to change the shortcut in broot. * [specific solution for alt-enter](https://github.com/Canop/broot/issues/86#issuecomment-635974557) * [general shortcut configuration](../conf_verbs/#keyboard-key) # Slow remote disk Broot dives into all visible directories to look for the best matches. This can be a problem if you mount a remote disk. The solution is to tell broot not to automatically enter the directory. It will still be entered if you focus it yourself. * [define a special-path in configuration](../conf_file/#special-paths) * [relevant issue](https://github.com/Canop/broot/issues/251) # No xdg-open If your system doesn't have a standard file opener, you can rebind enter to the program of your choice. * [change standard file opening](../tricks/#change-standard-file-opening) # Everything feels slow It's probably your terminal app's fault. You could check that by using any other TUI application. Most terminal apps are fine but some, made with Electron or worse, or crippled with fancy plugins, take dozens of milliseconds to redraw the screen. You should not use those terminals. # msysgit or git bash I have no solution for that. If you know how to tackle the problem, the maintainers of [Crossterm](https://github.com/crossterm-rs/crossterm) would be interested too. # Windows Broot isn't as fast or feature complete on Windows. I'm not a Windows programmer and I don't even have a machine to test. I'd welcome the help of a programmer with the relevant competences and the will to improve broot. # Windows 9- Even Microsoft doesn't support versions of Windows before the 10. If you have a cheap solution it's welcome but I don't have any. # PowerShell Encoding Some problems in PowerShell are linked to a wrong encoding. You should set it to UTF-8 in your [profile](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles?view=powershell-7.1). This will create a profile if it doesn't exist: ```powershell if (!(Test-Path -Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force } ``` `notepad $PROFILE` will open the profile config. Add this line: ``` [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 ``` Run the following with admin privilege ([source](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy?view=powershell-7.1)): ```powershell Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser Unblock-File -Path $PROFILE ``` This will ensure your profile is loaded in new terminals/sessions. You can check it by opening a new terminal then running `[Console]::Out`. The output should show an encoding of `System.Text.UTF8Encoding`. # Hi-Res images in Kitty When using Kitty (and no terminal multiplexer), image preview is normally in high resolution. If it's not the case, it's probably because the `TERM` environment variable has been redefined. Set either `TERM` or `TERMINAL` to include `kitty`. This can be done several ways, for example by adding `env TERMINAL=xterm-kitty` in your [kitty.conf](https://sw.kovidgoyal.net/kitty/conf/) file # Edit The standard `edit` verb, launched with `:e`, starts your favourite terminal editor to edit the selected file. It works by executing `"$EDITOR +{line} {file}"` which assumes that the `$EDITOR` variable is defined and that your editor takes the line number as argument. If it doesn't work on your configuration, you should probably just edit this verb definition with a more suitable command, for example `"hx {file}:{line}"` or `"/usr/bin/my-editor --line {line} {file}"` ================================================ FILE: website/src/community.md ================================================ **broot** is developed by **Denys Séguret**, also known as [Canop](https://github.com/Canop) or [dystroy](https://dystroy.org). Major updates are announced on Mastodon : [@dystroy@mastodon.dystroy.org](https://mastodon.dystroy.org/@dystroy) and BlueSky: [@dystroy.bsky.social](https://bsky.app/profile/dystroy.bsky.social) # Sponsorship **broot** is free for all uses. If it helps your company make money, consider helping me find time to add features and to develop new free open-source software. # Discuss Broot in a chat room The best place to chat about broot, to talk about features or bugs, is the Miaou chat. There's a dedicated room: [![Chat on Miaou](https://miaou.dystroy.org/static/shields/room-en.svg?v=1)](https://miaou.dystroy.org/3490?broot) If you're French speaking, you might prefer to directly come where other French speaking programmers hang: [![Chat on Miaou](https://miaou.dystroy.org/static/shields/room-fr.svg?v=1)](https://miaou.dystroy.org/3) Don't hesitate to come if you have a question. You're also welcome to give any feedback. I like to read about how people use Broot. # Issues We use [GitHub's issue manager](https://github.com/Canop/broot/issues). Before posting a new issue, check your problem hasn't already been raised and in case of doubt **please come first discuss it on the chat**. # Your wishes [Issues](https://github.com/Canop/broot/issues) is also where I test new ideas. If you're interested in the directions broot takes, **please come and vote on issues**, or maybe comment. This would help me prioritize developments: if nobody's interested in a feature I'm not sure I want, I'll do something else. # Log When something looks like a bug, especially keyboard problems, I need both to know the exact configuration (OS, terminal program, mainly) and to have the log. The log can be obtained this way: 1. start broot with `BROOT_LOG=debug br` 2. do the action which seems not to properly work, and nothing else 3. quit broot 4. go to the chat (or the GitHub issue if you already made one) and paste the content of the `broot.log` file # Benchmark To get a precise idea of the time taken by operations in real broot use, it's often a good idea to run them with `--cmd`. For example full text search performances can be measured (and compared to other tools) with ``` time broot -c "c/memmap;:pt" ~/code ``` # Contribute **Broot** is written in [Rust](https://www.rust-lang.org/). The current focus is linux+mac but I try to support Windows too (use a modern terminal like the [new MS one](https://github.com/microsoft/terminal)). If you think you might help, as a tester or coder, you're welcome, but please read [Contributing to my FOSS projects](https://dystroy.org/blog/contributing/). **Don't open a PR without discussing the design before**, either in the chat or in an issue, unless you're just fixing a typo. Coding is the easy part. Determining the exact requirement and how we want it to be done is the hard part. This is especially important if you plan to add a dependency or to change the visible parts, eg the launch arguments. # This documentation... ... needs your help too. Tell me what seems to be unclear or missing, what tricks should be added. Or for simple corrections, head to [the source](https://github.com/Canop/broot/tree/main/website) ================================================ FILE: website/src/conf_file.md ================================================ # Hjson or TOML Two formats are supported: [TOML](https://github.com/toml-lang/toml) and [Hjson](https://hjson.github.io/). This documentation will often show you the same setting in both formats, with two tabs, like this: ```Hjson // setting to use if your config file is in .hjson ``` ```TOML # setting to use if your config file is in .toml ``` # Reset If you want to get back to the default set of configuration files, delete or rename the `broot` directory in your standard configuration place (e.g. `~/.config/broot`). On next launch, broot will recreate all the necessary files. # Opening the config files The main configuration file is called either `conf.toml` or `conf.hjson`. This default file's location follows the XDG convention, which depends on your system settings. This location in your case can be found on the help screen (use ?). The default configuration file contains several example sections that you may uncomment and modify for your goals. It typically imports other files in the same directory. # Imports A configuration file can import some other files. This eases management, as you may for example define your skin in a file, or the list of verbs in another one. An import can have as condition whether the terminal is in dark or light mode, so that broot can take the most suitable skin on launch. All imports are defined in an `imports` array. For example: ```Hjson imports: [ verbs.toml { luma: light file: white-skin.hjson } { luma: [ dark unknown ] file: dark-blue-skin.hjson } ] ``` This example defines 3 imports. The first one has the simplest form: just a (relative or absolute) path. This import isn't conditional. The second import is done only if the terminal's *luma* is determined to be light. And the third one is done when the terminal's luma is either dark or couldn't be determined. Starting from version 1.14, the default configuration is released in several files. **Note:** Be careful when installing a configuration file from an unknown source: it may contain an arbitrary command to execute. Check it before importing it **Note:** On non linux systems, Background color determination is currently disabled (always "unknown"). This is expected to be fixed. # Default flags Broot accepts a few flags at launch (the complete list is available with `broot --help`). The `default_flags` entry lets you specify them in configuration, with the same syntax. For example, if you want to see hidden files (the ones whose name starts with a dot) and the status of files related to git, you launch broot with br -gh If you almost always want those flags, you may define them as default in the configuration file file, with the `default_flags` setting. ```Hjson default_flags: -gh ``` ```TOML default_flags = "-gh" ``` Those flags can still be overridden at launch with the negating ones. For example, with the above `default_flags`, if you don't want to see hidden files on a specific launch, do br -H # Special Paths You may map special paths to specific behaviors. You may for example - have some link to a directory to always automatically be handled as a normal directory - exclude some path because it's on a slow device or non relevant - have a file be visible even while it's normally ignored A special paths entry is defined by 3 parameters: `show`, `list`, and `sum`. Each of them can be `default`, `always`, or `never`. Example configuration: ```Hjson special_paths: { "/media" : { list: "never" sum: "never" } "~/.config": { "show": "always" } "trav": { show: always list: "always", sum: "never" } "~/useless": { "show": "never" } "~/my-link-I-want-to-explore": { "list": "always" } } ``` ```TOML [special-paths] "/media" = { list = "never", sum = "never" } "~/.config" = { show = "always" } "trav" = { show = "always", list = "always", sum = "never" } "~/useless" = { show = "never" } "~/my-link-I-want-to-explore" = { list = "always" } ``` Be careful that those paths (globs, in fact) are checked a lot when broot builds trees and that defining a lot of paths will impact the overall speed. When the goal is to have a gitignored file visible, it's more convenient and efficient to use a `.ignore` file. # Search Modes It's possible to redefine the mode mappings, for example if you usually prefer to do exact searches: ```Hjson "search-modes": { : regex name /: fuzzy path z/: regex path } ``` ```TOML [search-modes] "" = "regex name" "/" = "fuzzy path" "z/" = "regex path" ``` The search mode must be made of two parts : * the search kind: Either `exact`, `fuzzy`, `regex`, or `tokens` * the search object: Either `name`, `path`, or `content` # Selection Mark When the background colors aren't rendered in your terminal, aren't visible enough, or just aren't clear enough for you, you may have the selected lines marked with triangles with ```Hjson show_selection_mark: true ``` ```TOML show_selection_mark = true ``` # Columns order You may change the order of file attributes in file lists: * mark: a small triangle flagging the selected line * git : Git file info * branch : shows the depth and parent in the tree * permission : mode, user, group * date : last modification date * size : ISO size (and size bar when sorting) * count : number of files in directories * name : file name For example, if you prefer to have the branches left of the tree (as was the default in broot prior 0.18.1) you can use ```Hjson cols_order: [ mark git branch permission date size count name ] ``` ```TOML cols_order = [ "mark", "git", "branch", "permission", "date", "size", "count", "name", ] ``` The name should be kept at end as it's the only one with a variable size. # Colors by file extension broot doesn't support `LS_COLORS` which isn't available on all systems and is limited to 16 system dependent colors. But you can still give a color to files by extension: ```Hjson ext_colors: { png: "rgb(255, 128, 75)" rs: "yellow" toml: "ansi(105)" } ``` ```TOML [ext-colors] png = "rgb(255, 128, 75)" rs = "yellow" toml = "ansi(105)" ``` (see [here](../skins#color) for precision about the color syntax in broot) # Syntax Theme You can choose any of the following syntaxic coloring themes for previewed files: * GitHub * SolarizedDark * SolarizedLight * EightiesDark * MochaDark * OceanDark * OceanLight ```Hjson syntax_theme: OceanLight ``` ```TOML syntax_theme = "OceanLight" ``` Those themes come from [syntect](https://github.com/trishume/syntect) and are bundled in broot. # Terminal title If you set the `terminal_title` parameter, broot will change the title of the terminal when the current tree root changes. The pattern allows using the same arguments than verb execution patterns, eg {file} or {git-name}. ```Hjson terminal_title: "{file} 🐄" ``` ```TOML terminal_title = "{file} 🐄" ``` # Preview ## Kitty Graphics Whenever possible, previewed images will be displayed in high resolution using [Kitty's graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). Two transmission media are possible: * `chunks`: slow but works remotely * `temp_file`: faster but doesn't work when broot is remotely executed, default ```Hjson kitty_graphics_transmission: chunks #kitty_graphics_transmission: temp_file ``` ```TOML kitty_graphics_transmission = "chunks" #kitty_graphics_transmission = "temp_file" ``` Possible display methods: * `none`: don't display images * `auto`: automatically detect how to display the image, default * `direct`: display the image directly * `unicode`: the more flexible way, works with tmux ```Hjson kitty_graphics_display: auto ``` ```TOML kitty_graphics_display = "auto" ``` ## Transformers It's possible to define transformers to apply to some files before preview. This makes it possible for example to render a specific kind of files as images, or to beautify some text ones. Below are examples that you may adapt to your needs and preferred tools. They must be included in a `preview_transformers` array, as shown in the [default conf.hjson](https://github.com/Canop/broot/blob/main/resources/default-conf/conf.hjson). ### Render PDF using mutool: ![preview pdf](img/20240706-preview-pdf.png) ```Hjson # Use mutool to render any PDF file as an image # In this example we use placeholders for the input and output files { input_extensions: [ "pdf" ] // case doesn't matter output_extension: png mode: image command: [ "mutool", "draw", "-w", "1000", "-o", "{output-path}", "{input-path}" ] } ``` ### Render Office files using libreoffice: ![preview odt](img/20240715-preview-odt.png) ```Hjson { input_extensions: [ "xls", "xlsx", "doc", "docx", "ppt", "pptx", "ods", "odt", "odp" ] output_extension: png mode: image command: [ "libreoffice", "--headless", "--convert-to", "png", "--outdir", "{output-dir}", "{input-path}" ] } ``` ### Beautify JSON using jq ![preview json](img/20240706-preview-json.png) ```Hjson # Use jq to beautify JSON # In this example, the command refers to neither the input nor the output, # so broot pipes them to the stdin and stdout of the jq process { input_extensions: [ "json" ] output_extension: json mode: text command: [ "jq" ] } ``` ## Match surroundings You may limit the filtered view to just the matching lines: ![0 line around match](img/20240706-match-surrounding-0-0.png) But if you want to have more than just the matching lines displayed in preview, you may set those parameters to ```Hjson lines_before_match_in_preview: 1 lines_after_match_in_preview: 1 ``` ```TOML lines_before_match_in_preview = 1 lines_after_match_in_preview = 1 ``` And you'll get ![1 line around match](img/20240706-match-surrounding-1-1.png) # Miscellaneous ## Keyboard enhancements By default, ANSI terminals make a lot of keyboard combinations impossible, for example spacen, or altab, or shiftspace, etc. Some terminals implement [Kitty's keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) and basically make it possible to bind verbs to such combinations. Broot tests whether the terminal supports those enhancements, and if it's the case, tries to enable them. A small downside is that broot will react on key release instead of key press (so that you may press other keys before you release the first one), which may feel less instant. If the Kitty protocol isn't supported by your terminal, broot will react on key press. Another problem is that it may push you towards key combinations that you wouldn't be able to reuse when switching terminal. And finally, some terminals have buggy implementations (at time of writing). To enable those keyboard enhancements change this setting to true ```Hjson enable_kitty_keyboard: true ``` ```TOML enable_kitty_keyboard = true ``` ## Staging area `auto_open_staging_area` defines whether to automatically open the staging area when staging a file (default true). ```Hjson auto_open_staging_area: false ``` ```TOML auto_open_staging_area = false ``` `max_staged_count` is the maximal number of files added by a `:stage_all_files` command ```Hjson max_staged_count: 1234 ``` ```TOML max_staged_count = 1234 ``` ## Mouse Capture Broot usually captures the mouse so that you can click or double click on items. If you want to disable this capture, you may add this: ```Hjson capture_mouse: false ``` ```TOML capture_mouse = false ``` ## File Sum Threads Count This is the number of threads used for directory size computation. Most users should not change this. In my measurements a number of 4 to 6 looks optimal. ```Hjson file_sum_threads_count: 10, ``` ```TOML file_sum_threads_count = 10 ``` ## Quit on last cancel You can usually cancel the last state change on escape. If you want the escape key to quit broot when there's nothing to cancel (for example when you just opened broot), you can set `quit_on_last_cancel` to true: ```Hjson quit_on_last_cancel: true ``` ```TOML quit_on_last_cancel = true ``` ## Update broot's work dir By default, the work dir of the broot process is synchronized with the root of the current panel. If you prefer to keep the work dir unchanged, disable this feature with ```Hjson update_work_dir: false ``` ```TOML update_work_dir = false ``` ## Pattern match display When your search pattern is applied to a path, the path is shown on each line so that you see why the line matches: ![shown](img/subpath-match-shown.png) If you don't really need to see matching characters, you may get a cleaner display with just file names with this option: ```Hjson show_matching_characters_on_path_searches: false ``` ```TOML show_matching_characters_on_path_searches = false ``` which gives this: ![not shown](img/subpath-match-not-shown.png) ================================================ FILE: website/src/conf_verbs.md ================================================ The most important part of broot configuration is the `verbs` sections, which let you define new commands or shortcuts. The [default file](https://github.com/Canop/broot/blob/main/resources/default-conf/verbs.hjson) contains several commented examples which should help understand the concepts before you dive in the reference. # Verb definition attributes You can define a new verb in the configuration file inside the `verbs` list. ```Hjson { invocation: edit key: F2 shortcut: e apply_to: file external: "nvim {file}" leave_broot: false } ``` ```TOML [[verbs]] invocation = "edit" key = "F2" shortcut = "e" apply_to = "file" external = "nvim {file}" leave_broot = false ``` The possible attributes are: name | default | role -|-|- apply_to | | the type of selection this verb applies to: `"file"`, `"text_file"`, `"binary_file"`, `"directory"` or `"any"`. You may declare two verbs with the same key if the first one applies, eg, to only text files or only to directories auto_exec | `true` | whether to execute the verb as soon as it's key-triggered (instead of waiting for enter) cmd | | a semicolon sequence to execute, similar to an argument you pass to `--cmd` extensions | | optional array of allowed file extensions external | | execution, when your verb is based on an external command from_shell | `false` | whether the verb must be executed from the parent shell (needs `br`). As this is executed after broot closed, this isn't compatible with `leave_broot = false` impacted_panel | `active` | panel on which to execute the action, can be `left`, `right`, `preview` internal | | execution, when your verb is based on a predefined broot verb invocation | | how the verb is called by the user, with placeholders for arguments key | | a keyboard key triggering execution keys | | several keyboard shortcuts triggering execution (if you want to have the choice) leave_broot | `true` | whether to quit broot on execution panels | *all* | optional list of panel types in which the verb can be called. Default is all panels: `[tree, fs, preview, help, stage]` set_working_dir | `false` | whether the working dir of the process must be set to the currently selected directory (it's equivalent to `workding_dir: "{directory}"`) shortcut | | an alternate way to call the verb (without the arguments part) switch_terminal | `true` | whether to switch from alternate to normal terminal during execution working_dir | | the working directory of the external application, for example `"{directory}"` for the closest directory (the working dir isn't set if the directory doesn't exist) The execution is defined either by `internal`, `external` or `cmd` so a verb must have exactly one of those (for compatibility with older versions broot still accepts `execution` for `internal` or `external` and guesses which one it is). **Note:** The `from_shell` attribute exists because some actions can't possibly be useful from a subshell. For example, `cd` is a shell builtin which must be executed in the parent shell. ## Verbs not leaving broot If you set `leave_broot = false`, broot won't quit when executing your command, but it will update the tree. This is useful for commands modifying the tree (like creating or moving files), or when you want to be back to broot after execution. # Call shell scripts With an external, you call an executable. If you want to run a script, you must call an executable able to run it. For example, if you have this shell script: ```bash #!/bin/bash echo "Hello, I got argument $1" sleep 5 ``` You can call it with this verb definition: ```hjson { invocation: hello external: ["sh" "-e" "/path/to/hello.sh" "{file}"] leave_broot: false } ``` ```toml [[verbs]] invocation = "hello" external = ["sh", "-e", "/path/to/hello.sh", "{file}"] leave_broot = false ``` # Using quotes If you want broot, for example, to execute `xterm -e "nvim {file}"`, you may either escape the quotes as `\"` or use the array format to separate parts. So the two following verb definitions are equivalent. With escaping: ```hjson { invocation: xtv external: "xterm -e \"nvim {file}\"" } ``` ```toml [[verbs]] invocation = "xtv" external = "xterm -e \"nvim {file}\"" ``` With an array: ```hjson { invocation: xtv external: ["xterm" "-e" "nvim {file}"] } ``` ```toml [[verbs]] invocation = "xtv" external = ["xterm", "-e", "nvim {file}"] ``` # File extensions You may filter the execution of a verb with file extensions. For example, if you want enter to work as predefined in most cases but just choose a specific action for some files, you might add this verb definition: ```hjson { name: open-code key: enter extensions: [ rs js toml ] execution: "$EDITOR +{line} {file}" working_dir: "{root}" leave_broot: false } ``` ```toml [[verbs]] name = "open-code" key = "enter" extensions = ["rs", "js", "toml"] execution = "$EDITOR +{line} {file}" working_dir = "{root}" leave_broot = false ``` Verb definitions are tried in order until one has been executed, starting with user-defined verbs, then built-in verbs. Thus you may define both verbs with extension filters and a catch-all verb. # Shortcuts and Verb search **broot** looks for the first token following a space or `:` and tries to find the verb you want. * If what you typed is exactly the shortcut or name of a verb, then this verb is selected: broot explains you what it would do if you were to type `enter` * If there's exactly one verb whose name or shortcut starts with the characters you typed, then it's selected * if there are several verbs whose name or shortcut start with the characters you typed, then broot waits for more * if no verb has a name or shortcut starting with those characters, broot tells you there's a problem Knowing this algorithm, you may understand the point in the following definition: ```toml [[verbs]] invocation = "p" internal = ":parent" ``` This verb is an alias to the internal builtin already available if you type `:parent`. Its interest is that if you do `:p`, then `enter`, it is executed even while there are other verbs whose invocation pattern starts with a `p`. Use shortcuts for verbs you frequently use. # Keyboard key The main keys you can use are * The function keys (for example F3) * Ctrl and Alt keys (for example ctrlT or alta) It's possible to define a verb just to add a trigger key to an internal verb. For example you could add those mappings: ```hjson verbs: [ { invocation: "root" key: "F9" internal: ":focus /" } { invocation: "home" key: "ctrl-H" internal: ":focus ~" } { key: "alt-j" internal: ":line_down" } { invocation: "top" key: "F6" internal: ":select_first" } { invocation: "bottom" key: F7 internal: ":select_last" } { invocation: "open" key: ctrl-O internal: ":open_stay" } { invocation: "edit" keys: [ // several possible shortcuts here F2 ctrl-e ] shortcut: "e" external: "$EDITOR +{line} {file}" from_shell: true } ] ``` ```toml [[verbs]] invocation = "root" key = "F9" internal = ":focus /" [[verbs]] invocation = "home" key = "ctrl-H" internal = ":focus ~" [[verbs]] key = "alt-j" internal = ":line_down" [[verbs]] invocation = "top" key = "F6" internal = ":select_first" [[verbs]] invocation = "bottom" key = "F7" internal = ":select_last" [[verbs]] invocation = "open" key = "ctrl-O" internal = ":open_stay" [[verbs]] invocation = "edit" key = [ "F2", "ctrl-e" ] shortcut = "e" external = "$EDITOR +{line} {file}" from_shell = true ``` Then, * when doing altJ, you would move the selection down (notice we don't need an invocation) * when doing Ctrl-H, you would go to you user home (`~` when on linux), * you would open files (without closing broot) with ctrl-O, * F7 would select the last line of the tree, * and you'd switch to your favorite editor with F2 Beware that consoles intercept some possible keys. Many keyboard shortcuts aren't available, depending on your configuration. Some keys are also reserved in broot for some uses, for example the enter key always validate an input command if there's some. The Tab, delete, backspace, esc keys are reserved too. If your chosen key doesn't seem to work, see [Key Combination Problem](../common-problems/#key-combination-problem). # Verb arguments The execution of a verb can take one or several arguments. For example it may be defined as `vi {file}̀`. ## Broot supplied arguments Some arguments are predefined in broot and depends on the current selection: name | expanded to -|- `{file}` | complete path of the current selection `{file-name}` | file name of the current selection `{file-extension}` | file extension of the current selection (example `rs` for `main.rs`) `{file-stem}` | file name of the current selection `{file-dot-extension}` | dot and extension of the current selection (example `.rs` for `main.rs`) or the empty string if there's no extension `{line}` | number of selected line in the previewed file `{parent}` | complete path of the current selection's parent `{directory}` | closest directory, either `{file}` or `{parent}` `{other-panel-file}` | complete path of the current selection in the other panel `{other-panel-parent}` | complete path of the current selection's parent in the other panel `{other-panel-directory}` | closest directory, either `{file}` or `{parent}` in the other panel `{root}` | current tree root (top of the displayed files tree) `{initial-root}` | tree root at launch `{git-root}` | The working directory of the Git repository containing the current selection `{git-name}` | Name of the working directory of the current Git repository `{file-git-relative}` | path of the current selection relative to the working directory of the containing Git repository. If the selection is not in a Git repository then the absolute path. `{server-name}` | name given with `--listen` at launch **Note:** when you're in the help screen, `{file}` is the configuration file, while `{directory}` is the configuration directory. ## Invocation pattern You may also define some arguments in the invocation pattern. For example: ```hjson { invocation: "mkdir {subpath}" external: "mkdir -p {directory}/{subpath}" } ``` ```toml [[verbs]] invocation = "mkdir {subpath}" external = "mkdir -p {directory}/{subpath}" ``` (the `mkdir` verb is standard so you don't have to write it in the configuration file) In this case the subpath is read from what you type: ![md sub](img/20190306-md.png) As you see, there's a space in this path, but it works. **broot** tries to determine when to wrap path in quotes and when to escape so that such a command correctly works. It also normalizes the paths it finds which eases the use of relative paths: ![mv](img/20190306-mv.png) Here's another example, where the invocation pattern defines two arguments by destructuring: ```hjson { invocation: "blop {name}\\.{type}" external: "mkdir {parent}/{type} && nvim {parent}/{type}/{name}.{type}" from_shell: true } ``` ```toml [[verbs]] invocation = "blop {name}\\.{type}" external = "mkdir {parent}/{type} && nvim {parent}/{type}/{name}.{type}" from_shell = true ``` And here's how it would look like: ![blop](img/20190306-blop.png) Notice the `\\.` in the invocation pattern ? That's because it is interpreted as a regular expression (with just a shortcut for the easy case, enabling `{name}`). The whole regular expression syntax may be useful for more complex rules. Let's say we don't want the type to contain dots, then we do this: ```hjson { invocation: "blop {name}\\.(?P[^.]+)" external: "mkdir {parent}/{type} && nvim {parent}/{type}/{name}.{type}" from_shell: true } ``` ```toml [[verbs]] invocation = "blop {name}\\.(?P[^.]+)" external = "mkdir {parent}/{type} && nvim {parent}/{type}/{name}.{type}" from_shell = true ``` You can override the default behavior of broot by giving your verb the same shortcut or invocation than a default one. ## Single command on stage By default, when a verb for an external command is called on several selected files (using the staging area), broot executes the external program once per selection. If you want all selections to be merged into a single command, you need to add one of the merging flags, `space-separated` or `comma-separated`, to the `external` pattern. For example, the following verb requires the user to type a "name" then zips all staged files and directories with one command looking like `zip -r some/path/new-archive.zip some/path/file-a some/other/directory`: ```hjson { invocation: "zip {name}" external: [ "zip" "-r" "{name:path-from-directory}.zip" "{file:space-separated}" ] leave_broot: false working_dir: "{root}" } ``` ```toml [[verbs]] invocation = "zip {name}" external = [ "zip", "-r", "{name:path-from-directory}.zip", "{file:space-separated}", ] leave_broot = false working_dir = "{root}" ``` ## Request user input A verb can be triggered by key but still require the user to type some argument(s), by having `auto_exec` set to `false`. Here's an example: ```hjson { name: touch key: ctrl-t invocation: "touch {new_file}" execution: "touch {directory}/{new_file}" leave_broot: false auto_exec: false } ``` ```toml [[verbs]] name = "touch" key = "ctrl-t" invocation = "touch {new_file}" execution = "touch {directory}/{new_file}" leave_broot = false auto_exec = false ``` When the user hits ctrlt, broot displays the invocation pattern and waits for some input which would end with enter: ![touch input](img/touch-user-input.png) If the invocation pattern doesn't contain any verb argument and `auto_exec` is set to `false`, the user will still have to hit enter. This is useful when you want the user to confirm on a potentially destructive action. # Internals Here's a list of internals: builtin actions you can add an alternate shortcut or keyboard key for, without referring to an external program or command: invocation | default key | default shortcut | behavior / details -|-|-|- :back | left | - | back to previous app state | :default_layout | - | - | restore the default panel sizes :clear_stage | - | cls | empty the staging area :close_panel_cancel | - | - | close the panel, not using the selected path :close_panel_ok | - | - | close the panel, validating the selected path :close_preview | - | - | close the preview panel :close_staging_area | - | csa | close the staging area panel :copy_line | altc | - | copy selected line (in tree or preview) :copy_path | - | - | copy path to system clipboard :escape | esc | - | escape from completions, current input, page, etc. (this internal can be bound to another key but should not be used in command sequences) :filesystems | - | fs | list mounted filesystems :focus | ctrlf | - | set the selected directory the root of the displayed tree (don't remove the filtering pattern) | :help | F1 | - | open the help page (which can also be open with ?) :line_down | | - | scroll one line down or select the next line (can be used with an argument eg `:line_down 4`) :line_down_no_cycle | - | - | same as line_down, but doesn't cycle :line_up | | - | scroll one line up or select the previous line :line_up_no_cycle | - | - | same as line_up, but doesn't cycle :move_panel_divider | - | - | ex: `:move_panel_divider 0 -5` reduces the size of the left panel by 5 "characters" (while growing the right panel by 5) :next_dir | - | - | select the next directory :next_match | tab | - | select the next matching file, or matching verb or path in auto-completion :next_same_depth | - | - | select the next file at the same depth :no_sort | - | ns | remove all sorts :open_leave | altenter | - | open the selected file in the default OS opener and leave broot :open_preview | - | - | open the preview panel :open_staging_area | - | osa | open the staging area :open_stay | enter | - | open the selected file in the default OS opener, or focus the directory :open_stay_filter | - | - | focus the directory but keeping the current filtering pattern :page_down | | - | scroll one page down :page_up | | - | scroll one page up :panel_left | - | - | move to or open a panel to the left :panel_left_no_open | ctrl | - | move to panel to the left :panel_right | ctrl | - | move to or open a panel to the right :panel_right_no_open | - | - | move to panel to the right :parent | - | - | focus the parent directory :preview_binary | - | - | preview the selection as binary :preview_image | - | - | preview the selection as image :preview_text | - | - | preview the selection as text :preview_tty | - | - | preview the selection as tty (with ANSI escape codes) :previous_dir | - | - | select the previous directory :previous_match | - | - | select the previous match :previous_same_depth | - | - | select the previous file at the same depth :print_path | - | pp | print path and leave broot :print_relative_path | - | prp | print relative path and leave broot :print_tree | - | pt | print tree and leave broot :quit | ctrlq | q | quit broot :refresh | F5 | - | refresh the displayed tree and clears the directory sizes cache :root_down | - | - | move tree root down :root_up | - | - | move tree root up :search_again | - | ctrls | either put back last search, or search deeper :select | - | - | select a path given as argument, if it's in the visible tree :select_first | - | - | select the first line :select_last | - | - | select the last line :set_panel_width | - | - | ex: `:set_panel_width 1 150` sets the width of the second panel to 150 "characters" :set_syntax_theme | - | - | set the [syntect theme](../conf_file/#syntax-theme) of code preview, eg `:set SolarizedDark` :show | - | - | similar to `:select` but will add missing lines to the tree. Does nothing if the provided path isn't a descendant of the current root :sort_by_count | - | sc | sort by count (only one level of the tree is displayed) :sort_by_date | - | sd | sort by date :sort_by_size | - | ss | sort by size :sort_by_type | - | st | sort by type :sort_by_type_dirs_first | - | - | sort by type, dirs first :sort_by_type_dirs_last | - | - | sort by type, dirs last :stage | + | - | add selection to staging area :stage_all_directories | - | - | add all directories verifying the pattern to the staging area :stage_all_files | ctrla | - | add all files verifying the pattern to the staging area :start_end_panel | - | - | either open or close an additional panel :toggle_counts | - | - | toggle display of total counts of files per directory :toggle_dates | - | - | toggle display of last modified dates (looking for the most recently changed file, even deep) :toggle_device_id | - | - | toggle display of device id (unix only) :toggle_files | - | - | toggle showing files (or just folders) :toggle_git_file_info | - | - | toggle display of git file information :toggle_git_status | - | - | toggle showing only the file which would show up on `git status` :toggle_hidden | - | - | toggle display of hidden files (the ones whose name starts with a dot on linux) :toggle_ignore | - | - | toggle display of files in .gitignore and .ignore :toggle_perm | - | - | toggle display of permissions (not available on Windows) :toggle_preview | - | - | toggle display of the preview panel :toggle_root_fs | - | - | toggle showing filesystem info on top :toggle_watch | - | - | toggle watching for changes and keeping the tree up to date :set_max_depth | - | - | set the maximum directory depth shown :unset_max_depth | - | - | clear the max_depth :toggle_second_tree | - | - | toggle displaying a second tree :toggle_sizes | - | - | toggle the size mode :toggle_stage | ctrlg | - | add or remove selection to staging area :toggle_staging_area | - | tsa | open/close the staging area panel :toggle_tree | - | - | toggle showing only one level of the tree (when not affected by sorting) :toggle_trim_root | - | - | toggle trimming of top level files in tree display :total_search | - | - | search again but on all children instead of stopping when the results look good enough :trash | - | - | move file to system trash :unstage | - | - | remove selection from staging area :up_tree | - | - | focus the parent of the current root :write_output | - | - | write to the `--verb-output` file :clear_output | - | - | clear the `--verb-output` file Note that - you can always call a verb with its default invocation, you don't *have* to define a shortcut - verbs whose invocation needs an argument (like `{newpath}`) can't be triggered with just a keyboard key. # Input related verbs Some internal actions can be bound to a key shortcut but can't be called explicitly from the input because they directly act on the input field: name | default binding | behavior -|-|- :input_clear | | empty the input :input_del_char_left | delete | delete the char left of the cursor :input_del_char_below | suppr | delete the char left at the cursor's position :input_del_word_left | - | delete the word left of the cursor :input_del_word_right | - | delete the word right of the cursor :input_go_to_end | end | move the cursor to the end of input :input_go_left | | move the cursor to the left :input_go_right | | move the cursor to the right :input_go_to_start | home | move the cursor to the start of input :input_go_word_left | - | move the cursor one word to the left :input_go_word_right | - | move the cursor one word to the right :input_selection_copy | - | copy the selected part of the input into the selection :input_selection_cut | - | cut the selected part of the input into the selection :input_paste | - | paste the clipboard content into the input You may add this kind of shortcuts in the `verbs` section: ```hjson { key: "alt-b", internal: ":input_go_word_left" } { key: "alt-f", internal: ":input_go_word_right" } { key: "alt-l", internal: ":input_del_word_left" } { key: "alt-r", internal: ":input_del_word_right" } ``` ```toml [[verbs]] key = "alt-b" internal = ":input_go_word_left" [[verbs]] key = "alt-f" internal = ":input_go_word_right" [[verbs]] key = "alt-l" internal = ":input_del_word_left" [[verbs]] key = "alt-r" internal = ":input_del_word_right" ``` ## Copy, Cut, Paste, in input Pasting into input is bound to ctrlV but copying from input and cutting aren't bound by default, because you don't usually write long texts here. You may add those bindings if you wish: ```hjson { key: "ctrl-c", internal: ":input_selection_copy" } { key: "ctrl-x", internal: ":input_selection_cut" } ``` ```toml [[verbs]] key = "ctrl-c" internal = ":input_selection_copy" [[verbs]] key = "ctrl-x" internal = ":input_selection_cut" ``` # Focus The `:focus` internal has many uses. It can be used without explicit argument in which case it takes the selection (for example `:!focus` is equivalent to ctrl). It can be used with an argument, for example you can go to a specific place without leaving broot by typing ` fo /usr/bin` then enter. You may also want to use it in some cases instead of enter because it keeps the active filter. It serves as base for several built-in commands, like `:home` whose execution is `:focus ~` (`~` is interpreted in broot as the user home even on Windows). And you can add your own ones: ```hjson { key: "ctrl-up", internal: ":focus .." } { key: "ctrl-d", internal: ":focus ~/dev" } { // make :go an alias of :focus invocation: "go {path}", internal: ":focus {path}" } { invocation: "gotar {path}", internal: ":focus {path}/target" } ``` ```toml [[verbs]] key = "ctrl-up" internal = ":focus .." [[verbs]] key = "ctrl-d" internal = ":focus ~/dev" ``` # cmd execution The `cmd` argument lets you define a sequence, just like the one you give to broot with [the `--cmd` argument](../launch/#the-cmd-launch-argument). Such a sequence can contain some searches, some calls to internals, some calls to already defined external based verbs. For example: ```Hjson { name: "backup" invocation: "bu {name}" cmd: ":cp {file}-back_{name};:!focus {file}-back_{name}" apply_to: directory } ``` ```TOML [[verbs]] name = "backup" invocation = "bu {name}" cmd = ":cp {file}-back_{name};:!focus {file}-back_{name}" apply_to = "directory" ``` This verb, which is only available when a directory is selected, copies this directory with a name partially composed from the command and focus the new directory in a new panel **Note:** The `cmd` execution type is still experimental in verbs and the precise behavior may change in future minor versions of broot. ================================================ FILE: website/src/css/site.css ================================================ /* standard classes and general style */ :root { --text: #444; --accent: #ffd700; --bg: #fdfdfd; --link-fg: #11699b; --nav-bg: #1b465f; --toc-active-border: 3px solid #1b465f; --nav-fg: #fff; --code-fg: #111; --code-bg: #ddd; --hovered-menu-bg: #5d9abd; --toc-bg: #1b465f33; --hovered-toc-bg: #1b465f66; --toc-fg: #444; --hovered-toc-fg: #000; --prev-next-separator: none; --prev-next-border: thin solid #bbb3; --prev-next-bg: var(--toc-bg); --hovered-prev-next-bg: var(--hovered-toc-item-bg); --search-panel-wrapper-bg: #fffe; --search-result-border: none; --search-results-bg: #1b465f33; --search-result-link-fg: #000c; --selected-search-result-bg: #3335; --hovered-search-result-link-fg: #000; --thead-border: thin solid var(--nav-bg); --tr-border: thin solid #d1dadf75; } html, body, div, nav, header { margin: 0; padding: 0; } html { scroll-padding-top: 80px; } body { color: var(--text); background: var(--bg); font-family: Arial, sans-serif; font-size: 17px; font-weight: 400; line-height: 1.5; padding-bottom: 80px; } @media (min-width: 1400px) { /* wide screens */ body { background-image: linear-gradient(to right, rgba(255,255,255,0.4) 0%, rgba(255,255,255,1) 20%, rgba(255,255,255,1) 75%, rgba(255,255,255,0.4) 100%), url("../img/cows.jpg"); background-position: center; background-size: cover; background-attachment: fixed; } } nav ul { list-style-type: none; } nav li { list-style: none; display: inline-block; } kbd { background: #444; border-radius: 2px; font-size: 80%; font-weight: bold; margin: 1px; border-radius: 3px; box-shadow: inset 0 -1px 0 rgba(0,0,0,.25); color: #fff; padding: 2px 4px; } kbd.b { font-size: 18px; padding: 0 5px; } /* Common nav */ nav ul, nav li, nav a { margin: 0; padding: 0; } nav a { color: var(--nav-fg); text-decoration: none; } /* Top navigation bar */ @media (max-width: 888px) { /* mobile */ body:has(#nav-toggle:checked) { overflow-y: hidden; /* prevent scrolling when menu is open */ } ul.nav-menu { background: var(--nav-bg); color: var(--nav-fg); width: 100%; flex-direction: column; } nav.site-nav { flex-direction: column; } nav.site-nav > ul.nav-menu { position: absolute; top: 42px; left: 0; min-width: 200px; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: all 0.3s ease; flex-direction: column; overflow-y: auto; max-height: calc(100vh - 42px); } #nav-toggle { display: none; /* hide checkbox */ } .nav-toggle-label { padding: 8px 14px; border-radius: 5px; cursor: pointer; display: inline-block; user-select: none; } /* Show menu when hamburger checkbox is checked */ nav.site-nav #nav-toggle:checked ~ ul.nav-menu { opacity: 1; visibility: visible; transform: translateY(0); } ul.nav-menu ul.nav-menu .nav-item { padding-left: 20px; } .nav-link img { margin: 0 5px; } .nav-link span { margin: 0 2px; } } @media (min-width: 888px) { /* wide screens */ nav.site-nav > ul.nav-menu { flex-direction: row; } .nav-item .nav-menu { /* revealed on hover */ display: none; position: relative; } .nav-item:hover .nav-menu { position: absolute; } #nav-toggle, .nav-toggle-label { /* hide hamburger on wide screens */ display: none; } nav.site-nav { flex-direction: row; align-items: center; } nav.before-menu { padding-left: 10px; } nav.after-menu { padding-right: 10px; } nav.before-menu, nav.after-menu { flex-direction: row; align-items: center; } .nav-link img { margin: 0 10px; } .nav-link span { margin: 0 5px; } } header .nav-link.broot-nav-logo span { margin: 0 20px 0 10px; font-weight: bold; } header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; display: flex; flex-direction: row; align-items: center; background: var(--nav-bg); border-bottom: 1px solid var(--accent); justify-content: space-between; color: var(--nav-fg); box-shadow: 0 0 3px rgba(0,0,0,0.7); border-bottom: none; } nav.before-menu, nav.after-menu { flex: 1 1 10%; display: flex; } nav.before-menu { justify-content: space-between; } nav.after-menu { justify-content: space-between; } nav.site-nav { flex: 0 0 auto; display: flex; } ul.nav-menu { flex: 0 0 auto; display: flex; } nav.site-nav > ul.nav-menu { align-items: stretch; } nav.site-nav a:hover { background: var(--hovered-menu-bg); } nav.site-nav .selected a, nav.site-nav .nav-item:has(.selected) > a { background: var(--hovered-menu-bg); } .nav-item a { flex: 0 0 auto; padding: 0.8rem 0.5rem; display: flex; } .nav li { flex: 0 0 auto; display: flex; } .nav-item:has(.nav-menu) > a:after { content: " ▼"; font-size: 0.9rem; margin-top: 2px; margin-left: 4px; } .nav-item:hover .nav-menu { display: flex; flex-direction: column; justify-content: flex-start; align-items: stretch; background: var(--nav-bg); box-shadow: 0 2px 5px rgba(0,0,0,0.5); } .nav-item:hover .nav-menu .nav-item { flex: 0 0 auto; } .nav-item .nav-menu .nav-item a { padding: 8px 24px 8px 12px; } body .nav-item:hover .nav-menu li.nav-item:hover a { background: var(--hovered-menu-bg); } .before-menu, .after-menu { display: flex; flex-direction: row; align-items: stretch; } .nav-link { display: flex; flex-direction: row; align-items: center; } .nav-link img { max-width: 40px; max-height: 40px; } .nav-link:hover { color: var(--accent); } .external-nav-link, .search-opener { opacity: .3; transition: opacity .5s; } .external-nav-link:hover, .search-opener:hover { opacity: 1; transition: opacity .5s; } /* article layout */ @media (max-width: 888px) { /* mobile */ aside.page-nav { /* display: none; */ /* uncomment to hide side nav on small screens */ } nav.page-toc { } nav.page-toc ul.toc-content { /* hide TOC on small screens */ display: none; } article nav.page-toc a.toc-title { /* still show page name */ margin-top: 40px; margin-left: 0; margin-right: 0; margin-bottom: 10px; padding: 10px 0; background: var(--nav-bg); display: flex; justify-content: center; flex-direction: row; align-items: center; color: var(--nav-fg); font-size: 16px; } main { margin: 10px 20px 40px 20px; } } @media (min-width: 888px) { /* wide screens */ article { margin: 80px auto 40px auto; max-width: 1000px; display: flex; flex-direction: row; } aside.page-nav { flex: 0 0 25%; max-width: 25%; position: relative; padding-right: 20px; } main { flex: 0 1 75%; min-width: 0; } } /* article's TOC */ nav.page-toc { top: 80px; position: sticky; max-height: calc(100vh - 90px); overflow-x: hidden; overflow-y: auto; } nav.page-toc a.toc-title { font-weight: bold; font-size: 18px; margin-left: 20px; padding-bottom: 8px; align-self: start; color: var(--text); } nav.page-toc .toc-content { background: var(--toc-bg); padding-top: 10px; padding-bottom: 10px; border-radius: 5px; width: 100%; display: flex; flex-direction: column; align-items: stretch; } nav.page-toc .toc-item { border-right: 3px solid transparent; } nav.page-toc .toc-item.active { border-right: var(--toc-active-border); } nav.page-toc .toc-item:hover { background: var(--hovered-toc-bg); } nav.page-toc a { display: inline-block; width: 100%; color: var(--toc-fg); } nav.page-toc .toc-item:hover a { color: var(--hovered-toc-fg); } nav.page-toc .h1, nav.page-toc .h2 { display: flex; align-items: stretch; } nav.page-toc .h1 a { padding: 8px 10px 8px 15px; font-size: 100%; } nav.page-toc .h2 a { padding: 6px 10px 6px 30px; font-size: 90%; } nav.page-toc .h3 { display: none; /* remove this line to show h3 in TOC */ padding: 6px 10px 6px 40px; font-size: 85%; } nav.page-toc .h4 { display: none; /* remove this line to show h4 in TOC */ padding: 6px 10px 6px 50px; font-size: 80%; } /* article main content */ main img { max-width: 100%; display: block; padding: 0; line-height: 1.428571429; border: none; border-radius: 0; margin: 10px auto 20px auto; } main table { margin: 20px 0 35px 0; border-collapse: collapse; } th, td { padding: 10px 4px; } table > thead tr { border-bottom: var(--thead-border); } table tr td { border-bottom: var(--tr-border); } pre { color: #333; overflow-x: auto; margin: 0 0 20px 0; padding: 0; border-radius: 5px; color: var(--code-fg); background: var(--code-bg); border: solid 1px var(--code-bg); font-weight: normal; max-width: 800px; } code { padding: 2px 5px; background: #fff; white-space: pre-wrap; word-wrap: break-word; border-radius: 0; color: var(--code-fg); background: var(--code-bg); font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 80%; } pre code { display: block; background: transparent; border: none; white-space: pre; word-wrap: normal; } p { margin-top: 0; margin-bottom: 1rem; } ol, ul, dl { margin-top: 0; margin-bottom: 1rem; } main a { text-decoration: underline; color: var(--link-fg); } main a:hover { background: var(--accent); } h1 { font-size: 22px; border-bottom: solid 1px; padding-bottom: 9px; margin: 30px 0 20px 0; border-image-source: linear-gradient(to right, #178accff, #fff0); border-image-slice: 1; } h2 { font-size: 20px; border-bottom: solid 1px; padding-bottom: 7px; margin: 20px 0 10px 0; border-image-source: linear-gradient(to right, #178accaa, #fff0, #fff0, #fff0); border-image-slice: 1; } h3 { font-size: 20px; } h4 { font-size: 18px; } .sponsorship { margin-top: 20px; display: flex; flex-direction: row; } .sponsorship a { margin-right: 10px; } /* search */ .ddoc-search-panel-wrapper { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--search-panel-wrapper-bg); z-index: 200; } .ddoc-search-panel { position: fixed; z-index: 210; padding: 0; display: flex; flex-direction: column; } .ddoc-search-controls { display: flex; flex-direction: row; align-items: center; padding: 20px; border-radius: 10px 10px 0 0; background: var(--nav-bg); } .ddoc-search-controls input { flex: 1 1 auto; padding: 5px 5px; font-size: 16px; } .ddoc-search-close { margin-left: 10px; padding: 6px 10px; font-size: 20px; font-weight: bold; cursor: pointer; color: var(--nav-fg); } .ddoc-search-close:hover { color: var(--accent); } .ddoc-search-results { flex: 0 0 auto; display: flex; flex-direction: column; overflow-y: auto; background: var(--search-results-bg); padding: 0 0 20px 0; border-radius: 0 0 10px 10px; } .ddoc-search-no-result { padding: 10px; } .ddoc-search-result + .ddoc-search-result { border-top: var(--search-result-border); } .ddoc-search-result { flex: 0 0 auto; padding: 10px; display: flex; flex-direction: column; align-items: stretch; } .ddoc-search-result-selected { background: var(--selected-search-result-bg); } .ddoc-search-result-path { flex: 0 0 auto; padding: 5px; display: flex; flex-direction: row; align-items: center; } .ddoc-search-result-path a { color: var(--search-result-link-fg); } .ddoc-search-result-path a:hover { color: var(--hovered-search-result-link-fg); } .ddoc-search-result-path .ddoc-search-result-sep { margin: 0 5px; color: var(--text); } .ddoc-search-result-extract { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--bg); color: var(--text); margin-left: 10px; padding: 2px; font-size: 90%; opacity: 0.9; } @media (max-width: 880px) { /* mobile */ .ddoc-search-panel { top: 30px; left: 10px; right: 10px; } .ddoc-search-results { max-height: calc(100vh - 150px); } } @media (min-width: 880px) { /* wide screens */ .ddoc-search-panel { top: 100px; width: 760px; left: calc(50% - 380px); } .ddoc-search-results { max-height: calc(100vh - 240px); } } /* footer */ footer { display: flex; flex-direction: column; align-items: stretch; font-size: 85%; } /* prev - next page links */ .prev-next { display: flex; flex-direction: row; justify-content: space-between; margin: 50px 0 10px 0; padding: 20px 0; font-size: 87%; border-top: var(--prev-next-separator); } .prev-next .unexpanded { opacity: 0; } .prev-next .nav-link.previous-page-link svg, .prev-next .nav-link.next-page-link svg { max-width: 20px; max-height: 20px; margin: 0 0 0 10px; } .prev-next .nav-link.previous-page-link svg path, .prev-next .nav-link.next-page-link svg path { fill: var(--text); stroke: var(--text); } .prev-next .nav-link.previous-page-link:hover svg path, .prev-next .nav-link.next-page-link:hover svg path { fill: #000; stroke: #000; } .prev-next .nav-link { flex: 1 1 30%; padding: 10px 15px; border: var(--prev-next-border); display: flex; flex-direction: row; align-items: center; background: var(--prev-next-bg); color: var(--text); transition: all 0.3s ease; } .prev-next .nav-link:hover { background: var(--hovered-toc-bg); color: #000; transition: all 0.3s ease; } .prev-next .previous-page-link { justify-content: flex-start; } .prev-next .next-page-link { justify-content: flex-end; } @media (max-width: 768px) { /* mobile */ .prev-next .nav-link { padding: 10px 2px; } .prev-next .nav-link.previous-page-link { margin-right: 2px; } .prev-next .nav-link.next-page-link { margin-left: 2px; } } @media (min-width: 768px) { /* wide screens */ .prev-next .nav-link { padding: 10px 15px; border-radius: 5px; } .prev-next .nav-link.previous-page-link { margin-right: 5px; } .prev-next .nav-link.next-page-link { margin-left: 5px; } } ================================================ FILE: website/src/css/tab-langs.css ================================================ :root { --code-fg: #111; --code-bg: #eee; } .lang-tab { color: #555; padding: 5px 8px; cursor: pointer; font-size: 90%; } .lang-tab.active { color: #333; border-top: solid 1px #e1e4e5; border-right: solid 1px #e1e4e5; border-bottom: solid 3px var(--code-bg); border-left: solid 1px #e1e4e5; background: var(--code-bg); cursor: auto; } pre.tabbed { } pre code.language-Hjson, pre code.language-hjson, pre code.language-TOML, pre code.language-toml { display: block; overflow-x: auto; padding: 0.5em; color: #333; background: var(--code-bg); } ================================================ FILE: website/src/export.md ================================================ If you want to use the pruned tree out of broot (for example for a documentation), you may use the `:print_tree` verb. It can be used in several ways. The easiest is to execute it from inside the application (the verb is also accessible with the `:pt` shortcut). This quits broot and you find the tree on your console, without the status line and the input, but with the same filtering state as when you were browsing. Example with a filter: ![exported styled tree](img/20190321-cmd-pt-styled.png) Example without style or color, thanks to `--color no`: ![exported unstyled tree](img/20210222-cmd-pt-unstyled.png) This is also how would look the tree directly exported into a file. With the `--out` command, the tree is written in a given file. For example `br --out test.txt`. You can also redirect the output of broot in a standard unix way. You don't have to enter broot, you may also directly get the tree by using the [`--cmd` argument](../launch/#the-cmd-launch-argument). An additional parameter may come handy: `--height` which specifies the size of the virtual screen, which may be smaller or bigger than the real one (no problem if you want 10000 lines). For example br --cmd ":pt" > my_file.txt will export the local tree to the `my_file.txt` file. Or br > tree.txt in which case you'll manually do `:pt` when in broot but after having had the opportunity to navigate, filter and change toggles as desired. ================================================ FILE: website/src/file-operations.md ================================================ Those common file operations may be discovered elsewhere in the site, or in the help screen in Broot. This page is a cheat sheet gathering them in one place. It focuses on the default settings, on the basis that you change the settings only after you understood the feature. I'll assume here you know the basics of command line file operations. Broot doesn't hide the operations, it makes them easier and faster to type and shows you immediately the result but you won't be able to do them if you don't know what a `cp` or a `mkdir` does. For each command here, don't forget you can hit esc to remove it. It's the fast way to cancel. **Note:** To be easier to read, this page uses `:` to start verbs but a space is equivalent and is often more convenient to type. **Note:** Most screenshots here don't feature a filtering pattern (i.e. the part before the first space or colon) but you never need to remove your filter to execute your file operation. Typing a pattern is usually the fastest way to select the file you want. # cd A `cd` here means changing the current directory in the shell from which broot was launched. This is done either by hitting altenter or by issuing the `:cd` verb. It needs broot to be launched with the [`br` shell function](../install-br). # chmod This operation only exists on unix-like systems. The built in `chmod` command takes as argument the mode modifier. For example, to make the selected file executable, you type `:ch` then a space or tab, then `+x`. ![chmod](img/20210603-chmod.png) After having checked the status line, you hit enter and the modification is done. When you're not sure about the current mode, you may show them before starting the operation. This is done either - by starting broot with the `-p` command line argument - by hitting `:perm` in broot Here's how the same `chmod` operation would look like with the mode displayed: ![chmod](img/20210603-chmod-perm.png) # copy ## with one panel Select the file or directory you want to copy, type `:cp` then a tab or a space, then the destination path. Don't hesitate to hit the tab key while typing your path to get completion. Broot accepts and solves relative paths: ![file op](img/20210603-cp.png) After having checked the status line, you hit enter and the copy is done. ## with two panels When you do changes involving distant location, you may want to see both trees side to side. This is done by [opening two panels](../panels). When using two panels, no argument is needed for the copy. The verb to use is `:cpp`: ![file op](img/20210603-cpp.png) As there's no argument, you may [define a key binding](../conf_verbs#keyboard-key) for cpp in your configuration if you like this operation. # create a file The default configuration assumes a terminal editor is defined either with the `$EDITOR` env variable or with an `editor` command in the paths. If you don't have a default terminal editor, you should edit the `create` verb in the [configuration file](../conf_file). The desired parent being selected, type `:cr` then hit tab or a space, then the name of the new file: ![file op](img/20210603-cr.png) On enter you'll enter your favourite text editor and you'll be back to broot when quitting it. # create a directory Select the desired parent, type `:md` then hit a space or a tab, then enter the name of the new directory and hit enter. You may type several slash separated elements as the command will contain the `-p` flag: ![file op](img/20210603-md.png) # delete ## the selected file or directory Select a file, type `:rm` ![file op](img/20210603-rm.png) Check the status line then hit enter: ## with staging Let's assume you want to remove a lot of files and directories because your disk is full. In such a case, you start broot in "whale hunt" mode, with `br -w` to have files and directories sorted by size. ![file op](img/20210603-br-w.png) Instead of removing files one per one, you may want to "stage" them, that is to add them to the staging area where they're counted, summed, and where you'll remove them in one operation at the end. You stage a file (and display the staging area) with the ctrlg shortcut: ![file op](img/20210603-br-w-stage.png) The total size of the staging area is displayed on top (here "8.1G"). (when not doing screenshots for a website, I suggest you use a bigger console, so it's less crowded, but as you see broot stays usable in a tiny one) When you ready to remove everything, you go to the staging area with ctrlright then type `:rm`: ![file op](img/20210603-br-w-stage-rm.png) You finish by hitting enter. # edit a text file The default configuration assumes a terminal editor is defined in your system. If it isn't and you can't set it, you should edit the `edit` verb in the [configuration file](../conf_file). Select the file you want to edit, type `:e` then hit enter. ![file op](img/20210603-e.png) As there's no argument, you may [define a key binding](../conf_verbs#keyboard-key). You may have noticed the `+0`. If you're in a preview on a specific line, or you [searched on file content](../navigation#file-content-searches), the file may be directly open on that line with this standard argument : ![file op](img/20210603-e-line.png) Here, on enter, we'd go to line 13 where is the first occurrence of "lazy-" in this file. (you may change the configuration if your editor has a different syntax) # move *Note that there's also a [rename](#rename) which is usually better if you only want to change the name.* ## with one panel Select the file or directory you want to copy, type `:mv` then a tab or a space, then the destination path. Don't hesitate to hit the tab key while typing your path to get completion. Broot accepts and solves relative paths: ![file op](img/20210603-mv.png) After having checked the status line, you hit enter and the copy is done. ## with two panels When you do changes involving distant location, you may want to see both trees side to side. This is done by [opening two panels](../panels). When using two panels, no argument is needed for the copy. The verb to use is `:mvp`: ![file op](img/20210603-mvp.png) As there's no argument, you may [define a key binding](../conf_verbs#keyboard-key) for mvp in your configuration if you like this operation. # open When a file (not a directory) is selected and you hit enter, the `:open_stay` internal is executed, which calls the standard file opening solution of your system and doesn't close Broot. For example on a desktop linux, this calls a variant of xdg-open which in turns will choose an application using the file's extension. When your system doesn't know how to open files (for example on server linux with no windowing solution), this may be a problem and you can [change this behavior](../tricks#change-standard-file-opening). # rename If all you want is to change part of the name of a file, perhaps its extension, the `:rename` verb is ideal. It's mapped by default to F2. Just hitting the trigger key prefills the input with the command with the name as argument. You only have to edit this name then hit enter. ![file op](img/20210603-rename.png) ================================================ FILE: website/src/help.md ================================================ Broot's help screen is designed to be as short as possible while giving the essential references and directions. It starts with the version (useful to get help), links to the documentation (you may ctrl-click the link on most terminals) then explains how to edit the configuration, lists the commands and shortcuts, the search modes, and ends with the list of special feature the executable you're using was compiled with (once again, this is handy when you need help). # Open the help As for everything, this can be configured, but you'll normally get to the help either * by hitting the ? key * by typing `:help` then enter And you'll leave this screen with a hit on the esc key. # edit the configuration When in this screen, the current selection is the configuration file. What it means is that any verb taking as argument a file can be executed. For example, hitting enter calls the standard opening of this file. And if you configured a text editor, let's say bound on `:e`, it would work too. This is the fastest way to change the configuration. # Verbs *Verbs* are what is combined with the selection and optional arguments to make the commands you execute. They're behind every action in Broot, so their list, made from both the built-in verbs and the ones you configured, is essential. ![unfiltered help](img/help-unfiltered.png) But this list is a little to long for scanning, so you'll most often search it. For example, let's imagine you want to see what's the shortcut for showing *hidden* files. You search for the first letter of your topic, here "hi". The list is filtered while you type to reveal the interesting verb(s): ![filtered help](img/help-filtered.png) In this example you see that you can toggle showing hidden files by hitting alth or by typing `:h` then enter. # Search modes There are [several kinds of searches](../input). You might want to check those modes and their prefixes (which can be [configured](../conf_file/#search-modes)): ![help search modes](img/help-search-modes.png) ================================================ FILE: website/src/icons.md ================================================ # Icons Overview Broot supports showing icons in terminal if a supported font is installed. Currently, two fonts are supported, `nerdfonts` and `vscode`. Here is a screenshot, with nerdfont to the left and vscode at right: ![Broot icon comparison](img/20240225-icon-comparison.png) # Configuration First add the appropriate lines to your broot config. ```hjson icon_theme: vscode ``` ```toml icon_theme = "vscode" ``` or ```hjson icon_theme: nerdfont ``` ```toml icon_theme = "nerdfont" ``` If the appropriate fonts are already installed correctly on your system then new instances of broot will show icons correctly. # Installation and checking for presence ## Checking the font ### VSCode #### Bash (and compatible): ```bash echo -e "file_type_rust looks like \U001002D2" ``` #### powershell ```powershell echo "Rust is `u{1002D2}" ``` If you see a rust gear icon, your terminal is displaying the correct font. ### Nerdfont #### Bash (and compatible): ```bash echo -e "file_type_rust looks like \ue7a8" ``` This should display a rust icon Unless you're really unlucky (there is some font that has the rust gear icon at the exact unicode point as the query but doesn't have other glyphs), you probably have the font installed correctly. If not, move to the installation section. ## Installation ### VSCode If the font isn't installed, you may * take it from `/resources/icons/vscode/vscode.ttf` if you have broot sources * download it from [https://github.com/Canop/broot/blob/main/resources/icons/vscode/vscode.ttf](https://github.com/Canop/broot/blob/main/resources/icons/vscode/vscode.ttf), * or take it from the release archive if you installed broot from its zipped archive. #### Installation on linux: Copy the `vscode.ttf` file to `~/.local/share/fonts`. #### Installation on Windows Double click the `vscode.ttf` file icon and click on "Install font". ### Nerdfonts 1. In order for the nerdfont setting to work you need to have a patched nerdfont installed and set as your font in the terminal emulator of your choice. 2. After a successful installation, you need to add or uncomment the `icon_theme: "nerdfont"` line in broot's config file 3. You should now be able to see icons when opening broot in your terminal. ## FAQ **Q:** Why does broot show a generic icon for this very common file type? **A:** The icon mappings aren't complete. You can help out very easily without any coding knowledge. Go to the github [repository](https://github.com/Canop/broot/tree/main/resources/icons). Enter the directory corresponding to your theme. Inside `data`, edit `extension_to_icon_name_map.rs` and add a line corresponding to your extension. The first field would be the extensions you would like, and the second field should be referred from `icon_name_to_icon_code_point_map.rs`. Submit a PR. **Q:** Can I set up a totally different set of icons or mappings ? **A:** Broot can be configured for a different mapping or a different font, but this needs some coding and a new compilation. To get started, have a look at look at the resources/icons/nerdfont directory and, if necessary, come and chat on miaou. **Q:** Why isn't the icon mapping configurable? **A:** For performance reasons, icon mapping is hardcoded. If this looks like a problem, please come and chat with us on miaou. **Q:** How do I remap in nerdfont? **A:** If you want to map or remap icons, please go to nerdfont-cheat-sheet and search for an icon you would like to set in its place. In order to correctly fix the icon mapping you need a FILE_EXTENSION and a NERDFONT_ICON_CODE. For this example we are remapping the json file extension. The first thing you need to do is find the "json" icon you want to map to in the cheatsheet and copy its iconCode ("the code inside the red box in the screenshot below"). Once done, find the corresponding file mapping in resources/icons/nerdfont by searching for your filetype in icon_name_to_icon_code_point_map: In this case its this line: ``` ( "file_type_json", 0xe60b ), ``` As you can see: the icon is mapped by "0x" followed by your iconCode. After changing this you should be able to recompile broot and see your new icon. If this is a new file mapping or a missing icon. Please consider contributing! ![nerdfont cheatsheet iconCode](img/20240225-nerdfont-cheatsheet.png) ================================================ FILE: website/src/index.md ================================================

Broot is a better way to navigate directories, find files, and launch commands. ![cows](img/20241027-cows.png) # Directory Overview Hit `br -s` ![overview](img/20230930-overview.png) Notice the *unlisted*? That's what makes it usable where the old `tree` command would produce pages of output. `.gitignore` and `.ignore` files are properly dealt with to put unwanted files out of your way. As you sometimes want to see ignored files, or hidden ones, you'll soon get used to the alti and alth shortcuts to toggle those visibilities. (you can ignore them though, see [documentation](../navigation/#toggles)). # Find a directory then cd type a few letters ![cd](img/20230930-cd.png) Hit altenter and you're back to the terminal in the desired location. This way, you can navigate to a directory with the minimum amount of keystrokes, even if you don't exactly remember where it is. Broot is fast and doesn't block (any keystroke interrupts the current search to start the next one). Most useful keys for this: * the letters of what you're looking for * enter on the root line to go up to the parent (staying in broot) * enter to focus a directory (staying in broot) * esc to get back to the previous state or clear your search * and may be used to move the selection * altenter to get back to the shell having `cd` to the selected directory * alth to toggle showing hidden files (the ones whose name starts with a dot) * alti to toggle showing ignored files * `:q` if you just want to quit (you can use ctrlq if you prefer) # Never lose track of file hierarchy while you search ![search](img/20230930-gccran.png) Broot tries to select the most relevant file. You can still go from one match to another one using tab or arrow keys. You may also search with a regular expression. To do this, add a `/` before the pattern. And you have [other types of searches](input/#the-filtering-pattern), for example searching on file content (start with `c/`): ![content search](img/20230930-content-memm.png) You can also apply logical operators or combine patterns, for example searching `test` in all files except json ones could be `!/json$/&c/test` and searching `carg` both in file names and file contents would be `carg|c/carg`. Once the file you want is selected you can * hit enter (or double-click) to open it in your system's default program * hit altenter to open it in your system's default program and close broot * hit ctrl to preview it (and then a second time to go inside the preview) * type a verb. For example `:e` opens the file in your preferred editor (which may be a terminal one) [blog: a broot content search workflow](https://dystroy.org/blog/broot-c-search/) # Manipulate your files Most often, when not using broot, you move your files in the blind. You do a few `ls` before, then your manipulation, and maybe you check after. You can instead do it without losing the view of the file hierarchy. ![mv](img/20230930-mv.png) Move, copy, rm, mkdir, are built in and you can add your own shortcuts. Here's chmod: ![chmod](img/20230930-chmod.png) # Manage files with panels When a directory is selected, press ctrl and you open another panel (you may open other ones, or navigate between them, with ctrl and ctrl). ![custom colors tree](img/20230930-colored-panels.png) (yes, colors are fully customizable) You can for example copy or move elements between panels: ![cpp](img/20230930-cpp.png) If you like you may do it Norton Commander style by binding `:copy_to_panel` to F5 and `:move_to_panel` to F6. # Preview files Hit ctrl when a file is selected and the preview panel appears. ![preview](img/20230930-preview.png) ![preview](img/20230930-preview-image.png) The preview panel stays synchronized with the selection in tree panels. Broot displays images in high resolution when the terminal supports Kitty's graphics protocol (compatible terminals: [Kitty](https://sw.kovidgoyal.net/kitty/index.html), [WezTerm](https://wezfurlong.org/wezterm/)): ![kitty preview](img/20201127-kitty-preview.png) With [transformers](conf_file/#transformers), you can also preview PDF or Office files. # Apply a command to a file ![size](img/20230930-edit.png) Just find the file you want to edit with a few keystrokes, type `:e`, then enter. You can add verbs or configure the existing ones; see [documentation](conf_file/#verbs-shortcuts-and-keys). And you can add shortcuts, for example a ctrl sequence or a function key # Apply commands on several files Add files to the [staging area](staging-area) then execute any command on all of them. ![staging mv](img/20230930-staging-mv.png) # Replace `ls` (and its clones) If you want to display *sizes*, *dates* and *permissions*, do `br -sdp` which gets you this: ![replace ls](img/20240501-sdp.png) You may also toggle options with a few keystrokes while inside broot. For example you could have typed this `-sdp` while in broot. Or hit alth and you see hidden files. # See what takes space: You can sort by launching broot with `--sort-by-size` or `--sort-by-date`. Or you may, inside broot, type a space, then `sd`, and enter and you toggled the `:sort_by_date` mode. When sorting, the whole content of directories is taken into account. So if you want to find on Monday morning the most recently modified files, launch `br --sort-by-date ~`. If you start broot with the `--whale-spotting` option (or its shortcut `-w`), you get a mode tailored to "whale spotting" navigation, making it easy to determine what files or folders take space. Sizes, dates, files counts, are computed in the background, you don't have to wait for them when you navigate. ![size](img/20230930-whale-spotting.png) And you keep all broot tools, like filtering or the ability to delete or open files and directories. If you hit `:fs`, you can check the usage of all filesystems, so that you focus on cleaning the full ones. ![fs](img/20230930-fs.png) # Check git statuses: Use `:gf` to display the statuses of files (what are the new ones, the modified ones, etc.), the current branch name and the change statistics. ![size](img/20230930-git.png) And if you want to see *only* the files which would be displayed by the `git status` command, do `:gs`. ![gg](img/20230930-gg.png) From there it's easy to edit, diff, or revert selected files. [blog: use broot and meld to diff before commit](https://dystroy.org/blog/gg/) # More... See [how to install](install), [configure](conf_file), or [use](launch). ================================================ FILE: website/src/input.md ================================================ # General form The input is the area at the bottom of the focused panel, in which you can type a filter or a command. Its parts are * a filtering pattern * a verb invocation, starting with a space or a colon (`:`) Both parts are optional. # The filtering pattern A search pattern is made of 1 to 3 parts separated by the `/` character but you rarely need the two `/`. The syntax is globally [/] The mode is either nothing (fuzzy path), just a slash (regex name) or some letters followed by a slash. The search mode combines * the search type: fuzzy, regex, exact, tokens * the search object: file name, file path, file content mode | example query | example match | explanation -|-|-|- fuzzy path | `abc` or `p/abc` | `a/bac.txt` | search for "abc" in a fuzzy way in sub-paths from current tree root fuzzy name | `n/abc` or `nf/abc` | `abac.txt` | search for "abc" in a fuzzy way in filenames tokens name | `nt/ab,cd` | `dcdAbac.txt` | search for the "ab" and "cd" tokens, in whatever order (case and diacritics insensitive) exact name | `e/Bac` or `en/Bac` | `ABac.txt` | search for the string "Bac" in filenames regex name | `/[yz]{3}` or `/[yz]{3}/` | `fuzzy.rs` | search for the regular expression `[yz]{3}` in filenames regex name | `/(json\|xml)$/i` | `thing.XML` | find files whose name ends in `json` or `xml`, case insensitive regex name | `/abc/i` | `aBc.txt` | search for the regular expression `abc` with flag `i` in filenames exact path | `ep/te\/d` or `pe/te\/d/` | `website/docs` | search for "te/d" in sub-paths from current tree root regex path | `rp/\d{3}.*txt` | `dir/a256/abc.txt` | search for the `\d{3}.*txt` regex in sub-paths from current tree root tokens path | `t/ab,cd` | `DCD/a256/abc.txt` | search for the "ab" and "cd" tokens in sub-paths from current tree root exact content | `c/mask` or `c/mask/` | `umask = "1.0"` | search for the "mask" string in file contents regex content | `rc/[abc]{5}/i` | `bAAAc` | search with a regular expression in file contents - `i` making it case insensitive regex content | `cr/\bzh\b` | `"zh":{` | search a word with a regular expression in file contents It's also possible to [redefine those mode mappings](../conf_file/#search-modes). # Combining filtering patterns Patterns can be combined with the `!` (not), `&` (and) and `|` (or) operators, and parentheses if necessary. You can for example list files whose name contains a `z` and whose content contains one too with z&c/z To display non `json` files containing either `isize` or `i32`, type !/\.json$/&(c/isize/|c/i32/) The last closing characters are often unnecessary when no ambiguity is possible, so you could have typed this: !/\.json$/&(c/isize/|c/i32 # Escaping ## Why escaping ? Look at this input: `a|b rm`. It's for searching files whose name contains either a `a` or a `b`, then removing the selected one. The pattern here is `a|b`, it's a composite pattern. A space or a colon starts the verb invocation. So if you needs one of them in your pattern, you need to escape it with `\`. For example * to search for a file whose name contains a x and a colon, you type `x\:` * to search for a file whose name contains a space just before a digit, you can use a regular expression: `/\ \d` The characters you use as operators and the parenthesis can be useful in patterns too, either because you want to search for them in fuzzy patterns or in file contents, or because you write non trivial regular expressions. If you want to search for the `|` character (or a `&`, or `(`, or `)`), you can't just type it because it's used to combine elementary patterns. I needs escaping. So if you need to search for the `|` character in file names, you type `\|`. An elementary pattern which starts with a `/` can only be ended with a `/`, a space, or a colon. That's why you don't have to escape other characters you want to include in your elementary pattern. This lets you type this regular expression with no unnecessary escaping: /(\d-){2}\w ![regex](img/regex-antislash-d.png) Regular expression escaping rules still apply, so if you want to search with a regex for a file containing a `(`, you'll type `/\(`. ## Escaping Rules The escaping character is the antislash `\`. Most often, you don't need to know more: when broot tells you it doesn't understand your pattern, it should click that your special character needs escaping and you prefix it with a `\ `. More precisely: 1. After the first `/` of a pattern, only ` `, `:`, `/` and `\` need escaping. 2. Otherwise, `&,` `|`, `(`, `)`, `\` need escaping too. 3. When there's no ambiguity, ending characters are often unnecessary 4. Two successive `:` in pattern position may be left unescaped # Performances broot interprets the left operand before the right one and doesn't interpret the second one if it's not necessary. So if you want to search your whole disk for json files containing `abcd`, it will be faster to use `/\.json$/&c/abcd` rather than `c/abcd/&/\.json$/` which would look at the file name only after having scanned the content. # The verb invocation The verb invocation is : or where arguments can be empty, depending on the verb's behaviour and invocation pattern. Verbs are detailed in the [Verbs & Commands](/verbs) chapter. # Examples ## Fuzzy Path search `re` ![fuzzy](img/20210511-fuzzy-re.png) ## Regular expression based search `/R` ![fuzzy](img/20210511-regex.png) ## Search followed by a command without arguments `re rm` (which is equivalent to `re:rm`) This is very natural: You use the search to select your element and you don't need to remove it before typing the command. ![fuzzy](img/20200526-input-fuzzy-rm.png) ## Search followed by a command taking an argument `re mv ../regex.rs` ![fuzzy](img/20200526-input-fuzzy-mv.png) ## Full text search In this case with an escaped space: `c/two\ p` ![content search](img/20210511-twop.png) ## Regular expression based full text search ![content regex search](img/20201002-cr-search.png) ## Search by name/extension and content Here's searching files whose name ends in "toml" and containing "crokey": `/\.toml$/&c/crokey` ![extension & content](img/20230210-extension-content.png) In practice, you won't usually bother with the `\.`. And if you want to cover `"TOML"` too, you'll add a `i`: `/toml/i&c/crokey`. ## Complex composite search Here we search for `"carg"` both in file names and file contents, and we exclude `"lock"` files: `!lock&(carg|c/carg/)` ![complex composite](img/20200620-complex-composite.png) note: the `/` at the end of `c/carg/` is necessary to tell broot that the following parenthesis isn't part of the pattern. ================================================ FILE: website/src/install-br.md ================================================ broot is convenient to find a directory then `cd` to it, which is done using altenter or `:cd`. But broot needs a companion function in the shell in order to be able to change directory. # Automatic shell function installation This is normally the easiest solution and it's safe. When you start broot, it checks whether the `br` shell function seems to have been installed (or to have been refused). If needed, and if the used shell seems compatible, then broot asks the permission to register this shell function. When it's done, you can do `br` to launch broot, and typing altenter will cd for you. Supported shells today are bash, zsh, fish, nushell, and powershell. **Note:** If your shell is zsh and there's no `.zshrc` file yet, broot won't patch it. The solution is to create it with `touch ~/.zshrc` then run `broot --install` **Note:** broot may need additional rights at first use in order to write its configuration file. You may also have to allow script execution (`set-executionpolicy unrestricted`) **Note:** Use either `alias` or `def --env` when aliasing the `br` shell function. `def` alone won't enable the shell function to perform `cd`. # Retry the automatic installation If you have messed with the configuration files, you might want to have the shell function reinstalled. In order to do this, either remove all broot config files, or launch `broot --install`. You can also use the `--install` argument when you first refused and then decided you want it installed. # Manual shell function installation If you prefer to manage the function sourcing yourself, or to automate the installation your way, or if you use an unsupported configuration, you still can get some help of broot: `broot --print-shell-function bash` (you can replace `bash` with `zsh`, `fish`, or `nushell`) outputs a recommended shell function. `broot --set-install-state installed` tells broot the `br` function is installed (other possible values are `undefined` and `refused`). # `br` alias for Xonsh shell The shortcut for [xonsh](https://xon.sh/) shell can be installed with using [xontrib-broot](https://github.com/jnoortheen/xontrib-broot) ================================================ FILE: website/src/install.md ================================================ **broot** works on linux, mac and windows (win 10+). Current version: **download** [CHANGELOG](https://github.com/Canop/broot/blob/main/CHANGELOG.md) **Windows users:** broot may need additional rights at first use in order to write its configuration file. Some users on Windows also report problems with the colon; remember that a space can be used instead of a colon. You should also use a modern terminal, for example the [new Microsoft one](https://github.com/microsoft/terminal) # Precompiled binaries Binaries are made available at every release in [download](https://dystroy.org/broot/download). The archives there contain precompiled binaries, as well as licenses, shell completion scripts, and other files. All releases are also available on [GitHub releases](https://github.com/Canop/broot/releases). When you download executable files, you'll have to ensure the shell can find them. An easy solution on linux is for example to put them in `/usr/local/bin`. You may also have to set them executable using `chmod +x broot`. As I can't compile myself for all possible systems, you'll need to compile broot yourself or use a third-party repository (see below) if your system isn't one I can compile for. # From crates.io ## Dependencies You'll need to have the [Rust development environment](https://www.rustup.rs) installed and up to date. The main cause of compilation error is an outdated rust compiler. Try updating it with `rustup update`. You may also have problems compiling if you're missing dependencies. Here's how to install them on several distributions: Debian, Ubuntu: ```bash sudo apt install build-essential libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev -y ``` Fedora, Centos, Red Hat: ```bash sudo dnf install libxcb -y ``` openSUSE: ```bash sudo zypper --non-interactive install xorg-x11-util-devel libxcb-composite0 libxcb-render0 libxcb-shape0 libxcb-xfixes0 ``` Arch Linux: ```bash sudo pacman -Syu --noconfirm libxcb ``` ## Broot installation Once you have rust and dependencies installed, use cargo to install broot: ```bash cargo install --locked broot ``` or, for clipboard support: ```bash cargo install --locked --features clipboard broot ``` # From source As for installing from crates.io, you'll need [rust and other dependencies](#dependencies) first. Fetch the [Canop/broot](https://github.com/Canop/broot) repository, move to the broot directory, then run ```bash cargo install --locked --path . ``` If you want a custom compilation, have a look at the [optional features documentation](https://github.com/Canop/broot/blob/main/features.md). The most common feature is the "clipboard" one: ```bash cargo install --locked --features clipboard --path . ``` # Third party repositories Those packages are maintained by third parties and may be less up to date. [![Packaging status](https://repology.org/badge/vertical-allrepos/broot.svg)](https://repology.org/project/broot/versions) ## Homebrew ```bash brew install broot ``` ## MacPorts ```bash sudo port selfupdate sudo port install broot ``` ## Scoop ```bash scoop install broot ``` ## Alpine Linux ```bash apk add broot ``` *note: broot package is available in Alpine 3.13 and newer* ## APT / Deb Ubuntu and Debian users may use this apt repository: [https://packages.azlux.fr/](https://packages.azlux.fr/) ## NetBSD ```bash pkgin install broot ``` ## Gentoo Linux ```bash emerge broot ``` # Reinstall To reinstall, just change the executable. It's always been compatible with the previous configuration files but if your previous installation is old (especially if it's pre 1.14), you might want to get the new [configuration files](https://github.com/Canop/broot/tree/main/resources/default-conf) which have more relevant sections. The simplest solution is to remove your old configuration directory (or rename if you want to keep things) so that broot recreates it. # After installation Now you should 1. [install the br shell function](../install-br/) 2. have a look at the verbs.hjson configuration file, and especially setup the editor of your choice ================================================ FILE: website/src/js/broot.js ================================================ window.addEventListener("load", function(){ // Join code blocks with the same set of languages // then add tabs to switch between them tab_langs.group("Hjson", "JSON", "TOML"); // Highlight all code blocks hljs.registerAliases(["Hjson", "hjson"], { languageName: "rust" }); hljs.highlightAll(); }); ================================================ FILE: website/src/js/tab-langs.js ================================================ ;window.tab_langs = (function(){ // find and group the pre/code elements with the matching languages // return [[e]] function find_groups(langs) { let groups = []; document.querySelectorAll("code").forEach(function(code){ let lang = langs.find( lang => code.className.toLowerCase().split(/[ -]/).includes(lang.toLowerCase()) ); if (!lang) return; let pre = code.parentElement; pre.classList.add("tabbed"); let last_group = groups[groups.length-1]; let item = { lang, pre }; if (last_group) { if (last_group[last_group.length-1].pre == pre.previousElementSibling) { last_group.push(item); return; } } groups.push([item]); }); return groups; } function add_tabs(group) { let tabs = document.createElement("div"); tabs.className = "lang-tabs"; group.forEach((item, idx) => { let tab = document.createElement("span"); tab.className = "lang-tab"; tab.textContent = item.lang; tabs.appendChild(tab); if (idx) item.pre.style.display = "none"; else tab.classList.add("active"); tab.addEventListener("click", function(){ tabs.querySelectorAll(".lang-tab").forEach(t => t.classList.remove("active")); group.forEach((_item, _idx) => { if (_idx == idx) _item.pre.style.display = ""; else _item.pre.style.display = "none"; }); this.classList.add("active"); }); }); group[0].pre.parentNode.insertBefore(tabs, group[0].pre); } // group together with tabs the provided languages function group(...langs) { let groups = find_groups(langs); for (let group of groups) { add_tabs(group); } } return { find_groups, add_tabs, group, }; })(); ================================================ FILE: website/src/launch.md ================================================ # Launch Broot When the installation is [complete](../install/#installation-completion-the-br-shell-function), you may start broot with either broot or br If your shell is compatible, you should prefer `br` which enables some features like `cd` from broot. You can pass as argument the path you want to see, for example br ~ Broot renders on `stderr` and can be ran in a subshell, which means you can also (on Unix) do things like my_unix_command "$(broot some_dir)" and quit broot with `:pp` on the selected path. But most often you'll more conveniently simply add your command (and maybe a shortcut) to the [config file](../conf_file/#verbs-shortcuts-and-keys). # Launch Arguments **broot** and **br** can be passed as argument the path to display, either a directory or a file. When it's a file, it's opened in preview. They also accept a few other arguments which you can view with `br --help`. ## display toggles Many of the launch arguments toggle display settings and are usually in pair: a lowercase toggle argument is uppercased for the opposite action. Here are the most frequently used: * `-d` and `-D`: showing (or hiding) dates * `-f` and `-F`: showing only folders * `-g` and `-G`: git info * `-h` and `-H`: whether to show "hidden" files (whose name starts with a dot on unix) * `-i` and `-I`: whether to show git-ignored files * `-p` and `-P`: permissions, user, group * `-s` and `-S`: size of files and directories * `-w` and `-W`: whale mode, useful to find what takes space on your disks Those toggles can be combined. For example, to show files that aren't usually displayed, and also to show sizes, use `br -shi`. They can also be used as verbs in the application. Instead of launching broot as `br -shi`, you could, when in broot, type a space or colon then `-shi` and enter. ## the `--outcmd` launch argument Some external commands can't be executed from a program. This is especially the case of `cd`, which isn't a program but a shell function. In order to have any useful effect, it must be called from the parent shell, the one from which broot was launched, and a shell which isn't accessible from broot. The trick to enable broot to `cd` your shell when you do `alt-enter` is the following one: * **br** is a shell function. It creates a temp file whose path it gives as argument to **broot** using `--outcmd` * when you do `alt-enter`, **broot** writes `cd your-selected-path` in this file, then quits * **br** reads the file, deletes it, then evaluates the command Most users have no reason to use `--outcmd` on their own, but it can still be used to write an alternative to **br** or to port it to shells which aren't currently supported. ## the `--cmd` launch argument This argument lets you pass commands to broot. Those commands are executed exactly like any command you would type yourself in the application. Commands must be separated. The default separator is the semicolon (`;`) but another separator may be provided using the `BROOT_CMD_SEPARATOR` environment variable (the separator may be several characters long if needed). Broot waits for the end of execution of every command. For example if you launch br --cmd cow / Then broot is launched in the `/` directory and there's a filter typed for you. If you do br --cmd "/^vache;:p" Then broot looks for a file whose name starts with "vache" and focus its parent. If you do br -c "/mucca$;:cd" then broot searches for a file whose name ends with "mucca", and `cd` to the closest directory, leaving you on the shell, in your new directory (you may not have the time to notice the broot GUI was displayed). If you do BROOT_CMD_SEPARATOR=@ broot -c ":gi@target@:pp" then broot toggles the git_ignore filter, searches for `target` then prints the selection path on stdout (when doing it in my broot repository, I get `/home/dys/dev/broot/target`). The `--cmd` argument may be the basis for many of your own shell functions or programs. # Environment Variables Most users don't have to bother with environment variables. But they come handy in some cases, so here's a complete reference of the variables read by broot. Variables whose names doesn't start with `BR_` or `BROOT_` aren't specific to broot and may be already present in your system. variable | usage -|- `BROOT_CONFIG_DIR` | Optional path to the config directory. If not set, broot uses the conventions of the system, for example `~/.config/broot` `BR_INSTALL` | Setting it to `no` prevents broot for installing the `br` shell function `BROOT_CMD_SEPARATOR` | Set the separator to use in command sequences, e.g. `BROOT_CMD_SEPARATOR=',' br -c 'filter,:pp'` `BROOT_LOG` | Set up the [log level](../community/#log) (default is none), for example `BROOT_LOG=debug br` `COLORTERM` | If this conventional variable contains `24bit` or `truecolor`, then broot won't limit itself to a reduced set of colors when rendering images. This may also be set in conf with `true_colors: true` `TERM` or `TERMINAL` | If one of them contains `kitty`, then broot will use Kitty's [terminal graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) to render images in high definition `TERM_PROGRAM` and `TERM_PROGRAM_VERSION` | If the current terminal is [Wezterm](https://wezfurlong.org/wezterm/index.html) with a recent enough version, broot recognizes it with those variables and uses the Kitty's terminal graphics protocol to render images `TMUX_NEST_COUNT` | When using Kitty's terminal graphics protocol in nested tmux, tell broot how deeply tmux is nested. Starts at 1 when there's no nesting. `COLORFGBG` | This is one of the ways the [terminal-light](https://github.com/Canop/terminal-light) library uses to detect whether your terminal is set in dark or light mode ================================================ FILE: website/src/modal.md ================================================ # Why The "modal mode", which may be familiar to vim users, changes a little the way you interact with broot: - The input at the bottom isn't immediately focused, you must type a space, a `:`, or a `/` to focus it. And you unfocus it with the escape key. - The upside is you can use keyboard shortcuts without ctrl, for example you may move the selection with j and k. - The downside is you have one letter more to type to start searching, which isn't to dismiss as searching is usually the first thing you do in broot. I recommend you **don't** activate this mode until you really tried broot. Broot isn't a text editor and can't be confused with one. This mode may be more comfortable when you constantly jump from vim to broot but only after you understood how broot works. You may be an avid vim user, as I am, and still prefer not to use modality in broot. Starting in *command* mode means you have one more letter to type before searching, because search is done in *input* mode. And broot is search oriented and often used in very short sessions (less than 5 seconds from intent to launch to being back in the shell in the right directory or editing the right file in your favorite editor). # Configuration You need first to enable the "modal mode" and to decide whether you want to start in `command` or `input` mode: ```hjson modal: true initial_mode: command ``` ```TOML # note that this must be near the start of the configuration file modal = true initial_mode = "command" ``` If `modal` isn't set to `true`, the single letter shortcuts you define in configuration will be ignored (so you don't have to remove them if you don't want modality anymore). # Usage Broot may be in one of two modes: 1. **input** mode, with the input field at the bottom focused and received standard keys 1. **command** mode, with input not focused, and single key shortcuts enabled In *command* mode, you'll find those keys already configured: * `j` and `k` to go down and up * `h` and `l` to go to parent or to enter a directory You enter *input* mode by typing one of those letters: ` ` (space), `:`, or `/`. You leave it with the `escape` key. You may add other bindings to the `:mode_input` and `:mode_command` verbs. ================================================ FILE: website/src/navigation.md ================================================ # Basics When you start broot, the current directory is displayed, with most often some directories open and some lines truncated, in order to fit the available height. The first line is called the root, and is currently selected. From here you may navigate using the following keys: * or : select the next or previous line * ctrl or ctrl : focus (or open) a panel to the left or to the right * on a file : open the file using xdg-open (or your OS equivalent) * alt on a file : leave broot and open the file using xdg-open * on a directory : focus the directory (i.e. make it the new root) * alt on a directory : leave broot and `cd` the shell to that directory. * on the first line : goes up one level (focus the parent directory) * esc gets you back to the previous state (or leave broot if there's none) * ? brings you to the help screen There are also a few more shortcuts: * you can quit with Ctrlq * you can select a line with a mouse click * you can open a line with a mouse double-click and you can define your own [shortcuts](../conf_verbs/#shortcuts-and-verb-search) or triggering [keyboard keys](../conf_verbs/#keyboard-key). # Fuzzy Patterns The best way to navigate is by filtering the tree. This is done by typing a few letters. The pattern filters the tree while you type. It's interpreted in a fuzzy way so that you don't have to type all the letters or even consecutive letters. The best match is automatically selected. For example: ![search hel](img/20190305-search-hel.png) Hitting esc clears the current pattern. # Regular Expressions If there's a `/` before or after the pattern, it's interpreted as a regular expression. For example `/pat+ern` would match `"patern.zip"` or `"some_patttern.rar"` but not `"pATTern"`. If you want the regex to be case insensitive, add the `i` flag: `/pat+ern/i`. # File content searches To display only files containing `"memmap"`, type `c/memmap`: ![content](img/20200620-content-search.png) (as the search is displayed in real time you'll usually stop as soon as you have the right matches) # Composite patterns Simple patterns can be composed with the `!`, `&` and `|` operators. Examples: If you don't want to see files whose name ends in `"rs"`, you may type `!/rs$` (it's the negation of the `/rs$` regular expression). If you want to see all files containing `"pattern"` but not the rust ones, you'll type `!rs&c/pattern`: ![composite](img/20200620-composite-notrs.png) If you're looking for a file whose name you don't remember exactly ("rust_test" ? "test-rust" ?), you may type `test&rust` meaning the name contains both "test" and "rust". # More about searches If you want to know more about the exact pattern syntax, see [reference](../input/#the-filtering-pattern) and [examples](../input/#examples). # Total Search When you search in broot in a very big directory on a slow disk, broot doesn't always look at all files. It stops when it found enough matches and then rates those matches. If you think there might be a better match, hidden deeper, you may require a *total search*, which is a search which look at *all* files. This is done using the `:search_again` verb, which may be triggered with the CtrlS key combination (you may redefine it, see [configuration](../conf_file/#keyboard-key)). As for other searches, it's interrupted as soon as you type anything. # Search again If no filtering is active, hit CtrlS to bring back the last used filtering pattern. # Quitting broot Other than executing a command leaving broot, there are several ways to quit: * hit ctrlQ or ctrlC * type `:q` or `space` `q` then `enter` * call any other verb whose action quits broot, for example `:print_path` ================================================ FILE: website/src/panels.md ================================================ Broot can display two panels. Most frequent uses are: * previewing files * copying or moving files between two locations # Keyboard shortcuts The ctrl and ctrl shortcuts should be enough to support all common panel related operation: * When there's only one panel, use ctrl to open a panel to the left, and ctrl to open one to the right * When two panels are open, you may go to the panel left of the current one with ctrl and to the panel to the right with ctrl * When two panels are open, you may close the non selected one by going the opposite direction (i.e. if you're in the left panel, you may close the right one with ctrl) The type of open panel depends on the selection: * If the current selection is a directory, the new panel will be a file tree * If the current selection is a regular file, the new panel will be a preview You may also close the current panel with ctrlW, which is a shortcut for `:close_panel` (you can [change all bindings](../conf_verbs/#keyboard-key)). **Note:** Depending on your system and terminal, the ctrl and ctrl key bindings might not convenient or not usable. In such a case, you should [rebind](../conf_verbs/#keyboard-key) the `:panel_left` and `:panel_right` internals. # Use a verb to open a panel Another way to open a panel is to add a bang (`!`) to a verb. It tells broot to show the result in a new panel. For example, while `:focus ~` navigates to your home directory in the current panel, you can use `:!focus ~` or `:focus! ~` to open a new panel on your home. # The preview panel ![preview](img/20200716-preview.png) It's not immediately focused on creation, because most often you'll want to preview a few files and it's convenient to stay in the tree to navigate. To focus it, for example to scroll it or to do a search, do ctrl again. Files that can't be interpreted as text or image are shown as binary: ![binary](img/2020081609-preview-binary.png) You can search with fuzzy patterns or regular expressions inside a text preview panel: ![search-preview](img/20200727-search-preview.png) You can go from the selected matched line to the unfiltered text, at the right place, with ctrl (and then back to the list of matching lines with ctrl). Hopefully [this blog post](https://dystroy.org/blog/broot-c-search/) should make the complete search workflow look natural. # Copy & move between panels When exactly two panels are displayed, `{other-panel-file}` `{other-panel-directory}`, and `{other-panel-parent}` are available for verbs. Two built-in verbs use those arguments: `:copy_to_panel` (alias `:cpp`) and `:move_to_panel` (alias `:mvp`). By having two panels displayed you can thus copy (or move) the current panel's selection to the other one: ![cpp](img/20200525-cpp.png) The default configuration file contains this that you may uncomment to add F5 and F6 shortcuts: ```Hjson # { # key: F5 # internal: ":copy_to_panel" # } # { # key: F6 # internal: ":move_to_panel" # } ``` ```TOML # [[verbs]] # key = "F5" # internal = ":copy_to_panel" # # [[verbs]] # key = "F6" # internal = ":move_to_panel" ``` You may define other shortcuts, or your own bi-panels verbs. # Edit a verb argument Assuming you started from just one panel and wanted to execute a command taking a path as argument. You may use tab-completion to type it faster but you may also hit ctrlP to create a panel and select it. Here's the complete workflow. * Start with selecting a file and typing the verb of your choice: ![image](img/20200520-ctrlp-1.png) * hit ctrl-p. This opens a new panel which becomes the focused panel: ![image](img/20200520-ctrlp-2.png) * navigate to the desired destination using standard broot features: ![image](img/20200520-ctrlp-3.png) * hit ctrl-p again, which closes the panel and updates the input in the first panel: ![image](img/20200520-ctrlp-4.png) You may now hit enter to execute the command, maybe after having completed the path. This workflow is based on the `:start_end_panel` verb which can be bound to another key if desired. # More panels The default configuration limits the number of panels to two, because most people never needs more and it makes it easier to alternate between one or two panels. But if you want more panels, for a specific configuration of for your main one, you may change the value of `max_panels_count` in the configuration file. If your terminal is wide enough, you may then open more panels: ![image](img/20200526-3-panels.png) # Resize panels 3 verbs are at your disposal if you ever need to change the width of panels: * `move_panel_divider`: eg `move_panel_divider 0 -4` moves the first divider 4 cells to the left, making the second panel wider * `set_panel_width`: eg `set_panel_width 1 50` sets to 50 cells the width of the second panel * `default_layout` removes the previous resizing instructions You may bind keyboard shortcuts, eg (in the `verbs` array of verbs.hjson) ```hjson { invocation: move_divider_left key: alt-< execution: ":move_panel_divider 0 -1" leave_broot: false } { invocation: move_divider_right key: alt-> execution: ":move_panel_divider 0 1" leave_broot: false } ``` Resizing instructions can also be provided in a configuration file, eg ```hjson layout_instructions: [ { panel: 1, width: 80 } ] ``` or ```hjson layout_instructions: [ { divider: 0, dx: 5 } ] ``` ================================================ FILE: website/src/remote.md ================================================ # Presentation Broot can also act as client or server, which lets you * control broot from another process * query the state of broot from another process Example use cases: * synchronize broot with another program (shell, editor, etc.), both ways * have a viewer automatically display the file selected in broot * have broot automatically show the content of a directory focused in another program **Note:** This feature is only available on unix like systems today because the current implementation is based on unix sockets. # Usage 3 launch arguments are involved: * `--listen ` : listen on a specific socket * `--send `: send the command(s) to the given server and quit * `--get-root`: ask the server for its current root (in the active panel) For example if you start broot with br --listen my_broot broot will run normally but will *also* listen to commands sent from elsewhere (using linux sockets). Now that the "server" is running, try launching a command from another terminal: br --send my_broot -c "img;:parent;:focus" this will make the running "server" search for something like "img" and focus its parent. If you run br --send my_broot --get-root then the server's current root is printed on stdout. If you pass neither the `--get-root` nor the `--cmd` (shortened in `-c`) argument, then the server is told to focus the current directory or the path given as argument. # Hooks ## zsh `chpwd(){ ( broot --send global_file_viewer "$PWD" & ) > /dev/null 2>&1 }` ================================================ FILE: website/src/skins.md ================================================ # Install a skin To setup a new skin 1. download or create a configuration file, either TOML or Hjson, with a `skin` map 2. [import](../conf_file#imports) this file from the main one, either unconditionally or depending on the terminal's luma (dark or light) If you installed broot since version 1.14, you should already have a few skins in your configuration directory. You may look for other ones in the [dev repo](https://github.com/Canop/broot/tree/main/resources/default-conf). The default configuration selects the skin according to the light or dark mode. # Skin definition A skin is defined by a `[skin]` section in a TOML or Hjson configuration file. For example: ```Hjson skin: { default: gray(23) none / gray(20) none tree: ansi(94) None / gray(3) None parent: gray(18) None / gray(13) None file: gray(20) None / gray(15) None directory: "#fb0 None Bold / ansi(172) None bold" exe: Cyan None link: Magenta None pruning: gray(12) None Italic perm__: gray(5) None perm_r: ansi(94) None perm_w: ansi(132) None perm_x: ansi(65) None owner: ansi(138) None group: ansi(131) None count: ansi(136) gray(3) dates: ansi(66) None sparse: ansi(214) None content_extract: ansi(29) None content_match: ansi(34) None device_id_major: ansi(138) None device_id_sep: ansi(102) None device_id_minor: ansi(138) None git_branch: ansi(229) None git_insertions: ansi(28) None git_deletions: ansi(160) None git_status_current: gray(5) None git_status_modified: ansi(28) None git_status_new: ansi(94) None Bold git_status_ignored: gray(17) None git_status_conflicted: ansi(88) None git_status_other: ansi(88) None selected_line: None gray(5) / None gray(4) char_match: Yellow None file_error: Red None flag_label: gray(15) None flag_value: ansi(208) None Bold input: White None / gray(15) gray(2) status_error: gray(22) ansi(124) status_job: ansi(220) gray(5) status_normal: gray(20) gray(3) / gray(2) gray(2) status_italic: ansi(208) gray(3) / gray(2) gray(2) status_bold: ansi(208) gray(3) Bold / gray(2) gray(2) status_code: ansi(229) gray(3) / gray(2) gray(2) status_ellipsis: gray(19) gray(1) / gray(2) gray(2) purpose_normal: gray(20) gray(2) purpose_italic: ansi(178) gray(2) purpose_bold: ansi(178) gray(2) Bold purpose_ellipsis: gray(20) gray(2) scrollbar_track: gray(7) None / gray(4) None scrollbar_thumb: gray(22) None / gray(14) None help_paragraph: gray(20) None help_bold: ansi(208) None Bold help_italic: ansi(166) None help_code: gray(21) gray(3) help_headers: ansi(208) None help_table_border: ansi(239) None preview_title: gray(23) None / gray(21) None preview: gray(20) gray(1) / gray(18) gray(2) preview_separator: ansi(94) None / gray(3) None preview_line_number: gray(12) gray(3) preview_match: None ansi(29) hex_null: gray(11) None hex_ascii_graphic: gray(18) None hex_ascii_whitespace: ansi(143) None hex_ascii_other: ansi(215) None hex_non_ascii: ansi(167) None staging_area_title: gray(22) None / gray(20) None mode_command_mark: gray(5) ansi(204) Bold good_to_bad_0: ansi(28) good_to_bad_1: ansi(29) good_to_bad_2: ansi(29) good_to_bad_3: ansi(29) good_to_bad_4: ansi(29) good_to_bad_5: ansi(100) good_to_bad_6: ansi(136) good_to_bad_7: ansi(172) good_to_bad_8: ansi(166) good_to_bad_9: ansi(196) } ``` ```toml [skin] default = "gray(23) none / gray(20) none" tree = "ansi(94) None / gray(3) None" parent = "gray(18) None / gray(13) None" file = "gray(20) None / gray(15) None" directory = "#fb0 None Bold / ansi(172) None bold" exe = "Cyan None" link = "Magenta None" pruning = "gray(12) None Italic" perm__ = "gray(5) None" perm_r = "ansi(94) None" perm_w = "ansi(132) None" perm_x = "ansi(65) None" owner = "ansi(138) None" group = "ansi(131) None" count = "ansi(136) gray(3)" dates = "ansi(66) None" sparse = "ansi(214) None" content_extract = "ansi(29) None" content_match = "ansi(34) None" device_id_major = "ansi(138) None" device_id_sep = "ansi(102) None" device_id_minor = "ansi(138) None" git_branch = "ansi(229) None" git_insertions = "ansi(28) None" git_deletions = "ansi(160) None" git_status_current = "gray(5) None" git_status_modified = "ansi(28) None" git_status_new = "ansi(94) None Bold" git_status_ignored = "gray(17) None" git_status_conflicted = "ansi(88) None" git_status_other = "ansi(88) None" selected_line = "None gray(5) / None gray(4)" char_match = "Yellow None" file_error = "Red None" flag_label = "gray(15) None" flag_value = "ansi(208) None Bold" input = "White None / gray(15) gray(2)" status_error = "gray(22) ansi(124)" status_job = "ansi(220) gray(5)" status_normal = "gray(20) gray(3) / gray(2) gray(2)" status_italic = "ansi(208) gray(3) / gray(2) gray(2)" status_bold = "ansi(208) gray(3) Bold / gray(2) gray(2)" status_code = "ansi(229) gray(3) / gray(2) gray(2)" status_ellipsis = "gray(19) gray(1) / gray(2) gray(2)" purpose_normal = "gray(20) gray(2)" purpose_italic = "ansi(178) gray(2)" purpose_bold = "ansi(178) gray(2) Bold" purpose_ellipsis = "gray(20) gray(2)" scrollbar_track = "gray(7) None / gray(4) None" scrollbar_thumb = "gray(22) None / gray(14) None" help_paragraph = "gray(20) None" help_bold = "ansi(208) None Bold" help_italic = "ansi(166) None" help_code = "gray(21) gray(3)" help_headers = "ansi(208) None" help_table_border = "ansi(239) None" preview_title = "gray(23) None / gray(21) None" preview = "gray(20) gray(1) / gray(18) gray(2)" preview_line_number = "gray(12) gray(3)" preview_separator: "ansi(94) None / gray(3) None" preview_match = "None ansi(29)" hex_null = "gray(11) None" hex_ascii_graphic = "gray(18) None" hex_ascii_whitespace = "ansi(143) None" hex_ascii_other = "ansi(215) None" hex_non_ascii = "ansi(167) None" staging_area_title = "gray(22) None / gray(20) None" mode_command_mark = "gray(5) ansi(204) Bold" good_to_bad_0 = "ansi(28)" good_to_bad_1 = "ansi(29)" good_to_bad_2 = "ansi(29)" good_to_bad_3 = "ansi(29)" good_to_bad_4 = "ansi(29)" good_to_bad_5 = "ansi(100)" good_to_bad_6 = "ansi(136)" good_to_bad_7 = "ansi(172)" good_to_bad_8 = "ansi(166)" good_to_bad_9 = "ansi(196)" ``` This would look like this: ![custom colors tree](img/20200525-custom-colors-panels.png) Each skin entry value is made of * a foreground color * a background color (or `none`) * zero, one, or more *attributes* Those three parts can be repeated, after a `/`, to define the style to use in non focused panels (when more than one panel is used). Example: ```hjson directory: "ansi(208) None Bold / ansi(172) None" ``` ```toml directory = "ansi(208) None Bold / ansi(172) None" ``` ## Color A color in a skin or in the [ext_colors](../conf_file/#colors-by-file-extension) section can be: * `none` * an [Ansi value](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit), for example `ansi(160)` * a grayscale value, with a level between 0 and 23, for example `grey(3)` * a RGB color, for example `rgb(255, 187, 0)` or `#fb0` Warnings: * many terminals aren't compatible with RGB 24 bits colors (or aren't usually configured for) * when using the hexa notation for colors (eg `#45ff1e`) in Hjson, the skin entry must be between quotes as the `#` character marks comments ## Style attributes Currently supported attributes are: * bold * crossedout * italic * overlined * reverse * underlined Note that some of them may be ignored by your terminal especially if you're not on a Unix system. The mapping between keys and screen parts may not always be obvious. Don't hesitate to come ask for help on [Miaou](https://miaou.dystroy.org/3490?broot). ## Transparent background If you want to set the background of broot transparent (i.e. to be the background of your terminal), you can set the default style like this: ```hjson default: "gray(23) none / gray(20) none" ``` ```toml default = "gray(23) none / gray(20) none" ``` ![transparent](img/20200529-transparent-broot.png) ## Good to Bad scale The `good_to_bad_0` to `good_to_bad_9` entries define a scale used to express the cluttering of filesystems: ![fs](img/20230517-fs.png) Only the foreground color is used, so you may use the shortened definition, eg ```hjson good_to_bad_9: rgb(200, 15, 14) ``` There's no obligation to really use a green to red scale. You may even use only one color if you like. # Contribute your own skin Don't hesitate to contact me on [Miaou](https://miaou.dystroy.org/3490) if you have a skin to propose or to discuss an existing one. ================================================ FILE: website/src/staging-area.md ================================================ The staging area is broot's solution to let you execute commands on several files in one go. When the staging area is focused, commands apply to all the files it contains. # Stage and unstage files You can change the bindings to the `:stage`, `:unstage` and `:toggle_stage` verbs. The standard bindings are below verb | default binding | comment -|-|- `:toggle_stage` | ctrlg | the easiest solution when not using broot in [modal](../modal) `:stage` | + | only in [command mode](../modal#usage) `:unstage` | - | only in [command mode](../modal#usage) `:clear_stage` | | shortcut: `:cls` When staging a file, the staging area opens (but doesn't get focused) if it wasn't and there's not already the max number of panels open. # Execute a command Focus the staging area (usually with ctrl) then type the verb in the input. The verb will be executed, in order, to all files of the staging area. ![staging mv](img/20210424-staging-mv.png) Computed groups which would have the same value for all files are shown in the status bar. For example here, when you type ` mv ../app-panels`, broot can tell you that it will run `mv {file} /home/dys/dev/broot/src/app-panels/` for each file of the staging area. Some verbs aren't compatible with execution on the staging area: * Verbs which don't come back to broot after execution (for example `:cd` or any verb quitting broot) * [Sequences](../conf_verbs#cmd-execution) # Read the staging area The staging area can be opened or closed with the `:open_staging_area`, `:close_staging_area`, and `:toggle_staging_area` verbs, which have shortcuts `:osa`, `:csa`, and `:tsa`. But you rarely need those verbs as the staging area opens when you add to it and closes when it goes empty. You can filter, select, scroll, as in other panels. This may be convenient either to unstage a precise file from the staging area, or to check some files are present (or not) when there are many of them. ![staging filter](img/20210425-staging-filter.png) # Tips All files from left panel can be added at once to the staging panel with `ctrl-a`. ================================================ FILE: website/src/trash.md ================================================ ## Commands On Windows and Linux, some trash related 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. ================================================ FILE: website/src/tree_view.md ================================================ # Introduction The tree view gives you an overview of the current directory, fitting into your screen even when there are millions of files. It does so with a parallel trimming of child directories to give you a balanced view: ![tree view basic](img/tree_view-basic.png) This basic representation will probably be the one you'll most frequently use but you'll also want at times - to show hidden files, or ignored ones - to show the many properties of the files and directories - to sort files - to disable trimming - to show git work information - to show the device's occupation - to trim or not # hidden & ignored files With default configuration, hidden files (the ones whose name starts with a dot), ignored files (due to a `.ignore` or `.gitignore` file) are initially hidden. If you don't want to hide those files, you may either 1. launch broot with flags: `br -hi` would show both categories, try `br --help` to see the description of those flags 2. set the flags directly in your config file: see [default flags](../conf_file/#default-flags) 3. toggle hiding when in the application The toggle commands are `:toggle_hidden` (shortcut: `:h`) and `:toggle_ignore` (shortcut: `:gi`). If you use those toggles frequently enough, you'll remember the alth alti key combinations. And if you forget the combinations, have a look at [the help](../help/#verbs). **Details:** The files applicable to a directory are: - the global `.gitignore` file (if any) - all the `.ignore` files found in the current directory and in parents - the `.git/info/exclude` file of the current git repository - all the `.gitignore` files found in the current directory and in parents but not outside the current git repository (i.e. not in git repositories containing the current git repository) Deeper files have a bigger priority. `.ignore` files have a bigger priority than `.gitignore` files. Later rules override previous rules. # File Properties File properties are shown with "toggle" commands, which have shortcuts and which can be [bound](../conf_verbs/#keyboard-key) to key combinations if you want. ## Owner and Permissions Use `:toggle_perm` (shortcut: `:perm`) to show unix permissions (depending on your system): ![perm](img/tree-perm.png) (you could have them from the start by launching broot with `br -p`) ## Sizes Use `:toggle_sizes` (shortcut: `:size`) to show the size of files and directories. The size of directories is computed in background: if you have a great number of files in some directories, their size may not be displayed immediately but you can go on using broot while the computation is done (results are cached so you may move around and come back to find the sizes already here). ![size](img/tree-sizes.png) To the right of the name of the root directory, you'll notice some disk information: type, total size, occupation. This is the disk holding the root directory (and most usually also its children). **Note:** The displayed size on Unix is the space the file takes on disk, that is the number of blocks multiplied by the size of a block. If a file is sparse, a little 's' is displayed next to the size. ## Last Modification Dates The last modification date is computed in a very similar way: it's the max modification date of a directory's content. You display it with `:toggle_dates` (shortcut: `:dates`). ![date](img/tree-dates.png) ## Counts Similar again is the number of files in directories (or `1` for files). Here it is, combined with the sizes: ![size](img/tree-sizes-and-counts.png) # Sort By default, files are sorted with a rough alpha order. To see the available sorts, type `:sort`: broot proposes you the possible completions (you may rotate between them with the tab key): ![sorts](img/sorts.png) There are 4 kinds of sort: * sort by date * sort by count * sort by size * sort by type, with directories either first or last The 3 first kinds involve that only one level of files is displayed, there's no visible file hierarchy. Here's for example all files of my home directory sorted by date (this is a cool way to find out on Monday morning what you were doing before the week-end): ![sort_by_date](img/sort_by_date.png) ## Whale Mode Sorting by size is the basis of the "whale mode" (dedicated to finding the big fat files). This mode involves also showing all files. You start it with `br -w` and it sports bars to make relavive sizes more obvious: ![br -w](img/br-w.png) # Git information With `:toggle_git_file_info`) (shortcut: `:gf`) you can see what files have been modified, or are new. The top line tells you on what branch you are and summarizes changes: ![gf](img/gf.png) With [some customization](https://dystroy.org/blog/gg/) you have the perfect tool for reviewing your changes before a commit. # Auto-Refresh With `:toggle_watch`) (shortcut: `:watch`) you can ask broot to watch for changes in the current directory and its descendants, and to automatically refresh the view. When broot is watching, an eye is visible to the left of the status line: ![watch](img/watch.png) On some platforms, it's possible for broot to be unable to watch some huge directories. In such cases, broot simply leaves watch mode, the eye disappears, and you can try again watching after having moved to a smaller directory. # All Toggles Each of those toggles lets you alternate between 2 or 3 modes. | name | shortcut | key |description |----------------------|----------|-------|---------------------------------------------- | toggle_counts | counts | | toggle showing deep counts of files in directories | toggle_dates | dates | | toggle showing last modified dates (deep computed) | toggle_files | files | | toggle showing files (or just folders) | toggle_git_file_info | gf | | toggle display of git file information | toggle_ignore | gi | alti | toggle use of .gitignore and .ignore | toggle_hidden | h | alth | toggle showing hidden files | toggle_perm | perm | | toggle showing file permissions (Unix only) | toggle_sizes | sizes | | toggle showing sizes | toggle_trim_root | t | | toggle removing nodes at first level too | toggle_tree | tree | | toggle showing file tree (when not affected by sorting mode) | toggle_watch | watch | altw | toggle refreshing on file/directory changes To apply one, type a space (or `:`), then the start of its shortcut, then hit . For example typing `:s` then enter will show file and directory sizes. Those toggles may also be defined with [launch option syntax](../launch#display-toggles) (ie type a space, then `-hi` then enter) and the [default_flags preference](../conf_file/#default-flags). And you can see them in the application with ?. ================================================ FILE: website/src/tricks.md ================================================ # Replace tree This bash function gives you a better `tree` optimizing for the height of the screen: function tree { br -c :pt "$@" } ![tree](img/20241011-alias-tree.png) This function supports most broot arguments: ![tree with args](img/20241011-tree-with-args.png) # Search again The search normally stops after some time, or when it found enough matches (that is a few times what can be displayed, in order to only show the ones with the best ranking). Sometimes, you want a more exhaustive search: - you want to see **all** matches - you want the search to go deeper to find better ranked matches, even if it's slower Then, you can hit ctrls, which calls `:search_again` and ensures the whole tree is searched and all matches are displayed. The downside is it takes time and the most relevant matches may be hard to find among hundred of less relevant ones which wouldn't be shown otherwise. ctrls is also used to bring back the last used pattern, for example when you focused a directory to search locally # A generic fuzzy finder The goal here is to have a function you can use in shell to give you a path. **Step 1:** create a file `~/.config/broot/select.toml` with this content: ```Hjson verbs: [ { invocation: "ok" key: "enter" leave_broot: true execution: ":print_path" apply_to: "file" } ] ``` ```TOML [[verbs]] invocation = "ok" key = "enter" leave_broot = true execution = ":print_path" apply_to = "file" ``` **Step 2:** create a shortcut of some type, for example using `~/.bash_aliases` ``` alias bo="br --conf ~/.config/broot/select.toml" ``` **Step 3:** you can then use broot as a selector in other commands: ``` echo $(bo) ``` or ``` echo $(bo some/path) ``` Here, the configuration file was used to ensure you can select a file with the enter key. You may use the same configuration file to also specify colors to remember yourself you're not in a standard broot. # dcd : Deep fuzzy cd When you want to cd to a deep directory, using `br` is fast and easy enough: * you type `br` (and `enter`) * you type the name of the deep folder (or part of it) * you check the right line is selected * you do `alt-enter` * you're done But when you frequently go to a few specific places, you may prefer a shortcut. As broot can be driven by commands, you can define this function: # deep fuzzy cd function dcd { br --only-folders --cmd "$1;:cd" } (paste this for example in your .bashrc) This is the *"I'm feeling lucky"* of broot, you can use it to directly jump to directories you know, when you don't need the interactive search of br. Example: ![dcd ruleset](img/20190122-dcd_rulset.png) # Run a script or program If your system is normally configured, doing `alt`-`enter` on an executable will close broot and execute the file. # Change file opening When you hit enter on a file, broot asks the system to open the file. It's usually the best solution as it selects the program according to the file's type and to settings you set system wide. If you're editing text files in your terminal (vi, emacs, helix, etc.), then you'd rather have your editor open in the same terminal on enter, and be back to broot on quitting it. Here's an example: ```Hjson { invocation: edit key: enter shortcut: e execution: "/usr/bin/nvim +{line} {file}" apply_to: text_file leave_broot: false } ``` ```TOML [[verbs]] invocation = "edit" key = "enter" shortcut = "e" execution = "/usr/bin/nvim +{line} {file}" apply_to = "text_file" leave_broot = false ``` You'll also need such kind of setting if your computer is missing xdg-open or equivalent. If you need to use a different application for some kind(s) of file, you may additionally [filter by extension](../conf_verbs/#file-extensions). # Git Status If you want to start navigating with a view of the files which changed, you may do br -ghc :gs Then just hitting the `esc` key will show you the normal unfiltered broot view. (note: this isn't equivalent to `git status`. Most notably, removed files aren't displayed) From there you may use the `:gd` verb (`:git_diff`) to open the selection into your favourite diff viewer. If you want more: [Use broot and meld to diff before commit](https://dystroy.org/blog/gg/). # Negative filters Here's a (real) example of how negative filters and combination can help you navigate. Here's the initial view of a directory in which you land: ![initial view](img/20200709-combneg-1.png) Type `!txt` to hide unwanted files: ![without txt](img/20200709-combneg-2.png) (it's filtered as you type so you stop at `!tx`, it's enough) Now let's add `&` then some letters of what we want. ![on target](img/20200709-combneg-3.png) We can also select the desired file with arrow keys at this point. When you grasped the basic logic of [combined filters](../input/#combining-filtering-patterns), navigation is incredibly efficient. # Composite searches in preview You can apply composition and negation to searches in the preview panel which is convenient when filtering, for example, a log file. In this example I show lines containing "youtube" but not "txt" nor " 0 ms". ![search log](img/20200716-search-log.png) # Escape key Broot usage, just like vim, relies a lot on the esc key. If you're a frequent user of the terminal, you may want to remap an easy to reach and otherwise useless key (for example caps-lock) to esc. This brings a lot of comfort, not just in broot. # Vim integration/plugin In case you want to use broot for opening files fuzzily in vim (and potentially replace netrw), check out: [broot.vim](https://github.com/lstwn/broot.vim) # Unignore files A gitignored file may be made visible in broot with a `.ignore` file with a negative pattern. For example, assuming the `.gitignore` file contains `/work`, you may have the `work` directory always visible with a `!/work` entry in a `.ignore` file. A common pattern is to globally define files that you want gitignored but visible in broot: In `~/.config/git/ignore` : ```ignore .ignore my-notes.* ``` In `~/.ignore` : ```ignore !my-notes.* ``` This way you don't have to specify anything in your git repositories. ================================================ FILE: website/src/verbs.md ================================================ When you used a toggle, you executed a command in it simplest form: without argument and independent from the current selection. The simplest verbs are just executed by typing a space (or `:`), then its first letters, then enter. A verb can be related to the current selection. For example typing `:p` will execute the `:parent` verb, which focuses the parent of the selection (*focusing* a directory means making it the current root). Verbs are listed in help. As there are many of them, it can be sometimes useful to use pattern filtering to look at what's available for your concern. For example to see [stage](../staging-area) related verbs and their shortcuts: ![help filter](img/20210425-help-filter-stage.png) # Verbs using the selection The `rm` verb executes the standard `rm` command. It's defined by this couple (invocation, external): ```Hjson invocation: "rm" external: "rm -rf {file}" ``` ```TOML invocation = "rm" external = "rm -rf {file}" ``` Selection based arguments: name | expanded to -|- `{file}` | the complete path of the current selection `{parent}` | the complete path of the current selection's parent `{directory}` | the closest directory, either `{file}` or `{parent}` `{other-panel-file}` | the complete path of the current selection in the other panel `{other-panel-parent}` | the complete path of the current selection's parent in the other panel `{other-panel-directory}` | the closest directory, either `{file}` or `{parent}` in the other panel Several selection based arguments can be used. For example the (built-in) `:copy_to_panel` verb is defined as ```Hjson invocation: "copy_to_panel" external: "cp -r {file} {other-panel-directory}" ``` ```TOML invocation = "copy_to_panel" external = "cp -r {file} {other-panel-directory}" ``` When you type a verb, the execution pattern is completed using the selection(s), the exact command is displayed in the status line: ![rm](img/20190305-rm.png) As for filters, hitting esc clears the command. # Verbs using user provided arguments Some commands not only use the selection but also takes one or several argument(s). For example mkdir is virtually defined as ```Hjson invocation: "mkdir {subpath}" external: "mkdir -p {directory}/{subpath}" ``` ```TOML invocation = "mkdir {subpath}" external = "mkdir -p {directory}/{subpath}" ``` (it's now a built-in, you won't see it in the config file) which means that if you type `c/d`, and the file `/a/b/some_file.rs` is selected, then the created directory would be `a/b/c/d`. Example: Before you type a subpath, broot tells you, in red, the argument is missing: ![md](img/20191112-md-missing-subpath.png) If you type an argument, the command to execute is computed and shown: ![md](img/20191112-md-list.png) In this screenshot, you didn't type `mkdir` or its start but `md`. That's because the complete definition of this verb includes this line: ```Hjson shortcut: "md" ``` ```TOML shortcut = "md" ``` **Note:** The help screen lists the whole set of available verbs, including the ones coming from the configuration. # Tab completion When you type a verb, a few letters are often enough because broot just want enough of them to be sure there's no confusion. But sometimes there are a lot of verbs with the same start (especially if you add them liberally in the config file). You might want to have broot complete or propose the few possible completions. The tab key can be used for this purpose. Tab completion is probably more useful even with paths you provide to verbs. It works intuitively. Note: there's another solution to gain time when typing a path, especially when you're not sure of it: hitting ctrlp will open a new panel in which you can navigate until you have your selection that you validate with another hit on ctrlp (see [panels](/panels)). # Builtins & external commands, leaving or not There are three types of verbs (they will be covered in more details in the [configuration page](../conf_verbs/#verb-definition-attributes)): * builtin features apply internal functions, for example `:toggle_perm` to trigger computation and display of Unix file permissions * external commands, whose execution implies calling an external program, for example `rm -rf {file}` * sequences of commands A command may leave broot (for example to start a program), or not (the tree will be refreshed). # Exclamation mark The exclamation mark can be used to open the execution result in a new panel instead of replacing the current one. It can be located before or after the verb. Examples: command | result -|- `:focus!` | focus the current directory in a new panel `:!help` | open the help side to your content `:!focus ~` | show your home directory in a new panel It can be used in a verb declaration in configuration too. # Adding verbs You may start with the common set of verbs but you'll very quickly want to define how to edit or create files, and probably have a few personal commands. That's why should see [how to configure verbs](../conf_verbs/#verbs-shortcuts-and-keys).