Release 0.21.0 (#602)

* Add xplr.util.lscolor and xplr.util.paint (#569)

* Add xplr.util.lscolor and xplr.util.style

* Fix formatting

* Fix clippy suggestions

* Remove redundant closures

* Optimize, support NO_COLOR, and rename style to paint

* Use xplr.util.paint and xplr.util.color in init.lua

Co-authored-by: Noah Mayr <dev@noahmayr.com>

* Add utility function xplr.util.textwrap (#567)

* Add utility function xplr.util.wrap

* Cleanup and fix formatting

* Update src/lua/util.rs

Co-authored-by: Arijit Basu <sayanarijit@users.noreply.github.com>

* Update wrap to return lines instead

* Fix doc

* Rename wrap -> text wrap

Co-authored-by: Arijit Basu <sayanarijit@users.noreply.github.com>
Co-authored-by: Arijit Basu <sayanarijit@gmail.com>

* Add xplr.util.relative_to and xplr.util.path_shorthand (#568)

* Add xplr.util.relative_to and xplr.util.path_shorthand

* Remove duplicate slash at end

* Use pwd from env and remove pathdiff package

* Some fixes and improvements

* Generate docs

* Some more improvements

* Improve selection rendering

* Improve functions with test cases

* Update docs

* Minor doc fix

* Rename path_shorthand -> shortened

* Handle homedir edgecase

Also fix init.lua

* Minor fix

* Use config argument for relative and shortened paths

* Prefix relative paths with "." and fix edge cases where we're not showing the file name

* Use and_then instead of map and flatten

* WIP: Move selection rendering to lua

* Make selection renderer function configurable on lua side

* Some improvements

* Some impovements

* Minor doc fix

* Remove symlink style

---------

Co-authored-by: Arijit Basu <sayanarijit@gmail.com>

* Add xplr.util.layout_replaced (#574)

Closes: https://github.com/sayanarijit/xplr/issues/573

* Improve selection operations (#575)

- `:sl` to list selection.
- `:ss` to softlink.
- `:sh` to hardlink.
- Avoid conflict by adding suffix.
- Unselect individual path only on operation success.

Closes:

- https://github.com/sayanarijit/xplr/issues/572
- https://github.com/sayanarijit/xplr/issues/571
- https://github.com/sayanarijit/xplr/issues/570

* Minor updates

* Add more features (#581)

* Add more features

- Key binding ":se" to edit selection list in $EDITOR
- New utility functions:
  - xplr.util.clone
  - xplr.util.exists
  - xplr.util.is_dir
  - xplr.util.is_file
  - xplr.util.is_symlink
  - xplr.util.is_absolute
  - xplr.util.path_split
  - xplr.util.node

Closes: https://github.com/sayanarijit/xplr/issues/580
Closes: https://github.com/sayanarijit/xplr/issues/579
Closes: https://github.com/sayanarijit/xplr/issues/577

* Fix edit selection list

* Fix clippy lints

* Fix layout link in doc

* xplr.util.shortened -> xplr.util.shorten

* Fix more clippy lints

* Fix xplr.util.shorten name change

* More UI utilities and improvements (#582)

* More UI utilities and improvements

- Apply style only to the file column in the table.
- Properly quote paths.
- Expose the applicable style from config in the table renderer argument.
- Add utility functions:
  - xplr.util.node_type
  - xplr.util.style_mix
  - xplr.util.shell_escape

* Make escaping play nice with shorten

* Fix tests

* Fix doc

* Some fixes

* Fix selection editor

* Fix clear selection for selection editor

* Add selection navigation (#583)

* Add selection navigation

- FocusNextSelection      (ctrl-n)
- FocusPreviousSelection  (ctrl-p)

Also improve batch operations

* Minor doc fixes

* Minor doc fix

* Remove tab -> ctrl-i binding

* Improve batch operation interaction

- More robust focus operation.
- Focus on failed to delete paths.

* Fix Rust compatibility

* Fix panic on permission denial

Also, improve the error messages.

* More logging improvements

* Fix layout_replace only working with table parameters (#586)

* Improve builtin search mode (#585)

* Improve builtin search mode

* Remove commented out code

* Make search ranking and algorithm more extensible

* Flatten messages

BREAKING: xplr.config.general.sort_and_filter_ui.search_identifier -> xplr.config.general.sort_and_filter_ui.search_identifiers

Messages:

- Search
- SearchFromInput
- SearchFuzzy
- SearchFuzzyUnranked
- SearchFuzzyUnrankedFromInput
- SearchRegexUnrankedFromInput
- SearchRegex
- SearchRegexUnranked
- SearchRegexUnrankedFromInput
- SearchRegexUnrankedFromInput
- CycleSearchAlgorithm
- EnableRankedSearch
- DisableRankedSearch
- ToggleRankedSearch

Static config:

xplr.config.general.search.algorithm = "Fuzzy"

* Handle search ranking in search algorithm

* Make CycleSearchAlgorithm only cycle between algorithms, without changing ranking

* Separate algorithm and ordering

* Minor doc updates

* Some cleanup

* Final touch

* Cycle -> Toggle

---------

Co-authored-by: Arijit Basu <sayanarijit@gmail.com>

* Fix layout replace for unit layouts (#588)

* Allow custom title and ui config in custom layout. (#589)

* Allow custom title and ui config in custom layout.

Adds the following layouts:

- Static
- Dynamic

Deprecates `CustomContent` (but won't be removed to maintain compatibility).

Closes: https://github.com/sayanarijit/xplr/issues/563

* Delete init.lua

* Update docs/en/src/layout.md

* Update docs/en/src/layout.md

* Rename

- Paragraph => CustomParagraph
- List => CustomList
- Table => CustomTable

Also update init.lua

* Fix clippy errs

* Fix doc links

* Fix search order

* Improve working with file permissions (#591)

* Improve working with file permissions

Implements:

- xplr.util.permissions_rwx
- xplr.util.permissions_octal

* Edit permissions

* Add permissions in Resolved Node (#592)

* Add permissions in Relolved Node

And handle application/x-executable mime type.

* Fix bench

* Improve permissions editor

* More permissions editor improvements

* Doc updates

* Remove ResolvedNode.permissions (#593)

Reason: Too much serialization making lua calls slow.

* Add workaround for macos with legacy coreutils (#595)

Refs:
- https://github.com/sayanarijit/xplr/issues/594
- https://github.com/sayanarijit/xplr/issues/559

* Use H:M:S format to display logs (#596)

* Keep the selection list and clear manually (#597)

* Keep the selection list and clear manually

Ref: https://github.com/sayanarijit/map.xplr/issues/4

* Fix linting err

* Fix broken history (#599)

* Fix broken hostory

Fixes: https://github.com/sayanarijit/xplr/issues/598

* Minor cleanup

* Slightly optimize selection retention (#600)

* Update deps

* chrono -> time

* update: 0.20.2 -> 0.21.1

* Update post-install.md

* Upgrade guide

* Minor fix

* Fix tests

* Add missing doc

* Fix clippy lints

---------

Co-authored-by: Noah Mayr <dev@noahmayr.com>
extend-selection v0.21.0
Arijit Basu 1 year ago committed by GitHub
parent 59279b816d
commit e0d683b13a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

824
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -8,7 +8,7 @@ path = './benches/criterion.rs'
[package]
name = 'xplr'
version = '0.20.2'
version = '0.21.0'
authors = ['Arijit Basu <hi@arijitbasu.in>']
edition = '2021'
description = 'A hackable, minimal, fast TUI file explorer'
@ -22,20 +22,29 @@ categories = ['command-line-interface', 'command-line-utilities']
include = ['src/**/*', 'docs/en/src/**/*', 'LICENSE', 'README.md']
[dependencies]
libc = "0.2.139"
libc = "0.2.140"
humansize = "2.1.3"
natord = "1.0.9"
anyhow = "1.0.68"
serde_yaml = "0.9.16"
crossterm = "0.25.0"
dirs = "4.0.0"
ansi-to-tui = "2.0.0"
anyhow = "1.0.70"
serde_yaml = "0.9.19"
crossterm = "0.26.1"
dirs = "5.0.0"
ansi-to-tui-forked = "3.0.0-ratatui"
regex = "1.7.1"
gethostname = "0.4.1"
fuzzy-matcher = "0.3.7"
serde_json = "1.0.91"
serde_json = "1.0.94"
path-absolutize = "3.0.14"
which = "4.3.0"
which = "4.4.0"
nu-ansi-term = "0.47.0"
textwrap = "0.16"
snailquote = "0.3.1"
skim = "0.10.4"
time = { version = "0.3.20", features = ["serde", "local-offset", "formatting", "macros"] }
[dependencies.lscolors]
version = "0.13.0"
default-features = false
features = ["nu-ansi-term"]
[dependencies.lazy_static]
version = "1.4.0"
@ -46,24 +55,21 @@ version = "2.0.4"
features = ["rev-mappings"]
[dependencies.tui]
version = "0.19.0"
version = "0.20.0"
default-features = false
features = ['crossterm', 'serde']
package = 'ratatui'
[dependencies.serde]
version = "1.0.152"
version = "1.0.157"
features = ['derive']
[dependencies.chrono]
version = "0.4.23"
features = ['serde']
[dependencies.indexmap]
version = "1.9.2"
features = ['serde']
[dependencies.mlua]
version = "0.8.7"
version = "0.8.8"
features = ['luajit', 'vendored', 'serialize', 'send']
[dependencies.tui-input]
@ -72,7 +78,7 @@ features = ['serde']
[dev-dependencies]
criterion = "0.4.0"
assert_cmd = "2.0.8"
assert_cmd = "2.0.10"
[profile.release]
lto = true

@ -18,8 +18,9 @@ fn navigation_benchmark(c: &mut Criterion) {
});
let lua = mlua::Lua::new();
let mut app = app::App::create(PWD.into(), &lua, None, [].into())
.expect("failed to create app");
let mut app =
app::App::create("xplr".into(), None, PWD.into(), &lua, None, [].into())
.expect("failed to create app");
app = app
.clone()
@ -97,8 +98,9 @@ fn draw_benchmark(c: &mut Criterion) {
});
let lua = mlua::Lua::new();
let mut app = app::App::create(PWD.into(), &lua, None, [].into())
.expect("failed to create app");
let mut app =
app::App::create("xplr".into(), None, PWD.into(), &lua, None, [].into())
.expect("failed to create app");
app = app
.clone()

@ -22,6 +22,7 @@
- [Input Operation][39]
- [Borders][31]
- [Style][11]
- [Searching][41]
- [Sorting][12]
- [Filtering][13]
- [Column Renderer][26]
@ -79,3 +80,4 @@
[38]: messages.md
[39]: input-operation.md
[40]: xplr.util.md
[41]: searching.md

@ -20,6 +20,10 @@ A border can be one of the following:
- Double
- Thick
### Border Style
The [style][1] of the borders.
## Example
```lua
@ -28,3 +32,5 @@ xplr.config.general.panel_ui.default.border_type = "Thick"
xplr.config.general.panel_ui.default.border_style.fg = "Black"
xplr.config.general.panel_ui.default.border_style.bg = "Gray"
```
[1]: style.md#style

@ -73,6 +73,7 @@ The special argument contains the following fields
- [is_selected][25]
- [is_focused][26]
- [total][27]
- [style][38]
- [meta][28]
### parent
@ -254,6 +255,12 @@ Type: integer
The total number of the nodes.
### style
Type: [Style][39]
The applicable [style object][39] for the node.
### meta
Type: mapping of string and string
@ -333,3 +340,5 @@ It contains the following fields.
[35]: #last_modified
[36]: #uid
[37]: #gid
[38]: #style
[39]: style.md#style

@ -23,8 +23,10 @@ of [modes][4] and the key mappings for each mode.
| G | | go to bottom |
| V | ctrl-a | select/unselect all |
| ctrl-d | | duplicate as |
| ctrl-i | tab | next visited path |
| ctrl-i | | next visited path |
| ctrl-n | | next selection |
| ctrl-o | | last visited path |
| ctrl-p | | prev selection |
| ctrl-r | | refresh screen |
| ctrl-u | | clear selection |
| ctrl-w | | switch layout |
@ -47,89 +49,16 @@ of [modes][4] and the key mappings for each mode.
| ~ | | go home |
| [0-9] | | input |
### filter
| key | remaps | action |
| --------- | ------ | ---------------------------------- |
| R | | relative path does not match regex |
| backspace | | remove last filter |
| ctrl-r | | reset filters |
| ctrl-u | | clear filters |
| r | | relative path does match regex |
### vroot
| key | remaps | action |
| ------ | ------ | ------------ |
| . | | vroot $PWD |
| / | | vroot / |
| ~ | | vroot $HOME |
| v | | toggle vroot |
| ctrl-r | | reset vroot |
| ctrl-u | | unset vroot |
### create_file
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
### selection_ops
| key | remaps | action |
| --- | ------ | --------------- |
| c | | copy here |
| m | | move here |
| u | | clear selection |
### create
| key | remaps | action |
| --- | ------ | ---------------- |
| d | | create directory |
| f | | create file |
### quit
| key | remaps | action |
| ----- | ------ | ----------------------- |
| enter | | just quit |
| f | | quit printing focus |
| p | | quit printing pwd |
| r | | quit printing result |
| s | | quit printing selection |
### switch_layout
| key | remaps | action |
| --- | ------ | -------------------- |
| 1 | | default |
| 2 | | no help menu |
| 3 | | no selection panel |
| 4 | | no help or selection |
### delete
| key | remaps | action |
| --- | ------ | ------------ |
| D | | force delete |
| d | | delete |
### relative_path_does_not_match_regex
| key | remaps | action |
| ----- | ------ | ------ |
| enter | | submit |
### number
| key | remaps | action |
| ----- | ------ | -------- |
| down | j | to down |
| enter | | to index |
| k | up | to up |
| [0-9] | | input |
| v | | toggle vroot |
| ~ | | vroot $HOME |
### relative_path_does_match_regex
@ -137,7 +66,7 @@ of [modes][4] and the key mappings for each mode.
| ----- | ------ | ------ |
| enter | | submit |
### create_directory
### go_to_path
| key | remaps | action |
| ----- | ------ | ------------ |
@ -151,19 +80,24 @@ of [modes][4] and the key mappings for each mode.
| enter | | submit |
| tab | | try complete |
### rename
### debug_error
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
| key | remaps | action |
| ----- | ------ | ------------------- |
| enter | | open logs in editor |
| q | | quit |
### go_to_path
### selection_ops
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
| key | remaps | action |
| --- | ------ | --------------- |
| c | | copy here |
| e | | edit selection |
| h | | hardlink here |
| l | | list selection |
| m | | move here |
| s | | softlink here |
| u | | clear selection |
### sort
@ -189,24 +123,86 @@ of [modes][4] and the key mappings for each mode.
| r | | by relative path |
| s | | by size |
### go_to
| key | remaps | action |
| --- | ------ | -------------- |
| f | | follow symlink |
| g | | top |
| i | | initial $PWD |
| p | | path |
| x | | open in gui |
### edit_permissions
| key | remaps | action |
| ------ | ------ | ------ |
| G | | -group |
| M | | min |
| O | | -other |
| U | | -user |
| ctrl-r | | reset |
| enter | | submit |
| g | | +group |
| m | | max |
| o | | +other |
| u | | +user |
### switch_layout
| key | remaps | action |
| --- | ------ | -------------------- |
| 1 | | default |
| 2 | | no help menu |
| 3 | | no selection panel |
| 4 | | no help or selection |
### create
| key | remaps | action |
| --- | ------ | ---------------- |
| d | | create directory |
| f | | create file |
### create_directory
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
### create_file
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
### search
| key | remaps | action |
| ------ | ------ | ---------------- |
| ctrl-n | down | down |
| ctrl-p | up | up |
| enter | | submit |
| esc | | cancel |
| left | | back |
| right | | enter |
| tab | | toggle selection |
| key | remaps | action |
| ------ | ------ | ----------------------- |
| ctrl-a | | toggle search algorithm |
| ctrl-f | | fuzzy search |
| ctrl-n | down | down |
| ctrl-p | up | up |
| ctrl-r | | regex search |
| ctrl-s | | sort (no search order) |
| ctrl-z | | toggle ordering |
| enter | | submit |
| esc | | cancel |
| left | | back |
| right | | enter |
| tab | | toggle selection |
### debug_error
### number
| key | remaps | action |
| ----- | ------ | ------------------- |
| enter | | open logs in editor |
| q | | quit |
| key | remaps | action |
| ----- | ------ | -------- |
| down | j | to down |
| enter | | to index |
| k | up | to up |
| [0-9] | | input |
### action
@ -217,22 +213,53 @@ of [modes][4] and the key mappings for each mode.
| e | | open in editor |
| l | | logs |
| m | | toggle mouse |
| p | | edit permissions |
| q | | quit options |
| s | | selection operations |
| v | | vroot |
| [0-9] | | go to index |
### filter
| key | remaps | action |
| --------- | ------ | ---------------------------------- |
| R | | relative path does not match regex |
| backspace | | remove last filter |
| ctrl-r | | reset filters |
| ctrl-u | | clear filters |
| r | | relative path does match regex |
### rename
| key | remaps | action |
| ----- | ------ | ------------ |
| enter | | submit |
| tab | | try complete |
### relative_path_does_not_match_regex
| key | remaps | action |
| ----- | ------ | ------ |
| enter | | submit |
### quit
| key | remaps | action |
| ----- | ------ | ----------------------- |
| enter | | just quit |
| f | | quit printing focus |
| p | | quit printing pwd |
| r | | quit printing result |
| s | | quit printing selection |
### recover
| key | remaps | action |
| --- | ------ | ------ |
### go_to
### delete
| key | remaps | action |
| --- | ------ | -------------- |
| f | | follow symlink |
| g | | top |
| i | | initial $PWD |
| p | | path |
| x | | open in gui |
| key | remaps | action |
| --- | ------ | ------------ |
| D | | force delete |
| d | | delete |

@ -181,6 +181,30 @@ Constraint for the column widths.
Type: nullable list of [Constraint](https://xplr.dev/en/layouts#constraint)
#### xplr.config.general.selection.item.format
Renderer for each item in the selection list.
Type: nullable string
#### xplr.config.general.selection.item.style
Style for each item in the selection list.
Type: [Style](https://xplr.dev/en/style)
#### xplr.config.general.search.algorithm
The default search algorithm
Type: [Search Algorithm](https://xplr.dev/en/searching#algorithm)
#### xplr.config.general.search.unordered
The default search ordering
Type: boolean
#### xplr.config.general.default_ui.prefix
The content that is placed before the item name for each row by default.
@ -322,12 +346,24 @@ Type: nullable mapping of the following key-value pairs:
- format: nullable string
- style: [Style](https://xplr.dev/en/style)
#### xplr.config.general.sort_and_filter_ui.search_identifier
#### xplr.config.general.sort_and_filter_ui.search_identifiers
The identifiers used to denote applied search input.
Type: { format = nullable string, style = [Style](https://xplr.dev/en/style) }
#### xplr.config.general.sort_and_filter_ui.search_direction_identifiers.ordered.format
The shape of ordered indicator for search ordering identifiers in Sort & filter panel.
Type: nullable string
#### xplr.config.general.sort_and_filter_ui.search_direction_identifiers.unordered.format
The shape of unordered indicator for search ordering identifiers in Sort & filter panel.
Type: nullable string
#### xplr.config.general.panel_ui.default.title.format
The content for panel title by default.

@ -32,56 +32,61 @@ A layout can be one of the following:
- [Selection][11]
- [HelpMenu][12]
- [SortAndFilter][13]
- [CustomContent][25]
- [Static][25]
- [Dynamic][26]
- [Horizontal][14]
- [Vertical][16]
- CustomContent (deprecated, use `Static` or `Dynamic`)
### Nothing
This layout contains a blank panel.
Example: "Nothing"
Type: "Nothing"
### Table
This layout contains the table displaying the files and directories in the
current directory.
This layout contains the table displaying the files and directories in the current
directory.
### InputAndLogs
This layout contains the panel displaying the input prompt and logs.
Example: "InputAndLogs"
Type: "InputAndLogs"
### Selection
This layout contains the panel displaying the selected paths.
Example: "Selection"
Type: "Selection"
### HelpMenu
This layout contains the panel displaying the help menu for the current mode in
real-time.
Example: "HelpMenu"
Type: "HelpMenu"
### SortAndFilter
This layout contains the panel displaying the pipeline of sorters and filters
applied of the list of paths being displayed.
This layout contains the panel displaying the pipeline of sorters and filters applied on
the list of paths being displayed.
Example: "SortAndFilter"
Type: "SortAndFilter"
### Custom Content
### Static
Custom content is a special layout to render something custom.
It contains the following information:
This is a custom layout to render static content.
Type: { Static = [Custom Panel][27] }
### Dynamic
- [title][33]
- [body][34]
This is a custom layout to render dynamic content using a function defined in
[xplr.fn][28] that takes [Content Renderer Argument][36] and returns [Custom Panel][27].
Example: { CustomContent = { title = [title][33], body = [body][34] }
Type: { Dynamic = [Content Renderer][35] }
### Horizontal
@ -92,7 +97,7 @@ It contains the following information:
- [config][15]
- [splits][17]
Example: { Horizontal = { config = [config][15], splits = [splits][17] }
Type: { Horizontal = { config = [config][15], splits = [splits][17] }
### Vertical
@ -103,7 +108,7 @@ It contains the following information:
- [config][15]
- [splits][17]
Example: { Vertical = { config = [config][15], splits = [splits][17] }
Type: { Vertical = { config = [config][15], splits = [splits][17] }
## Layout Config
@ -166,187 +171,153 @@ Type: list of [Layout][3]
The list of child layouts to fit into the parent layout.
## title
Type: nullable string
The title of the panel.
## body
## Custom Panel
Type: [Content Body][26]
Custom panel can be one of the following:
The body of the panel.
- [CustomParagraph][29]
- [CustomList][30]
- [CustomTable][31]
## Content Body
Content body can be one of the following:
- [StaticParagraph][27]
- [DynamicParagraph][28]
- [StaticList][29]
- [DynamicList][30]
- [StaticTable][31]
- [DynamicTable][32]
## Static Paragraph
### CustomParagraph
A paragraph to render. It contains the following fields:
- **render** (string): The string to render.
- **ui** (nullable [Panel UI Config][32]): Optional UI config for the panel.
- **body** (string): The string to render.
#### Example: Render a custom static paragraph
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = {
StaticParagraph = { render = "custom body" },
Static = {
CustomParagraph = {
ui = { title = { format = " custom title " } },
body = "custom body",
},
},
}
```
## Dynamic Paragraph
A [Lua function][35] to render a custom paragraph.
It contains the following fields:
- **render** (string): The [lua function][35] that returns the paragraph to
render.
#### Example: Render a custom dynamic paragraph
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = { DynamicParagraph = { render = "custom.render_layout" } },
},
}
xplr.config.layouts.builtin.default = { Dynamic = "custom.render_layout" }
xplr.fn.custom.render_layout = function(ctx)
return ctx.app.pwd
return {
CustomParagraph = {
ui = { title = { format = ctx.app.pwd } },
body = xplr.util.to_yaml(ctx.app.focused_node),
},
}
end
```
## Static List
### CustomList
A list to render. It contains the following fields:
- **render** (list of string): The list to render.
- **ui** (nullable [Panel UI Config][32]): Optional UI config for the panel.
- **body** (list of string): The list of strings to display.
#### Example: Render a custom static list
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = {
StaticList = { render = { "1", "2", "3" } },
Static = {
CustomList = {
ui = { title = { format = " custom title " } },
body = { "1", "2", "3" },
},
},
}
```
## Dynamic List
A [Lua function][35] to render a custom list.
It contains the following fields:
- **render** (string): The [lua function][35] that returns the list to render.
#### Example: Render a custom dynamic list
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = { DynamicList = { render = "custom.render_layout" } },
},
}
xplr.config.layouts.builtin.default = { Dynamic = "custom.render_layout" }
xplr.fn.custom.render_layout = function(ctx)
return {
ctx.app.pwd,
ctx.app.version,
tostring(ctx.app.pid),
CustomList = {
ui = { title = { format = ctx.app.pwd } },
body = {
(ctx.app.focused_node or {}).relative_path or "",
ctx.app.version,
tostring(ctx.app.pid),
},
},
}
end
```
## Static Table
## CustomTable
A table to render. It contains the following fields:
A custom table to render. It contains the following fields:
- **ui** (nullable [Panel UI Config][32]): Optional UI config for the panel.
- **widths** (list of [Constraint][22]): Width of the columns.
- **col_spacing** (nullable int): Spacing between columns. Defaults to 1.
- **render** (list of list of string): The rows and columns to render.
- **body** (list of list of string): The rows and columns to render.
#### Example: Render a custom static table
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = {
StaticTable = {
widths = {
{ Percentage = 50 },
{ Percentage = 50 },
},
col_spacing = 1,
render = {
{ "a", "b" },
{ "c", "d" },
},
Static = {
CustomTable = {
ui = { title = { format = " custom title " } },
widths = {
{ Percentage = 50 },
{ Percentage = 50 },
},
body = {
{ "a", "b" },
{ "c", "d" },
},
},
},
}
```
## Dynamic Table
A [Lua function][35] to render a custom table.
It contains the following fields:
- **widths** (list of [Constraint][22]): Width of the columns.
- **col_spacing** (nullable int): Spacing between columns. Defaults to 1.
- **render** (string): The [lua function][35] that returns the table to render.
#### Example: Render a custom dynamic table
```lua
xplr.config.layouts.builtin.default = {
CustomContent = {
title = "custom title",
body = {
DynamicTable = {
widths = {
{ Percentage = 50 },
{ Percentage = 50 },
},
col_spacing = 1,
render = "custom.render_layout",
},
},
},
}
xplr.config.layouts.builtin.default = {Dynamic = "custom.render_layout" }
xplr.fn.custom.render_layout = function(ctx)
return {
{ "", "" },
{ "Layout height", tostring(ctx.layout_size.height) },
{ "Layout width", tostring(ctx.layout_size.width) },
{ "", "" },
{ "Screen height", tostring(ctx.screen_size.height) },
{ "Screen width", tostring(ctx.screen_size.width) },
CustomTable = {
ui = { title = { format = ctx.app.pwd } },
widths = {
{ Percentage = 50 },
{ Percentage = 50 },
},
body = {
{ "", "" },
{ "Layout height", tostring(ctx.layout_size.height) },
{ "Layout width", tostring(ctx.layout_size.width) },
{ "", "" },
{ "Screen height", tostring(ctx.screen_size.height) },
{ "Screen width", tostring(ctx.screen_size.width) },
},
},
}
end
```
## Panel UI Config
It contains the following optional fields:
- **title** ({ format = "string", style = [Style][33] }): the title of the panel.
- **style** ([Style][33]): The style of the panel body.
- **borders** (nullable list of [Border][34]): The shape of the borders.
- **border_type** ([Border Type][54]): The type of the borders.
- **border_style** ([Style][33]): The style of the borders.
## Content Renderer
It is a Lua function that receives [a special argument][36] as input and
@ -421,16 +392,16 @@ Hence, only the following fields are avilable.
[22]: #constraint
[23]: https://s6.gifyu.com/images/layout.png
[24]: https://gifyu.com/image/1X38
[25]: #custom-content
[26]: #content-body
[27]: #static-paragraph
[28]: #dynamic-paragraph
[29]: #static-list
[30]: #dynamic-list
[31]: #static-table
[32]: #dynamic-table
[33]: #title
[34]: #body
[25]: #static
[26]: #dynamic
[27]: #custom-panel
[28]: configuration.md#function
[29]: #customparagraph
[30]: #customlist
[31]: #customtable
[32]: #panel-ui-config
[33]: style.md#style
[34]: borders.md#border
[35]: #content-renderer
[36]: #content-renderer-argument
[37]: #size
@ -450,3 +421,4 @@ Hence, only the following fields are avilable.
[51]: layouts.md
[52]: lua-function-calls.md#vroot
[53]: lua-function-calls.md#initial_pwd
[54]: borders.md#border-type

@ -368,26 +368,9 @@ Type: list of [Node Sorter Applicable][81]
### searcher
Type: nullable [Node Searcher][82]
The searcher to use (if any).
## Node Searcher
Node Searcher contains the following fields:
- [pattern][83]
- [recoverable_focus][84]
### pattern
The patters used to search.
Type: string
### recoverable_focus
Where to focus when search is cancelled.
Type: nullable string
Type: nullable [Node Searcher Applicable][82]
## Also Ssee:
@ -457,7 +440,5 @@ Type: nullable string
[79]: #searcher
[80]: filtering.md#node-filter-applicable
[81]: sorting.md#node-sorter-applicable
[82]: #node-searcher
[83]: #pattern
[84]: #recoverable_focus
[82]: searching.md#node-searcher-applicable
[85]: xplr.util.md

@ -97,6 +97,15 @@ Example:
- Lua: `"FocusNext"`
- YAML: `FocusNext`
#### FocusNextSelection
Focus on the next selected node.
Example:
- Lua: `"FocusNextSelection"`
- YAML: `FocusNextSelection`
#### FocusNextByRelativeIndex
Focus on the `n`th node relative to the current focus where `n` is a
@ -128,6 +137,15 @@ Example:
- Lua: `"FocusPrevious"`
- YAML: `FocusPrevious`
#### FocusPreviousSelection
Focus on the previous selection item.
Example:
- Lua: `"FocusPreviousSelection"`
- YAML: `FocusPreviousSelection`
#### FocusPreviousByRelativeIndex
Focus on the `-n`th node relative to the current focus where `n` is a
@ -1012,6 +1030,28 @@ Example:
### Search Operations
#### Search
Search files using the current or default (fuzzy) search algorithm.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Type: { Search = "string" }
Example:
- Lua: `{ Search = "pattern" }`
- YAML: `Search: pattern`
#### SearchFromInput
Calls `Search` with the input taken from the input buffer.
Example:
- Lua: `"SearchFromInput"`
- YAML: `SearchFromInput`
#### SearchFuzzy
Search files using fuzzy match algorithm.
@ -1030,12 +1070,126 @@ Example:
Calls `SearchFuzzy` with the input taken from the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Example:
- Lua: `"SearchFuzzyFromInput"`
- YAML: `SearchFuzzyFromInput`
#### SearchFuzzyUnordered
Like `SearchFuzzy`, but doesn't not perform rank based sorting.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Type: { SearchFuzzyUnordered = "string" }
Example:
- Lua: `{ SearchFuzzyUnordered = "pattern" }`
- YAML: `SearchFuzzyUnordered: pattern`
#### SearchFuzzyUnorderedFromInput
Calls `SearchFuzzyUnordered` with the input taken from the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Example:
- Lua: `"SearchFuzzyUnorderedFromInput"`
- YAML: `SearchFuzzyUnorderedFromInput`
#### SearchRegex
Search files using regex match algorithm.
It keeps the filters, but overrides the sorters.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Type: { SearchRegex = "string" }
Example:
- Lua: `{ SearchRegex = "pattern" }`
- YAML: `SearchRegex: pattern`
#### SearchRegexFromInput
Calls `SearchRegex` with the input taken from the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Example:
- Lua: `"SearchRegexFromInput"`
- YAML: `SearchRegexFromInput`
#### SearchRegexUnordered
Like `SearchRegex`, but doesn't not perform rank based sorting.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Type: { SearchRegexUnordered = "string" }
Example:
- Lua: `{ SearchRegexUnordered = "pattern" }`
- YAML: `SearchRegexUnordered: pattern`
#### SearchRegexUnorderedFromInput
Calls `SearchRegexUnordered` with the input taken from the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
It gets reset automatically when changing directory.
Example:
- Lua: `"SearchRegexUnorderedFromInput"`
- YAML: `SearchRegexUnorderedFromInput`
#### ToggleSearchAlgorithm
Toggles between different search algorithms, without changing the input
buffer
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
Example:
- Lua: `"ToggleSearchAlgorithm"`
- YAML: `ToggleSearchAlgorithm`
#### EnableSearchOrder
Enables ranked search without changing the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
Example:
- Lua: `"EnableOrderedSearch"`
- YAML: `EnableSearchOrder`
#### DisableSearchOrder
Disabled ranked search without changing the input buffer.
You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
Example:
- Lua: `"DisableSearchOrder"`
- YAML: `DisableSearchOrder`
#### ToggleSearchOrder
Toggles ranked search without changing the input buffer.
Example:
- Lua: `"ToggleSearchOrder"`
- YAML: `ToggleSearchOrder`
#### AcceptSearch
Accepts the search by keeping the latest focus while in search mode.

@ -143,6 +143,12 @@ The builtin vroot mode.
Type: [Mode](https://xplr.dev/en/mode)
#### xplr.config.modes.builtin.edit_permissions
The builtin edit permissions mode.
Type: [Mode](https://xplr.dev/en/mode)
#### xplr.config.modes.custom
This is where you define custom modes.

@ -0,0 +1,77 @@
# Searching
xplr supports searching paths using different algorithm. The search mechanism
generally appears between filters and sorters in the `Sort & filter` panel.
Example:
```
fzy:foo↓
```
This line means that the nodes visible on the table are being filtered using the
[fuzzy matching][1] algorithm on the input `foo`. The arrow means that ranking based
ordering is being applied, i.e. [sorters][2] are being ignored.
## Node Searcher Applicable
Node Searcher contains the following fields:
- [pattern][3]
- [recoverable_focus][4]
- [algorithm][5]
- [unordered][7]
### pattern
The patters used to search.
Type: string
### recoverable_focus
Where to focus when search is cancelled.
Type: nullable string
### algorithm
Search algorithm to use. Defaults to the value set in
[xplr.config.general.search.algorithm][8].
It can be one of the following:
- Fuzzy
- Regex
### unordered
Whether to skip ordering the search result by algorithm based ranking. Defaults
to the value set in [xplr.config.general.search.unordered][9].
Type: boolean
## Example:
```lua
local searcher = {
pattern = "pattern to search",
recoverable_focus = "/path/to/focus/on/cancel",
algorithm = "Fuzzy",
unordered = false,
}
xplr.util.explore({ searcher = searcher })
```
See [xplr.util.explore][6].
[1]: https://en.wikipedia.org/wiki/Approximate_string_matching
[2]: sorting.md
[3]: #pattern
[4]: #recoverable_focus
[5]: #algorithm
[6]: xplr.util.md#xplrutilexplore
[7]: #unordered
[8]: general-config.md#xplrconfiggeneralsearchalgorithm
[9]: general-config.md#xplrconfiggeneralsearchunordered

@ -45,6 +45,82 @@ compatibility.
### Instructions
#### [v0.20.2][48] -> [v0.21.0][49]
- Some plugins might stop rendering colors. Wait for them to update.
- Rename `xplr.config.general.sort_and_filter_ui.search_identifier` to
`xplr.config.general.sort_and_filter_ui.search_identifiers`.
- Resolved Node API will not contain the `permissions` field anymore.
Use the utility function `xplr.util.node` to get its permissions.
- Layout `CustomContent` has been undocumented. It will stay for compatibility,
but you should prefer using the following new layouts, because they support
custom title:
- Static
- Dynamic
- Use the new messages for improved search operations:
- Search
- SearchFromInput
- SearchFuzzyUnordered
- SearchFuzzyUnorderedFromInput
- SearchRegex
- SearchRegexFromInput
- SearchRegexUnordered
- SearchRegexUnorderedFromInput
- ToggleSearchAlgorithm
- EnableSearchOrder
- DisableSearchOrder
- ToggleSearchOrder
- Use skim's [search syntax][50] to customize the search.
- Set your preferred search algorithm and ordering:
`xplr.config.general.search.algorithm = "Fuzzy" -- or "Regex"`.
`xplr.config.general.search.unordered = false -- or true`
- You need to clear the selection list manually after performing batch
operation like copy, softlink creation etc.
- Use the following new key bindings:
- `:sl` to list selection in a $PAGER.
- `:ss` to create softlink of the selected items.
- `:sh` to create hardlink of the selected items.
- `:se` to edit selection list in your $EDITOR.
- Better conflict handling: add suffix rather than overriding/skipping.
- Navigate between the selected paths using the following messages:
- FocusPreviousSelection (`ctrl-p`)
- FocusNextSelection (`ctrl-n`)
- Use `LS_COLORS` environment variable, along with the following utility
- functions for applying better styling/theaming.
- xplr.util.lscolor
- xplr.util.paint
- xplr.util.textwrap
- xplr.util.style_mix
- Use new the fields in Column Renderer Argument:
- style
- permissions
- Use the following config to specify how the paths in selection list should be
rendered:
- xplr.config.general.selection.item.format
- xplr.config.general.selection.item.style
- Use the following utility functions to work with teh file permissions:
- xplr.util.permissions_rwx
- xplr.util.permissions_octal
- Type `:p` to edit file permissions interactively.
- Also check out the following utility functions:
- xplr.util.layout_replace
- xplr.util.relative_to
- xplr.util.shorthand
- xplr.util.clone
- xplr.util.exists
- xplr.util.is_dir
- xplr.util.is_file
- xplr.util.is_symlink
- xplr.util.is_absolute
- xplr.util.path_split
- xplr.util.node
- xplr.util.node_type
- xplr.util.shell_escape
- Executables will me marked with the mime type: `application/x-executable`.
- macOS legacy coreutils will be generally supported, but please update it.
Thanks to @noahmayr for contributing to a major part of this release.
#### [v0.19.4][47] -> [v0.20.2][48]
- BREAKING: xplr shell (`:!`) will default to null (`\0`) delimited pipes, as
@ -440,3 +516,5 @@ Else do the following:
[46]: https://github.com/sayanarijit/xplr/releases/tag/v0.18.0
[47]: https://github.com/sayanarijit/xplr/releases/tag/v0.19.4
[48]: https://github.com/sayanarijit/xplr/releases/tag/v0.20.2
[49]: https://github.com/sayanarijit/xplr/releases/tag/v0.21.0
[50]: https://github.com/lotabout/skim#search-syntax

@ -11,6 +11,141 @@ xplr.util.version()
-- { major = 0, minor = 0, patch = 0 }
```
### xplr.util.clone
Clone/deepcopy a Lua value. Doesn't work with functions.
Type: function( value ) -> value
Example:
```lua
local val = { foo = "bar" }
local val_clone = xplr.util.clone(val)
val.foo = "baz"
print(val_clone.foo)
-- "bar"
```
### xplr.util.exists
Check if the given path exists.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.exists("/foo/bar")
-- true
```
### xplr.util.is_dir
Check if the given path is a directory.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.is_dir("/foo/bar")
-- true
```
### xplr.util.is_file
Check if the given path is a file.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.is_file("/foo/bar")
-- true
```
### xplr.util.is_symlink
Check if the given path is a symlink.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.is_file("/foo/bar")
-- true
```
### xplr.util.is_absolute
Check if the given path is an absolute path.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.is_absolute("/foo/bar")
-- true
```
### xplr.util.path_split
Split a path into its components.
Type: function( path:string ) -> boolean
Example:
```lua
xplr.util.path_split("/foo/bar")
-- { "/", "foo", "bar" }
xplr.util.path_split(".././foo")
-- { "..", "foo" }
```
### xplr.util.node
Get [Node][5] information of a given path.
Doesn't check if the path exists.
Returns nil if the path is "/".
Errors out if absolute path can't be obtained.
Type: function( path:string ) -> [Node][5]|nil
Example:
```lua
xplr.util.node("./bar")
-- { parent = "/pwd", relative_path = "bar", absolute_path = "/pwd/bar", ... }
xplr.util.node("/")
-- nil
```
### xplr.util.node_type
Get the configured [Node Type][6] of a given [Node][5].
Type: function( [Node][5], [xplr.config.node_types][7]|nil ) -> [Node Type][6]
If the second argument is missing, global config `xplr.config.node_types`
will be used.
Example:
```lua
xplr.util.node_type(app.focused_node)
-- { style = { fg = "Red", ... }, meta = { icon = "", ... } ... }
xplr.util.node_type(xplr.util.node("/foo/bar"), xplr.config.node_types)
-- { style = { fg = "Red", ... }, meta = { icon = "", ... } ... }
```
### xplr.util.dirname
Get the directory name of a given path.
@ -51,36 +186,108 @@ xplr.util.absolute("foo/bar")
-- "/tmp/foo/bar"
```
### xplr.util.relative_to
Get the relative path based on the given base path or current working dir.
Will error if it fails to determine a relative path.
Type: function( path:string, options:table|nil ) -> path:string
Options type: { base:string|nil, with_prefix_dots:bookean|nil, without_suffix_dots:boolean|nil }
- If `base` path is given, the path will be relative to it.
- If `with_prefix_dots` is true, the path will always start with dots `..` / `.`
- If `without_suffix_dots` is true, the name will be visible instead of dots `..` / `.`
Example:
```lua
xplr.util.relative_to("/present/working/directory")
-- "."
xplr.util.relative_to("/present/working/directory/foo")
-- "foo"
xplr.util.relative_to("/present/working/directory/foo", { with_prefix_dots = true })
-- "./foo"
xplr.util.relative_to("/present/working/directory", { without_suffix_dots = true })
-- "../directory"
xplr.util.relative_to("/present/working")
-- ".."
xplr.util.relative_to("/present/working", { without_suffix_dots = true })
-- "../../working"
xplr.util.relative_to("/present/working/directory", { base = "/present/foo/bar" })
-- "../../working/directory"
```
### xplr.util.shorten
Shorten the given absolute path using the following rules:
- either relative to your home dir if it makes sense
- or relative to the current working directory
- or absolute path if it makes the most sense
Type: Similar to `xplr.util.relative_to`
Example:
```lua
xplr.util.shorten("/home/username/.config")
-- "~/.config"
xplr.util.shorten("/present/working/directory")
-- "."
xplr.util.shorten("/present/working/directory/foo")
-- "foo"
xplr.util.shorten("/present/working/directory/foo", { with_prefix_dots = true })
-- "./foo"
xplr.util.shorten("/present/working/directory", { without_suffix_dots = true })
-- "../directory"
xplr.util.shorten("/present/working/directory", { base = "/present/foo/bar" })
-- "../../working/directory"
xplr.util.shorten("/tmp")
-- "/tmp"
```
### xplr.util.explore
Explore directories with the given explorer config.
Type: function( path:string, config:[Explorer Config][1]|nil )
-> { node:[Node][2]... }
Type: function( path:string, [ExplorerConfig][1]|nil ) -> { [Node][2], ... }
Example:
```lua
xplr.util.explore("/tmp")
-- { { absolute_path = "/tmp/a", ... }, ... }
xplr.util.explore("/tmp", app.explorer_config)
-- { { absolute_path = "/tmp/a", ... }, ... }
```
[1]: https://xplr.dev/en/lua-function-calls#explorer-config
[2]: https://xplr.dev/en/lua-function-calls#node
### xplr.util.shell_execute
Execute shell commands safely.
Type: function( program:string, args:{ arg:string... }|nil )
-> { stdout = string, stderr = string, returncode = number|nil }
Type: function( program:string, args:{ string, ... }|nil ) -> { stdout = string, stderr = string, returncode = number|nil }
Example:
```lua
xplr.util.shell_execute("pwd")
-- "/present/working/directory"
xplr.util.shell_execute("bash", {"-c", "xplr --help"})
-- { stdout = "xplr...", stderr = "", returncode = 0 }
```
@ -98,11 +305,24 @@ xplr.util.shell_quote("a'b\"c")
-- 'a'"'"'b"c'
```
### xplr.util.shell_escape
Escape commands and paths safely.
Type: function( string ) -> string
Example:
```lua
xplr.util.shell_escape("a'b\"c")
-- "\"a'b\\\"c\""
```
### xplr.util.from_json
Load JSON string into Lua value.
Type: function( string ) -> value
Type: function( string ) -> any
Example:
@ -121,11 +341,11 @@ Example:
```lua
xplr.util.to_json({ foo = "bar" })
-- [[{ "foos": "bar" }]]
-- [[{ "foo": "bar" }]]
xplr.util.to_json({ foo = "bar" }, { pretty = true })
-- [[{
-- "foos": "bar"
-- "foo": "bar"
-- }]]
```
@ -154,3 +374,138 @@ Example:
xplr.util.to_yaml({ foo = "bar" })
-- "foo: bar"
```
### xplr.util.lscolor
Get a [Style][3] object for the given path based on the LS_COLORS
environment variable.
Type: function( path:string ) -> [Style][3]|nil
Example:
```lua
xplr.util.lscolor("Desktop")
-- { fg = "Red", bg = nil, add_modifiers = {}, sub_modifiers = {} }
```
### xplr.util.paint
Apply style (escape sequence) to string using a given [Style][3] object.
Type: function( string, [Style][3]|nil ) -> string
Example:
```lua
xplr.util.paint("Desktop", { fg = "Red", bg = nil, add_modifiers = {}, sub_modifiers = {} })
-- "\u001b[31mDesktop\u001b[0m"
```
### xplr.util.style_mix
Mix multiple [Style][3] objects into one.
Type: function( { [Style][3], [Style][3], ... } ) -> [Style][3]
Example:
```lua
xplr.util.style_mix({{ fg = "Red" }, { bg = "Blue" }, { add_modifiers = {"Bold"} }})
-- { fg = "Red", bg = "Blue", add_modifiers = { "Bold" }, sub_modifiers = {} }
```
### xplr.util.textwrap
Wrap the given text to fit the specified width.
It will try to not split words when possible.
Type: function( string, options:number|table ) -> { string, ...}
Options type: { width = number, initial_indent = string|nil, subsequent_indent = string|nil, break_words = boolean|nil }
Example:
```lua
xplr.util.textwrap("this will be cut off", 11)
-- { "this will', 'be cut off" }
xplr.util.textwrap(
"this will be cut off",
{ width = 12, initial_indent = "", subsequent_indent = " ", break_words = false }
)
-- { "this will be", " cut off" }
```
### xplr.util.layout_replace
Find the target layout in the given layout and replace it with the replacement layout,
returning a new layout.
Type: function( layout:[Layout][4], target:[Layout][4], replacement:[Layout][4] ) -> layout:[Layout][4]
Example:
```lua
local layout = {
Horizontal = {
splits = {
"Table", -- Target
"HelpMenu",
},
config = ...,
}
}
xplr.util.layout_replace(layout, "Table", "Selection")
-- {
-- Horizontal = {
-- splits = {
-- "Selection", -- Replacement
-- "HelpMenu",
-- },
-- config = ...
-- }
-- }
```
### xplr.util.permissions_rwx
Convert [Permission][8] to rwxrwxrwx representation with special bits.
Type: function( [Permission][8] ) -> string
Example:
```lua
xplr.util.permissions_rwx({ user_read = true })
-- "r--------"
xplr.util.permissions_rwx(app.focused_node.permission)
-- "rwxrwsrwT"
```
### xplr.util.permissions_octal
Convert [Permission][8] to octal representation.
Type: function( [Permission][8] ) -> { number, number, number, number }
Example:
```lua
xplr.util.permissions_octal({ user_read = true })
-- { 0, 4, 0, 0 }
xplr.util.permissions_octal(app.focused_node.permission)
-- { 0, 7, 5, 4 }
```
[1]: https://xplr.dev/en/lua-function-calls#explorer-config
[2]: https://xplr.dev/en/lua-function-calls#node
[3]: https://xplr.dev/en/style
[4]: https://xplr.dev/en/layout
[5]: https://xplr.dev/en/lua-function-calls#node
[6]: https://xplr.dev/en/node-type
[7]: https://xplr.dev/en/node_types
[8]: https://xplr.dev/en/column-renderer#permission

@ -260,6 +260,9 @@ def gen_xplr_util():
print("\n".join(function.doc))
print("\n".join(function.doc), file=f)
if reading:
print("\n".join(reading.doc), file=f)
def format_docs():
os.system("prettier --write docs/en/src")

@ -9,7 +9,7 @@ pub use crate::msg::in_::external::Command;
pub use crate::msg::in_::external::ExplorerConfig;
pub use crate::msg::in_::external::NodeFilter;
pub use crate::msg::in_::external::NodeFilterApplicable;
use crate::msg::in_::external::NodeSearcher;
use crate::msg::in_::external::NodeSearcherApplicable;
pub use crate::msg::in_::external::NodeSorter;
pub use crate::msg::in_::external::NodeSorterApplicable;
pub use crate::msg::in_::ExternalMsg;
@ -19,9 +19,9 @@ pub use crate::msg::out::MsgOut;
pub use crate::node::Node;
pub use crate::node::ResolvedNode;
pub use crate::pipe::Pipe;
use crate::search::SearchAlgorithm;
use crate::ui::Layout;
use anyhow::{bail, Result};
use chrono::{DateTime, Local};
use gethostname::gethostname;
use indexmap::set::IndexSet;
use path_absolutize::*;
@ -31,6 +31,7 @@ use std::collections::VecDeque;
use std::env;
use std::fs;
use std::path::PathBuf;
use time::OffsetDateTime;
use tui_input::{Input, InputRequest};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -62,7 +63,7 @@ pub enum LogLevel {
pub struct Log {
pub level: LogLevel,
pub message: String,
pub created_at: DateTime<Local>,
pub created_at: OffsetDateTime,
}
impl Log {
@ -70,7 +71,9 @@ impl Log {
Self {
level,
message,
created_at: Local::now(),
created_at: OffsetDateTime::now_local()
.ok()
.unwrap_or_else(OffsetDateTime::now_utc),
}
}
}
@ -83,7 +86,7 @@ impl std::fmt::Display for Log {
LogLevel::Success => "SUCCESS",
LogLevel::Error => "ERROR ",
};
write!(f, "[{}] {} {}", &self.created_at, level_str, &self.message)
write!(f, "[{0}] {level_str} {1}", &self.created_at, &self.message)
}
}
@ -100,23 +103,67 @@ pub struct History {
}
impl History {
fn loc_exists(&self) -> bool {
self.peek()
.map(|p| PathBuf::from(p).exists())
.unwrap_or(false)
}
fn cleanup(mut self) -> Self {
while self.loc > 0
&& self
.paths
.get(self.loc.saturating_sub(1))
.and_then(|p1| self.peek().map(|p2| p1 == p2))
.unwrap_or(false)
{
self.paths.remove(self.loc);
self.loc = self.loc.saturating_sub(1);
}
while self.loc < self.paths.len().saturating_sub(1)
&& self
.paths
.get(self.loc.saturating_add(1))
.and_then(|p1| self.peek().map(|p2| p1 == p2))
.unwrap_or(false)
{
self.paths.remove(self.loc.saturating_add(1));
}
self
}
fn push(mut self, path: String) -> Self {
if self.peek() != Some(&path) {
self.paths = self.paths.into_iter().take(self.loc + 1).collect();
self.paths.push(path);
self.loc = self.paths.len().max(1) - 1;
self.loc = self.paths.len().saturating_sub(1);
}
self
}
fn visit_last(mut self) -> Self {
self.loc = self.loc.max(1) - 1;
self
self.loc = self.loc.saturating_sub(1);
while self.loc > 0 && !self.loc_exists() {
self.paths.remove(self.loc);
self.loc = self.loc.saturating_sub(1);
}
self.cleanup()
}
fn visit_next(mut self) -> Self {
self.loc = (self.loc + 1).min(self.paths.len().max(1) - 1);
self
self.loc = self
.loc
.saturating_add(1)
.min(self.paths.len().saturating_sub(1));
while self.loc < self.paths.len().saturating_sub(1) && !self.loc_exists() {
self.paths.remove(self.loc);
}
self.cleanup()
}
fn peek(&self) -> Option<&String> {
@ -165,7 +212,7 @@ pub struct InputBuffer {
pub prompt: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct App {
pub bin: String,
pub version: String,
@ -415,7 +462,7 @@ impl App {
fn handle_external(self, msg: ExternalMsg, key: Option<Key>) -> Result<Self> {
if self.config.general.read_only && !msg.is_read_only() {
self.log_error("Cannot execute code in read-only mode.".into())
self.log_error("could not execute code in read-only mode.".into())
} else {
use ExternalMsg::*;
match msg {
@ -427,6 +474,7 @@ impl App {
FocusFirst => self.focus_first(true),
FocusLast => self.focus_last(),
FocusPrevious => self.focus_previous(),
FocusPreviousSelection => self.focus_previous_selection(),
FocusPreviousByRelativeIndex(i) => {
self.focus_previous_by_relative_index(i)
}
@ -435,6 +483,7 @@ impl App {
self.focus_previous_by_relative_index_from_input()
}
FocusNext => self.focus_next(),
FocusNextSelection => self.focus_next_selection(),
FocusNextByRelativeIndex(i) => self.focus_next_by_relative_index(i),
FocusNextByRelativeIndexFromInput => {
self.focus_next_by_relative_index_from_input()
@ -524,8 +573,32 @@ impl App {
ReverseNodeSorters => self.reverse_node_sorters(),
ResetNodeSorters => self.reset_node_sorters(),
ClearNodeSorters => self.clear_node_sorters(),
SearchFuzzy(p) => self.search_fuzzy(p),
SearchFuzzyFromInput => self.search_fuzzy_from_input(),
Search(p) => self.search(p),
SearchFromInput => self.search_from_input(),
SearchFuzzy(p) => self.search_with(p, SearchAlgorithm::Fuzzy, false),
SearchFuzzyFromInput => {
self.search_from_input_with(SearchAlgorithm::Fuzzy, false)
}
SearchRegex(p) => self.search_with(p, SearchAlgorithm::Regex, false),
SearchRegexFromInput => {
self.search_from_input_with(SearchAlgorithm::Regex, false)
}
SearchFuzzyUnordered(p) => {
self.search_with(p, SearchAlgorithm::Fuzzy, true)
}
SearchFuzzyUnorderedFromInput => {
self.search_from_input_with(SearchAlgorithm::Fuzzy, true)
}
SearchRegexUnordered(p) => {
self.search_with(p, SearchAlgorithm::Regex, true)
}
SearchRegexUnorderedFromInput => {
self.search_from_input_with(SearchAlgorithm::Regex, true)
}
EnableSearchOrder => self.enable_search_order(),
DisableSearchOrder => self.disable_search_order(),
ToggleSearchOrder => self.toggle_search_order(),
ToggleSearchAlgorithm => self.toggle_search_algorithm(),
AcceptSearch => self.accept_search(),
CancelSearch => self.cancel_search(),
EnableMouse => self.enable_mouse(),
@ -592,7 +665,7 @@ impl App {
if self.config.general.enable_recover_mode {
vec![ExternalMsg::SwitchModeBuiltin("recover".into())]
} else {
vec![ExternalMsg::LogWarning("Key map not found.".into())]
vec![ExternalMsg::LogWarning("key map not found.".into())]
}
});
@ -606,14 +679,20 @@ impl App {
pub fn explore_pwd(mut self) -> Result<Self> {
let focus = &self.last_focus.get(&self.pwd).cloned().unwrap_or(None);
let pwd = self.pwd.clone();
self = self.add_last_focus(pwd, focus.clone())?;
let dir = explorer::explore_sync(
self = self.add_last_focus(pwd.clone(), focus.clone())?;
match explorer::explore_sync(
self.explorer_config.clone(),
self.pwd.clone().into(),
focus.as_ref().map(PathBuf::from),
self.directory_buffer.as_ref().map(|d| d.focus).unwrap_or(0),
)?;
self.set_directory(dir)
) {
Ok(dir) => self.set_directory(dir),
Err(e) => {
self.directory_buffer = None;
self.log_error(format!("could not explore {pwd:?}: {e}"))
}
}
}
fn explore_pwd_async(mut self) -> Result<Self> {
@ -663,7 +742,7 @@ impl App {
history = history.push(n.absolute_path.clone());
}
dir.focus = dir.total.max(1) - 1;
dir.focus = dir.total.saturating_sub(1);
if let Some(n) = dir.focused_node() {
self.history = history.push(n.absolute_path.clone());
@ -680,15 +759,55 @@ impl App {
if bounded {
dir.focus
} else {
dir.total.max(1) - 1
dir.total.saturating_sub(1)
}
} else {
dir.focus.max(1) - 1
dir.focus.saturating_sub(1)
};
};
Ok(self)
}
fn focus_previous_selection(mut self) -> Result<Self> {
let total = self.selection.len();
if total == 0 {
return Ok(self);
}
let bounded = self.config.general.enforce_bounded_index_navigation;
if let Some(n) = self
.directory_buffer
.as_ref()
.and_then(|d| d.focused_node())
{
if let Some(idx) = self.selection.get_index_of(n) {
let idx = if idx == 0 {
if bounded {
idx
} else {
total.saturating_sub(1)
}
} else {
idx.saturating_sub(1)
};
if let Some(p) = self
.selection
.get_index(idx)
.map(|n| n.absolute_path.clone())
{
self = self.focus_path(&p, true)?;
}
} else if let Some(p) =
self.selection.last().map(|n| n.absolute_path.clone())
{
self = self.focus_path(&p, true)?;
}
}
Ok(self)
}
pub fn focus_previous_by_relative_index(mut self, index: usize) -> Result<Self> {
let mut history = self.history.clone();
if let Some(dir) = self.directory_buffer_mut() {
@ -696,7 +815,7 @@ impl App {
history = history.push(n.absolute_path.clone());
}
dir.focus = dir.focus.max(index) - index;
dir.focus = dir.focus.saturating_sub(index);
if let Some(n) = self.focused_node() {
self.history = history.push(n.absolute_path.clone());
}
@ -734,6 +853,46 @@ impl App {
Ok(self)
}
fn focus_next_selection(mut self) -> Result<Self> {
let total = self.selection.len();
if total == 0 {
return Ok(self);
}
let bounded = self.config.general.enforce_bounded_index_navigation;
if let Some(n) = self
.directory_buffer
.as_ref()
.and_then(|d| d.focused_node())
{
if let Some(idx) = self.selection.get_index_of(n) {
let idx = if idx + 1 == total {
if bounded {
idx
} else {
0
}
} else {
idx + 1
};
if let Some(p) = self
.selection
.get_index(idx)
.map(|n| n.absolute_path.clone())
{
self = self.focus_path(&p, true)?;
}
} else if let Some(p) =
self.selection.first().map(|n| n.absolute_path.clone())
{
self = self.focus_path(&p, true)?;
}
}
Ok(self)
}
pub fn focus_next_by_relative_index(mut self, index: usize) -> Result<Self> {
let mut history = self.history.clone();
if let Some(dir) = self.directory_buffer_mut() {
@ -741,7 +900,11 @@ impl App {
history = history.push(n.absolute_path.clone());
}
dir.focus = (dir.focus + index).min(dir.total.max(1) - 1);
dir.focus = dir
.focus
.saturating_add(index)
.min(dir.total.saturating_sub(1));
if let Some(n) = self.focused_node() {
self.history = history.push(n.absolute_path.clone());
}
@ -785,7 +948,7 @@ impl App {
}
} else {
self.log_error(format!(
"not a valid directory: {}",
"not a valid directory: {:?}",
vroot.to_string_lossy()
))
}
@ -833,17 +996,22 @@ impl App {
match env::set_current_dir(&dir) {
Ok(()) => {
let pwd = self.pwd.clone();
let lwd = self.pwd.clone();
let focus = self.focused_node().map(|n| n.relative_path.clone());
self = self.add_last_focus(pwd, focus)?;
self = self.add_last_focus(lwd, focus)?;
self.pwd = dir.to_string_lossy().to_string();
self.explorer_config.searcher = None;
if save_history {
self.history = self.history.push(format!("{}/", self.pwd));
let hist = if &self.pwd == "/" {
self.pwd.clone()
} else {
format!("{0}/", &self.pwd)
};
self.history = self.history.push(hist);
}
self.explore_pwd()
}
Err(e) => self.log_error(e.to_string()),
Err(e) => self.log_error(format!("could not enter {dir:?}: {e}")),
}
}
@ -981,7 +1149,7 @@ impl App {
fn focus_by_index(mut self, index: usize) -> Result<Self> {
let history = self.history.clone();
if let Some(dir) = self.directory_buffer_mut() {
dir.focus = index.min(dir.total.max(1) - 1);
dir.focus = index.min(dir.total.saturating_sub(1));
if let Some(n) = self.focused_node() {
self.history = history.push(n.absolute_path.clone());
}
@ -1026,7 +1194,7 @@ impl App {
}
Ok(self)
} else {
self.log_error(format!("{} not found in $PWD", name))
self.log_error(format!("{name:?} not found in $PWD"))
}
} else {
Ok(self)
@ -1060,10 +1228,10 @@ impl App {
self.change_directory(&parent.to_string_lossy(), false)?
.focus_by_file_name(&filename.to_string_lossy(), save_history)
} else {
self.log_error(format!("{} not found", path))
self.log_error(format!("{path:?} not found"))
}
} else {
self.log_error(format!("Cannot focus on {}", path))
self.log_error(format!("could not focus on {path:?}"))
}
}
@ -1111,7 +1279,7 @@ impl App {
} else if self.config.modes.custom.contains_key(mode) {
self.switch_mode_custom_keeping_input_buffer(mode)
} else {
self.log_error(format!("Mode not found: {}", mode))
self.log_error(format!("mode not found: {mode:?}"))
}
}
@ -1136,7 +1304,7 @@ impl App {
Ok(self)
} else {
self.log_error(format!("Builtin mode not found: {}", mode))
self.log_error(format!("builtin mode not found: {mode:?}"))
}
}
@ -1161,7 +1329,7 @@ impl App {
Ok(self)
} else {
self.log_error(format!("Custom mode not found: {}", mode))
self.log_error(format!("custom mode not found: {mode:?}"))
}
}
@ -1171,7 +1339,7 @@ impl App {
} else if self.config.layouts.custom.contains_key(layout) {
self.switch_layout_custom(layout)
} else {
self.log_error(format!("Layout not found: {}", layout))
self.log_error(format!("layout not found: {layout:?}"))
}
}
@ -1187,7 +1355,7 @@ impl App {
Ok(self)
} else {
self.log_error(format!("Builtin layout not found: {}", layout))
self.log_error(format!("builtin layout not found: {layout:?}"))
}
}
@ -1203,7 +1371,7 @@ impl App {
Ok(self)
} else {
self.log_error(format!("Custom layout not found: {}", layout))
self.log_error(format!("custom layout not found: {layout:?}"))
}
}
@ -1333,21 +1501,12 @@ impl App {
pub fn select_all(mut self) -> Result<Self> {
if let Some(d) = self.directory_buffer.as_ref() {
d.nodes.clone().into_iter().for_each(|n| {
self.selection.insert(n);
});
self.selection = d.nodes.clone().into_iter().collect();
};
Ok(self)
}
pub fn un_select(mut self) -> Result<Self> {
if let Some(n) = self.focused_node().map(|n| n.to_owned()) {
self.selection.retain(|s| s != &n);
}
Ok(self)
}
pub fn un_select_path(mut self, path: String) -> Result<Self> {
let pathbuf = PathBuf::from(path).absolutize()?.to_path_buf();
self.selection
@ -1355,10 +1514,19 @@ impl App {
Ok(self)
}
pub fn un_select(mut self) -> Result<Self> {
if let Some(n) = self.focused_node().map(|n| n.to_owned()) {
self.selection
.retain(|s| s.absolute_path != n.absolute_path);
}
Ok(self)
}
pub fn un_select_all(mut self) -> Result<Self> {
if let Some(d) = self.directory_buffer.as_ref() {
d.nodes.clone().into_iter().for_each(|n| {
self.selection.retain(|s| s != &n);
self.selection
.retain(|s| s.absolute_path != n.absolute_path);
});
};
@ -1518,7 +1686,34 @@ impl App {
Ok(self)
}
pub fn search_fuzzy(mut self, pattern: String) -> Result<Self> {
pub fn search(self, pattern: String) -> Result<Self> {
let (algorithm, unordered) = self
.explorer_config
.searcher
.as_ref()
.map(|s| (s.algorithm, s.unordered))
.unwrap_or((
self.config.general.search.algorithm,
self.config.general.search.unordered,
));
self.search_with(pattern, algorithm, unordered)
}
fn search_from_input(self) -> Result<Self> {
if let Some(pattern) = self.input.buffer.as_ref().map(Input::to_string) {
self.search(pattern)
} else {
Ok(self)
}
}
pub fn search_with(
mut self,
pattern: String,
algorithm: SearchAlgorithm,
unordered: bool,
) -> Result<Self> {
let rf = self
.explorer_config
.searcher
@ -1526,18 +1721,54 @@ impl App {
.map(|s| s.recoverable_focus.clone())
.unwrap_or_else(|| self.focused_node().map(|n| n.absolute_path.clone()));
self.explorer_config.searcher = Some(NodeSearcher::new(pattern, rf));
self.explorer_config.searcher = Some(NodeSearcherApplicable::new(
pattern, rf, algorithm, unordered,
));
Ok(self)
}
fn search_fuzzy_from_input(self) -> Result<Self> {
fn search_from_input_with(
self,
algorithm: SearchAlgorithm,
unordered: bool,
) -> Result<Self> {
if let Some(pattern) = self.input.buffer.as_ref().map(Input::to_string) {
self.search_fuzzy(pattern)
self.search_with(pattern, algorithm, unordered)
} else {
Ok(self)
}
}
fn enable_search_order(mut self) -> Result<Self> {
self.explorer_config.searcher = self
.explorer_config
.searcher
.map(|s| s.enable_search_order());
Ok(self)
}
fn disable_search_order(mut self) -> Result<Self> {
self.explorer_config.searcher = self
.explorer_config
.searcher
.map(|s| s.disable_search_order());
Ok(self)
}
fn toggle_search_order(mut self) -> Result<Self> {
self.explorer_config.searcher = self
.explorer_config
.searcher
.map(|s| s.toggle_search_order());
Ok(self)
}
fn toggle_search_algorithm(mut self) -> Result<Self> {
self.explorer_config.searcher =
self.explorer_config.searcher.map(|s| s.toggle_algorithm());
Ok(self)
}
fn accept_search(mut self) -> Result<Self> {
let focus = self
.directory_buffer
@ -1664,13 +1895,15 @@ impl App {
}
pub fn mode_str(&self) -> String {
format!("{}\n", &self.mode.name)
format!("{0}\n", &self.mode.name)
}
fn refresh_selection(mut self) -> Result<Self> {
// Should be able to select broken symlink
self.selection
.retain(|n| PathBuf::from(&n.absolute_path).symlink_metadata().is_ok());
self.selection.retain(|n| {
let p = PathBuf::from(&n.absolute_path);
// Should be able to retain broken symlink
p.exists() || p.symlink_metadata().is_ok()
});
Ok(self)
}
@ -1688,7 +1921,7 @@ impl App {
.map(|d| {
d.nodes
.iter()
.map(|n| format!("{}{}", n.absolute_path, delimiter))
.map(|n| format!("{0}{delimiter}", n.absolute_path))
.collect::<Vec<String>>()
.join("")
})
@ -1696,13 +1929,13 @@ impl App {
}
pub fn pwd_str(&self, delimiter: char) -> String {
format!("{}{}", &self.pwd, delimiter)
format!("{0}{delimiter}", &self.pwd)
}
pub fn selection_str(&self, delimiter: char) -> String {
self.selection
.iter()
.map(|n| format!("{}{}", n.absolute_path, delimiter))
.map(|n| format!("{0}{delimiter}", n.absolute_path))
.collect::<Vec<String>>()
.join("")
}
@ -1710,7 +1943,7 @@ impl App {
pub fn result_str(&self, delimiter: char) -> String {
self.result()
.into_iter()
.map(|n| format!("{}{}", n.absolute_path, delimiter))
.map(|n| format!("{0}{delimiter}", n.absolute_path))
.collect::<Vec<String>>()
.join("")
}
@ -1718,7 +1951,7 @@ impl App {
pub fn logs_str(&self, delimiter: char) -> String {
self.logs
.iter()
.map(|l| format!("{}{}", l, delimiter))
.map(|l| format!("{l}{delimiter}"))
.collect::<Vec<String>>()
.join("")
}
@ -1739,18 +1972,17 @@ impl App {
.help_menu()
.iter()
.map(|l| match l {
HelpMenuLine::Paragraph(p) => format!("\t{}{}", p, delimiter),
HelpMenuLine::Paragraph(p) => format!("\t{p}{delimiter}"),
HelpMenuLine::KeyMap(k, remaps, h) => {
let remaps = remaps.join(", ");
format!(" {:15} | {:25} | {}{}", k, remaps, h , delimiter)
format!(" {k:15} | {remaps:25} | {h}{delimiter}")
}
})
.collect::<Vec<String>>()
.join("");
format!(
"### {}{d}{d} key | remaps | action\n --------------- | ------------------------- | ------{d}{}{d}",
name, help, d = delimiter
"### {name}{delimiter}{delimiter} key | remaps | action\n --------------- | ------------------------- | ------{delimiter}{help}{delimiter}"
)
})
.collect::<Vec<String>>()
@ -1761,7 +1993,7 @@ impl App {
self.history
.paths
.iter()
.map(|p| format!("{}{}", &p, delimiter))
.map(|p| format!("{p}{delimiter}"))
.collect::<Vec<String>>()
.join("")
}

@ -6,7 +6,7 @@ use xplr::runner;
fn main() {
let cli = Cli::parse(env::args()).unwrap_or_else(|e| {
eprintln!("error: {}", e);
eprintln!("error: {e}");
std::process::exit(1);
});
@ -54,28 +54,28 @@ fn main() {
);
let help = help.trim();
println!("{}", help);
println!("{help}");
} else if cli.version {
println!("xplr {}", xplr::app::VERSION);
} else if !cli.pipe_msg_in.is_empty() {
if let Err(err) = cli::pipe_msg_in(cli.pipe_msg_in) {
eprintln!("error: {}", err);
eprintln!("error: {err}");
std::process::exit(1);
}
} else if !cli.print_msg_in.is_empty() {
if let Err(err) = cli::print_msg_in(cli.print_msg_in) {
eprintln!("error: {}", err);
eprintln!("error: {err}");
std::process::exit(1);
}
} else {
match runner::from_cli(cli).and_then(|a| a.run()) {
Ok(Some(out)) => {
print!("{}", out);
print!("{out}");
}
Ok(None) => {}
Err(err) => {
if !err.to_string().is_empty() {
eprintln!("error: {}", err);
eprintln!("error: {err}");
};
std::process::exit(1);

@ -106,17 +106,17 @@ impl Cli {
// Options
"-c" | "--config" => {
cli.config = Some(
args.next().map(|a| Cli::read_path(&a)).with_context(
|| format!("usage: xplr {} PATH", arg),
)??,
args.next()
.map(|a| Cli::read_path(&a))
.with_context(|| format!("usage: xplr {arg} PATH"))??,
);
}
"--vroot" => {
cli.vroot = Some(
args.next().map(|a| Cli::read_path(&a)).with_context(
|| format!("usage: xplr {} PATH", arg),
)??,
args.next()
.map(|a| Cli::read_path(&a))
.with_context(|| format!("usage: xplr {arg} PATH"))??,
);
}
@ -191,7 +191,7 @@ pub fn pipe_msg_in(args: Vec<String>) -> Result<()> {
.open(&path)?
.write_all(msg.as_bytes())?;
} else {
println!("{}", msg);
println!("{msg}");
};
Ok(())
@ -199,7 +199,7 @@ pub fn pipe_msg_in(args: Vec<String>) -> Result<()> {
pub fn print_msg_in(args: Vec<String>) -> Result<()> {
let msg = fmt_msg_in(args)?;
print!("{}", msg);
print!("{msg}");
Ok(())
}
@ -220,24 +220,21 @@ fn fmt_msg_in(args: Vec<String>) -> Result<String> {
}
('q', Some('%')) => {
let arg = args.next().context(format!(
"argument missing for the placeholder at column {}",
col
"argument missing for the placeholder at column {col}"
))?;
msg.push_str(&json::to_string(&arg)?);
last_char = None;
}
('s', Some('%')) => {
let arg = args.next().context(format!(
"argument missing for the placeholder at column {}",
col
"argument missing for the placeholder at column {col}",
))?;
msg.push_str(&arg);
last_char = None;
}
(ch, Some('%')) => {
bail!(format!(
"invalid placeholder '%{}' at column {}, use one of '%s' or '%q', or escape it using '%%'",
ch, col
"invalid placeholder '%{ch}' at column {col}, use one of '%s' or '%q', or escape it using '%%'",
));
}
(ch, _) => {

@ -0,0 +1,224 @@
// Things of the past, mostly bad decisions, which cannot erased, stays in this
// haunted module.
use crate::app;
use crate::lua;
use crate::ui::block;
use crate::ui::string_to_text;
use crate::ui::Constraint;
use crate::ui::ContentRendererArg;
use mlua::Lua;
use serde::{Deserialize, Serialize};
use tui::backend::Backend;
use tui::layout::Constraint as TuiConstraint;
use tui::layout::Rect as TuiRect;
use tui::widgets::Cell;
use tui::widgets::List;
use tui::widgets::ListItem;
use tui::widgets::Paragraph;
use tui::widgets::Row;
use tui::widgets::Table;
use tui::Frame;
/// A cursed enum from crate::ui.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub enum ContentBody {
/// A paragraph to render
StaticParagraph { render: String },
/// A Lua function that returns a paragraph to render
DynamicParagraph { render: String },
/// List to render
StaticList { render: Vec<String> },
/// A Lua function that returns lines to render
DynamicList { render: String },
/// A table to render
StaticTable {
widths: Vec<Constraint>,
col_spacing: Option<u16>,
render: Vec<Vec<String>>,
},
/// A Lua function that returns a table to render
DynamicTable {
widths: Vec<Constraint>,
col_spacing: Option<u16>,
render: String,
},
}
/// A cursed struct from crate::ui.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CustomContent {
pub title: Option<String>,
pub body: ContentBody,
}
/// A cursed function from crate::ui.
pub fn draw_custom_content<B: Backend>(
f: &mut Frame<B>,
screen_size: TuiRect,
layout_size: TuiRect,
app: &app::App,
content: CustomContent,
lua: &Lua,
) {
let config = app.config.general.panel_ui.default.clone();
let title = content.title;
let body = content.body;
match body {
ContentBody::StaticParagraph { render } => {
let render = string_to_text(render);
let content = Paragraph::new(render).block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::DynamicParagraph { render } => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let render = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg).unwrap_or_else(|e| format!("{e:?}"))
})
.unwrap_or_else(|e| e.to_string());
let render = string_to_text(render);
let content = Paragraph::new(render).block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::StaticList { render } => {
let items = render
.into_iter()
.map(string_to_text)
.map(ListItem::new)
.collect::<Vec<ListItem>>();
let content = List::new(items).block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::DynamicList { render } => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let items = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg)
.unwrap_or_else(|e| vec![format!("{e:?}")])
})
.unwrap_or_else(|e| vec![e.to_string()])
.into_iter()
.map(string_to_text)
.map(ListItem::new)
.collect::<Vec<ListItem>>();
let content = List::new(items).block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::StaticTable {
widths,
col_spacing,
render,
} => {
let rows = render
.into_iter()
.map(|cols| {
Row::new(
cols.into_iter()
.map(string_to_text)
.map(Cell::from)
.collect::<Vec<Cell>>(),
)
})
.collect::<Vec<Row>>();
let widths = widths
.into_iter()
.map(|w| w.to_tui(screen_size, layout_size))
.collect::<Vec<TuiConstraint>>();
let content = Table::new(rows)
.widths(&widths)
.column_spacing(col_spacing.unwrap_or(1))
.block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::DynamicTable {
widths,
col_spacing,
render,
} => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let rows = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg)
.unwrap_or_else(|e| vec![vec![format!("{e:?}")]])
})
.unwrap_or_else(|e| vec![vec![e.to_string()]])
.into_iter()
.map(|cols| {
Row::new(
cols.into_iter()
.map(string_to_text)
.map(Cell::from)
.collect::<Vec<Cell>>(),
)
})
.collect::<Vec<Row>>();
let widths = widths
.into_iter()
.map(|w| w.to_tui(screen_size, layout_size))
.collect::<Vec<TuiConstraint>>();
let mut content = Table::new(rows).widths(&widths).block(block(
config,
title.map(|t| format!(" {t} ")).unwrap_or_default(),
));
if let Some(col_spacing) = col_spacing {
content = content.column_spacing(col_spacing);
};
f.render_widget(content, layout_size);
}
}
}

@ -3,6 +3,8 @@ use crate::app::HelpMenuLine;
use crate::app::NodeFilter;
use crate::app::NodeSorter;
use crate::app::NodeSorterApplicable;
use crate::node::Node;
use crate::search::SearchAlgorithm;
use crate::ui::Border;
use crate::ui::BorderType;
use crate::ui::Constraint;
@ -80,6 +82,40 @@ pub struct NodeTypesConfig {
pub special: HashMap<String, NodeTypeConfig>,
}
impl NodeTypesConfig {
pub fn get(&self, node: &Node) -> NodeTypeConfig {
let mut node_type = if node.is_symlink {
self.symlink.to_owned()
} else if node.is_dir {
self.directory.to_owned()
} else {
self.file.to_owned()
};
let mut me = node.mime_essence.splitn(2, '/');
let mimetype: String = me.next().map(|s| s.into()).unwrap_or_default();
let mimesub: String = me.next().map(|s| s.into()).unwrap_or_default();
if let Some(conf) = self
.mime_essence
.get(&mimetype)
.and_then(|t| t.get(&mimesub).or_else(|| t.get("*")))
{
node_type = node_type.extend(conf);
}
if let Some(conf) = self.extension.get(&node.extension) {
node_type = node_type.extend(conf);
}
if let Some(conf) = self.special.get(&node.relative_path) {
node_type = node_type.extend(conf);
}
node_type
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiConfig {
@ -146,6 +182,23 @@ pub struct TableConfig {
pub col_widths: Option<Vec<Constraint>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectionConfig {
#[serde(default)]
pub item: UiElement,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchConfig {
#[serde(default)]
pub algorithm: SearchAlgorithm,
#[serde(default)]
pub unordered: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LogsConfig {
@ -172,6 +225,16 @@ pub struct SortDirectionIdentifiersUi {
pub reverse: UiElement,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchDirectionIdentifiersUi {
#[serde(default)]
pub ordered: UiElement,
#[serde(default)]
pub unordered: UiElement,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SortAndFilterUi {
@ -191,7 +254,10 @@ pub struct SortAndFilterUi {
pub filter_identifiers: HashMap<NodeFilter, UiElement>,
#[serde(default)]
pub search_identifier: Option<UiElement>,
pub search_direction_identifiers: SearchDirectionIdentifiersUi,
#[serde(default)]
pub search_identifiers: HashMap<SearchAlgorithm, UiElement>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -249,6 +315,12 @@ pub struct GeneralConfig {
#[serde(default)]
pub table: TableConfig,
#[serde(default)]
pub selection: SelectionConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub default_ui: UiConfig,

@ -1,6 +1,6 @@
use crate::node::Node;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct DirectoryBuffer {
@ -9,8 +9,8 @@ pub struct DirectoryBuffer {
pub total: usize,
pub focus: usize,
#[serde(skip)]
pub explored_at: DateTime<Utc>,
#[serde(skip, default = "now")]
pub explored_at: OffsetDateTime,
}
impl DirectoryBuffer {
@ -21,7 +21,7 @@ impl DirectoryBuffer {
nodes,
total,
focus,
explored_at: Utc::now(),
explored_at: now(),
}
}
@ -29,3 +29,9 @@ impl DirectoryBuffer {
self.nodes.get(self.focus)
}
}
fn now() -> OffsetDateTime {
OffsetDateTime::now_local()
.ok()
.unwrap_or_else(OffsetDateTime::now_utc)
}

@ -2,21 +2,14 @@ use crate::app::{
DirectoryBuffer, ExplorerConfig, ExternalMsg, InternalMsg, MsgIn, Node, Task,
};
use anyhow::{Error, Result};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use lazy_static::lazy_static;
use std::fs;
use std::path::PathBuf;
use std::sync::mpsc::Sender;
use std::thread;
lazy_static! {
static ref FUZZY_MATCHER: SkimMatcherV2 = SkimMatcherV2::default();
}
pub fn explore(parent: &PathBuf, config: &ExplorerConfig) -> Result<Vec<Node>> {
let dirs = fs::read_dir(parent)?;
let mut nodes = dirs
let nodes = dirs
.filter_map(|d| {
d.ok().map(|e| {
e.path()
@ -26,26 +19,24 @@ pub fn explore(parent: &PathBuf, config: &ExplorerConfig) -> Result<Vec<Node>> {
})
})
.map(|name| Node::new(parent.to_string_lossy().to_string(), name))
.filter(|n| config.filter(n))
.collect::<Vec<Node>>();
nodes = if let Some(pattern) = config.searcher.as_ref().map(|s| &s.pattern) {
let mut nodes = nodes
.into_iter()
.filter_map(|n| {
FUZZY_MATCHER
.fuzzy_match(&n.relative_path, pattern)
.map(|score| (n, score))
})
.collect::<Vec<(_, _)>>();
.filter(|n| config.filter(n));
nodes.sort_by(|(_, s1), (_, s2)| s2.cmp(s1));
nodes.into_iter().map(|(n, _)| n).collect::<Vec<_>>()
let mut nodes = if let Some(searcher) = config.searcher.as_ref() {
searcher.search(nodes)
} else {
nodes.sort_by(|a, b| config.sort(a, b));
nodes
nodes.collect()
};
let is_ordered_search = config
.searcher
.as_ref()
.map(|s| !s.unordered)
.unwrap_or(false);
if !is_ordered_search {
nodes.sort_by(|a, b| config.sort(a, b));
}
Ok(nodes)
}
@ -65,7 +56,7 @@ pub(crate) fn explore_sync(
.enumerate()
.find(|(_, n)| n.relative_path == focus_str)
.map(|(i, _)| i)
.unwrap_or_else(|| fallback_focus.min(nodes.len().max(1) - 1))
.unwrap_or_else(|| fallback_focus.min(nodes.len().saturating_sub(1)))
} else {
0
};

@ -159,7 +159,7 @@ xplr.config.general.logs.error.style = { fg = "Red" }
xplr.config.general.table.header.cols = {
{ format = " index", style = {} },
{ format = "╭─── path", style = {} },
{ format = "permissions", style = {} },
{ format = "perm", style = {} },
{ format = "size", style = {} },
{ format = "modified", style = {} },
}
@ -246,6 +246,26 @@ xplr.config.general.table.col_widths = {
{ Percentage = 20 },
}
-- Renderer for each item in the selection list.
--
-- Type: nullable string
xplr.config.general.selection.item.format = "builtin.fmt_general_selection_item"
-- Style for each item in the selection list.
--
-- Type: [Style](https://xplr.dev/en/style)
xplr.config.general.selection.item.style = {}
-- The default search algorithm
--
-- Type: [Search Algorithm](https://xplr.dev/en/searching#algorithm)
xplr.config.general.search.algorithm = "Fuzzy"
-- The default search ordering
--
-- Type: boolean
xplr.config.general.search.unordered = false
-- The content that is placed before the item name for each row by default.
--
-- Type: nullable string
@ -414,7 +434,6 @@ xplr.config.general.sort_and_filter_ui.filter_identifiers = {
RelativePathIsNot = { format = "rel!=", style = {} },
RelativePathDoesMatchRegex = { format = "rel=/", style = {} },
RelativePathDoesNotMatchRegex = { format = "rel!/", style = {} },
IRelativePathDoesContain = { format = "[i]rel=~", style = {} },
IRelativePathDoesEndWith = { format = "[i]rel=$", style = {} },
IRelativePathDoesNotContain = { format = "[i]rel!~", style = {} },
@ -425,7 +444,6 @@ xplr.config.general.sort_and_filter_ui.filter_identifiers = {
IRelativePathIsNot = { format = "[i]rel!=", style = {} },
IRelativePathDoesMatchRegex = { format = "[i]rel=/", style = {} },
IRelativePathDoesNotMatchRegex = { format = "[i]rel!/", style = {} },
AbsolutePathDoesContain = { format = "abs=~", style = {} },
AbsolutePathDoesEndWith = { format = "abs=$", style = {} },
AbsolutePathDoesNotContain = { format = "abs!~", style = {} },
@ -436,7 +454,6 @@ xplr.config.general.sort_and_filter_ui.filter_identifiers = {
AbsolutePathIsNot = { format = "abs!=", style = {} },
AbsolutePathDoesMatchRegex = { format = "abs=/", style = {} },
AbsolutePathDoesNotMatchRegex = { format = "abs!/", style = {} },
IAbsolutePathDoesContain = { format = "[i]abs=~", style = {} },
IAbsolutePathDoesEndWith = { format = "[i]abs=$", style = {} },
IAbsolutePathDoesNotContain = { format = "[i]abs!~", style = {} },
@ -452,11 +469,22 @@ xplr.config.general.sort_and_filter_ui.filter_identifiers = {
-- The identifiers used to denote applied search input.
--
-- Type: { format = nullable string, style = [Style](https://xplr.dev/en/style) }
xplr.config.general.sort_and_filter_ui.search_identifier = {
format = "search:",
style = {},
xplr.config.general.sort_and_filter_ui.search_identifiers = {
Fuzzy = { format = "fzy:", style = {} },
Regex = { format = "reg:", style = {} },
}
-- The shape of ordered indicator for search ordering identifiers in Sort & filter panel.
--
-- Type: nullable string
xplr.config.general.sort_and_filter_ui.search_direction_identifiers.ordered.format =
""
-- The shape of unordered indicator for search ordering identifiers in Sort & filter panel.
--
-- Type: nullable string
xplr.config.general.sort_and_filter_ui.search_direction_identifiers.unordered.format = ""
-- The content for panel title by default.
--
-- Type: nullable string
@ -701,7 +729,7 @@ xplr.config.general.global_key_bindings = {
--
-- Type: [Style](https://xplr.dev/en/style)
xplr.config.node_types.directory.style = {
fg = "Cyan",
fg = "Blue",
}
-- Metadata for the directory nodes.
@ -1232,6 +1260,18 @@ xplr.config.modes.builtin.default = {
"ScrollDownHalf",
},
},
["ctrl-n"] = {
help = "next selection",
messages = {
"FocusNextSelection",
},
},
["ctrl-p"] = {
help = "prev selection",
messages = {
"FocusPreviousSelection",
},
},
},
on_number = {
help = "input",
@ -1244,8 +1284,6 @@ xplr.config.modes.builtin.default = {
},
}
xplr.config.modes.builtin.default.key_bindings.on_key["tab"] =
xplr.config.modes.builtin.default.key_bindings.on_key["ctrl-i"]
xplr.config.modes.builtin.default.key_bindings.on_key["v"] =
xplr.config.modes.builtin.default.key_bindings.on_key["space"]
xplr.config.modes.builtin.default.key_bindings.on_key["V"] =
@ -1260,6 +1298,8 @@ xplr.config.modes.builtin.default.key_bindings.on_key["k"] =
xplr.config.modes.builtin.default.key_bindings.on_key["up"]
xplr.config.modes.builtin.default.key_bindings.on_key["l"] =
xplr.config.modes.builtin.default.key_bindings.on_key["right"]
xplr.config.modes.builtin.default.key_bindings.on_key["tab"] =
xplr.config.modes.builtin.default.key_bindings.on_key["ctrl-i"] -- compatibility workaround
-- The builtin debug error mode.
--
@ -1276,11 +1316,10 @@ xplr.config.modes.builtin.debug_error = {
},
splits = {
{
CustomContent = {
title = "debug error",
body = {
StaticParagraph = {
render = [[
Static = {
CustomParagraph = {
ui = { title = { format = "debug error" } },
body = [[
Some errors occurred during startup.
If you think this is a bug, please report it at:
@ -1292,8 +1331,7 @@ xplr.config.modes.builtin.debug_error = {
To disable this mode, set `xplr.config.general.disable_debug_error_mode`
to `true` in your config file.
]],
},
]],
},
},
},
@ -1332,11 +1370,10 @@ xplr.config.modes.builtin.debug_error = {
xplr.config.modes.builtin.recover = {
name = "recover",
layout = {
CustomContent = {
title = " recover ",
body = {
StaticParagraph = {
render = [[
Static = {
CustomParagraph = {
ui = { title = { format = "recover" } },
body = [[
You pressed an invalid key and went into "recover" mode.
This mode saves you from performing unwanted actions.
@ -1345,8 +1382,7 @@ xplr.config.modes.builtin.recover = {
To disable this mode, set `xplr.config.general.enable_recover_mode`
to `false` in your config file.
]],
},
]],
},
},
},
@ -1369,7 +1405,7 @@ xplr.config.modes.builtin.go_to_path = {
messages = {
{
BashExecSilently0 = [===[
PTH=${XPLR_INPUT_BUFFER}
PTH="$XPLR_INPUT_BUFFER"
PTH_ESC=$(printf %q "$PTH")
if [ -d "$PTH" ]; then
"$XPLR" -m 'ChangeDirectory: %q' "$PTH"
@ -1406,21 +1442,63 @@ xplr.config.modes.builtin.selection_ops = {
layout = "HelpMenu",
key_bindings = {
on_key = {
["e"] = {
help = "edit selection",
messages = {
{
BashExec0 = [===[
TMPFILE="$(mktemp)"
(while IFS= read -r -d '' PTH; do
echo $(printf %q "${PTH:?}") >> "${TMPFILE:?}"
done < "${XPLR_PIPE_SELECTION_OUT:?}")
${EDITOR:-vi} "${TMPFILE:?}"
[ ! -e "$TMPFILE" ] && exit
"$XPLR" -m ClearSelection
(while IFS= read -r PTH_ESC; do
"$XPLR" -m 'SelectPath: %q' "$(eval printf %s ${PTH_ESC:?})"
done < "${TMPFILE:?}")
rm -- "${TMPFILE:?}"
]===],
},
"PopMode",
},
},
["l"] = {
help = "list selection",
messages = {
{
BashExec0 = [===[
[ -z "$PAGER" ] && PAGER="less -+F"
while IFS= read -r -d '' PTH; do
echo $(printf %q "$PTH")
done < "${XPLR_PIPE_SELECTION_OUT:?}" | ${PAGER:?}
]===],
},
"PopMode",
},
},
["c"] = {
help = "copy here",
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
if cp -vr -- "${PTH:?}" ./; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC copied to ."
BASENAME=$(basename -- "$PTH")
BASENAME_ESC=$(printf %q "$BASENAME")
while [ -e "$BASENAME" ]; do
BASENAME="$BASENAME (copied)"
BASENAME_ESC=$(printf %q "$BASENAME")
done
if cp -vr -- "${PTH:?}" "./${BASENAME:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC copied to ./$BASENAME_ESC"
"$XPLR" -m 'FocusPath: %q' "$BASENAME"
else
"$XPLR" -m 'LogError: %q' "Failed to copy $PTH_ESC to ."
"$XPLR" -m 'LogError: %q' "could not copy $PTH_ESC to ./$BASENAME_ESC"
fi
done < "${XPLR_PIPE_SELECTION_OUT:?}")
"$XPLR" -m ExplorePwdAsync
"$XPLR" -m ClearSelection
read -p "[enter to continue]"
]===],
},
@ -1432,15 +1510,76 @@ xplr.config.modes.builtin.selection_ops = {
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
BASENAME=$(basename -- "$PTH")
BASENAME_ESC=$(printf %q "$BASENAME")
while [ -e "$BASENAME" ]; do
BASENAME="$BASENAME (moved)"
BASENAME_ESC=$(printf %q "$BASENAME")
done
if mv -v -- "${PTH:?}" "./${BASENAME:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC moved to ./$BASENAME_ESC"
"$XPLR" -m 'FocusPath: %q' "$BASENAME"
else
"$XPLR" -m 'LogError: %q' "could not move $PTH_ESC to ./$BASENAME_ESC"
fi
done < "${XPLR_PIPE_SELECTION_OUT:?}")
read -p "[enter to continue]"
]===],
},
"PopMode",
},
},
["s"] = {
help = "softlink here",
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
if mv -v -- "${PTH:?}" ./; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC moved to ."
BASENAME=$(basename -- "$PTH")
BASENAME_ESC=$(printf %q "$BASENAME")
while [ -e "$BASENAME" ]; do
BASENAME="$BASENAME (softlinked)"
BASENAME_ESC=$(printf %q "$BASENAME")
done
if ln -sv -- "${PTH:?}" "./${BASENAME:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC softlinked as ./$BASENAME_ESC"
"$XPLR" -m 'FocusPath: %q' "$BASENAME"
else
"$XPLR" -m 'LogError: %q' "Failed to move $PTH_ESC to ."
"$XPLR" -m 'LogError: %q' "could not softlink $PTH_ESC as ./$BASENAME_ESC"
fi
done < "${XPLR_PIPE_SELECTION_OUT:?}")
read -p "[enter to continue]"
]===],
},
"PopMode",
},
},
["h"] = {
help = "hardlink here",
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
BASENAME=$(basename -- "$PTH")
BASENAME_ESC=$(printf %q "$BASENAME")
while [ -e "$BASENAME" ]; do
BASENAME="$BASENAME (hardlinked)"
BASENAME_ESC=$(printf %q "$BASENAME")
done
if ln -v -- "${PTH:?}" "./${BASENAME:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC hardlinked as ./$BASENAME_ESC"
"$XPLR" -m 'FocusPath: %q' "$BASENAME"
else
"$XPLR" -m 'LogError: %q' "could not hardlink $PTH_ESC as ./$BASENAME_ESC"
fi
done < "${XPLR_PIPE_SELECTION_OUT:?}")
"$XPLR" -m ExplorePwdAsync
read -p "[enter to continue]"
]===],
},
@ -1551,8 +1690,8 @@ xplr.config.modes.builtin.create_file = {
PTH="$XPLR_INPUT_BUFFER"
PTH_ESC=$(printf %q "$PTH")
if [ "$PTH" ]; then
mkdir -p -- "$(dirname $(realpath -m $PTH))" \
&& touch -- "$PTH" \
mkdir -p -- "$(dirname $(realpath -m $PTH))" # This may fail.
touch -- "$PTH" \
&& "$XPLR" -m 'SetInputBuffer: ""' \
&& "$XPLR" -m 'LogSuccess: %q' "$PTH_ESC created" \
&& "$XPLR" -m 'ExplorePwd' \
@ -1674,7 +1813,7 @@ xplr.config.modes.builtin.go_to = {
elif command -v open; then
OPENER=open
else
"$XPLR" -m 'LogError: "$OPENER not found"'
"$XPLR" -m 'LogError: %q' "$OPENER not found"
exit 1
fi
fi
@ -1792,15 +1931,16 @@ xplr.config.modes.builtin.delete = {
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
if rm -rfv -- "${PTH:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC deleted"
else
"$XPLR" -m 'LogError: %q' "Failed to delete $PTH_ESC"
"$XPLR" -m 'LogError: %q' "could not delete $PTH_ESC"
"$XPLR" -m 'FocusPath: %q' "$PTH"
fi
done < "${XPLR_PIPE_RESULT_OUT:?}")
"$XPLR" -m ExplorePwdAsync
read -p "[enter to continue]"
]===],
},
@ -1812,23 +1952,25 @@ xplr.config.modes.builtin.delete = {
messages = {
{
BashExec0 = [===[
"$XPLR" -m ExplorePwd
(while IFS= read -r -d '' PTH; do
PTH_ESC=$(printf %q "$PTH")
if [ -d "$PTH" ] && [ ! -L "$PTH" ]; then
if rmdir -v -- "${PTH:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC deleted"
else
"$XPLR" -m 'LogError: %q' "Failed to delete $PTH_ESC"
"$XPLR" -m 'LogError: %q' "could not delete $PTH_ESC"
"$XPLR" -m 'FocusPath: %q' "$PTH"
fi
else
if rm -v -- "${PTH:?}"; then
"$XPLR" -m 'LogSuccess: %q' "$PTH_ESC deleted"
else
"$XPLR" -m 'LogError: %q' "Failed to delete $PTH_ESC"
"$XPLR" -m 'LogError: %q' "could not delete $PTH_ESC"
"$XPLR" -m 'FocusPath: %q' "$PTH"
fi
fi
done < "${XPLR_PIPE_RESULT_OUT:?}")
"$XPLR" -m ExplorePwdAsync
read -p "[enter to continue]"
]===],
},
@ -1899,6 +2041,19 @@ xplr.config.modes.builtin.action = {
"ToggleMouse",
},
},
["p"] = {
help = "edit permissions",
messages = {
"PopMode",
{ SwitchModeBuiltin = "edit_permissions" },
{
BashExecSilently0 = [===[
PERM=$(stat -c '%a' -- "${XPLR_FOCUS_PATH:?}")
"$XPLR" -m 'SetInputBuffer: %q' "${PERM:?}"
]===],
},
},
},
["v"] = {
help = "vroot",
messages = {
@ -1987,6 +2142,42 @@ xplr.config.modes.builtin.search = {
"FocusNext",
},
},
["ctrl-z"] = {
help = "toggle ordering",
messages = {
"ToggleSearchOrder",
"ExplorePwdAsync",
},
},
["ctrl-a"] = {
help = "toggle search algorithm",
messages = {
"ToggleSearchAlgorithm",
"ExplorePwdAsync",
},
},
["ctrl-r"] = {
help = "regex search",
messages = {
"SearchRegexFromInput",
"ExplorePwdAsync",
},
},
["ctrl-f"] = {
help = "fuzzy search",
messages = {
"SearchFuzzyFromInput",
"ExplorePwdAsync",
},
},
["ctrl-s"] = {
help = "sort (no search order)",
messages = {
"DisableSearchOrder",
"ExplorePwdAsync",
{ SwitchModeBuiltinKeepingInputBuffer = "sort" },
},
},
["right"] = {
help = "enter",
messages = {
@ -2026,7 +2217,7 @@ xplr.config.modes.builtin.search = {
default = {
messages = {
"UpdateInputBufferFromKey",
"SearchFuzzyFromInput",
"SearchFromInput",
"ExplorePwdAsync",
},
},
@ -2236,7 +2427,12 @@ xplr.config.modes.builtin.sort = {
["enter"] = {
help = "submit",
messages = {
"PopMode",
"PopModeKeepingInputBuffer",
},
},
["esc"] = {
messages = {
"PopModeKeepingInputBuffer",
},
},
["m"] = {
@ -2407,6 +2603,161 @@ xplr.config.modes.builtin.vroot = {
},
}
-- The builtin edit permissions mode.
--
-- Type: [Mode](https://xplr.dev/en/mode)
xplr.config.modes.builtin.edit_permissions = {
name = "edit permissions",
key_bindings = {
on_key = {
["u"] = {
help = "+user",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
U="$(( (${U:-0} + 1) % 8 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["U"] = {
help = "-user",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
U="$(( ${U:-0}-1 < 0 ? 7 : ${U:-0}-1 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["g"] = {
help = "+group",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
G="$(( (${G:-0} + 1) % 8 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["G"] = {
help = "-group",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
G="$(( ${G:-0}-1 < 0 ? 7 : ${G:-0}-1 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["o"] = {
help = "+other",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
O="$(( (${O:-0} + 1) % 8 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["O"] = {
help = "-other",
messages = {
{
BashExecSilently0 = [===[
PERM="${XPLR_INPUT_BUFFER:-000}"
U="${PERM: -3:-2}"
G="${PERM: -2:-1}"
O="${PERM: -1}"
O="$(( ${O:-0}-1 < 0 ? 7 : ${O:-0}-1 ))"
"$XPLR" -m 'SetInputBuffer: %q' "${U:-0}${G:-0}${O:-0}"
]===],
},
},
},
["m"] = {
help = "max",
messages = {
{
BashExecSilently0 = [===[
"$XPLR" -m 'SetInputBuffer: %q' "777"
]===],
},
},
},
["M"] = {
help = "min",
messages = {
{
BashExecSilently0 = [===[
"$XPLR" -m 'SetInputBuffer: %q' "000"
]===],
},
},
},
["ctrl-r"] = {
help = "reset",
messages = {
{
BashExecSilently0 = [===[
PERM=$(stat -c '%a' -- "${XPLR_FOCUS_PATH:?}")
"$XPLR" -m 'SetInputBuffer: %q' "${PERM:?}"
]===],
},
},
},
["enter"] = {
help = "submit",
messages = {
{
BashExecSilently0 = [===[
chmod "${XPLR_INPUT_BUFFER:?}" -- "${XPLR_FOCUS_PATH:?}"
]===],
},
"PopMode",
"ExplorePwdAsync",
},
},
},
default = {
messages = {
"UpdateInputBufferFromKey",
},
},
},
}
-- This is where you define custom modes.
--
-- Type: mapping of the following key-value pairs:
@ -2505,6 +2856,19 @@ xplr.fn.builtin.try_complete_path = function(m)
end
end
xplr.fn.builtin.fmt_general_selection_item = function(n)
local nl = xplr.util.paint("\\n", { add_modifiers = { "Italic", "Dim" } })
local sh_config = { with_prefix_dots = true, without_suffix_dots = true }
local shortened = xplr.util.shorten(n.absolute_path, sh_config)
if n.is_dir then
shortened = shortened .. "/"
end
local ls_style = xplr.util.lscolor(n.absolute_path)
local meta_style = xplr.util.node_type(n).style
local style = xplr.util.style_mix({ meta_style, ls_style })
return xplr.util.paint(shortened:gsub("\n", nl), style)
end
-- Renders the first column in the table
xplr.fn.builtin.fmt_general_table_row_cols_0 = function(m)
local r = ""
@ -2521,11 +2885,10 @@ end
-- Renders the second column in the table
xplr.fn.builtin.fmt_general_table_row_cols_1 = function(m)
local nl = xplr.util.paint("\\n", { add_modifiers = { "Italic", "Dim" } })
local r = m.tree .. m.prefix
local function path_escape(path)
return string.gsub(string.gsub(path, "\\", "\\\\"), "\n", "\\n")
end
local style = xplr.util.lscolor(m.absolute_path)
style = xplr.util.style_mix({ m.style, style })
if m.meta.icon == nil then
r = r .. ""
@ -2533,11 +2896,11 @@ xplr.fn.builtin.fmt_general_table_row_cols_1 = function(m)
r = r .. m.meta.icon .. " "
end
r = r .. path_escape(m.relative_path)
local rel = m.relative_path
if m.is_dir then
r = r .. "/"
rel = rel .. "/"
end
r = r .. xplr.util.paint(xplr.util.shell_escape(rel), style)
r = r .. m.suffix .. " "
@ -2547,11 +2910,11 @@ xplr.fn.builtin.fmt_general_table_row_cols_1 = function(m)
if m.is_broken then
r = r .. "×"
else
r = r .. path_escape(m.symlink.absolute_path)
local symlink_path = xplr.util.shorten(m.symlink.absolute_path)
if m.symlink.is_dir then
r = r .. "/"
symlink_path = symlink_path .. "/"
end
r = r .. symlink_path:gsub("\n", nl)
end
end
@ -2560,84 +2923,23 @@ end
-- Renders the third column in the table
xplr.fn.builtin.fmt_general_table_row_cols_2 = function(m)
local no_color = os.getenv("NO_COLOR")
local function green(x)
if no_color == nil then
return "\x1b[32m" .. x .. "\x1b[0m"
else
return x
end
end
local function yellow(x)
if no_color == nil then
return "\x1b[33m" .. x .. "\x1b[0m"
else
return x
end
end
local function red(x)
if no_color == nil then
return "\x1b[31m" .. x .. "\x1b[0m"
else
return x
end
end
local function bit(x, color, cond)
if cond then
return color(x)
else
return color("-")
end
end
local p = m.permissions
local r = ""
r = r .. bit("r", green, p.user_read)
r = r .. bit("w", yellow, p.user_write)
if p.user_execute == false and p.setuid == false then
r = r .. bit("-", red, p.user_execute)
elseif p.user_execute == true and p.setuid == false then
r = r .. bit("x", red, p.user_execute)
elseif p.user_execute == false and p.setuid == true then
r = r .. bit("S", red, p.user_execute)
else
r = r .. bit("s", red, p.user_execute)
end
r = r .. bit("r", green, p.group_read)
r = r .. bit("w", yellow, p.group_write)
if p.group_execute == false and p.setuid == false then
r = r .. bit("-", red, p.group_execute)
elseif p.group_execute == true and p.setuid == false then
r = r .. bit("x", red, p.group_execute)
elseif p.group_execute == false and p.setuid == true then
r = r .. bit("S", red, p.group_execute)
else
r = r .. bit("s", red, p.group_execute)
end
r = r .. bit("r", green, p.other_read)
r = r .. bit("w", yellow, p.other_write)
if p.other_execute == false and p.setuid == false then
r = r .. bit("-", red, p.other_execute)
elseif p.other_execute == true and p.setuid == false then
r = r .. bit("x", red, p.other_execute)
elseif p.other_execute == false and p.setuid == true then
r = r .. bit("T", red, p.other_execute)
else
r = r .. bit("t", red, p.other_execute)
end
return r
local r = xplr.util.paint("r", { fg = "Green" })
local w = xplr.util.paint("w", { fg = "Yellow" })
local x = xplr.util.paint("x", { fg = "Red" })
local s = xplr.util.paint("s", { fg = "Red" })
local S = xplr.util.paint("S", { fg = "Red" })
local t = xplr.util.paint("t", { fg = "Red" })
local T = xplr.util.paint("T", { fg = "Red" })
return xplr.util
.permissions_rwx(m.permissions)
:gsub("r", r)
:gsub("w", w)
:gsub("x", x)
:gsub("s", s)
:gsub("S", S)
:gsub("t", t)
:gsub("T", T)
end
-- Renders the fourth column in the table

@ -203,7 +203,7 @@ impl std::fmt::Display for Key {
.unwrap_or_default()
});
write!(f, "{}", key_str)
write!(f, "{key_str}")
}
}

@ -4,6 +4,7 @@
pub mod app;
pub mod cli;
pub mod compat;
pub mod config;
pub mod directory_buffer;
pub mod event_reader;
@ -12,10 +13,12 @@ pub mod input;
pub mod lua;
pub mod msg;
pub mod node;
pub mod path;
pub mod permissions;
pub mod pipe;
pub mod pwd_watcher;
pub mod runner;
pub mod search;
pub mod ui;
pub mod yaml;

@ -143,7 +143,7 @@ pub fn call<'lua, R: Deserialize<'lua>>(
func: &str,
arg: mlua::Value<'lua>,
) -> Result<R> {
let func = format!("xplr.fn.{}", func);
let func = format!("xplr.fn.{func}");
let func = resolve_fn(&lua.globals(), &func)?;
let res: mlua::Value = func.call(arg)?;
let res: R = lua.from_value(res)?;
@ -160,24 +160,24 @@ mod tests {
assert!(check_version(VERSION, "foo path").is_ok());
// Current release if OK
assert!(check_version("0.20.2", "foo path").is_ok());
assert!(check_version("0.21.0", "foo path").is_ok());
// Prev major release is ERR
// - Not yet
// Prev minor release is ERR (Change when we get to v1)
assert!(check_version("0.19.2", "foo path").is_err());
assert!(check_version("0.20.0", "foo path").is_err());
// Prev bugfix release is OK
assert!(check_version("0.20.1", "foo path").is_ok());
// assert!(check_version("0.21.-1", "foo path").is_ok());
// Next major release is ERR
assert!(check_version("1.20.2", "foo path").is_err());
assert!(check_version("1.20.0", "foo path").is_err());
// Next minor release is ERR
assert!(check_version("0.21.2", "foo path").is_err());
assert!(check_version("0.22.0", "foo path").is_err());
// Next bugfix release is ERR (Change when we get to v1)
assert!(check_version("0.20.3", "foo path").is_err());
assert!(check_version("0.21.1", "foo path").is_err());
}
}

@ -1,8 +1,19 @@
use crate::app::VERSION;
use crate::config::NodeTypesConfig;
use crate::explorer;
use crate::lua;
use crate::msg::in_::external::ExplorerConfig;
use crate::node::Node;
use crate::path;
use crate::path::RelativityConfig;
use crate::permissions::Octal;
use crate::permissions::Permissions;
use crate::ui;
use crate::ui::Layout;
use crate::ui::Style;
use crate::ui::WrapOptions;
use anyhow::Result;
use lscolors::LsColors;
use mlua::Error as LuaError;
use mlua::Lua;
use mlua::LuaSerdeExt;
@ -13,27 +24,10 @@ use serde::de::Error;
use serde::{Deserialize, Serialize};
use serde_json as json;
use serde_yaml as yaml;
use std::borrow::Cow;
use std::path::PathBuf;
use std::process::Command;
pub(crate) fn create_table(lua: &Lua) -> Result<Table> {
let mut util = lua.create_table()?;
util = version(util, lua)?;
util = dirname(util, lua)?;
util = basename(util, lua)?;
util = absolute(util, lua)?;
util = explore(util, lua)?;
util = shell_execute(util, lua)?;
util = shell_quote(util, lua)?;
util = from_json(util, lua)?;
util = to_json(util, lua)?;
util = from_yaml(util, lua)?;
util = to_yaml(util, lua)?;
Ok(util)
}
/// Get the xplr version details.
///
/// Type: function() -> { major: number, minor: number, patch: number }
@ -70,6 +64,209 @@ pub fn version<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
Ok(util)
}
/// Clone/deepcopy a Lua value. Doesn't work with functions.
///
/// Type: function( value ) -> value
///
/// Example:
///
/// ```lua
/// local val = { foo = "bar" }
/// local val_clone = xplr.util.clone(val)
/// val.foo = "baz"
/// print(val_clone.foo)
/// -- "bar"
/// ```
pub fn clone<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(move |lua, value: Value| {
lua::serialize(lua, &value).map_err(LuaError::custom)
})?;
util.set("clone", func)?;
Ok(util)
}
/// Check if the given path exists.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.exists("/foo/bar")
/// -- true
/// ```
pub fn exists<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(move |_, path: String| Ok(PathBuf::from(path).exists()))?;
util.set("exists", func)?;
Ok(util)
}
/// Check if the given path is a directory.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.is_dir("/foo/bar")
/// -- true
/// ```
pub fn is_dir<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(move |_, path: String| Ok(PathBuf::from(path).is_dir()))?;
util.set("is_dir", func)?;
Ok(util)
}
/// Check if the given path is a file.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.is_file("/foo/bar")
/// -- true
/// ```
pub fn is_file<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(move |_, path: String| Ok(PathBuf::from(path).is_dir()))?;
util.set("is_file", func)?;
Ok(util)
}
/// Check if the given path is a symlink.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.is_file("/foo/bar")
/// -- true
/// ```
pub fn is_symlink<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua
.create_function(move |_, path: String| Ok(PathBuf::from(path).is_symlink()))?;
util.set("is_symlink", func)?;
Ok(util)
}
/// Check if the given path is an absolute path.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.is_absolute("/foo/bar")
/// -- true
/// ```
pub fn is_absolute<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua
.create_function(move |_, path: String| Ok(PathBuf::from(path).is_absolute()))?;
util.set("is_absolute", func)?;
Ok(util)
}
/// Split a path into its components.
///
/// Type: function( path:string ) -> boolean
///
/// Example:
///
/// ```lua
/// xplr.util.path_split("/foo/bar")
/// -- { "/", "foo", "bar" }
///
/// xplr.util.path_split(".././foo")
/// -- { "..", "foo" }
/// ```
pub fn path_split<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(move |_, path: String| {
let components: Vec<String> = PathBuf::from(path)
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect();
Ok(components)
})?;
util.set("path_split", func)?;
Ok(util)
}
/// Get [Node][5] information of a given path.
/// Doesn't check if the path exists.
/// Returns nil if the path is "/".
/// Errors out if absolute path can't be obtained.
///
/// Type: function( path:string ) -> [Node][5]|nil
///
/// Example:
///
/// ```lua
/// xplr.util.node("./bar")
/// -- { parent = "/pwd", relative_path = "bar", absolute_path = "/pwd/bar", ... }
///
/// xplr.util.node("/")
/// -- nil
/// ```
pub fn node<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(move |lua, path: String| {
let path = PathBuf::from(path);
let abs = path.absolutize()?;
match (abs.parent(), abs.file_name()) {
(Some(parent), Some(name)) => {
let node = Node::new(
parent.to_string_lossy().to_string(),
name.to_string_lossy().to_string(),
);
Ok(lua::serialize(lua, &node).map_err(LuaError::custom)?)
}
(_, _) => Ok(Value::Nil),
}
})?;
util.set("node", func)?;
Ok(util)
}
/// Get the configured [Node Type][6] of a given [Node][5].
///
/// Type: function( [Node][5], [xplr.config.node_types][7]|nil ) -> [Node Type][6]
///
/// If the second argument is missing, global config `xplr.config.node_types`
/// will be used.
///
/// Example:
///
/// ```lua
/// xplr.util.node_type(app.focused_node)
/// -- { style = { fg = "Red", ... }, meta = { icon = "", ... } ... }
///
/// xplr.util.node_type(xplr.util.node("/foo/bar"), xplr.config.node_types)
/// -- { style = { fg = "Red", ... }, meta = { icon = "", ... } ... }
/// ```
pub fn node_type<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(move |lua, (node, config): (Table, Option<Table>)| {
let node: Node = lua.from_value(Value::Table(node))?;
let config: Table = if let Some(config) = config {
config
} else {
lua.globals()
.get::<_, Table>("xplr")?
.get::<_, Table>("config")?
.get::<_, Table>("node_types")?
};
let config: NodeTypesConfig = lua.from_value(Value::Table(config))?;
let node_type = config.get(&node);
let node_type = lua::serialize(lua, &node_type).map_err(LuaError::custom)?;
Ok(node_type)
})?;
util.set("node_type", func)?;
Ok(util)
}
/// Get the directory name of a given path.
///
/// Type: function( path:string ) -> path:string|nil
@ -125,32 +322,119 @@ pub fn basename<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
/// ```
pub fn absolute<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|_, path: String| {
let parent = PathBuf::from(path)
let abs = PathBuf::from(path)
.absolutize()?
.to_string_lossy()
.to_string();
Ok(parent)
Ok(abs)
})?;
util.set("absolute", func)?;
Ok(util)
}
/// Get the relative path based on the given base path or current working dir.
/// Will error if it fails to determine a relative path.
///
/// Type: function( path:string, options:table|nil ) -> path:string
///
/// Options type: { base:string|nil, with_prefix_dots:bookean|nil, without_suffix_dots:boolean|nil }
///
/// - If `base` path is given, the path will be relative to it.
/// - If `with_prefix_dots` is true, the path will always start with dots `..` / `.`
/// - If `without_suffix_dots` is true, the name will be visible instead of dots `..` / `.`
///
/// Example:
///
/// ```lua
/// xplr.util.relative_to("/present/working/directory")
/// -- "."
///
/// xplr.util.relative_to("/present/working/directory/foo")
/// -- "foo"
///
/// xplr.util.relative_to("/present/working/directory/foo", { with_prefix_dots = true })
/// -- "./foo"
///
/// xplr.util.relative_to("/present/working/directory", { without_suffix_dots = true })
/// -- "../directory"
///
/// xplr.util.relative_to("/present/working")
/// -- ".."
///
/// xplr.util.relative_to("/present/working", { without_suffix_dots = true })
/// -- "../../working"
///
/// xplr.util.relative_to("/present/working/directory", { base = "/present/foo/bar" })
/// -- "../../working/directory"
/// ```
pub fn relative_to<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, (path, config): (String, Option<Table>)| {
let config: Option<RelativityConfig<String>> =
lua.from_value(config.map(Value::Table).unwrap_or(Value::Nil))?;
path::relative_to(path, config.as_ref())
.map(|p| p.to_string_lossy().to_string())
.map_err(LuaError::custom)
})?;
util.set("relative_to", func)?;
Ok(util)
}
/// Shorten the given absolute path using the following rules:
/// - either relative to your home dir if it makes sense
/// - or relative to the current working directory
/// - or absolute path if it makes the most sense
///
/// Type: Similar to `xplr.util.relative_to`
///
/// Example:
///
/// ```lua
/// xplr.util.shorten("/home/username/.config")
/// -- "~/.config"
///
/// xplr.util.shorten("/present/working/directory")
/// -- "."
///
/// xplr.util.shorten("/present/working/directory/foo")
/// -- "foo"
///
/// xplr.util.shorten("/present/working/directory/foo", { with_prefix_dots = true })
/// -- "./foo"
///
/// xplr.util.shorten("/present/working/directory", { without_suffix_dots = true })
/// -- "../directory"
///
/// xplr.util.shorten("/present/working/directory", { base = "/present/foo/bar" })
/// -- "../../working/directory"
///
/// xplr.util.shorten("/tmp")
/// -- "/tmp"
/// ```
pub fn shorten<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(move |lua, (path, config): (String, Option<Table>)| {
let config: Option<RelativityConfig<String>> =
lua.from_value(config.map(Value::Table).unwrap_or(Value::Nil))?;
path::shorten(path, config.as_ref()).map_err(LuaError::custom)
})?;
util.set("shorten", func)?;
Ok(util)
}
/// Explore directories with the given explorer config.
///
/// Type: function( path:string, config:[Explorer Config][1]|nil )
/// -> { node:[Node][2]... }
/// Type: function( path:string, [ExplorerConfig][1]|nil ) -> { [Node][2], ... }
///
/// Example:
///
/// ```lua
///
/// xplr.util.explore("/tmp")
/// -- { { absolute_path = "/tmp/a", ... }, ... }
///
/// xplr.util.explore("/tmp", app.explorer_config)
/// -- { { absolute_path = "/tmp/a", ... }, ... }
/// ```
///
/// [1]: https://xplr.dev/en/lua-function-calls#explorer-config
/// [2]: https://xplr.dev/en/lua-function-calls#node
pub fn explore<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, (path, config): (String, Option<Table>)| {
let config: ExplorerConfig = if let Some(cfg) = config {
@ -170,13 +454,14 @@ pub fn explore<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
/// Execute shell commands safely.
///
/// Type: function( program:string, args:{ arg:string... }|nil )
/// -> { stdout = string, stderr = string, returncode = number|nil }
/// Type: function( program:string, args:{ string, ... }|nil ) -> { stdout = string, stderr = string, returncode = number|nil }
///
/// Example:
///
/// ```lua
/// xplr.util.shell_execute("pwd")
/// -- "/present/working/directory"
///
/// xplr.util.shell_execute("bash", {"-c", "xplr --help"})
/// -- { stdout = "xplr...", stderr = "", returncode = 0 }
/// ```
@ -218,9 +503,28 @@ pub fn shell_quote<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
Ok(util)
}
/// Escape commands and paths safely.
///
/// Type: function( string ) -> string
///
/// Example:
///
/// ```lua
/// xplr.util.shell_escape("a'b\"c")
/// -- "\"a'b\\\"c\""
/// ```
pub fn shell_escape<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(move |_, string: String| {
let val = path::escape(&string).to_string();
Ok(val)
})?;
util.set("shell_escape", func)?;
Ok(util)
}
/// Load JSON string into Lua value.
///
/// Type: function( string ) -> value
/// Type: function( string ) -> any
///
/// Example:
///
@ -245,11 +549,11 @@ pub fn from_json<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
///
/// ```lua
/// xplr.util.to_json({ foo = "bar" })
/// -- [[{ "foos": "bar" }]]
/// -- [[{ "foo": "bar" }]]
///
/// xplr.util.to_json({ foo = "bar" }, { pretty = true })
/// -- [[{
/// -- "foos": "bar"
/// -- "foo": "bar"
/// -- }]]
/// ```
pub fn to_json<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
@ -317,3 +621,252 @@ pub fn to_yaml<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
util.set("to_yaml", func)?;
Ok(util)
}
/// Get a [Style][3] object for the given path based on the LS_COLORS
/// environment variable.
///
/// Type: function( path:string ) -> [Style][3]|nil
///
/// Example:
///
/// ```lua
/// xplr.util.lscolor("Desktop")
/// -- { fg = "Red", bg = nil, add_modifiers = {}, sub_modifiers = {} }
/// ```
pub fn lscolor<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let lscolors = LsColors::from_env().unwrap_or_default();
let func = lua.create_function(move |lua, path: String| {
let style = lscolors.style_for_path(path).map(Style::from);
lua::serialize(lua, &style).map_err(LuaError::custom)
})?;
util.set("lscolor", func)?;
Ok(util)
}
/// Apply style (escape sequence) to string using a given [Style][3] object.
///
/// Type: function( string, [Style][3]|nil ) -> string
///
/// Example:
///
/// ```lua
/// xplr.util.paint("Desktop", { fg = "Red", bg = nil, add_modifiers = {}, sub_modifiers = {} })
/// -- "\u001b[31mDesktop\u001b[0m"
/// ```
pub fn paint<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func =
lua.create_function(|lua, (string, style): (String, Option<Table>)| {
if *ui::NO_COLOR {
return Ok(string);
}
if let Some(style) = style {
let style: Style = lua.from_value(Value::Table(style))?;
let ansi_style: nu_ansi_term::Style = style.into();
Ok::<String, LuaError>(ansi_style.paint(string).to_string())
} else {
Ok(string)
}
})?;
util.set("paint", func)?;
Ok(util)
}
/// Mix multiple [Style][3] objects into one.
///
/// Type: function( { [Style][3], [Style][3], ... } ) -> [Style][3]
///
/// Example:
///
/// ```lua
/// xplr.util.style_mix({{ fg = "Red" }, { bg = "Blue" }, { add_modifiers = {"Bold"} }})
/// -- { fg = "Red", bg = "Blue", add_modifiers = { "Bold" }, sub_modifiers = {} }
/// ```
pub fn style_mix<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, styles: Vec<Table>| {
let mut style = Style::default();
for other in styles {
let other: Style = lua.from_value(Value::Table(other))?;
style = style.extend(&other);
}
lua::serialize(lua, &style).map_err(LuaError::custom)
})?;
util.set("style_mix", func)?;
Ok(util)
}
/// Wrap the given text to fit the specified width.
/// It will try to not split words when possible.
///
/// Type: function( string, options:number|table ) -> { string, ...}
///
/// Options type: { width = number, initial_indent = string|nil, subsequent_indent = string|nil, break_words = boolean|nil }
///
/// Example:
///
/// ```lua
/// xplr.util.textwrap("this will be cut off", 11)
/// -- { "this will', 'be cut off" }
///
/// xplr.util.textwrap(
/// "this will be cut off",
/// { width = 12, initial_indent = "", subsequent_indent = " ", break_words = false }
/// )
/// -- { "this will be", " cut off" }
/// ```
pub fn textwrap<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, (text, options): (String, Value)| {
let lines = match lua.from_value::<usize>(options.clone()) {
Ok(width) => textwrap::wrap(&text, width),
Err(_) => {
let options = lua.from_value::<WrapOptions>(options)?;
textwrap::wrap(&text, options.get_options())
}
};
Ok(lines.iter().map(Cow::to_string).collect::<Vec<String>>())
})?;
util.set("textwrap", func)?;
Ok(util)
}
/// Find the target layout in the given layout and replace it with the replacement layout,
/// returning a new layout.
///
/// Type: function( layout:[Layout][4], target:[Layout][4], replacement:[Layout][4] ) -> layout:[Layout][4]
///
/// Example:
///
/// ```lua
/// local layout = {
/// Horizontal = {
/// splits = {
/// "Table", -- Target
/// "HelpMenu",
/// },
/// config = ...,
/// }
/// }
///
/// xplr.util.layout_replace(layout, "Table", "Selection")
/// -- {
/// -- Horizontal = {
/// -- splits = {
/// -- "Selection", -- Replacement
/// -- "HelpMenu",
/// -- },
/// -- config = ...
/// -- }
/// -- }
/// ```
pub fn layout_replace<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(
move |lua, (layout, target, replacement): (Value, Value, Value)| {
let layout: Layout = lua.from_value(layout)?;
let target: Layout = lua.from_value(target)?;
let replacement: Layout = lua.from_value(replacement)?;
let res = layout.replace(&target, &replacement);
let res = lua::serialize(lua, &res).map_err(LuaError::custom)?;
Ok(res)
},
)?;
util.set("layout_replace", func)?;
Ok(util)
}
/// Convert [Permission][8] to rwxrwxrwx representation with special bits.
///
/// Type: function( [Permission][8] ) -> string
///
/// Example:
///
/// ```lua
/// xplr.util.permissions_rwx({ user_read = true })
/// -- "r--------"
///
/// xplr.util.permissions_rwx(app.focused_node.permission)
/// -- "rwxrwsrwT"
/// ```
pub fn permissions_rwx<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, permission: Table| {
let permissions: Permissions = lua.from_value(Value::Table(permission))?;
let permissions = permissions.to_string();
Ok(permissions)
})?;
util.set("permissions_rwx", func)?;
Ok(util)
}
/// Convert [Permission][8] to octal representation.
///
/// Type: function( [Permission][8] ) -> { number, number, number, number }
///
/// Example:
///
/// ```lua
/// xplr.util.permissions_octal({ user_read = true })
/// -- { 0, 4, 0, 0 }
///
/// xplr.util.permissions_octal(app.focused_node.permission)
/// -- { 0, 7, 5, 4 }
/// ```
pub fn permissions_octal<'a>(util: Table<'a>, lua: &Lua) -> Result<Table<'a>> {
let func = lua.create_function(|lua, permission: Table| {
let permissions: Permissions = lua.from_value(Value::Table(permission))?;
let permissions: Octal = permissions.into();
let permissions = lua::serialize(lua, &permissions).map_err(LuaError::custom)?;
Ok(permissions)
})?;
util.set("permissions_octal", func)?;
Ok(util)
}
///
/// [1]: https://xplr.dev/en/lua-function-calls#explorer-config
/// [2]: https://xplr.dev/en/lua-function-calls#node
/// [3]: https://xplr.dev/en/style
/// [4]: https://xplr.dev/en/layout
/// [5]: https://xplr.dev/en/lua-function-calls#node
/// [6]: https://xplr.dev/en/node-type
/// [7]: https://xplr.dev/en/node_types
/// [8]: https://xplr.dev/en/column-renderer#permission
pub(crate) fn create_table(lua: &Lua) -> Result<Table> {
let mut util = lua.create_table()?;
util = version(util, lua)?;
util = clone(util, lua)?;
util = exists(util, lua)?;
util = is_dir(util, lua)?;
util = is_file(util, lua)?;
util = is_symlink(util, lua)?;
util = is_absolute(util, lua)?;
util = path_split(util, lua)?;
util = node(util, lua)?;
util = node_type(util, lua)?;
util = dirname(util, lua)?;
util = basename(util, lua)?;
util = absolute(util, lua)?;
util = relative_to(util, lua)?;
util = shorten(util, lua)?;
util = explore(util, lua)?;
util = shell_execute(util, lua)?;
util = shell_quote(util, lua)?;
util = shell_escape(util, lua)?;
util = from_json(util, lua)?;
util = to_json(util, lua)?;
util = from_yaml(util, lua)?;
util = to_yaml(util, lua)?;
util = lscolor(util, lua)?;
util = paint(util, lua)?;
util = style_mix(util, lua)?;
util = textwrap(util, lua)?;
util = layout_replace(util, lua)?;
util = permissions_rwx(util, lua)?;
util = permissions_octal(util, lua)?;
Ok(util)
}

@ -1,8 +1,11 @@
use crate::{app::Node, input::InputOperation};
use crate::app::Node;
use crate::input::InputOperation;
use crate::search::PathItem;
use crate::search::SearchAlgorithm;
use indexmap::IndexSet;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::{cmp::Ordering, sync::Arc};
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum ExternalMsg {
@ -74,6 +77,14 @@ pub enum ExternalMsg {
/// - YAML: `FocusNext`
FocusNext,
/// Focus on the next selected node.
///
/// Example:
///
/// - Lua: `"FocusNextSelection"`
/// - YAML: `FocusNextSelection`
FocusNextSelection,
/// Focus on the `n`th node relative to the current focus where `n` is a
/// given value.
///
@ -102,6 +113,14 @@ pub enum ExternalMsg {
/// - YAML: `FocusPrevious`
FocusPrevious,
/// Focus on the previous selection item.
///
/// Example:
///
/// - Lua: `"FocusPreviousSelection"`
/// - YAML: `FocusPreviousSelection`
FocusPreviousSelection,
/// Focus on the `-n`th node relative to the current focus where `n` is a
/// given value.
///
@ -905,6 +924,26 @@ pub enum ExternalMsg {
/// ### Search Operations --------------------------------------------------
/// Search files using the current or default (fuzzy) search algorithm.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Type: { Search = "string" }
///
/// Example:
///
/// - Lua: `{ Search = "pattern" }`
/// - YAML: `Search: pattern`
Search(String),
/// Calls `Search` with the input taken from the input buffer.
///
/// Example:
///
/// - Lua: `"SearchFromInput"`
/// - YAML: `SearchFromInput`
SearchFromInput,
/// Search files using fuzzy match algorithm.
/// It keeps the filters, but overrides the sorters.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
@ -920,6 +959,7 @@ pub enum ExternalMsg {
/// Calls `SearchFuzzy` with the input taken from the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Example:
///
@ -927,6 +967,109 @@ pub enum ExternalMsg {
/// - YAML: `SearchFuzzyFromInput`
SearchFuzzyFromInput,
/// Like `SearchFuzzy`, but doesn't not perform rank based sorting.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Type: { SearchFuzzyUnordered = "string" }
///
/// Example:
///
/// - Lua: `{ SearchFuzzyUnordered = "pattern" }`
/// - YAML: `SearchFuzzyUnordered: pattern`
SearchFuzzyUnordered(String),
/// Calls `SearchFuzzyUnordered` with the input taken from the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Example:
///
/// - Lua: `"SearchFuzzyUnorderedFromInput"`
/// - YAML: `SearchFuzzyUnorderedFromInput`
SearchFuzzyUnorderedFromInput,
/// Search files using regex match algorithm.
/// It keeps the filters, but overrides the sorters.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Type: { SearchRegex = "string" }
///
/// Example:
///
/// - Lua: `{ SearchRegex = "pattern" }`
/// - YAML: `SearchRegex: pattern`
SearchRegex(String),
/// Calls `SearchRegex` with the input taken from the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Example:
///
/// - Lua: `"SearchRegexFromInput"`
/// - YAML: `SearchRegexFromInput`
SearchRegexFromInput,
/// Like `SearchRegex`, but doesn't not perform rank based sorting.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Type: { SearchRegexUnordered = "string" }
///
/// Example:
///
/// - Lua: `{ SearchRegexUnordered = "pattern" }`
/// - YAML: `SearchRegexUnordered: pattern`
SearchRegexUnordered(String),
/// Calls `SearchRegexUnordered` with the input taken from the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
/// It gets reset automatically when changing directory.
///
/// Example:
///
/// - Lua: `"SearchRegexUnorderedFromInput"`
/// - YAML: `SearchRegexUnorderedFromInput`
SearchRegexUnorderedFromInput,
/// Toggles between different search algorithms, without changing the input
/// buffer
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
///
/// Example:
///
/// - Lua: `"ToggleSearchAlgorithm"`
/// - YAML: `ToggleSearchAlgorithm`
ToggleSearchAlgorithm,
/// Enables ranked search without changing the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
///
/// Example:
///
/// - Lua: `"EnableOrderedSearch"`
/// - YAML: `EnableSearchOrder`
EnableSearchOrder,
/// Disabled ranked search without changing the input buffer.
/// You need to call `ExplorePwd` or `ExplorePwdAsync` explicitely.
///
/// Example:
///
/// - Lua: `"DisableSearchOrder"`
/// - YAML: `DisableSearchOrder`
DisableSearchOrder,
/// Toggles ranked search without changing the input buffer.
///
/// Example:
///
/// - Lua: `"ToggleSearchOrder"`
/// - YAML: `ToggleSearchOrder`
ToggleSearchOrder,
/// Accepts the search by keeping the latest focus while in search mode.
/// Automatically calls `ExplorePwd`.
///
@ -1635,18 +1778,78 @@ impl NodeFilterApplicable {
}
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct NodeSearcher {
pub struct NodeSearcherApplicable {
pub pattern: String,
#[serde(default)]
pub recoverable_focus: Option<String>,
#[serde(default)]
pub algorithm: SearchAlgorithm,
#[serde(default)]
pub unordered: bool,
}
impl NodeSearcher {
pub fn new(pattern: String, recoverable_focus: Option<String>) -> Self {
impl NodeSearcherApplicable {
pub fn new(
pattern: String,
recoverable_focus: Option<String>,
algorithm: SearchAlgorithm,
unordered: bool,
) -> Self {
Self {
pattern,
recoverable_focus,
algorithm,
unordered,
}
}
pub fn search<I>(&self, nodes: I) -> Vec<Node>
where
I: IntoIterator<Item = Node>,
{
let engine = self.algorithm.engine(&self.pattern);
let ranked_nodes = nodes.into_iter().filter_map(|n| {
let item = Arc::new(PathItem::from(n.relative_path.clone()));
engine.match_item(item).map(|res| (n, res.rank))
});
if self.unordered {
ranked_nodes.map(|(n, _)| n).collect()
} else {
let mut ranked_nodes = ranked_nodes.collect::<Vec<_>>();
ranked_nodes.sort_by(|(_, s1), (_, s2)| s1.cmp(s2));
ranked_nodes.into_iter().map(|(n, _)| n).collect()
}
}
pub fn enable_search_order(self) -> Self {
Self {
unordered: false,
..self
}
}
pub fn disable_search_order(self) -> Self {
Self {
unordered: true,
..self
}
}
pub fn toggle_search_order(self) -> Self {
Self {
unordered: !self.unordered,
..self
}
}
pub fn toggle_algorithm(self) -> Self {
Self {
algorithm: self.algorithm.toggle(),
..self
}
}
}
@ -1660,7 +1863,7 @@ pub struct ExplorerConfig {
pub sorters: IndexSet<NodeSorterApplicable>,
#[serde(default)]
pub searcher: Option<NodeSearcher>,
pub searcher: Option<NodeSearcherApplicable>,
}
impl ExplorerConfig {

@ -10,9 +10,16 @@ fn to_human_size(size: u64) -> String {
format_size(size, DECIMAL)
}
fn mime_essence(path: &Path, is_dir: bool) -> String {
fn mime_essence(
path: &Path,
is_dir: bool,
extension: &str,
is_executable: bool,
) -> String {
if is_dir {
String::from("inode/directory")
} else if extension.is_empty() && is_executable {
String::from("application/x-executable")
} else {
mime_guess::from_path(path)
.first()
@ -44,29 +51,43 @@ impl ResolvedNode {
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let (is_dir, is_file, is_readonly, size, created, last_modified, uid, gid) =
path.metadata()
.map(|m| {
(
m.is_dir(),
m.is_file(),
m.permissions().readonly(),
m.len(),
m.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos()),
m.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos()),
m.uid(),
m.gid(),
)
})
.unwrap_or((false, false, false, 0, None, None, 0, 0));
let mime_essence = mime_essence(&path, is_dir);
let (
is_dir,
is_file,
is_readonly,
size,
permissions,
created,
last_modified,
uid,
gid,
) = path
.metadata()
.map(|m| {
(
m.is_dir(),
m.is_file(),
m.permissions().readonly(),
m.len(),
Permissions::from(&m),
m.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos()),
m.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos()),
m.uid(),
m.gid(),
)
})
.unwrap_or((false, false, false, 0, Default::default(), None, None, 0, 0));
let is_executable = permissions.user_execute
|| permissions.group_execute
|| permissions.other_execute;
let mime_essence = mime_essence(&path, is_dir, &extension, is_executable);
let human_size = to_human_size(size);
Self {
@ -177,7 +198,11 @@ impl Node {
)
});
let mime_essence = mime_essence(&path, is_dir);
let is_executable = permissions.user_execute
|| permissions.group_execute
|| permissions.other_execute;
let mime_essence = mime_essence(&path, is_dir, &extension, is_executable);
let human_size = to_human_size(size);
Self {

@ -0,0 +1,495 @@
use anyhow::{bail, Result};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
pub use snailquote::escape;
use std::path::{Component, Path, PathBuf};
lazy_static! {
pub static ref HOME: Option<PathBuf> = dirs::home_dir();
}
// Stolen from https://github.com/Manishearth/pathdiff/blob/master/src/lib.rs
pub fn diff<P, B>(path: P, base: B) -> Result<PathBuf>
where
P: AsRef<Path>,
B: AsRef<Path>,
{
let path = path.as_ref();
let base = base.as_ref();
if path.is_absolute() != base.is_absolute() {
if path.is_absolute() {
Ok(PathBuf::from(path))
} else {
let path = path.to_string_lossy();
bail!("{path}: is not absolute")
}
} else {
let mut ita = path.components();
let mut itb = base.components();
let mut comps: Vec<Component> = vec![];
loop {
match (ita.next(), itb.next()) {
(None, None) => break,
(Some(a), None) => {
comps.push(a);
comps.extend(ita.by_ref());
break;
}
(None, _) => comps.push(Component::ParentDir),
(Some(a), Some(b)) if comps.is_empty() && a == b => (),
(Some(a), Some(b)) if b == Component::CurDir => comps.push(a),
(Some(_), Some(b)) if b == Component::ParentDir => {
let path = path.to_string_lossy();
let base = base.to_string_lossy();
bail!("{base} is not a parent of {path}")
}
(Some(a), Some(_)) => {
comps.push(Component::ParentDir);
for _ in itb {
comps.push(Component::ParentDir);
}
comps.push(a);
comps.extend(ita.by_ref());
break;
}
}
}
Ok(comps.iter().map(|c| c.as_os_str()).collect())
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct RelativityConfig<B: AsRef<Path>> {
base: Option<B>,
with_prefix_dots: Option<bool>,
without_suffix_dots: Option<bool>,
}
impl<B: AsRef<Path>> RelativityConfig<B> {
pub fn with_base(mut self, base: B) -> Self {
self.base = Some(base);
self
}
pub fn with_prefix_dots(mut self) -> Self {
self.with_prefix_dots = Some(true);
self
}
pub fn without_suffix_dots(mut self) -> Self {
self.without_suffix_dots = Some(true);
self
}
}
pub fn relative_to<P, B>(
path: P,
config: Option<&RelativityConfig<B>>,
) -> Result<PathBuf>
where
P: AsRef<Path>,
B: AsRef<Path>,
{
let path = path.as_ref();
let base = match config.and_then(|c| c.base.as_ref()) {
Some(base) => PathBuf::from(base.as_ref()),
None => std::env::current_dir()?,
};
let diff = diff(path, base)?;
let relative = if diff.to_str() == Some("") {
".".into()
} else {
diff
};
let relative = if config.and_then(|c| c.with_prefix_dots).unwrap_or(false)
&& !relative.starts_with(".")
&& !relative.starts_with("..")
{
PathBuf::from(".").join(relative)
} else {
relative
};
let relative = if !config.and_then(|c| c.without_suffix_dots).unwrap_or(false) {
relative
} else if relative.ends_with(".") {
match (path.parent(), path.file_name()) {
(Some(_), Some(name)) => PathBuf::from("..").join(name),
(_, _) => relative,
}
} else if relative.ends_with("..") {
match (path.parent(), path.file_name()) {
(Some(parent), Some(name)) => {
if parent.parent().is_some() {
relative.join("..").join(name)
} else {
// always prefer absolute path if it's a child of the root directory
// to guarantee that the basename is included
path.into()
}
}
(_, _) => relative,
}
} else {
relative
};
Ok(relative)
}
pub fn shorten<P, B>(path: P, config: Option<&RelativityConfig<B>>) -> Result<String>
where
P: AsRef<Path>,
B: AsRef<Path>,
{
let path = path.as_ref();
let pathstring = path.to_string_lossy().to_string();
let relative = relative_to(path, config)?;
let relative = relative.to_string_lossy().to_string();
let fromhome = HOME
.as_ref()
.and_then(|h| {
path.strip_prefix(h).ok().map(|p| {
if p.to_str() == Some("") {
"~".into()
} else {
PathBuf::from("~").join(p).to_string_lossy().to_string()
}
})
})
.unwrap_or(pathstring);
if relative.len() < fromhome.len() {
Ok(relative)
} else {
Ok(fromhome)
}
}
#[cfg(test)]
mod tests {
use super::*;
type Config<'a> = Option<&'a RelativityConfig<String>>;
const NONE: Config = Config::None;
fn default<'a>() -> RelativityConfig<&'a str> {
Default::default()
}
#[test]
fn test_relative_to_pwd() {
let path = std::env::current_dir().unwrap();
let relative = relative_to(&path, NONE).unwrap();
assert_eq!(relative, PathBuf::from("."));
let relative = relative_to(&path, Some(&default().with_prefix_dots())).unwrap();
assert_eq!(relative, PathBuf::from("."));
let relative =
relative_to(&path, Some(&default().without_suffix_dots())).unwrap();
assert_eq!(
relative,
PathBuf::from("..").join(path.file_name().unwrap())
);
let relative = relative_to(
&path,
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(
relative,
PathBuf::from("..").join(path.file_name().unwrap())
);
}
#[test]
fn test_relative_to_parent() {
let path = std::env::current_dir().unwrap();
let parent = path.parent().unwrap();
let relative = relative_to(parent, NONE).unwrap();
assert_eq!(relative, PathBuf::from(".."));
let relative = relative_to(parent, Some(&default().with_prefix_dots())).unwrap();
assert_eq!(relative, PathBuf::from(".."));
let relative =
relative_to(parent, Some(&default().without_suffix_dots())).unwrap();
assert_eq!(
relative,
PathBuf::from("../..").join(parent.file_name().unwrap())
);
let relative = relative_to(
parent,
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(
relative,
PathBuf::from("../..").join(parent.file_name().unwrap())
);
}
#[test]
fn test_relative_to_file() {
let path = std::env::current_dir().unwrap().join("foo").join("bar");
let relative = relative_to(&path, NONE).unwrap();
assert_eq!(relative, PathBuf::from("foo/bar"));
let relative = relative_to(&path, Some(&default().with_prefix_dots())).unwrap();
assert_eq!(relative, PathBuf::from("./foo/bar"));
let relative = relative_to(
&path,
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(relative, PathBuf::from("./foo/bar"));
}
#[test]
fn test_relative_to_root() {
let relative = relative_to("/foo", Some(&default().with_base("/"))).unwrap();
assert_eq!(relative, PathBuf::from("foo"));
let relative = relative_to(
"/foo",
Some(
&default()
.with_base("/")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(relative, PathBuf::from("./foo"));
let relative = relative_to("/", Some(&default().with_base("/"))).unwrap();
assert_eq!(relative, PathBuf::from("."));
let relative = relative_to(
"/",
Some(
&default()
.with_base("/")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(relative, PathBuf::from("."));
let relative = relative_to("/", Some(&default().with_base("/foo"))).unwrap();
assert_eq!(relative, PathBuf::from(".."));
let relative = relative_to(
"/",
Some(
&default()
.with_base("/foo")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(relative, PathBuf::from(".."));
}
#[test]
fn test_relative_to_base() {
let path = "/some/directory";
let base = "/another/foo/bar";
let relative = relative_to(path, Some(&default().with_base(base))).unwrap();
assert_eq!(relative, PathBuf::from("../../../some/directory"));
let relative = relative_to(
path,
Some(
&default()
.with_base(base)
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(relative, PathBuf::from("../../../some/directory"));
}
#[test]
fn test_shorten_home() {
let path = HOME.as_ref().unwrap();
let res = shorten(path, NONE).unwrap();
assert_eq!(res, "~");
let res = shorten(
path,
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(res, "~");
let res = shorten(
path,
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(res, "~");
let res = shorten(path.join("foo"), NONE).unwrap();
assert_eq!(res, "~/foo");
let res = shorten(
path.join("foo"),
Some(&default().with_prefix_dots().without_suffix_dots()),
)
.unwrap();
assert_eq!(res, "~/foo");
let res = shorten(format!("{}foo", path.to_string_lossy()), NONE).unwrap();
assert_ne!(res, "~/foo");
assert_eq!(res, format!("{}foo", path.to_string_lossy()));
}
#[test]
fn test_shorten_base() {
let path = "/present/working/directory";
let base = "/present/foo/bar";
let res = shorten(path, Some(&default().with_base(base))).unwrap();
assert_eq!(res, "../../working/directory");
let res = shorten(
path,
Some(
&default()
.with_base(base)
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "../../working/directory");
}
#[test]
fn test_shorten_pwd() {
let path = "/present/working/directory";
let res = shorten(path, Some(&default().with_base(path))).unwrap();
assert_eq!(res, ".");
let res = shorten(
path,
Some(
&default()
.with_base(path)
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "../directory");
}
#[test]
fn test_shorten_parent() {
let path = "/present/working";
let base = "/present/working/directory";
let res = shorten(path, Some(&default().with_base(base))).unwrap();
assert_eq!(res, "..");
let res = shorten(
path,
Some(
&default()
.with_base(base)
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "../../working");
}
#[test]
fn test_shorten_root() {
let res = shorten("/", Some(&default().with_base("/"))).unwrap();
assert_eq!(res, "/");
let res = shorten(
"/",
Some(
&default()
.with_base("/")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "/");
let res = shorten("/foo", Some(&default().with_base("/"))).unwrap();
assert_eq!(res, "foo");
let res = shorten(
"/foo",
Some(
&default()
.with_base("/")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "/foo");
let res = shorten(
"/",
Some(
&default()
.with_base("/foo")
.with_prefix_dots()
.without_suffix_dots(),
),
)
.unwrap();
assert_eq!(res, "/");
}
#[test]
fn test_path_escape() {
let text = "foo".to_string();
assert_eq!(escape(&text), "foo");
let text = "foo bar".to_string();
assert_eq!(escape(&text), "'foo bar'");
let text = "foo\nbar".to_string();
assert_eq!(escape(&text), "\"foo\\nbar\"");
let text = "foo$bar".to_string();
assert_eq!(escape(&text), "'foo$bar'");
let text = "foo'$\n'bar".to_string();
assert_eq!(escape(&text), "\"foo'\\$\\n'bar\"");
let text = "a'b\"c".to_string();
assert_eq!(escape(&text), "\"a'b\\\"c\"");
}
}

@ -1,27 +1,52 @@
// Stolen from https://github.com/Peltoche/lsd/blob/master/src/meta/permissions.rs
use serde::{Deserialize, Serialize};
use std::fs::Metadata;
use std::{fmt::Display, fs::Metadata};
#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize, Deserialize, Hash, Default)]
pub type RWX = (char, char, char, char, char, char, char, char, char);
pub type Octal = (u8, u8, u8, u8);
#[derive(Debug, Default, PartialEq, Eq, Copy, Clone, Serialize, Deserialize, Hash)]
pub struct Permissions {
#[serde(default)]
pub user_read: bool,
#[serde(default)]
pub user_write: bool,
#[serde(default)]
pub user_execute: bool,
#[serde(default)]
pub group_read: bool,
#[serde(default)]
pub group_write: bool,
#[serde(default)]
pub group_execute: bool,
#[serde(default)]
pub other_read: bool,
#[serde(default)]
pub other_write: bool,
#[serde(default)]
pub other_execute: bool,
#[serde(default)]
pub sticky: bool,
#[serde(default)]
pub setgid: bool,
#[serde(default)]
pub setuid: bool,
}
impl Permissions {}
impl<'a> From<&'a Metadata> for Permissions {
#[cfg(unix)]
fn from(meta: &Metadata) -> Self {
@ -55,6 +80,68 @@ impl<'a> From<&'a Metadata> for Permissions {
}
}
impl Display for Permissions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (ur, uw, ux, gr, gw, gx, or, ow, ox) = (*self).into();
write!(f, "{ur}{uw}{ux}{gr}{gw}{gx}{or}{ow}{ox}")
}
}
impl Into<RWX> for Permissions {
fn into(self) -> RWX {
let bit = |bit: bool, chr: char| {
if bit {
chr
} else {
'-'
}
};
let ur = bit(self.user_read, 'r');
let uw = bit(self.user_write, 'w');
let ux = match (self.user_execute, self.setuid) {
(true, true) => 's',
(true, false) => 'x',
(false, true) => 'S',
(false, false) => '-',
};
let gr = bit(self.group_read, 'r');
let gw = bit(self.group_write, 'w');
let gx = match (self.group_execute, self.setgid) {
(true, true) => 's',
(true, false) => 'x',
(false, true) => 'S',
(false, false) => '-',
};
let or = bit(self.other_read, 'r');
let ow = bit(self.other_write, 'w');
let ox = match (self.other_execute, self.sticky) {
(true, true) => 't',
(true, false) => 'x',
(false, true) => 'T',
(false, false) => '-',
};
(ur, uw, ux, gr, gw, gx, or, ow, ox)
}
}
impl Into<Octal> for Permissions {
fn into(self) -> Octal {
let bits_to_octal =
|r: bool, w: bool, x: bool| (r as u8) * 4 + (w as u8) * 2 + (x as u8);
(
bits_to_octal(self.setuid, self.setgid, self.sticky),
bits_to_octal(self.user_read, self.user_write, self.user_execute),
bits_to_octal(self.group_read, self.group_write, self.group_execute),
bits_to_octal(self.other_read, self.other_write, self.other_execute),
)
}
}
// More readable aliases for the permission bits exposed by libc.
#[allow(trivial_numeric_casts)]
#[cfg(unix)]

@ -31,7 +31,7 @@ pub fn get_tty() -> Result<fs::File> {
match fs::OpenOptions::new().read(true).write(true).open(tty) {
Ok(f) => Ok(f),
Err(e) => {
bail!(format!("Failed to open {}. {}", tty, e))
bail!(format!("could not open {tty}. {e}"))
}
}
}
@ -140,7 +140,7 @@ fn call(
if s.success() {
Ok(())
} else {
Err(format!("process exited with code {}", &s))
Err(format!("process exited with code {s}"))
}
})
.unwrap_or_else(|e| Err(e.to_string()));
@ -184,7 +184,7 @@ fn call(
fn start_fifo(path: &str, focus_path: &str) -> Result<fs::File> {
match fs::OpenOptions::new().write(true).open(path) {
Ok(mut file) => {
writeln!(file, "{}", focus_path)?;
writeln!(file, "{focus_path}")?;
Ok(file)
}
Err(e) => Err(e.into()),
@ -297,12 +297,13 @@ impl Runner {
execute!(stdout, term::EnterAlternateScreen)?;
let mut fifo: Option<fs::File> =
if let Some(path) = app.config.general.start_fifo.as_ref() {
if let Some(path) = app.config.general.start_fifo.clone() {
// TODO remove duplicate segment
match start_fifo(path, &app.focused_node_str()) {
match start_fifo(&path, &app.focused_node_str()) {
Ok(file) => Some(file),
Err(e) => {
app = app.log_error(e.to_string())?;
app = app
.log_error(format!("could not start fifo {path:?}: {e}"))?;
None
}
}
@ -316,7 +317,7 @@ impl Runner {
let mut mouse_enabled = app.config.general.enable_mouse;
if mouse_enabled {
if let Err(e) = execute!(stdout, event::EnableMouseCapture) {
app = app.log_error(e.to_string())?;
app = app.log_error(format!("could not enable mouse: {e}"))?;
}
}
@ -508,7 +509,9 @@ impl Runner {
mouse_enabled = true;
}
Err(e) => {
app = app.log_error(e.to_string())?;
app = app.log_error(format!(
"could not enable mouse: {e}"
))?;
}
}
}
@ -536,7 +539,9 @@ impl Runner {
mouse_enabled = false;
}
Err(e) => {
app = app.log_error(e.to_string())?;
app = app.log_error(format!(
"could not disable mouse: {e}"
))?;
}
}
}
@ -546,7 +551,9 @@ impl Runner {
fifo = match start_fifo(&path, &app.focused_node_str()) {
Ok(file) => Some(file),
Err(e) => {
app = app.log_error(e.to_string())?;
app = app.log_error(format!(
"could not start fifo {path:?}: {e}"
))?;
None
}
}
@ -569,7 +576,9 @@ impl Runner {
{
Ok(file) => Some(file),
Err(e) => {
app = app.log_error(e.to_string())?;
app = app.log_error(format!(
"could not toggle fifo {path:?}: {e}"
))?;
None
}
}

@ -0,0 +1,50 @@
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use skim::prelude::{ExactOrFuzzyEngineFactory, RegexEngineFactory};
use skim::{MatchEngine, MatchEngineFactory, SkimItem};
lazy_static! {
static ref FUZZY_FACTORY: ExactOrFuzzyEngineFactory =
ExactOrFuzzyEngineFactory::builder().build();
static ref REGEX_FACTORY: RegexEngineFactory = RegexEngineFactory::builder().build();
}
pub struct PathItem {
path: String,
}
impl From<String> for PathItem {
fn from(value: String) -> Self {
Self { path: value }
}
}
impl SkimItem for PathItem {
fn text(&self) -> std::borrow::Cow<str> {
std::borrow::Cow::from(&self.path)
}
}
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum SearchAlgorithm {
#[default]
Fuzzy,
Regex,
}
impl SearchAlgorithm {
pub fn toggle(self) -> Self {
match self {
Self::Fuzzy => Self::Regex,
Self::Regex => Self::Fuzzy,
}
}
pub fn engine(&self, pattern: &str) -> Box<dyn MatchEngine> {
match self {
Self::Fuzzy => FUZZY_FACTORY.create_engine(pattern),
Self::Regex => REGEX_FACTORY.create_engine(pattern),
}
}
}

@ -1,18 +1,21 @@
use crate::app;
use crate::app::{HelpMenuLine, NodeFilterApplicable, NodeSorterApplicable};
use crate::app::{Node, ResolvedNode};
use crate::compat::{draw_custom_content, CustomContent};
use crate::config::PanelUiConfig;
use crate::lua;
use crate::permissions::Permissions;
use ansi_to_tui::IntoText;
use crate::{app, path};
use ansi_to_tui_forked::IntoText;
use indexmap::IndexSet;
use lazy_static::lazy_static;
use lscolors::{Color as LsColorsColor, Style as LsColorsStyle};
use mlua::Lua;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::env;
use std::ops::BitXor;
use time::macros::format_description;
use tui::backend::Backend;
use tui::layout::Rect as TuiRect;
use tui::layout::{Constraint as TuiConstraint, Direction, Layout as TuiLayout};
@ -37,14 +40,14 @@ fn read_only_indicator(app: &app::App) -> &str {
}
}
fn string_to_text<'a>(string: String) -> Text<'a> {
pub fn string_to_text<'a>(string: String) -> Text<'a> {
if *NO_COLOR {
Text::raw(string)
} else {
string
.as_bytes()
.into_text()
.unwrap_or_else(|e| Text::raw(format!("{:?}", e)))
.unwrap_or_else(|e| Text::raw(format!("{e:?}")))
}
}
@ -76,31 +79,23 @@ impl LayoutOptions {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub enum ContentBody {
/// A paragraph to render
StaticParagraph { render: String },
/// A Lua function that returns a paragraph to render
DynamicParagraph { render: String },
/// List to render
StaticList { render: Vec<String> },
/// A Lua function that returns lines to render
DynamicList { render: String },
/// A table to render
StaticTable {
widths: Vec<Constraint>,
col_spacing: Option<u16>,
render: Vec<Vec<String>>,
pub enum CustomPanel {
CustomParagraph {
#[serde(default)]
ui: PanelUiConfig,
body: String,
},
/// A Lua function that returns a table to render
DynamicTable {
CustomList {
#[serde(default)]
ui: PanelUiConfig,
body: Vec<String>,
},
CustomTable {
#[serde(default)]
ui: PanelUiConfig,
widths: Vec<Constraint>,
col_spacing: Option<u16>,
render: String,
body: Vec<Vec<String>>,
},
}
@ -113,10 +108,8 @@ pub enum Layout {
Selection,
HelpMenu,
SortAndFilter,
CustomContent {
title: Option<String>,
body: ContentBody,
},
Static(Box<CustomPanel>),
Dynamic(String),
Horizontal {
config: LayoutOptions,
splits: Vec<Layout>,
@ -125,6 +118,9 @@ pub enum Layout {
config: LayoutOptions,
splits: Vec<Layout>,
},
/// For compatibility only. A better choice is Static or Dymanic layout.
CustomContent(Box<CustomContent>),
}
impl Default for Layout {
@ -167,6 +163,32 @@ impl Layout {
(_, other) => other.to_owned(),
}
}
pub fn replace(self, target: &Self, replacement: &Self) -> Self {
match self {
Self::Horizontal { splits, config } => Self::Horizontal {
splits: splits
.into_iter()
.map(|s| s.replace(target, replacement))
.collect(),
config,
},
Self::Vertical { splits, config } => Self::Vertical {
splits: splits
.into_iter()
.map(|s| s.replace(target, replacement))
.collect(),
config,
},
other => {
if other == *target {
replacement.to_owned()
} else {
other
}
}
}
}
}
#[derive(
@ -181,7 +203,7 @@ pub enum Border {
}
impl Border {
pub fn bits(self) -> u32 {
pub fn bits(self) -> u8 {
match self {
Self::Top => TuiBorders::TOP.bits(),
Self::Right => TuiBorders::RIGHT.bits(),
@ -236,7 +258,7 @@ pub enum Modifier {
}
impl Modifier {
pub fn bits(self) -> u16 {
pub fn bits(self) -> u8 {
match self {
Self::Bold => TuiModifier::BOLD.bits(),
Self::Dim => TuiModifier::DIM.bits(),
@ -251,6 +273,21 @@ impl Modifier {
}
}
fn extend_optional_modifiers(
a: Option<IndexSet<Modifier>>,
b: Option<IndexSet<Modifier>>,
) -> Option<IndexSet<Modifier>> {
match (a, b) {
(Some(mut a), Some(b)) => {
a.extend(b);
Some(a)
}
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
#[derive(Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Style {
@ -264,15 +301,21 @@ impl Style {
pub fn extend(mut self, other: &Self) -> Self {
self.fg = other.fg.or(self.fg);
self.bg = other.bg.or(self.bg);
self.add_modifiers = other.add_modifiers.to_owned().or(self.add_modifiers);
self.sub_modifiers = other.sub_modifiers.to_owned().or(self.sub_modifiers);
self.add_modifiers = extend_optional_modifiers(
self.add_modifiers,
other.add_modifiers.to_owned(),
);
self.sub_modifiers = extend_optional_modifiers(
self.sub_modifiers,
other.sub_modifiers.to_owned(),
);
self
}
}
impl Into<TuiStyle> for Style {
fn into(self) -> TuiStyle {
fn xor(modifiers: Option<IndexSet<Modifier>>) -> u16 {
fn xor(modifiers: Option<IndexSet<Modifier>>) -> u8 {
modifiers
.unwrap_or_default()
.into_iter()
@ -292,6 +335,115 @@ impl Into<TuiStyle> for Style {
}
}
impl From<&LsColorsStyle> for Style {
fn from(style: &LsColorsStyle) -> Self {
fn convert_color(color: &LsColorsColor) -> Color {
match color {
LsColorsColor::Black => Color::Black,
LsColorsColor::Red => Color::Red,
LsColorsColor::Green => Color::Green,
LsColorsColor::Yellow => Color::Yellow,
LsColorsColor::Blue => Color::Blue,
LsColorsColor::Magenta => Color::Magenta,
LsColorsColor::Cyan => Color::Cyan,
LsColorsColor::White => Color::Gray,
LsColorsColor::BrightBlack => Color::DarkGray,
LsColorsColor::BrightRed => Color::LightRed,
LsColorsColor::BrightGreen => Color::LightGreen,
LsColorsColor::BrightYellow => Color::LightYellow,
LsColorsColor::BrightBlue => Color::LightBlue,
LsColorsColor::BrightMagenta => Color::LightMagenta,
LsColorsColor::BrightCyan => Color::LightCyan,
LsColorsColor::BrightWhite => Color::White,
LsColorsColor::Fixed(index) => Color::Indexed(*index),
LsColorsColor::RGB(r, g, b) => Color::Rgb(*r, *g, *b),
}
}
Self {
fg: style.foreground.as_ref().map(convert_color),
bg: style.background.as_ref().map(convert_color),
add_modifiers: None,
sub_modifiers: None,
}
}
}
impl Into<nu_ansi_term::Style> for Style {
fn into(self) -> nu_ansi_term::Style {
fn convert_color(color: Color) -> Option<nu_ansi_term::Color> {
match color {
Color::Black => Some(nu_ansi_term::Color::Black),
Color::Red => Some(nu_ansi_term::Color::Red),
Color::Green => Some(nu_ansi_term::Color::Green),
Color::Yellow => Some(nu_ansi_term::Color::Yellow),
Color::Blue => Some(nu_ansi_term::Color::Blue),
Color::Magenta => Some(nu_ansi_term::Color::Purple),
Color::Cyan => Some(nu_ansi_term::Color::Cyan),
Color::Gray => Some(nu_ansi_term::Color::LightGray),
Color::DarkGray => Some(nu_ansi_term::Color::DarkGray),
Color::LightRed => Some(nu_ansi_term::Color::LightRed),
Color::LightGreen => Some(nu_ansi_term::Color::LightGreen),
Color::LightYellow => Some(nu_ansi_term::Color::LightYellow),
Color::LightBlue => Some(nu_ansi_term::Color::LightBlue),
Color::LightMagenta => Some(nu_ansi_term::Color::LightMagenta),
Color::LightCyan => Some(nu_ansi_term::Color::LightCyan),
Color::White => Some(nu_ansi_term::Color::White),
Color::Rgb(r, g, b) => Some(nu_ansi_term::Color::Rgb(r, g, b)),
Color::Indexed(index) => Some(nu_ansi_term::Color::Fixed(index)),
_ => None,
}
}
fn match_modifiers<F>(style: &Style, f: F) -> bool
where
F: Fn(&IndexSet<Modifier>) -> bool,
{
style.add_modifiers.as_ref().map_or(false, f)
}
nu_ansi_term::Style {
foreground: self.fg.and_then(convert_color),
background: self.bg.and_then(convert_color),
is_bold: match_modifiers(&self, |m| m.contains(&Modifier::Bold)),
is_dimmed: match_modifiers(&self, |m| m.contains(&Modifier::Dim)),
is_italic: match_modifiers(&self, |m| m.contains(&Modifier::Italic)),
is_underline: match_modifiers(&self, |m| m.contains(&Modifier::Underlined)),
is_blink: match_modifiers(&self, |m| {
m.contains(&Modifier::SlowBlink) || m.contains(&Modifier::RapidBlink)
}),
is_reverse: match_modifiers(&self, |m| m.contains(&Modifier::Reversed)),
is_hidden: match_modifiers(&self, |m| m.contains(&Modifier::Hidden)),
is_strikethrough: match_modifiers(&self, |m| {
m.contains(&Modifier::CrossedOut)
}),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WrapOptions {
pub width: usize,
pub initial_indent: Option<String>,
pub subsequent_indent: Option<String>,
pub break_words: Option<bool>,
}
impl WrapOptions {
pub fn get_options(&self) -> textwrap::Options<'_> {
let mut options = textwrap::Options::new(self.width);
if let Some(initial_indent) = &self.initial_indent {
options = options.initial_indent(initial_indent);
}
if let Some(subsequent_indent) = &self.subsequent_indent {
options = options.subsequent_indent(subsequent_indent);
}
if let Some(break_words) = self.break_words {
options = options.break_words(break_words);
}
options
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub enum Constraint {
@ -428,6 +580,7 @@ pub struct NodeUiMetadata {
pub is_focused: bool,
pub total: usize,
pub meta: HashMap<String, String>,
pub style: Style,
}
impl NodeUiMetadata {
@ -444,6 +597,7 @@ impl NodeUiMetadata {
is_focused: bool,
total: usize,
meta: HashMap<String, String>,
style: Style,
) -> Self {
Self {
parent: node.parent.to_owned(),
@ -476,11 +630,12 @@ impl NodeUiMetadata {
is_focused,
total,
meta,
style,
}
}
}
fn block<'a>(config: PanelUiConfig, default_title: String) -> Block<'a> {
pub fn block<'a>(config: PanelUiConfig, default_title: String) -> Block<'a> {
Block::default()
.borders(TuiBorders::from_bits_truncate(
config
@ -550,40 +705,7 @@ fn draw_table<B: Backend>(
})
.unwrap_or_default();
let mut me = node.mime_essence.splitn(2, '/');
let mimetype: String =
me.next().map(|s| s.into()).unwrap_or_default();
let mimesub: String =
me.next().map(|s| s.into()).unwrap_or_default();
let mut node_type = if node.is_symlink {
app_config.node_types.symlink.to_owned()
} else if node.is_dir {
app_config.node_types.directory.to_owned()
} else {
app_config.node_types.file.to_owned()
};
if let Some(conf) = app_config
.node_types
.mime_essence
.get(&mimetype)
.and_then(|t| t.get(&mimesub).or_else(|| t.get("*")))
{
node_type = node_type.extend(conf);
}
if let Some(conf) =
app_config.node_types.extension.get(&node.extension)
{
node_type = node_type.extend(conf);
}
if let Some(conf) =
app_config.node_types.special.get(&node.relative_path)
{
node_type = node_type.extend(conf);
}
let node_type = app_config.node_types.get(node);
let (relative_index, is_before_focus, is_after_focus) =
match dir.focus.cmp(&index) {
@ -627,6 +749,7 @@ fn draw_table<B: Backend>(
is_focused,
dir.total,
node_type.meta,
style,
);
let cols = lua::serialize::<NodeUiMetadata>(lua, &meta)
@ -642,7 +765,7 @@ fn draw_table<B: Backend>(
.filter_map(|c| {
c.format.as_ref().map(|f| {
let out = lua::call(lua, f, v.clone())
.unwrap_or_else(|e| e.to_string());
.unwrap_or_else(|e| format!("{e:?}"));
string_to_text(out)
})
})
@ -653,7 +776,7 @@ fn draw_table<B: Backend>(
.map(|x| Cell::from(x.to_owned()))
.collect::<Vec<Cell>>();
Row::new(cols).style(style.into())
Row::new(cols)
})
.collect::<Vec<Row>>()
})
@ -674,9 +797,9 @@ fn draw_table<B: Backend>(
} else {
&app.pwd
}
.trim_matches('/')
.replace('\\', "\\\\")
.replace('\n', "\\n");
.trim_matches('/');
let pwd = path::escape(pwd);
let vroot_indicator = if app.vroot.is_some() { "vroot:" } else { "" };
@ -722,7 +845,7 @@ fn draw_selection<B: Backend>(
_screen_size: TuiRect,
layout_size: TuiRect,
app: &app::App,
_: &Lua,
lua: &Lua,
) {
let panel_config = &app.config.general.panel_ui;
let config = panel_config
@ -738,7 +861,22 @@ fn draw_selection<B: Backend>(
.rev()
.take((layout_size.height.max(2) - 2).into())
.rev()
.map(|n| n.absolute_path.replace('\\', "\\\\").replace('\n', "\\n"))
.map(|n| {
let out = app
.config
.general
.selection
.item
.format
.as_ref()
.map(|f| {
lua::serialize::<Node>(lua, n)
.and_then(|n| lua::call(lua, f, n))
.unwrap_or_else(|e| format!("{e:?}"))
})
.unwrap_or_else(|| n.absolute_path.clone());
string_to_text(out)
})
.map(ListItem::new)
.collect();
@ -874,11 +1012,7 @@ fn draw_sort_n_filter<B: Backend>(
let ui = app.config.general.sort_and_filter_ui.to_owned();
let filter_by: &IndexSet<NodeFilterApplicable> = &app.explorer_config.filters;
let sort_by: &IndexSet<NodeSorterApplicable> = &app.explorer_config.sorters;
let search = app
.explorer_config
.searcher
.as_ref()
.map(|s| s.pattern.clone());
let search = app.explorer_config.searcher.as_ref();
let defaultui = &ui.default_identifier;
let forwardui = defaultui
@ -888,6 +1022,15 @@ fn draw_sort_n_filter<B: Backend>(
.to_owned()
.extend(&ui.sort_direction_identifiers.reverse);
let orderedui = defaultui
.to_owned()
.extend(&ui.search_direction_identifiers.ordered);
let unorderedui = defaultui
.to_owned()
.extend(&ui.search_direction_identifiers.unordered);
let is_ordered_search = search.as_ref().map(|s| !s.unordered).unwrap_or(false);
let mut spans = filter_by
.iter()
.map(|f| {
@ -905,12 +1048,36 @@ fn draw_sort_n_filter<B: Backend>(
})
.unwrap_or((Span::raw("f"), Span::raw("")))
})
.chain(search.iter().map(|s| {
ui.search_identifiers
.get(&s.algorithm)
.map(|u| {
let direction = if s.unordered {
&unorderedui
} else {
&orderedui
};
let ui = defaultui.to_owned().extend(u);
let f = ui
.format
.as_ref()
.map(|f| format!("{f}{p}", p = &s.pattern))
.unwrap_or_else(|| s.pattern.clone());
(
Span::styled(f, ui.style.into()),
Span::styled(
direction.format.to_owned().unwrap_or_default(),
direction.style.to_owned().into(),
),
)
})
.unwrap_or((Span::raw("/"), Span::raw(&s.pattern)))
}))
.chain(
sort_by
.iter()
.map(|s| {
let direction = if s.reverse { &reverseui } else { &forwardui };
ui.sorter_identifiers
.get(&s.sorter)
.map(|u| {
@ -928,23 +1095,8 @@ fn draw_sort_n_filter<B: Backend>(
})
.unwrap_or((Span::raw("s"), Span::raw("")))
})
.take(if search.is_some() { 0 } else { sort_by.len() }),
.take(if !is_ordered_search { sort_by.len() } else { 0 }),
)
.chain(search.iter().map(|s| {
ui.search_identifier
.as_ref()
.map(|u| {
let ui = defaultui.to_owned().extend(u);
(
Span::styled(
ui.format.to_owned().unwrap_or_default(),
ui.style.to_owned().into(),
),
Span::styled(s, ui.style.into()),
)
})
.unwrap_or((Span::raw("/"), Span::raw(s)))
}))
.zip(std::iter::repeat(Span::styled(
ui.separator.format.to_owned().unwrap_or_default(),
ui.separator.style.to_owned().into(),
@ -988,7 +1140,8 @@ fn draw_logs<B: Backend>(
.rev()
.take(layout_size.height as usize)
.map(|log| {
let time = log.created_at.format("%r");
let fd = format_description!("[hour]:[minute]:[second]");
let time = log.created_at.format(fd).unwrap_or_else(|_| "when?".into());
let cfg = match log.level {
app::LogLevel::Info => &logs_config.info,
app::LogLevel::Warning => &logs_config.warning,
@ -997,7 +1150,7 @@ fn draw_logs<B: Backend>(
};
let prefix =
format!("{}|{}", time, cfg.format.to_owned().unwrap_or_default());
format!("{time}|{0}", cfg.format.to_owned().unwrap_or_default());
let padding = " ".repeat(prefix.chars().count());
@ -1007,9 +1160,9 @@ fn draw_logs<B: Backend>(
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!("{}: {}", &prefix, line)
format!("{prefix}) {line}")
} else {
format!("{} {}", &padding, line)
format!("{padding} {line}")
}
})
.take(layout_size.height as usize)
@ -1054,94 +1207,68 @@ pub fn draw_nothing<B: Backend>(
f.render_widget(nothing, layout_size);
}
pub fn draw_custom_content<B: Backend>(
pub fn draw_dynamic<B: Backend>(
f: &mut Frame<B>,
screen_size: TuiRect,
layout_size: TuiRect,
app: &app::App,
title: Option<String>,
body: ContentBody,
func: &str,
lua: &Lua,
) {
let config = app.config.general.panel_ui.default.clone();
match body {
ContentBody::StaticParagraph { render } => {
let render = string_to_text(render);
let content = Paragraph::new(render).block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::DynamicParagraph { render } => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let render = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg).unwrap_or_else(|e| format!("{:?}", e))
})
.unwrap_or_else(|e| e.to_string());
let render = string_to_text(render);
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let content = Paragraph::new(render).block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
let panel: CustomPanel = lua::serialize(lua, &ctx)
.and_then(|arg| lua::call(lua, func, arg))
.unwrap_or_else(|e| CustomPanel::CustomParagraph {
ui: app.config.general.panel_ui.default.clone(),
body: format!("{e:?}"),
});
ContentBody::StaticList { render } => {
let items = render
.into_iter()
.map(string_to_text)
.map(ListItem::new)
.collect::<Vec<ListItem>>();
draw_static(f, screen_size, layout_size, app, panel, lua);
}
let content = List::new(items).block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
pub fn draw_static<B: Backend>(
f: &mut Frame<B>,
screen_size: TuiRect,
layout_size: TuiRect,
app: &app::App,
panel: CustomPanel,
_lua: &Lua,
) {
let defaultui = app.config.general.panel_ui.default.clone();
match panel {
CustomPanel::CustomParagraph { ui, body } => {
let config = defaultui.extend(&ui);
let body = string_to_text(body);
let content = Paragraph::new(body).block(block(config, "".into()));
f.render_widget(content, layout_size);
}
ContentBody::DynamicList { render } => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
CustomPanel::CustomList { ui, body } => {
let config = defaultui.extend(&ui);
let items = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg)
.unwrap_or_else(|e| vec![format!("{:?}", e)])
})
.unwrap_or_else(|e| vec![e.to_string()])
let items = body
.into_iter()
.map(string_to_text)
.map(ListItem::new)
.collect::<Vec<ListItem>>();
let content = List::new(items).block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
let content = List::new(items).block(block(config, "".into()));
f.render_widget(content, layout_size);
}
ContentBody::StaticTable {
CustomPanel::CustomTable {
ui,
widths,
col_spacing,
render,
body,
} => {
let rows = render
let config = defaultui.extend(&ui);
let rows = body
.into_iter()
.map(|cols| {
Row::new(
@ -1161,55 +1288,7 @@ pub fn draw_custom_content<B: Backend>(
let content = Table::new(rows)
.widths(&widths)
.column_spacing(col_spacing.unwrap_or(1))
.block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
f.render_widget(content, layout_size);
}
ContentBody::DynamicTable {
widths,
col_spacing,
render,
} => {
let ctx = ContentRendererArg {
app: app.to_lua_ctx_light(),
layout_size: layout_size.into(),
screen_size: screen_size.into(),
};
let rows = lua::serialize(lua, &ctx)
.map(|arg| {
lua::call(lua, &render, arg)
.unwrap_or_else(|e| vec![vec![format!("{:?}", e)]])
})
.unwrap_or_else(|e| vec![vec![e.to_string()]])
.into_iter()
.map(|cols| {
Row::new(
cols.into_iter()
.map(string_to_text)
.map(Cell::from)
.collect::<Vec<Cell>>(),
)
})
.collect::<Vec<Row>>();
let widths = widths
.into_iter()
.map(|w| w.to_tui(screen_size, layout_size))
.collect::<Vec<TuiConstraint>>();
let mut content = Table::new(rows).widths(&widths).block(block(
config,
title.map(|t| format!(" {} ", t)).unwrap_or_default(),
));
if let Some(col_spacing) = col_spacing {
content = content.column_spacing(col_spacing);
};
.block(block(config, "".into()));
f.render_widget(content, layout_size);
}
@ -1237,9 +1316,9 @@ impl From<TuiRect> for Rect {
#[derive(Debug, Clone, Serialize)]
pub struct ContentRendererArg {
app: app::LuaContextLight,
screen_size: Rect,
layout_size: Rect,
pub app: app::LuaContextLight,
pub screen_size: Rect,
pub layout_size: Rect,
}
pub fn draw_layout<B: Backend>(
@ -1265,8 +1344,14 @@ pub fn draw_layout<B: Backend>(
draw_logs(f, screen_size, layout_size, app, lua);
};
}
Layout::CustomContent { title, body } => {
draw_custom_content(f, screen_size, layout_size, app, title, body, lua)
Layout::Static(panel) => {
draw_static(f, screen_size, layout_size, app, *panel, lua)
}
Layout::Dynamic(ref func) => {
draw_dynamic(f, screen_size, layout_size, app, func, lua)
}
Layout::CustomContent(content) => {
draw_custom_content(f, screen_size, layout_size, app, *content, lua)
}
Layout::Horizontal { config, splits } => {
let chunks = TuiLayout::default()
@ -1293,9 +1378,9 @@ pub fn draw_layout<B: Backend>(
splits
.into_iter()
.zip(chunks.into_iter())
.zip(chunks.iter())
.for_each(|(split, chunk)| {
draw_layout(split, f, screen_size, chunk, app, lua)
draw_layout(split, f, screen_size, *chunk, app, lua)
});
}
@ -1324,9 +1409,9 @@ pub fn draw_layout<B: Backend>(
splits
.into_iter()
.zip(chunks.into_iter())
.zip(chunks.iter())
.for_each(|(split, chunk)| {
draw_layout(split, f, screen_size, chunk, app, lua)
draw_layout(split, f, screen_size, *chunk, app, lua)
});
}
}
@ -1384,7 +1469,7 @@ mod tests {
);
assert_eq!(
b.to_owned().extend(&a),
b.extend(&a),
Style {
fg: Some(Color::Red),
bg: Some(Color::Blue),
@ -1398,19 +1483,71 @@ mod tests {
Style {
fg: Some(Color::Cyan),
bg: Some(Color::Magenta),
add_modifiers: modifier(Modifier::CrossedOut),
add_modifiers: Some(
vec![Modifier::Bold, Modifier::CrossedOut]
.into_iter()
.collect()
),
sub_modifiers: modifier(Modifier::Italic),
}
);
assert_eq!(
c.to_owned().extend(&a),
c.extend(&a),
Style {
fg: Some(Color::Red),
bg: Some(Color::Magenta),
add_modifiers: modifier(Modifier::Bold),
add_modifiers: Some(
vec![Modifier::Bold, Modifier::CrossedOut]
.into_iter()
.collect()
),
sub_modifiers: modifier(Modifier::Italic),
}
);
}
#[test]
fn test_layout_replace() {
let layout = Layout::Horizontal {
config: LayoutOptions {
margin: Some(2),
horizontal_margin: Some(3),
vertical_margin: Some(4),
constraints: Some(vec![
Constraint::Percentage(80),
Constraint::Percentage(20),
]),
},
splits: vec![Layout::Table, Layout::HelpMenu],
};
let res = layout.clone().replace(&Layout::Table, &Layout::Selection);
match (res, layout) {
(
Layout::Horizontal {
config: res_config,
splits: res_splits,
},
Layout::Horizontal {
config: layout_config,
splits: layout_splits,
},
) => {
assert_eq!(res_config, layout_config);
assert_eq!(res_splits.len(), layout_splits.len());
assert_eq!(res_splits[0], Layout::Selection);
assert_ne!(res_splits[0], layout_splits[0]);
assert_eq!(res_splits[1], layout_splits[1]);
}
_ => panic!("Unexpected layout"),
}
let res = Layout::Table.replace(&Layout::Table, &Layout::Selection);
assert_eq!(res, Layout::Selection);
let res = Layout::Table.replace(&Layout::Nothing, &Layout::Selection);
assert_eq!(res, Layout::Table);
}
}

Loading…
Cancel
Save