From e4d7977be10cc433aae3d990b10c8001a36e0f89 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Wed, 15 Jan 2025 13:40:35 +0200 Subject: [PATCH] Update yazi packages --- .../HybridBar/scripts/change-active-workspace | 0 config/HybridBar/scripts/get-active-workspace | 0 config/HybridBar/scripts/get-window-title | 0 config/HybridBar/scripts/get-workspaces | 0 config/btop/btop.conf | 2 +- config/eww/scripts/change-active-workspace | 0 config/eww/scripts/get-active-workspace | 0 config/eww/scripts/get-music | 0 config/eww/scripts/get-network | 0 config/eww/scripts/get-window-title | 0 config/eww/scripts/get-workspaces | 0 config/eww/scripts/getvol | 0 config/eww/scripts/github | 0 config/flameshot.ini | 2 +- config/lf/cleaner | 0 config/lf/lfrc | 0 config/nsxiv/exec/image-info | 0 config/nsxiv/exec/key-handler | 0 config/nsxiv/exec/nsxiv-url | 0 config/nsxiv/exec/thumb-info | 0 config/nsxiv/exec/win-title | 0 config/x11/opt-apps | 0 config/yazi/init.lua | 10 +- config/yazi/plugins/archive.yazi/main.lua | 1 + .../plugins/augment-command.yazi/README.md | 290 +- .../plugins/augment-command.yazi/main.lua | 3595 +++++++++++++++++ config/yazi/plugins/chmod.yazi/main.lua | 39 + config/yazi/plugins/exifaudio.yazi/main.lua | 231 ++ config/yazi/plugins/full-border.yazi/main.lua | 41 + config/yazi/plugins/git.yazi/main.lua | 208 + config/yazi/plugins/glow.yazi/main.lua | 76 + config/yazi/plugins/hexyl.yazi/main.lua | 57 + .../yazi/plugins/hide-preview.yazi/main.lua | 25 + config/yazi/plugins/max-preview.yazi/main.lua | 24 + config/yazi/plugins/mediainfo.yazi/main.lua | 1 + config/yazi/plugins/miller.yazi/main.lua | 59 + config/yazi/plugins/nbpreview.yazi/main.lua | 58 + config/yazi/plugins/ouch.yazi/README.md | 10 +- config/yazi/plugins/ouch.yazi/main.lua | 145 + .../plugins/relative-motions.yazi/main.lua | 326 ++ config/yazi/plugins/starship.yazi/main.lua | 1 + .../plugins/torrent-preview.yazi/main.lua | 46 + config/yazi/plugins/what-size.yazi/main.lua | 72 + config/yazi/plugins/yatline.yazi/main.lua | 1312 ++++++ config/yazi/yazi.toml | 8 +- dotter | Bin dotter.arm | Bin dotter.exe | Bin 48 files changed, 6566 insertions(+), 73 deletions(-) mode change 100644 => 100755 config/HybridBar/scripts/change-active-workspace mode change 100644 => 100755 config/HybridBar/scripts/get-active-workspace mode change 100644 => 100755 config/HybridBar/scripts/get-window-title mode change 100644 => 100755 config/HybridBar/scripts/get-workspaces mode change 100644 => 100755 config/eww/scripts/change-active-workspace mode change 100644 => 100755 config/eww/scripts/get-active-workspace mode change 100644 => 100755 config/eww/scripts/get-music mode change 100644 => 100755 config/eww/scripts/get-network mode change 100644 => 100755 config/eww/scripts/get-window-title mode change 100644 => 100755 config/eww/scripts/get-workspaces mode change 100644 => 100755 config/eww/scripts/getvol mode change 100644 => 100755 config/eww/scripts/github mode change 100644 => 100755 config/lf/cleaner mode change 100644 => 100755 config/lf/lfrc mode change 100644 => 100755 config/nsxiv/exec/image-info mode change 100644 => 100755 config/nsxiv/exec/key-handler mode change 100644 => 100755 config/nsxiv/exec/nsxiv-url mode change 100644 => 100755 config/nsxiv/exec/thumb-info mode change 100644 => 100755 config/nsxiv/exec/win-title mode change 100644 => 100755 config/x11/opt-apps create mode 120000 config/yazi/plugins/archive.yazi/main.lua create mode 100644 config/yazi/plugins/augment-command.yazi/main.lua create mode 100644 config/yazi/plugins/chmod.yazi/main.lua create mode 100644 config/yazi/plugins/exifaudio.yazi/main.lua create mode 100644 config/yazi/plugins/full-border.yazi/main.lua create mode 100644 config/yazi/plugins/git.yazi/main.lua create mode 100644 config/yazi/plugins/glow.yazi/main.lua create mode 100644 config/yazi/plugins/hexyl.yazi/main.lua create mode 100644 config/yazi/plugins/hide-preview.yazi/main.lua create mode 100644 config/yazi/plugins/max-preview.yazi/main.lua create mode 120000 config/yazi/plugins/mediainfo.yazi/main.lua create mode 100644 config/yazi/plugins/miller.yazi/main.lua create mode 100644 config/yazi/plugins/nbpreview.yazi/main.lua create mode 100644 config/yazi/plugins/ouch.yazi/main.lua create mode 100644 config/yazi/plugins/relative-motions.yazi/main.lua create mode 120000 config/yazi/plugins/starship.yazi/main.lua create mode 100644 config/yazi/plugins/torrent-preview.yazi/main.lua create mode 100644 config/yazi/plugins/what-size.yazi/main.lua create mode 100644 config/yazi/plugins/yatline.yazi/main.lua mode change 100644 => 100755 dotter mode change 100644 => 100755 dotter.arm mode change 100644 => 100755 dotter.exe diff --git a/config/HybridBar/scripts/change-active-workspace b/config/HybridBar/scripts/change-active-workspace old mode 100644 new mode 100755 diff --git a/config/HybridBar/scripts/get-active-workspace b/config/HybridBar/scripts/get-active-workspace old mode 100644 new mode 100755 diff --git a/config/HybridBar/scripts/get-window-title b/config/HybridBar/scripts/get-window-title old mode 100644 new mode 100755 diff --git a/config/HybridBar/scripts/get-workspaces b/config/HybridBar/scripts/get-workspaces old mode 100644 new mode 100755 diff --git a/config/btop/btop.conf b/config/btop/btop.conf index b30149c3..6a728033 100644 --- a/config/btop/btop.conf +++ b/config/btop/btop.conf @@ -57,7 +57,7 @@ update_ms = 500 #* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct", #* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly. -proc_sorting = "cpu lazy" +proc_sorting = "cpu direct" #* Reverse sorting order, True or False. proc_reversed = False diff --git a/config/eww/scripts/change-active-workspace b/config/eww/scripts/change-active-workspace old mode 100644 new mode 100755 diff --git a/config/eww/scripts/get-active-workspace b/config/eww/scripts/get-active-workspace old mode 100644 new mode 100755 diff --git a/config/eww/scripts/get-music b/config/eww/scripts/get-music old mode 100644 new mode 100755 diff --git a/config/eww/scripts/get-network b/config/eww/scripts/get-network old mode 100644 new mode 100755 diff --git a/config/eww/scripts/get-window-title b/config/eww/scripts/get-window-title old mode 100644 new mode 100755 diff --git a/config/eww/scripts/get-workspaces b/config/eww/scripts/get-workspaces old mode 100644 new mode 100755 diff --git a/config/eww/scripts/getvol b/config/eww/scripts/getvol old mode 100644 new mode 100755 diff --git a/config/eww/scripts/github b/config/eww/scripts/github old mode 100644 new mode 100755 diff --git a/config/flameshot.ini b/config/flameshot.ini index 464875ee..a24d626e 100644 --- a/config/flameshot.ini +++ b/config/flameshot.ini @@ -1,6 +1,6 @@ [General] contrastOpacity=188 -drawColor=#ffffff +drawColor=#ff0000 drawThickness=3 filenamePattern=%Y-%m-%d_%H-%M-%S saveAfterCopy=true diff --git a/config/lf/cleaner b/config/lf/cleaner old mode 100644 new mode 100755 diff --git a/config/lf/lfrc b/config/lf/lfrc old mode 100644 new mode 100755 diff --git a/config/nsxiv/exec/image-info b/config/nsxiv/exec/image-info old mode 100644 new mode 100755 diff --git a/config/nsxiv/exec/key-handler b/config/nsxiv/exec/key-handler old mode 100644 new mode 100755 diff --git a/config/nsxiv/exec/nsxiv-url b/config/nsxiv/exec/nsxiv-url old mode 100644 new mode 100755 diff --git a/config/nsxiv/exec/thumb-info b/config/nsxiv/exec/thumb-info old mode 100644 new mode 100755 diff --git a/config/nsxiv/exec/win-title b/config/nsxiv/exec/win-title old mode 100644 new mode 100755 diff --git a/config/x11/opt-apps b/config/x11/opt-apps old mode 100644 new mode 100755 diff --git a/config/yazi/init.lua b/config/yazi/init.lua index ff282cad..07d4b880 100644 --- a/config/yazi/init.lua +++ b/config/yazi/init.lua @@ -5,12 +5,18 @@ require("augment-command"):setup({ default_item_group_for_prompt = "hovered", smart_enter = true, smart_paste = false, + smart_tab_create = false, + smart_tab_switch = false, + open_file_after_creation = false, + enter_directory_after_creation = false, + use_default_create_behaviour = false, enter_archives = true, - -- extract_behaviour = "skip", + extract_retries = 3, + recursively_extract_archives = true, + preserve_file_permissions = false, must_have_hovered_item = true, skip_single_subdirectory_on_enter = false, skip_single_subdirectory_on_leave = false, - ignore_hidden_items = false, wraparound_file_navigation = false, }) require("git"):setup() diff --git a/config/yazi/plugins/archive.yazi/main.lua b/config/yazi/plugins/archive.yazi/main.lua new file mode 120000 index 00000000..8d8c8fb4 --- /dev/null +++ b/config/yazi/plugins/archive.yazi/main.lua @@ -0,0 +1 @@ +/home/kristofers/Nextcloud/repos/solorice/config/yazi/plugins/archive.yazi/init.lua \ No newline at end of file diff --git a/config/yazi/plugins/augment-command.yazi/README.md b/config/yazi/plugins/augment-command.yazi/README.md index 5cb5459a..b4170409 100644 --- a/config/yazi/plugins/augment-command.yazi/README.md +++ b/config/yazi/plugins/augment-command.yazi/README.md @@ -24,6 +24,10 @@ plugin. - [`7z` or `7zz` command][7z-link] - [`file` command][file-command-link] +### Optional dependencies + +- [`tar` command][gnu-tar-link] for the `preserve_file_permissions` option + ## Installation ```sh @@ -39,24 +43,25 @@ ya pack -u ## Configuration -| Configuration | Values | Default | Description | -| ----------------------------------- | ------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `prompt` | `true` or `false` | `false` | Create a prompt to choose between hovered and selected items when both exist. If this option is disabled, selected items will only be operated on when the hovered item is selected, otherwise the hovered item will be the default item that is operated on. | -| `default_item_group_for_prompt` | `hovered`, `selected` or `none` | `hovered` | The default item group to operate on when the prompt is submitted without any value. This only takes effect if `prompt` is set to `true`, otherwise this option doesn't do anything. `hovered` means the hovered item is operated on, `selected` means the selected items are operated on, and `none` just cancels the operation. | -| `smart_enter` | `true` or `false` | `true` | Use one command to open files or enter a directory. With this option set, the `enter` and `open` commands will both call the `enter` command when a directory is hovered and call the `open` command when a regular file is hovered. You can also enable this behaviour by passing the `--smart` flag to the `enter` or `open` commands. | -| `smart_paste` | `true` or `false` | `false` | Paste items into a directory without entering it. The behaviour is exactly the same as the [smart paste tip on Yazi's documentation][smart-paste-tip]. Setting this option to `false` will use the default `paste` behaviour. You can also enable this behaviour by passing the `--smart` flag to the `paste` command. | -| `smart_tab_create` | `true` or `false` | `false` | Create tabs in the directory that is being hovered instead of the current directory. The behaviour is exactly the same as the [smart tab tip on Yazi's documentation][smart-tab-tip]. Setting this option to `false` will use the default `tab_create` behaviour, which means you need to pass the `--current` flag to the command. You can also enable this behaviour by passing the `--smart` flag to the `tab_create` command. | -| `smart_tab_switch` | `true` or `false` | `false` | If the tab that is being switched to does not exist yet, setting this option to `true` will create all the tabs in between the current number of open tabs, and the tab that is being switched to. The behaviour is exactly the same as the [smart switch tip on Yazi's documentation][smart-switch-tip]. Setting this option to `false` will use the default `tab_switch` behaviour. You can also enable this behaviour by passing the `--smart` flag to the `tab_switch` command. | -| `open_file_after_creation` | `true` or `false` | `false` | This option determines whether the plugin will open a file after it has been created. Setting this option to `true` will cause the plugin to open the created file. You can also enable this behaviour by passing the `--open` flag to the `create` command. | -| `enter_directory_after_creation` | `true` or `false` | `false` | This option determines whether the plugin will enter a directory after it has been created. Setting this option to `true` will cause the plugin to enter the created directory. You can also enable this behaviour by passing the `--enter` flag to the `create` command. | -| `use_default_create_behaviour` | `true` or `false` | `false` | This option determines whether the plugin will use the behaviour of Yazi's `create` command. Setting this option to `true` will use the behaviour of Yazi's `create` command. You can also enable this behaviour by passing the `--default-behaviour` flag to the `create` command. | -| `enter_archives` | `true` or `false` | `true` | Automatically extract and enter archive files. This option requires the [`7z` or `7zz` command][7z-link] to be present. | -| `extract_retries` | An integer, like `1`, `3`, `10`, etc. | `3` | This option determines how many times the plugin will retry opening an encrypted or password-protected archive when a wrong password is given. This value plus 1 is the total number of times the plugin will try opening an encrypted or password-protected archive. | -| `recursively_extract_archives` | `true` or `false` | `true` | This option determines whether the plugin will extract all archives inside an archive file recursively. If this option is set to `false`, archive files inside an archive will not be extracted, and you will have to manually extract them yourself. | -| `must_have_hovered_item` | `true` or `false` | `true` | This option stops the plugin from executing any commands when there is no hovered item. | -| `skip_single_subdirectory_on_enter` | `true` or `false` | `true` | Skip directories when there is only one subdirectory and no other files when entering directories. This behaviour can be turned off by passing the `--no-skip` flag to the `enter` or `open` commands. | -| `skip_single_subdirectory_on_leave` | `true` or `false` | `true` | Skip directories when there is only one subdirectory and no other files when leaving directories. This behaviour can be turned off by passing the `--no-skip` flag to the `leave` command. | -| `wraparound_file_navigation` | `true` or `false` | `false` | Wrap around from the bottom to the top or from the top to the bottom when using the `arrow` or `parent_arrow` command to navigate. | +| Configuration | Values | Default | Description | +| ----------------------------------- | ------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prompt` | `true` or `false` | `false` | Create a prompt to choose between hovered and selected items when both exist. If this option is disabled, selected items will only be operated on when the hovered item is selected, otherwise the hovered item will be the default item that is operated on. | +| `default_item_group_for_prompt` | `hovered`, `selected` or `none` | `hovered` | The default item group to operate on when the prompt is submitted without any value. This only takes effect if `prompt` is set to `true`, otherwise this option doesn't do anything. `hovered` means the hovered item is operated on, `selected` means the selected items are operated on, and `none` just cancels the operation. | +| `smart_enter` | `true` or `false` | `true` | Use one command to open files or enter a directory. With this option set, the `enter` and `open` commands will both call the `enter` command when a directory is hovered and call the `open` command when a regular file is hovered. You can also enable this behaviour by passing the `--smart` flag to the `enter` or `open` commands. | +| `smart_paste` | `true` or `false` | `false` | Paste items into a directory without entering it. The behaviour is exactly the same as the [smart paste tip on Yazi's documentation][smart-paste-tip]. Setting this option to `false` will use the default `paste` behaviour. You can also enable this behaviour by passing the `--smart` flag to the `paste` command. | +| `smart_tab_create` | `true` or `false` | `false` | Create tabs in the directory that is being hovered instead of the current directory. The behaviour is exactly the same as the [smart tab tip on Yazi's documentation][smart-tab-tip]. Setting this option to `false` will use the default `tab_create` behaviour, which means you need to pass the `--current` flag to the command. You can also enable this behaviour by passing the `--smart` flag to the `tab_create` command. | +| `smart_tab_switch` | `true` or `false` | `false` | If the tab that is being switched to does not exist yet, setting this option to `true` will create all the tabs in between the current number of open tabs, and the tab that is being switched to. The behaviour is exactly the same as the [smart switch tip on Yazi's documentation][smart-switch-tip]. Setting this option to `false` will use the default `tab_switch` behaviour. You can also enable this behaviour by passing the `--smart` flag to the `tab_switch` command. | +| `open_file_after_creation` | `true` or `false` | `false` | This option determines whether the plugin will open a file after it has been created. Setting this option to `true` will cause the plugin to open the created file. You can also enable this behaviour by passing the `--open` flag to the `create` command. | +| `enter_directory_after_creation` | `true` or `false` | `false` | This option determines whether the plugin will enter a directory after it has been created. Setting this option to `true` will cause the plugin to enter the created directory. You can also enable this behaviour by passing the `--enter` flag to the `create` command. | +| `use_default_create_behaviour` | `true` or `false` | `false` | This option determines whether the plugin will use the behaviour of Yazi's `create` command. Setting this option to `true` will use the behaviour of Yazi's `create` command. You can also enable this behaviour by passing the `--default-behaviour` flag to the `create` command. | +| `enter_archives` | `true` or `false` | `true` | Automatically extract and enter archive files. This option requires the [`7z` or `7zz` command][7z-link] to be present. | +| `extract_retries` | An integer, like `1`, `3`, `10`, etc. | `3` | This option determines how many times the plugin will retry opening an encrypted or password-protected archive when a wrong password is given. This value plus 1 is the total number of times the plugin will try opening an encrypted or password-protected archive. | +| `recursively_extract_archives` | `true` or `false` | `true` | This option determines whether the plugin will extract all archives inside an archive file recursively. If this option is set to `false`, archive files inside an archive will not be extracted, and you will have to manually extract them yourself. | +| `preserve_file_permissions` | `true` or `false` | `false` | This option determines whether to preserve the file permissions of the items in the extracted archive. Setting this option to `true` will preserve the file permissions of the extracted items. It requires the [`tar` command][gnu-tar-link] and will only work on `tar` archives, or tarballs, as [`7z`][7z-link] does not support preserving file permissions. You will receive a warning if you have this option set but [`tar`][gnu-tar-link] is not installed. Do note that there are significant security implications of setting this option to `true`, as any executable file or binary in an archive can be immediately executed after it is extracted, which can compromise your system if you extract a malicious archive. As such, the default value is `false`, and it is strongly recommended to leave it as such. | +| `must_have_hovered_item` | `true` or `false` | `true` | This option stops the plugin from executing any commands when there is no hovered item. | +| `skip_single_subdirectory_on_enter` | `true` or `false` | `true` | Skip directories when there is only one subdirectory and no other files when entering directories. This behaviour can be turned off by passing the `--no-skip` flag to the `enter` or `open` commands. | +| `skip_single_subdirectory_on_leave` | `true` or `false` | `true` | Skip directories when there is only one subdirectory and no other files when leaving directories. This behaviour can be turned off by passing the `--no-skip` flag to the `leave` command. | +| `wraparound_file_navigation` | `true` or `false` | `false` | Wrap around from the bottom to the top or from the top to the bottom when using the `arrow` or `parent_arrow` command to navigate. | If you would like to use the default configuration, which is shown below, you don't need to add anything to your `~/.config/yazi/init.lua` @@ -82,6 +87,7 @@ require("augment-command"):setup({ enter_archives = true, extract_retries = 3, recursively_extract_archives = true, + preserve_file_permissions = false, must_have_hovered_item = true, skip_single_subdirectory_on_enter = true, skip_single_subdirectory_on_leave = true, @@ -117,7 +123,7 @@ require("augment-command"):setup({ All commands that can operate on multiple files and directories, like `open`, `rename`, `remove` and `shell`, -as well as the new commands `editor` and `pager`, +as well as the new commands `extract`, `editor` and `pager`, now determine an item group to operate on. By default, the command will operate on the hovered item, unless the hovered item is also selected, @@ -179,7 +185,98 @@ then it will operate on the selected items. [open-auto-extract-archives-video] -- If the extracted archive file contains other archive +- The `open` command makes use of the `extract` command, + so recursively extracting archives is also supported. + For more information, look at the section about the + [`extract` command](#extract-extract). + + Video: + + [open-recursively-extract-archives-video] + +### Extract (`extract`) + +- Technically this is a new command, as Yazi does not provide an `extract` + command. However, Yazi does provide a built-in plugin called `extract`, + so this command is included in the + [augmented commands section](#augmented-commands) instead of the + [new commands section](#new-commands). +- This command requires the [`7z` or `7zz` command][7z-link] to + be present to extract the archives, as well as the + [`file` command][file-command-link] to check if a file is an archive or not. +- You are not meant to use this command directly. However, you can do so + if you like, as the extract command is also augmented as stated in + [this section above][augment-section]. + + Videos: + + - When `must_have_hovered_item` is `true`: + + [extract-must-have-hovered-item-video] + + - When `must_have_hovered_item` is `false`: + + [extract-hovered-item-optional-video] + + - When `prompt` is set to `true`: + + [extract-prompt-video] + + - When `prompt` is set to `false`: + + [extract-behaviour-video] + +- Instead, this command is intended to replace the built-in `extract` plugin, + which is used for the `extract` opener. This way, you can use the + features that come with the augmented `extract` command, like + recursively extracting archives, with the `open` command. + This is the intended way to use this command, as the `open` command is + meant to be the command that opens everything, so it is a bit + counterintuitive to have to use a separate key to extract archives. + + To replace the built-in `extract` plugin, copy the + [`extract` openers section][yazi-yazi-toml-extract-openers] + in [Yazi's default `yazi.toml`][yazi-yazi-toml] into your `yazi.toml`, + which is located at `~/.config/yazi/yazi.toml` for Linux and macOS, and + `C:\Users\USERNAME\AppData\Roaming\yazi\config\yazi.toml` + file on Windows, where `USERNAME` is your Windows username. + Make sure that the `extract` openers are under the `opener` key in your + `yazi.toml`. Then replace `extract` with `augmented-extract`, + and you will be using the plugin's `extract` command instead of + Yazi's built-in `extract` plugin. + + Here is an example configuration: + + ```toml + # ~/.config/yazi/yazi.toml for Linux and macOS + # C:\Users\USERNAME\AppData\Roaming\yazi\config\yazi.toml for Windows + + [opener] + extract = [ + { run = 'ya pub augmented-extract --list "$@"', desc = "Extract here", for = "unix" }, + { run = 'ya pub augmented-extract --list %*', desc = "Extract here", for = "windows" }, + ] + ``` + + If that exceeds your editor's line length limit, another way to do it is: + + ```toml + # ~/.config/yazi/yazi.toml for Linux and macOS + # C:\Users\USERNAME\AppData\Roaming\yazi\config\yazi.toml for Windows + + [[opener.extract]] + run = 'ya pub augmented-extract --list "$@"' + desc = "Extract here" + for = "unix" + + [[opener.extract]] + run = 'ya pub augmented-extract --list %*' + desc = "Extract here" + for = "windows" + ``` + +- The `extract` command supports recursively extracting archives, + which means if the extracted archive file contains other archive files in it, those archives will be automatically extracted, keeping the directory structure of the archive if the archive doesn't @@ -198,7 +295,51 @@ then it will operate on the selected items. Video: - [open-recursively-extract-archives-video] + [extract-recursively-extract-archives-video] + +- The `extract` command also supports extracting encrypted archives, + and will prompt you for a password when it encounters an encrypted + archive. You can configure the number of times the plugin prompts + you for a password by setting the `extract_retries` configuration + option. The default value is `3`, which means the plugin will + prompt you `3` more times for the correct password after the + initial password attempt before giving up and showing an error. + + Video: + + [extract-encrypted-archive] + +- The `preserve_file_permissions` configuration option applies to + the `extract` command, and requires the [`tar` command][gnu-tar-link] + to be present, as [`7z`][7z-link] does not support preserving + file permissions. The plugin will show a warning if the + `preserve_file_permissions` option is set to `true` but + [`tar`][gnu-tar-link] is not installed. + + For macOS users, it is highly recommended to install and use + [GNU `tar`, or `gtar`][gnu-tar-link] instead of the + [Apple provided `tar` command][apple-tar-link]. + You can install it using the [`brew`][brew-link] command below: + + ```sh + brew install gnu-tar + ``` + + The plugin will automatically use [GNU `tar`][gnu-tar-link] + if it finds the [`gtar` command][gnu-tar-link] instead + of the [Apple provided `tar` command][apple-tar-link]. + + Setting the `preserve_file_permissions` configuration option to `true` + will preserve the file permissions of the files contained in a `tar` + archive or tarball. + + This has considerable security implications, as executables extracted from + all `tar` archives can be immediately executed on your system, possibly + compromising your system if you extract a malicious `tar` archive. + Hence, this option is set to `false` by default, and should be left as such. + This option is provided for your convenience, but do seriously consider + if such convenience is worth the risk of extracting a malicious `tar` + archive that executes malware on your system. ### Enter (`enter`) @@ -347,7 +488,7 @@ then it will operate on the selected items. Video: - [create-and-open-directories-video] + [create-and-enter-directories-video] To enable both behaviours with flags, just pass both the `--open` flag and the `--enter` flag to the `create` command. @@ -732,10 +873,18 @@ then add `plugin augment-command --args=` in front of it, which results in `plugin augment-command --args='enter'`. +### Using the `extract` command as an opener + +This is the intended way to use the `extract` command instead of binding +the `extract` command to a key in your `keymap.toml` file. +Look at the [`extract` command section](#extract-extract) +for details on how to do so. + ### Full configuration example For a full configuration example, -you can take a look at [my `keymap.toml` file][my-keymap-toml]. +you can take a look at [my `keymap.toml` file][my-keymap-toml] +and [my `yazi.toml` file][my-yazi-toml]. ## [Licence] @@ -751,89 +900,104 @@ You can view the full licence in the [`LICENSE`][Licence] file. [augment-section]: #what-about-the-commands-are-augmented [7z-link]: https://www.7-zip.org/ [file-command-link]: https://www.darwinsys.com/file/ -[my-keymap-toml]: https://github.com/hankertrix/Dotfiles/blob/main/.config/yazi/keymap.toml +[gnu-tar-link]: https://www.gnu.org/software/tar/ +[apple-tar-link]: https://ss64.com/mac/tar.html +[brew-link]: https://brew.sh/ +[yazi-yazi-toml-extract-openers]: https://github.com/sxyazi/yazi/blob/main/yazi-config/preset/yazi-default.toml#L51-L54 +[yazi-yazi-toml]: https://github.com/sxyazi/yazi/blob/main/yazi-config/preset/yazi-default.toml [yazi-keymap-toml]: https://github.com/sxyazi/yazi/blob/main/yazi-config/preset/keymap-default.toml +[my-keymap-toml]: https://github.com/hankertrix/Dotfiles/blob/main/.config/yazi/keymap.toml +[my-yazi-toml]: https://github.com/hankertrix/Dotfiles/blob/main/.config/yazi/yazi.toml [Licence]: LICENSE -[open-behaviour-video]: https://github.com/user-attachments/assets/5636ffc0-fe24-4da3-9f0e-98de9cd74096 -[open-prompt-video]: https://github.com/user-attachments/assets/6bad5a20-e5d3-491d-9c7c-0f5962b77c1c -[open-auto-extract-archives-video]: https://github.com/user-attachments/assets/aeb3368b-4f7d-431e-9f7a-69a443af7153 -[open-recursively-extract-archives-video]: https://github.com/user-attachments/assets/44228646-3e82-41e4-a445-f93ab5649309 +[open-prompt-video]: https://github.com/user-attachments/assets/82ddc67d-0b79-4487-8d29-6fd1eb754a8e +[open-behaviour-video]: https://github.com/user-attachments/assets/3f8eec80-ae39-4071-b7ed-e9e9367f10fe +[open-auto-extract-archives-video]: https://github.com/user-attachments/assets/35b356ed-9c3f-4093-ab59-f85ae64de757 +[open-recursively-extract-archives-video]: https://github.com/user-attachments/assets/dd1a5bd4-c7af-4d0a-9bf5-b087ee5a06f0 + + + +[extract-must-have-hovered-item-video]: https://github.com/user-attachments/assets/7c0516ff-01fd-48c2-ba27-4449ffede933 +[extract-hovered-item-optional-video]: https://github.com/user-attachments/assets/07ef7d25-3284-4d93-9485-c8635519c57e +[extract-prompt-video]: https://github.com/user-attachments/assets/be2cabc3-b47d-4aac-ac45-0f26957c606b +[extract-behaviour-video]: https://github.com/user-attachments/assets/6ea90612-da8f-45ad-8310-9b38c9e5a6f9 +[extract-recursively-extract-archives-video]: https://github.com/user-attachments/assets/bbf7f670-f86d-4aa4-85c7-35b41170924e +[extract-encrypted-archive]: https://github.com/user-attachments/assets/58645691-3559-44ad-918e-8c2cd127252f -[smart-enter-video]: https://github.com/user-attachments/assets/d3507110-1385-4029-bf64-da3225446d72 -[enter-skip-single-subdirectory-video]: https://github.com/user-attachments/assets/2cdb9289-ef41-454f-817b-81beb8a8d030 +[smart-enter-video]: https://github.com/user-attachments/assets/a00da3f5-305a-4615-b55c-483a06dd56d7 +[enter-skip-single-subdirectory-video]: https://github.com/user-attachments/assets/25ca5fb5-68f9-45fe-bf32-369e9335505d -[leave-skip-single-subdirectory-video]: https://github.com/user-attachments/assets/49acdddb-4d04-4624-8d29-057ada33fd01 +[leave-skip-single-subdirectory-video]: https://github.com/user-attachments/assets/4740fdae-2cd9-463d-b67b-7cdfd8d8b9a1 -[rename-hovered-item-optional-video]: https://github.com/user-attachments/assets/3f592893-cda6-4759-ae32-3059f0c285f0 -[rename-must-have-hovered-item-video]: https://github.com/user-attachments/assets/074c6713-60e8-4249-867f-926ac6ee2bd6 -[rename-behaviour-video]: https://github.com/user-attachments/assets/ba6c79e0-9062-43ae-a76b-2782f28a9a18 -[rename-prompt-video]: https://github.com/user-attachments/assets/4d42653e-9595-4322-b0c9-451b112dc596 +[rename-must-have-hovered-item-video]: https://github.com/user-attachments/assets/fd88a198-3de3-4d2b-8bcf-8d68142c965f +[rename-hovered-item-optional-video]: https://github.com/user-attachments/assets/324dcd94-6f83-49a2-9390-5f41da520689 +[rename-prompt-video]: https://github.com/user-attachments/assets/5aba29ae-8b16-4b92-a99c-ff7f0ec925fa +[rename-behaviour-video]: https://github.com/user-attachments/assets/280db6dd-10e4-4255-8c12-e13d23105e90 -[remove-hovered-item-optional-video]: https://github.com/user-attachments/assets/dd033b80-b8b0-46db-ab02-9fc1f1b7002d -[remove-must-have-hovered-item-video]: https://github.com/user-attachments/assets/5533b8b6-a966-4453-a86a-09d2ab2340e9 -[remove-behaviour-video]: https://github.com/user-attachments/assets/cc0617b1-fedf-45d3-b894-00524ba31434 -[remove-prompt-video]: https://github.com/user-attachments/assets/d23283fd-5068-429d-b06d-72b0c6a3bb36 +[remove-must-have-hovered-item-video]: https://github.com/user-attachments/assets/18649ff1-ef0d-409a-8f01-29431dcc8f2e +[remove-hovered-item-optional-video]: https://github.com/user-attachments/assets/6e9f5ca0-9b9f-47f8-8499-2b2c1db9f47c +[remove-prompt-video]: https://github.com/user-attachments/assets/3f94c6f8-2ffd-4970-a5a4-5ac6b3a621c0 +[remove-behaviour-video]: https://github.com/user-attachments/assets/37d3c059-84ff-4475-908b-2c167b23c488 -[create-and-open-directories-video]: https://github.com/user-attachments/assets/52b244db-50a8-4adc-912f-239e01a10cc6 -[create-and-open-files-video]: https://github.com/user-attachments/assets/8f2306ea-b795-4da4-9867-9a5ed34f7e12 -[create-and-open-files-and-directories-video]: https://github.com/user-attachments/assets/ed14e451-a8ca-4622-949f-1469e1d17643 -[create-behaviour-video]: https://github.com/user-attachments/assets/0bee02b7-f0c3-4b24-8e8d-43c9d3ada3d6 -[create-default-behaviour-video]: https://github.com/user-attachments/assets/8c59f579-8f32-443c-8ae1-edd8d18e5ba0 +[create-and-enter-directories-video]: https://github.com/user-attachments/assets/a102f918-8d99-491f-a6e3-fd8151f16f96 +[create-and-open-files-video]: https://github.com/user-attachments/assets/14341b9b-a048-4ea2-9322-e963293b6813 +[create-and-open-files-and-directories-video]: https://github.com/user-attachments/assets/dd05d84a-716b-4c4b-8e77-429bbfb4ea43 +[create-behaviour-video]: https://github.com/user-attachments/assets/a13745a5-a2cc-4c25-a3ff-0f10ac98b6f9 +[create-default-behaviour-video]: https://github.com/user-attachments/assets/5e9305c0-e56c-4fc3-b36b-e86c43571b06 -[shell-hovered-item-optional-video]: https://github.com/user-attachments/assets/db4b0a30-0cbb-4747-9788-2fb2f8857449 -[shell-must-have-hovered-item-video]: https://github.com/user-attachments/assets/f7699493-99e7-4926-92c7-8811d3428cd4 -[shell-behaviour-video]: https://github.com/user-attachments/assets/5d898205-e5ca-487e-b731-4624ca0123ee -[shell-prompt-video]: https://github.com/user-attachments/assets/d1790105-1e40-4639-bf65-d395a488ae94 -[shell-exit-if-directory-video]: https://github.com/user-attachments/assets/a992300a-2eed-40a1-97e4-d4efef57f7f0 +[shell-must-have-hovered-item-video]: https://github.com/user-attachments/assets/43404049-1a4c-458c-b33f-c221dddf15c6 +[shell-hovered-item-optional-video]: https://github.com/user-attachments/assets/b399450a-eec4-43d5-a75d-91c4f04a9d59 +[shell-prompt-video]: https://github.com/user-attachments/assets/e83eb468-96fd-463f-a96a-54ac9ee2295f +[shell-behaviour-video]: https://github.com/user-attachments/assets/caa32923-9c3e-4ea4-a1b6-e0a2c7968e9d +[shell-exit-if-directory-video]: https://github.com/user-attachments/assets/a0feab97-b7fc-4d58-8611-60ccf5e794d5 -[smart-paste-video]: https://github.com/user-attachments/assets/9796fbf1-6807-4f74-a0eb-a36c6306c761 +[smart-paste-video]: https://github.com/user-attachments/assets/d48c12a7-f652-4df7-90a5-271cbfa97683 -[smart-tab-create-video]: https://github.com/user-attachments/assets/2738598c-ccdf-49e4-9d57-90a6378f6155 +[smart-tab-create-video]: https://github.com/user-attachments/assets/2921df3d-b51d-4dbb-a42f-80e021feaaf6 -[smart-tab-switch-video]: https://github.com/user-attachments/assets/78240347-7d5e-4b45-85df-8446cfb61edf +[smart-tab-switch-video]: https://github.com/user-attachments/assets/1afb540d-47a9-4625-ae59-95d5cd91aa35 -[wraparound-arrow-video]: https://github.com/user-attachments/assets/28d96bb3-276d-41c8-aa17-eebd7fde9390 +[wraparound-arrow-video]: https://github.com/user-attachments/assets/41ea1fb0-a526-4549-95a2-547c3c4b0498 -[parent-arrow-video]: https://github.com/user-attachments/assets/d58a841d-0c05-4555-bf1b-f4d539b9d9c9 -[wraparound-parent-arrow-video]: https://github.com/user-attachments/assets/72dcd01a-63f0-4193-9a23-cefa61142d73 +[parent-arrow-video]: https://github.com/user-attachments/assets/f4dc492a-566b-4645-82e1-301713cff11f +[wraparound-parent-arrow-video]: https://github.com/user-attachments/assets/d19872f8-2851-47e6-8485-4e8e5be66871 -[editor-hovered-item-optional-video]: https://github.com/user-attachments/assets/97e7f313-afcd-4619-bdec-539ffa0ce9a4 -[editor-must-have-hovered-item-video]: https://github.com/user-attachments/assets/4fb901d2-9a86-44ec-9896-453f6df16ea1 -[editor-behaviour-video]: https://github.com/user-attachments/assets/af057282-8f75-4662-8b4b-29e594cf4163 -[editor-prompt-video]: https://github.com/user-attachments/assets/6c12380c-36fb-4a57-bd82-8452fdcad7e6 +[editor-must-have-hovered-item-video]: https://github.com/user-attachments/assets/c2811b90-e164-4a6d-9f3d-aefe8aec1d95 +[editor-hovered-item-optional-video]: https://github.com/user-attachments/assets/adad538a-fbe8-4ad3-8f6d-5600618a0673 +[editor-prompt-video]: https://github.com/user-attachments/assets/cccb8a3c-6afa-49a6-8808-04b0f235b391 +[editor-behaviour-video]: https://github.com/user-attachments/assets/b6821220-8530-4fd1-a40f-53d191a3fe1b -[pager-hovered-item-optional-video]: https://github.com/user-attachments/assets/e63af138-b553-4598-b6da-c7e3de57f328 -[pager-must-have-hovered-item-video]: https://github.com/user-attachments/assets/aa9e27e0-39ed-466f-ae84-812c08d93293 -[pager-behaviour-video]: https://github.com/user-attachments/assets/d18aec12-8be3-483a-a24a-2929ad8fc6c2 -[pager-prompt-video]: https://github.com/user-attachments/assets/ac3cd3b3-2624-4ea2-b22d-5ab6a49a98c6 +[pager-must-have-hovered-item-video]: https://github.com/user-attachments/assets/22a5211a-89cc-4c36-aadb-eb9e6ab1d578 +[pager-hovered-item-optional-video]: https://github.com/user-attachments/assets/6eaed3c9-91f4-4414-8d26-5eaf955a2861 +[pager-prompt-video]: https://github.com/user-attachments/assets/1ee621f4-704e-4cc3-a2ff-ba06e4eaf5a3 +[pager-behaviour-video]: https://github.com/user-attachments/assets/9ed0d520-4e73-44c3-82f7-18378994e0f4 diff --git a/config/yazi/plugins/augment-command.yazi/main.lua b/config/yazi/plugins/augment-command.yazi/main.lua new file mode 100644 index 00000000..4fb07e3b --- /dev/null +++ b/config/yazi/plugins/augment-command.yazi/main.lua @@ -0,0 +1,3595 @@ +-- Plugin to make some Yazi commands smarter +-- Written in Lua 5.4 + +-- Type aliases + +-- The type for the arguments +---@alias Arguments table + +-- The type for the function to handle a command +-- +-- Description of the function parameters: +-- args: The arguments to pass to the command +-- config: The configuration object +---@alias CommandFunction fun( +--- args: Arguments, +--- config: Configuration): nil + +-- The type of the command table +---@alias CommandTable table + +-- The type for the extractor list items command +---@alias ExtractorListItemsCommand fun( +--- self: Extractor, +---): output: CommandOutput|nil, error: Error|nil + +-- The type for the extractor get items function +---@alias ExtractorGetItems fun( +--- self: Extractor, +---): files: string[], directories: string[], error: string|nil + +-- The type for the extractor extract function. +---@alias ExtractorExtract fun( +--- self: Extractor, +--- has_only_one_file: boolean|nil, +---): ExtractionResult + +-- The type for the extractor function +---@alias ExtractorCommand fun(): output: CommandOutput|nil, error: Error|nil + +-- Custom types + +-- The type of the user configuration table +-- The user configuration for the plugin +---@class (exact) UserConfiguration +---@field prompt boolean Whether or not to prompt the user +---@field default_item_group_for_prompt ItemGroup The default prompt item group +---@field smart_enter boolean Whether to use smart enter +---@field smart_paste boolean Whether to use smart paste +---@field smart_tab_create boolean Whether to use smart tab create +---@field smart_tab_switch boolean Whether to use smart tab switch +---@field open_file_after_creation boolean Whether to open after creation +---@field enter_directory_after_creation boolean Whether to enter after creation +---@field use_default_create_behaviour boolean Use Yazi's create behaviour? +---@field enter_archives boolean Whether to enter archives +---@field extract_retries number How many times to retry extracting +---@field recursively_extract_archives boolean Extract inner archives or not +---@field preserve_file_permissions boolean Whether to preserve file permissions +---@field must_have_hovered_item boolean Whether to stop when no item is hovered +---@field skip_single_subdirectory_on_enter boolean Skip single subdir on enter +---@field skip_single_subdirectory_on_leave boolean Skip single subdir on leave +---@field wraparound_file_navigation boolean Have wraparound navigation or not + +-- The full configuration for the plugin +---@class (exact) Configuration: UserConfiguration + +-- The type for the state +---@class (exact) State +---@field config Configuration The configuration object + +-- The type for the extractor function result +---@class (exact) ExtractionResult +---@field successful boolean Whether the extractor function was successful +---@field output string|nil The output of the extractor function +---@field cancelled boolean|nil boolean Whether the extraction was cancelled +---@field error string|nil The error message +---@field archive_path string|nil The path to the archive +---@field destination_path string|nil The path to the destination +---@field extracted_items_path string|nil The path to the extracted items + +-- The name of the plugin +---@type string +local PLUGIN_NAME = "augment-command" + +-- The enum for the supported commands +---@enum SupportedCommands +local Commands = { + Open = "open", + Extract = "extract", + Enter = "enter", + Leave = "leave", + Rename = "rename", + Remove = "remove", + Create = "create", + Shell = "shell", + Paste = "paste", + TabCreate = "tab_create", + TabSwitch = "tab_switch", + Arrow = "arrow", + ParentArrow = "parent_arrow", + Editor = "editor", + Pager = "pager", +} + +-- The enum for which group of items to operate on +---@enum ItemGroup +local ItemGroup = { + Hovered = "hovered", + Selected = "selected", + None = "none", + Prompt = "prompt", +} + +-- The default configuration for the plugin +---@type UserConfiguration +local DEFAULT_CONFIG = { + prompt = false, + default_item_group_for_prompt = ItemGroup.Hovered, + smart_enter = true, + smart_paste = false, + smart_tab_create = false, + smart_tab_switch = false, + open_file_after_creation = false, + enter_directory_after_creation = false, + use_default_create_behaviour = false, + enter_archives = true, + extract_retries = 3, + recursively_extract_archives = true, + preserve_file_permissions = false, + must_have_hovered_item = true, + skip_single_subdirectory_on_enter = true, + skip_single_subdirectory_on_leave = true, + wraparound_file_navigation = false, +} + +-- The default input options for this plugin +local DEFAULT_INPUT_OPTIONS = { + position = { "top-center", x = 0, y = 2, w = 50, h = 3 }, +} + +-- The default confirm options for this plugin +local DEFAULT_CONFIRM_OPTIONS = { + pos = { "center", x = 0, y = 0, w = 50, h = 15 }, +} + +-- The default notification options for this plugin +local DEFAULT_NOTIFICATION_OPTIONS = { + title = "Augment Command Plugin", + timeout = 5, +} + +-- The tab preference keys. +-- The values are just dummy values +-- so that I don't have to maintain two +-- different types for the same thing. +---@type tab.Preference +local TAB_PREFERENCE_KEYS = { + sort_by = "alphabetical", + sort_sensitive = false, + sort_reverse = false, + sort_dir_first = true, + sort_translit = false, + linemode = "none", + show_hidden = false, +} + +-- The table of input options for the prompt +---@type table +local INPUT_OPTIONS_TABLE = { + [ItemGroup.Hovered] = "(H/s)", + [ItemGroup.Selected] = "(h/S)", + [ItemGroup.None] = "(h/s)", +} + +-- The extractor names +---@enum ExtractorName +local ExtractorName = { + SevenZip = "7z", + Tar = "tar", +} + +-- The extract behaviour flags +---@enum ExtractBehaviour +local ExtractBehaviour = { + Overwrite = "overwrite", + Rename = "rename", +} + +-- The list of archive file extensions +---@type table +local ARCHIVE_FILE_EXTENSIONS = { + ["7z"] = true, + boz = true, + bz = true, + bz2 = true, + bzip2 = true, + cb7 = true, + cbr = true, + cbt = true, + cbz = true, + gz = true, + gzip = true, + rar = true, + s7z = true, + tar = true, + tbz = true, + tbz2 = true, + tgz = true, + txz = true, + xz = true, + zip = true, +} + +-- The error for the base extractor class +-- which is an abstract base class that +-- does not implement any functionality +---@type string +local BASE_EXTRACTOR_ERROR = table.concat({ + "The Extractor class is does not implement any functionality.", + "How did you even manage to get here?", +}, "\n") + +-- Class definitions + +-- The base extractor that all extractors inherit from +---@class Extractor +---@field name string The name of the extractor +---@field command string The shell command for the extractor +---@field commands string[] The possible extractor commands +--- +--- Whether the extractor supports preserving file permissions +---@field supports_file_permissions boolean +--- +--- The map of the extract behaviour strings to the command flags +---@field extract_behaviour_map table +local Extractor = { + name = "BaseExtractor", + command = "", + commands = {}, + supports_file_permissions = false, + extract_behaviour_map = {}, +} + +-- The function to create a subclass of the abstract base extractor +---@param subclass table The subclass to create +---@return Extractor subclass Subclass of the base extractor +function Extractor:subclass(subclass) + -- + + -- Create a new instance + local instance = setmetatable(subclass or {}, self) + + -- Set where to find the object's methods or properties + self.__index = self + + -- Return the instance + return instance +end + +-- The method to get the archive items +---@type ExtractorGetItems +function Extractor:get_items() return {}, {}, BASE_EXTRACTOR_ERROR end + +-- The method to extract the archive +---@type ExtractorExtract +function Extractor:extract(_) + return { + successful = false, + error = BASE_EXTRACTOR_ERROR, + } +end + +-- The 7z extractor +---@class SevenZip: Extractor +---@field password string The password to the archive +local SevenZip = Extractor:subclass({ + name = ExtractorName.SevenZip, + commands = { "7z", "7zz" }, + + -- https://documentation.help/7-Zip/overwrite.htm + extract_behaviour_map = { + [ExtractBehaviour.Overwrite] = "-aoa", + [ExtractBehaviour.Rename] = "-aou", + }, + + password = "", +}) + +-- The tar extractor +---@class Tar: Extractor +local Tar = Extractor:subclass({ + name = ExtractorName.Tar, + commands = { "gtar", "tar" }, + supports_file_permissions = true, + + -- https://www.man7.org/linux/man-pages/man1/tar.1.html + -- https://ss64.com/mac/tar.html + extract_behaviour_map = { + + -- Tar overwrites by default + [ExtractBehaviour.Overwrite] = "", + [ExtractBehaviour.Rename] = "-k", + }, +}) + +-- The default extractor, which is set to 7zip +---@class DefaultExtractor: SevenZip +local DefaultExtractor = SevenZip:subclass({}) + +-- The table of archive mime types +---@type table +local ARCHIVE_MIME_TYPE_TO_EXTRACTOR_MAP = { + ["application/zip"] = DefaultExtractor, + ["application/gzip"] = DefaultExtractor, + ["application/tar"] = Tar, + ["application/bzip"] = DefaultExtractor, + ["application/bzip2"] = DefaultExtractor, + ["application/7z-compressed"] = DefaultExtractor, + ["application/rar"] = DefaultExtractor, + ["application/xz"] = DefaultExtractor, +} + +-- Patterns + +-- The list of mime type prefixes to remove +-- +-- The prefixes are used in a lua pattern +-- to match on the mime type, so special +-- characters need to be escaped +---@type string[] +local MIME_TYPE_PREFIXES_TO_REMOVE = { + "x%-", + "vnd%.", +} + +-- The pattern template to get the mime type without a prefix +---@type string +local get_mime_type_without_prefix_template_pattern = + "^(%%a-)/%s([%%-%%d%%a]-)$" + +-- The pattern to get the file extension +---@type string +local file_extension_pattern = "%.([%a]+)$" + +-- The pattern to get the shell variables in a command +---@type string +local shell_variable_pattern = "[%$%%][%*@0]" + +-- The pattern to match the bat command with the pager option passed +---@type string +local bat_command_with_pager_pattern = "%f[%a]bat%f[%A].*%-%-pager%s+" + +-- Utility functions + +-- Function to merge tables. +-- +-- The key-value pairs of the tables given later +-- in the argument list WILL OVERRIDE +-- the tables given earlier in the argument list. +-- +-- The list items in the table will be added in order, +-- with the items in the first table being added first, +-- and the items in the second table being added second, +-- and so on. +---@param ... table[] The tables to merge +---@return table merged_table The merged table +local function merge_tables(...) + -- + + -- Initialise a new table + local new_table = {} + + -- Initialise the index variable + local index = 1 + + -- Iterates over the tables given + for _, table in ipairs({ ... }) do + -- + + -- Iterate over all of the keys and values + for key, value in pairs(table) do + -- + + -- If the key is a number, then add using the index + -- instead of the key. + -- This is to allow lists to be merged. + if type(key) == "number" then + -- + + -- Set the value mapped to the index + new_table[index] = value + + -- Increment the index + index = index + 1 + + -- Otherwise, the key isn't a number + else + -- + + -- Set the key in the new table to the value given + new_table[key] = value + end + end + end + + -- Return the new table + return new_table +end + +-- Function to split a string into a list +---@param given_string string The string to split +---@param separator string|nil The character to split the string by +---@return string[] splitted_strings The list of strings split by the character +local function string_split(given_string, separator) + -- + + -- If the separator isn't given, set it to the whitespace character + separator = separator or "%s" + + -- Initialise the list of splitted strings + local splitted_strings = {} + + -- Iterate over all of the strings found by pattern + for string in string.gmatch(given_string, "([^" .. separator .. "]+)") do + -- + + -- Add the string to the list of splitted strings + table.insert(splitted_strings, string) + end + + -- Return the list of splitted strings + return splitted_strings +end + +-- Function to trim a string +---@param string string The string to trim +---@return string trimmed_string The trimmed string +local function string_trim(string) + -- + + -- Return the string with the whitespace characters + -- removed from the start and end + return string:match("^%s*(.-)%s*$") +end + +-- Function to get a value from a table +-- and return the default value if the key doesn't exist +---@param table table The table to get the value from +---@param key string|number The key to get the value from +---@param default any The default value to return if the key doesn't exist +local function table_get(table, key, default) return table[key] or default end + +-- Function to pop a key from a table +---@param table table The table to pop from +---@param key string|number The key to pop +---@param default any The default value to return if the key doesn't exist +---@return any value The value of the key or the default value +local function table_pop(table, key, default) + -- + + -- Get the value of the key from the table + local value = table[key] + + -- Remove the key from the table + table[key] = nil + + -- Return the value if it exist, + -- otherwise return the default value + return value or default +end + +-- Function to escape a percentage sign % +-- in the string that is being replaced +---@param replacement_string string The string to escape +---@return string replacement_result The escaped string +local function escape_replacement_string(replacement_string) + -- + + -- Get the result of the replacement + local replacement_result = replacement_string:gsub("%%", "%%%%") + + -- Return the result of the replacement + return replacement_result +end + +-- Function to parse the number arguments to the number type +---@param args Arguments The arguments to parse +---@return Arguments parsed_args The parsed arguments +local function parse_number_arguments(args) + -- + + -- The parsed arguments + ---@type Arguments + local parsed_args = {} + + -- Iterate over the arguments given + for arg_name, arg_value in pairs(args) do + -- + + -- Try to convert the argument to a number + local number_arg_value = tonumber(arg_value) + + -- Set the argument to the number argument value + -- if the argument is a number, + -- otherwise just set it to the given argument value + parsed_args[arg_name] = number_arg_value or arg_value + end + + -- Return the parsed arguments + return parsed_args +end + +-- Function to convert a table of arguments to a string +---@param args Arguments The arguments to convert +---@return string args_string The string of the arguments +local function convert_arguments_to_string(args) + -- + + -- The table of string arguments + ---@type string[] + local string_arguments = {} + + -- Iterate all the items in the argument table + for key, value in pairs(args) do + -- + + -- If the key is a number + if type(key) == "number" then + -- + + -- Add the stringified value to the string arguments table + table.insert(string_arguments, tostring(value)) + + -- Otherwise, if the key is a string + elseif type(key) == "string" then + -- + + -- Replace the underscores and spaces in the key with dashes + local key_with_dashes = key:gsub("_", "-"):gsub("%s", "-") + + -- If the value is a boolean and the boolean is true, + -- add the value to the string + if type(value) == "boolean" and value then + table.insert( + string_arguments, + string.format("--%s", key_with_dashes) + ) + + -- Otherwise, just add the key and the value to the string + else + table.insert( + string_arguments, + string.format("--%s=%s", key_with_dashes, value) + ) + end + end + end + + -- Combine the string arguments into a single string + local string_args = table.concat(string_arguments, " ") + + -- Return the string arguments + return string_args +end + +-- Function to show a warning +---@param warning_message string The warning message +---@param options YaziNotificationOptions|nil Options for the notification +---@return nil +local function show_warning(warning_message, options) + return ya.notify(merge_tables(DEFAULT_NOTIFICATION_OPTIONS, options or {}, { + content = warning_message, + level = "warn", + })) +end + +-- Function to show an error +---@param error_message string The error message +---@param options YaziNotificationOptions|nil Options for the notification +---@return nil +local function show_error(error_message, options) + return ya.notify(merge_tables(DEFAULT_NOTIFICATION_OPTIONS, options or {}, { + content = error_message, + level = "error", + })) +end + +-- Function to get the user's input +---@param prompt string The prompt to show to the user +---@param options YaziInputOptions|nil Options for the input +---@return string|nil user_input The user's input +---@return InputEvent event The event for the input function +local function get_user_input(prompt, options) + return ya.input(merge_tables(DEFAULT_INPUT_OPTIONS, options or {}, { + title = prompt, + })) +end + +-- Function to get the user's confirmation +-- TODO: Remove the `ya.input` version once `ya.confirm` is stable +---@param prompt string The prompt to show to the user +---@param title string|ui.Line The title of the confirmation prompt +---@param content string|ui.Text The content of the confirmation prompt +---@return boolean confirmation Whether the user has confirmed or not +local function get_user_confirmation(prompt, title, content) + -- + + -- If the ya.confirm API exists, use it + if ya.confirm then + -- + + -- Get the user's confirmation + local confirmation = ya.confirm(merge_tables(DEFAULT_CONFIRM_OPTIONS, { + title = title, + content = content, + })) + + -- Return the result of the confirmation + return confirmation + end + + -- TODO: Remove everything after this when `ya.confirm` is stable + + -- Get the user's input + local user_input, event = get_user_input(prompt) + + -- If the user has not confirmed the input, + -- or the user input is nil, + -- then return false + if not user_input or event ~= 1 then return false end + + -- Lowercase the user's input + user_input = user_input:lower() + + -- If the user input starts with a "y", then return true + if user_input:find("^y") then return true end + + -- Otherwise, return false + return false +end + +-- Function to merge the given configuration table with the default one +---@param config UserConfiguration|nil The configuration table to merge +---@return UserConfiguration merged_config The merged configuration table +local function merge_configuration(config) + -- + + -- If the configuration isn't given, then use the default one + if config == nil then return DEFAULT_CONFIG end + + -- Initialise the list of invalid configuration options + local invalid_configuration_options = {} + + -- Initialise the merged configuration + local merged_config = {} + + -- Iterate over the default configuration table + for key, value in pairs(DEFAULT_CONFIG) do + -- + + -- Add the default configuration to the merged configuration + merged_config[key] = value + end + + -- Iterate over the given configuration table + for key, value in pairs(config) do + -- + + -- If the key is not in the merged configuration + if merged_config[key] == nil then + -- + + -- Add the key to the list of invalid configuration options + table.insert(invalid_configuration_options, key) + + -- Continue the loop + goto continue + end + + -- Otherwise, overwrite the value in the merged configuration + merged_config[key] = value + + -- The label to continue the loop + ::continue:: + end + + -- If there are no invalid configuration options, + -- then return the merged configuration + if #invalid_configuration_options <= 0 then return merged_config end + + -- Otherwise, warn the user of the invalid configuration options + show_warning( + "Invalid configuration options: " + .. table.concat(invalid_configuration_options, ", ") + ) + + -- Return the merged configuration + return merged_config +end + +-- Function to initialise the configuration +---@type fun( +--- user_config: Configuration|nil, -- The configuration object +---): Configuration The initialised configuration object +local initialise_config = ya.sync(function(state, user_config) + -- + + -- Merge the default configuration with the user given one, + -- as well as the additional data given, + -- and set it to the state. + state.config = merge_configuration(user_config) + + -- Return the configuration object for async functions + return state.config +end) + +-- Function to try if a shell command exists +---@param shell_command string The shell command to check +---@param args string[]|nil The arguments to the shell command +---@return boolean shell_command_exists Whether the shell command exists +local function async_shell_command_exists(shell_command, args) + -- + + -- Get the output of the shell command with the given arguments + local output = Command(shell_command) + :args(args or {}) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() + + -- Return true if there's an output and false otherwise + return output ~= nil +end + +-- Function to emit a plugin command +---@param command string The plugin command to emit +---@param args Arguments The arguments to pass to the plugin command +---@return nil +local function emit_plugin_command(command, args) + return ya.manager_emit("plugin", { + PLUGIN_NAME, + args = string.format( + "%s %s", + command, + convert_arguments_to_string(args) + ), + }) +end + +-- Function to subscribe to the augmented-extract event +---@type fun(): nil +local subscribe_to_augmented_extract_event = ya.sync(function(_) + return ps.sub_remote("augmented-extract", function(args) + -- + + -- If the arguments given isn't a table, + -- exit the function + if type(args) ~= "table" then return end + + -- Iterate over the arguments + for _, arg in ipairs(args) do + -- + + -- Emit the command to call the plugin's extract function + -- with the given arguments and flags + emit_plugin_command("extract", { + archive_path = ya.quote(arg), + }) + end + end) +end) + +-- Function to initialise the plugin +---@param opts Configuration|nil The options given to the plugin +---@return Configuration config The initialised configuration object +local function initialise_plugin(opts) + -- + + -- Subscribe to the augmented extract event + subscribe_to_augmented_extract_event() + + -- Initialise the configuration object + local config = initialise_config(opts) + + -- Return the configuration object + return config +end + +-- Function to standardise the mime type of a file. +-- This function will follow what Yazi does to standardise +-- mime types returned by the file command. +---@param mime_type string The mime type of the file +---@return string standardised_mime_type The standardised mime type of the file +local function standardise_mime_type(mime_type) + -- + + -- Trim the whitespace from the mime type + local trimmed_mime_type = string_trim(mime_type) + + -- Iterate over the mime type prefixes to remove + for _, prefix in ipairs(MIME_TYPE_PREFIXES_TO_REMOVE) do + -- + + -- Get the pattern to remove the mime type prefix + local pattern = + get_mime_type_without_prefix_template_pattern:format(prefix) + + -- Remove the prefix from the mime type + local mime_type_without_prefix, replacement_count = + trimmed_mime_type:gsub(pattern, "%1/%2") + + -- If the replacement count is greater than zero, + -- return the mime type without the prefix + if replacement_count > 0 then return mime_type_without_prefix end + end + + -- Return the mime type with whitespace removed + return trimmed_mime_type +end + +-- Function to check if a given mime type is an archive +---@param mime_type string|nil The mime type of the file +---@return boolean is_archive Whether the mime type is an archive +local function is_archive_mime_type(mime_type) + -- + + -- If the mime type is nil, return false + if not mime_type then return false end + + -- Standardise the mime type + local standardised_mime_type = standardise_mime_type(mime_type) + + -- Get the archive extractor for the mime type + local archive_extractor = + ARCHIVE_MIME_TYPE_TO_EXTRACTOR_MAP[standardised_mime_type] + + -- Return if an extractor exists for the mime type + return archive_extractor ~= nil +end + +-- Function to check if a given file extension +-- is an archive file extension +---@param file_extension string|nil The file extension of the file +---@return boolean is_archive Whether the file extension is an archive +local function is_archive_file_extension(file_extension) + -- + + -- If the file extension is nil, return false + if not file_extension then return false end + + -- Make the file extension lower case + file_extension = file_extension:lower() + + -- Trim the whitespace from the file extension + file_extension = string_trim(file_extension) + + -- Get if the file extension is an archive + local is_archive = table_get(ARCHIVE_FILE_EXTENSIONS, file_extension, false) + + -- Return if the file extension is an archive file extension + return is_archive +end + +-- Function to get the mime type of a file +---@param file_path string The path to the file +---@return string mime_type The mime type of the file +local function get_mime_type(file_path) + -- + + -- Get the output of the file command + local output, _ = Command("file") + :args({ + + -- Don't prepend file names to the output + "-b", + + -- Print the mime type of the file + "--mime-type", + + -- The file path to get the mime type of + file_path, + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() + + -- If there is no output, then return an empty string + if not output then return "" end + + -- Otherwise, get the mime type from the standard output + local mime_type = string_trim(output.stdout) + + -- Standardise the mime type + local standardised_mime_type = standardise_mime_type(mime_type) + + -- Return the standardised mime type + return standardised_mime_type +end + +-- Function to get a temporary name. +-- The code is taken from Yazi's source code. +---@param path string The path to the item to create a temporary name +---@return string temporary_name The temporary name for the item +local function get_temporary_name(path) + return ".tmp_" + .. ya.hash(string.format("extract//%s//%.10f", path, ya.time())) +end + +-- Function to get a temporary directory url +-- for the given file path +---@param path string The path to the item to create a temporary directory +---@param destination_given boolean|nil Whether the destination was given +---@return Url|nil url The url of the temporary directory +local function get_temporary_directory_url(path, destination_given) + -- + + -- Get the url of the path given + ---@type Url + local path_url = Url(path) + + -- Initialise the parent directory to be the path given + ---@type Url + local parent_directory_url = path_url + + -- If the destination is not given + if not destination_given then + -- + + -- Get the parent directory of the given path + parent_directory_url = Url(path):parent() + + -- If the parent directory doesn't exist, return nil + if not parent_directory_url then return nil end + end + + -- Create the temporary directory path + local temporary_directory_url = + fs.unique_name(parent_directory_url:join(get_temporary_name(path))) + + -- Return the temporary directory path + return temporary_directory_url +end + +-- Function to get the configuration from an async function +---@type fun(): Configuration The configuration object +local get_config = ya.sync(function(state) + -- + + -- Returns the configuration object + return state.config +end) + +-- Function to get the current working directory +---@type fun(): string Returns the current working directory as a string +local get_current_directory = ya.sync( + function(_) return tostring(cx.active.current.cwd) end +) + +-- Function to get the path of the hovered item +---@type fun( +--- quote: boolean|nil, -- Whether to escape the characters in the path +---): string|nil The path of the hovered item +local get_path_of_hovered_item = ya.sync(function(_, quote) + -- + + -- Get the hovered item + local hovered_item = cx.active.current.hovered + + -- If there is no hovered item, exit the function + if not hovered_item then return end + + -- Convert the url of the hovered item to a string + local hovered_item_path = tostring(cx.active.current.hovered.url) + + -- If the quote flag is passed, + -- then quote the path of the hovered item + if quote then hovered_item_path = ya.quote(hovered_item_path) end + + -- Return the path of the hovered item + return hovered_item_path +end) + +-- Function to get if the hovered item is a directory +---@type fun(): boolean +local hovered_item_is_dir = ya.sync(function(_) + -- + + -- Get the hovered item + local hovered_item = cx.active.current.hovered + + -- Return if the hovered item exists and is a directory + return hovered_item and hovered_item.cha.is_dir +end) + +-- Function to get if the hovered item is an archive +---@type fun(): boolean +local hovered_item_is_archive = ya.sync(function(_) + -- + + -- Get the hovered item + local hovered_item = cx.active.current.hovered + + -- Return if the hovered item exists and is an archive + return hovered_item and is_archive_mime_type(hovered_item:mime()) +end) + +-- Function to get the paths of the selected items +---@type fun( +--- quote: boolean|nil, -- Whether to escape the characters in the path +---): string[]|nil The list of paths of the selected items +local get_paths_of_selected_items = ya.sync(function(_, quote) + -- + + -- Get the selected items + local selected_items = cx.active.selected + + -- If there are no selected items, exit the function + if #selected_items == 0 then return end + + -- Initialise the list of paths of the selected items + local paths_of_selected_items = {} + + -- Iterate over the selected items + for _, item in pairs(selected_items) do + -- + + -- Convert the url of the item to a string + local item_path = tostring(item) + + -- If the quote flag is passed, + -- then quote the path of the item + if quote then item_path = ya.quote(item_path) end + + -- Add the path of the item to the list of paths + table.insert(paths_of_selected_items, item_path) + end + + -- Return the list of paths of the selected items + return paths_of_selected_items +end) + +-- Function to get the tab preferences +---@type fun(): tab.Preference +local get_tab_preferences = ya.sync(function(_) + -- + + -- Create the table to store the tab preferences + local tab_preferences = {} + + -- Iterate over the tab preference keys + for key, _ in pairs(TAB_PREFERENCE_KEYS) do + -- + + -- Set the key in the table to the value + -- from the state + tab_preferences[key] = cx.active.pref[key] + end + + -- Return the tab preferences + return tab_preferences +end) + +-- Function to get if Yazi is loading +---@type fun(): boolean +local yazi_is_loading = ya.sync( + function(_) return cx.active.current.stage.is_loading end +) + +-- Function to wait until Yazi is loaded +---@return nil +local function wait_until_yazi_is_loaded() + while yazi_is_loading() do + end +end + +-- Function to choose which group of items to operate on. +-- It returns ItemGroup.Hovered for the hovered item, +-- ItemGroup.Selected for the selected items, +-- and ItemGroup.Prompt to tell the calling function +-- to prompt the user. +---@type fun(): ItemGroup|nil The desired item group +local get_item_group_from_state = ya.sync(function(state) + -- + + -- Get the hovered item + local hovered_item = cx.active.current.hovered + + -- The boolean representing that there are no selected items + local no_selected_items = #cx.active.selected == 0 + + -- If there is no hovered item + if not hovered_item then + -- + + -- If there are no selected items, exit the function + if no_selected_items then + return + + -- Otherwise, if the configuration is set to have a hovered item, + -- exit the function + elseif state.config.must_have_hovered_item then + return + + -- Otherwise, return the enum for the selected items + else + return ItemGroup.Selected + end + + -- Otherwise, there is a hovered item + -- and if there are no selected items, + -- return the enum for the hovered item. + elseif no_selected_items then + return ItemGroup.Hovered + + -- Otherwise if there are selected items and the user wants a prompt, + -- then tells the calling function to prompt them + elseif state.config.prompt then + return ItemGroup.Prompt + + -- Otherwise, if the hovered item is selected, + -- then return the enum for the selected items + elseif hovered_item:is_selected() then + return ItemGroup.Selected + + -- Otherwise, return the enum for the hovered item + else + return ItemGroup.Hovered + end +end) + +-- Function to prompt the user for their desired item group +---@return ItemGroup|nil item_group The item group selected by the user +local function prompt_for_desired_item_group() + -- + + -- Get the configuration + local config = get_config() + + -- Get the default item group + ---@type ItemGroup|nil + local default_item_group = config.default_item_group_for_prompt + + -- Get the input options + local input_options = INPUT_OPTIONS_TABLE[default_item_group] + + -- If the default item group is None, then set it to nil + if default_item_group == ItemGroup.None then default_item_group = nil end + + -- Prompt the user for their input + local user_input, event = get_user_input( + "Operate on hovered or selected items? " .. input_options + ) + + -- If the user input is empty, then exit the function + if not user_input then return end + + -- Lowercase the user's input + user_input = user_input:lower() + + -- If the user did not confirm the input, exit the function + if event ~= 1 then + return + + -- Otherwise, if the user's input starts with "h", + -- return the item group representing the hovered item + elseif user_input:find("^h") then + return ItemGroup.Hovered + + -- Otherwise, if the user's input starts with "s", + -- return the item group representing the selected items + elseif user_input:find("^s") then + return ItemGroup.Selected + + -- Otherwise, return the default item group + else + return default_item_group + end +end + +-- Function to get the item group +---@return ItemGroup|nil item_group The desired item group +local function get_item_group() + -- + + -- Get the item group from the state + local item_group = get_item_group_from_state() + + -- If the item group isn't the prompt one, + -- then return the item group immediately + if item_group ~= ItemGroup.Prompt then + return item_group + + -- Otherwise, prompt the user for the desired item group + else + return prompt_for_desired_item_group() + end +end + +-- Function to get all the items in the given directory +---@param directory_path string The path to the directory +---@param get_hidden_items boolean Whether to get hidden items +---@param directories_only boolean|nil Whether to only get directories +---@return string[] directory_items The list of urls to the directory items +local function get_directory_items( + directory_path, + get_hidden_items, + directories_only +) + -- + + -- Initialise the list of directory items + ---@type string[] + local directory_items = {} + + -- Read the contents of the directory + local directory_contents, _ = fs.read_dir(Url(directory_path), {}) + + -- If there are no directory contents, + -- then return the empty list of directory items + if not directory_contents then return directory_items end + + -- Iterate over the directory contents + for _, item in ipairs(directory_contents) do + -- + + -- If the get hidden items flag is set to false + -- and the item is a hidden item, + -- then continue the loop + if not get_hidden_items and item.cha.is_hidden then goto continue end + + -- If the directories only flag is passed + -- and the item is not a directory, + -- then continue the loop + if directories_only and not item.cha.is_dir then goto continue end + + -- Otherwise, add the item path to the list of directory items + table.insert(directory_items, tostring(item.url)) + + -- The continue label to continue the loop + ::continue:: + end + + -- Return the list of directory items + return directory_items +end + +-- Function to skip child directories with only one directory +---@param initial_directory_path string The path of the initial directory +---@return nil +local function skip_single_child_directories(initial_directory_path) + -- + + -- Initialise the directory variable to the initial directory given + local directory = initial_directory_path + + -- Get the tab preferences + local tab_preferences = get_tab_preferences() + + -- Start an infinite loop + while true do + -- + + -- Get all the items in the current directory + local directory_items = + get_directory_items(directory, tab_preferences.show_hidden) + + -- If the number of directory items is not 1, + -- then break out of the loop. + if #directory_items ~= 1 then break end + + -- Otherwise, get the directory item + local directory_item = table.unpack(directory_items) + + -- Get the cha object of the directory item + -- and don't follow symbolic links + local directory_item_cha = fs.cha(Url(directory_item), false) + + -- If the cha object of the directory item is nil + -- then break the loop + if not directory_item_cha then break end + + -- If the directory item is not a directory, + -- break the loop + if not directory_item_cha.is_dir then break end + + -- Otherwise, set the directory to the inner directory + directory = directory_item + end + + -- Emit the change directory command to change to the directory variable + ya.manager_emit("cd", { directory }) +end + +-- Class implementations + +-- The function to create a new instance of the extractor +---@param archive_path string The path to the archive +---@param destination_path string|nil The path to extract to +---@param config Configuration The configuration object +---@return Extractor|nil instance An instance of the extractor if available +function Extractor:new(archive_path, destination_path, config) + -- + + -- Initialise whether the extractor is available + local available = false + + -- Iterate over the commands + for _, command in ipairs(self.commands) do + -- + + -- Call the shell command exists function + -- on the command + local exists = async_shell_command_exists(command) + + -- If the command exists + if exists then + -- + + -- Save the command + self.command = command + + -- Set the available variable to true + available = true + + -- Break out of the loop + break + end + end + + -- If none of the commands for the extractor are available, + -- then return nil + if not available then return nil end + + -- Otherwise, create a new instance + local instance = setmetatable({}, self) + + -- Set where to find the object's methods or properties + self.__index = self + + -- Save the parameters given + self.archive_path = archive_path + self.destination_path = destination_path + self.config = config + + -- Return the instance + return instance +end + +-- Function to retry the extractor +---@private +---@param extractor_function ExtractorCommand Extractor command to retry +---@param clean_up_wanted boolean|nil Whether to clean up the destination path +---@return ExtractionResult result Result of the extractor function +function SevenZip:retry_extractor(extractor_function, clean_up_wanted) + -- + + -- Initialise the number of tries + -- to the number of retries plus 1 + local total_number_of_tries = self.config.extract_retries + 1 + + -- Get the url of the archive + ---@type Url + local archive_url = Url(self.archive_path) + + -- Get the archive name + local archive_name = archive_url:name() + + -- If the archive name is nil, + -- return the result of the extractor function + if not archive_name then + return { + successful = false, + error = string.format("%s does not have a name", self.archive_path), + } + end + + -- Initialise the initial password prompt + local initial_password_prompt = string.format("%s password:", archive_name) + + -- Initialise the wrong password prompt + local wrong_password_prompt = + string.format("Wrong password, %s password:", archive_name) + + -- Initialise the clean up function + local clean_up = clean_up_wanted + and function() fs.remove("dir_all", Url(self.destination_path)) end + or function() end + + -- Initialise the error message + local error_message = nil + + -- Iterate over the number of times to try the extraction + for tries = 0, total_number_of_tries do + -- + + -- Execute the extractor function + local output, error = extractor_function() + + -- If there is no output + if not output then + -- + + -- Clean up the extracted files + clean_up() + + -- Return the result of the extractor function + return { + successful = false, + error = tostring(error), + } + end + + -- If the output status code is 0, + -- which means the command was successful, + -- return the result of the extractor function + if output.status.code == 0 then + return { + successful = true, + output = output.stdout, + } + end + + -- Set the error message to the standard error + error_message = output.stderr + + -- If the command failed for a reason other + -- than the archive being encrypted, + -- or if the current try count + -- is the same as the total number of tries + if + not ( + output.status.code == 2 + and error_message:lower():find("wrong password") + ) or tries == total_number_of_tries + then + -- + + -- Clean up the extracted files + clean_up() + + -- Return the extractor function result + return { + successful = false, + error = error_message, + } + end + + -- Otherwise, get the prompt for the password + local password_prompt = tries == 0 and initial_password_prompt + or wrong_password_prompt + + -- Initialise the width of the input element + local input_width = DEFAULT_INPUT_OPTIONS.position.w + + -- If the length of the password prompt is larger + -- than the default input with, set the input width + -- to the length of the password prompt + 1 + if #password_prompt > input_width then + input_width = #password_prompt + 1 + end + + -- Get the new position object + -- for the new input element + ---@type Position + local new_position = + merge_tables(DEFAULT_INPUT_OPTIONS.position, { w = input_width }) + + -- Ask the user for the password + local user_input, event = + ---@diagnostic disable-next-line: missing-fields + get_user_input(password_prompt, { position = new_position }) + + -- If the user has confirmed the input, + -- and the user input is not nil, + -- set the password to the user's input + if event == 1 and user_input ~= nil then + self.password = user_input + + -- Otherwise + else + -- + + -- Call the clean up function + clean_up() + + -- Return the result of the extractor command + return { + successful = false, + cancelled = true, + error = error_message, + } + end + end + + -- If all the tries have been exhausted, + -- call the clean up function + clean_up() + + -- Return the result of the extractor command + return { + successful = false, + error = error_message, + } +end + +-- Function to list the archive items with the command +---@type ExtractorListItemsCommand +function SevenZip:list_items_command() + -- + + -- Initialise the arguments for the command + local arguments = { + + -- List the items in the archive + "l", + + -- Use UTF-8 encoding for console input and output + "-sccUTF-8", + + -- Pass the password to the command + "-p" .. self.password, + + -- Remove the headers (undocumented switch) + "-ba", + + -- The archive path + self.archive_path, + } + + -- Return the result of the command to list the items in the archive + return Command(self.command) + :args(arguments) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() +end + +-- Function to get the items in the archive +---@type ExtractorGetItems +function SevenZip:get_items() + -- + + -- Initialise the list of files in the archive + ---@type string[] + local files = {} + + -- Initialise the list of directories + ---@type string[] + local directories = {} + + -- Call the function to retry the extractor command + -- with the list items in the archive function + local extractor_result = self:retry_extractor( + function() return self:list_items_command() end + ) + + -- Get the output + local output = extractor_result.output + + -- Get the error + local error = extractor_result.error + + -- If the extractor command was not successful, + -- or the output was nil, + -- then return nil the error message, + -- and nil as the correct password + if not extractor_result.successful or not output then + return files, directories, error + end + + -- Otherwise, split the output at the newline character + local output_lines = string_split(output, "\n") + + -- The pattern to get the information from an archive item + ---@type string + local archive_item_info_pattern = "%s+([%.%a]+)%s+(%d+)%s+(%d+)%s+(.+)$" + + -- Iterate over the lines of the output + for _, line in ipairs(output_lines) do + -- + + -- Get the information about the archive item from the line. + -- The information is in the format: + -- Attributes, Size, Compressed Size, File Path + local attributes, _, _, file_path = + line:match(archive_item_info_pattern) + + -- If the file path doesn't exist, then continue the loop + if not file_path then goto continue end + + -- If the attributes of the item starts with a "D", + -- which means the item is a directory + if attributes and attributes:find("^D") then + -- + + -- Add the directory to the list of directories + table.insert(directories, file_path) + + -- Continue the loop + goto continue + end + + -- Otherwise, add the file path to the list of archive items + table.insert(files, file_path) + + -- The continue label to continue the loop + ::continue:: + end + + -- Return the list of files, the list of directories, + -- the error message, and the password + return files, directories, error +end + +-- Function to extract an archive using the command +---@param extract_files_only boolean|nil Extract the files only or not +---@param extract_behaviour ExtractBehaviour|nil The extraction behaviour +---@return CommandOutput|nil output The output of the command +---@return Error|nil error The error if any +function SevenZip:extract_command(extract_files_only, extract_behaviour) + -- + + -- Initialise the extract files only flag to false if it's not given + extract_files_only = extract_files_only or false + + -- Initialise the extract behaviour to rename if it's not given + extract_behaviour = + self.extract_behaviour_map[extract_behaviour or ExtractBehaviour.Rename] + + -- Initialise the extraction mode to use. + -- By default, it extracts the archive with + -- full paths, which keeps the archive structure. + local extraction_mode = "x" + + -- If the extract files only flag is passed + if extract_files_only then + -- + + -- Use the regular extract, + -- without the full paths, which will move + -- all files in the archive into the current directory + -- and ignore the archive folder structure. + extraction_mode = "e" + end + + -- Initialise the arguments for the command + local arguments = { + + -- The extraction mode + extraction_mode, + + -- Assume yes to all prompts + "-y", + + -- Use UTF-8 encoding for console input and output + "-sccUTF-8", + + -- Configure the extraction behaviour + extract_behaviour, + + -- Pass the password to the command + "-p" .. self.password, + + -- The archive file to extract + self.archive_path, + + -- The destination directory path + "-o" .. self.destination_path, + } + + -- Return the command to extract the archive + return Command(self.command) + :args(arguments) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() +end + +-- Function to extract the archive +---@type ExtractorExtract +function SevenZip:extract(has_only_one_file) + -- + + -- Extract the archive with the extractor command + local result = self:retry_extractor( + function() return self:extract_command(has_only_one_file) end, + true + ) + + -- Return the extractor result + return result +end + +-- Function to list the archive items with the command +---@type ExtractorListItemsCommand +function Tar:list_items_command() + -- + + -- Initialise the arguments for the command + local arguments = { + + -- List the items in the archive + "-t", + + -- Pass the file + "-f", + + -- The archive file path + self.archive_path, + } + + -- Return the result of the command + return Command(self.command) + :args(arguments) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() +end + +-- Function to get the items in the archive +---@type ExtractorGetItems +function Tar:get_items() + -- + + -- Call the function to get the list of items in the archive + local output, error = self:list_items_command() + + -- Initialise the list of files + ---@type string[] + local files = {} + + -- Initialise the list of directories + ---@type string[] + local directories = {} + + -- If there is no output, return the empty lists and the error + if not output then return files, directories, tostring(error) end + + -- Otherwise, split the output into lines and iterate over it + for _, line in ipairs(string_split(output.stdout, "\n")) do + -- + + -- If the line ends with a slash, it's a directory + if line:sub(-1) == "/" then + -- + + -- Add the directory without the trailing slash + -- to the list of directories + table.insert(directories, line:sub(1, -2)) + + -- Continue the loop + goto continue + end + + -- Otherwise, the item is a file, so add it to the list of files + table.insert(files, line) + + -- The label to continue the loop + ::continue:: + end + + -- Return the list of files and directories and the error + return files, directories, output.stderr +end + +-- Function to extract an archive using the command +---@param extract_behaviour ExtractBehaviour|nil The extract behaviour to use +function Tar:extract_command(extract_behaviour) + -- + + -- Initialise the extract behaviour to rename if it is not given + extract_behaviour = + self.extract_behaviour_map[extract_behaviour or ExtractBehaviour.Rename] + + -- Initialise the arguments for the command + local arguments = { + + -- Extract the archive + "-x", + + -- Verbose + "-v", + + -- The extract behaviour flag + extract_behaviour, + + -- Specify the destination directory + "-C", + + -- The destination directory path + self.destination_path, + } + + -- If keeping permissions is wanted, add the -p flag + if self.config.preserve_file_permissions then + table.insert(arguments, "-p") + end + + -- Add the -f flag and the archive path to the arguments + table.insert(arguments, "-f") + table.insert(arguments, self.archive_path) + + -- Create the destination path first. + -- + -- This is required because tar does not + -- automatically create the directory + -- pointed to by the -C flag. + -- Instead, tar just tries to change + -- the working directory to the directory + -- pointed to by the -C flag, which can + -- fail if the directory does not exist. + -- + -- GNU tar has a --one-top-level=[DIR] option, + -- which will automatically create the directory + -- given, but macOS tar does not have this option. + -- + -- The error here is ignored because if there + -- is an error creating the directory, + -- then the extractor will fail anyway. + fs.create("dir_all", Url(self.destination_path)) + + -- Return the output of the command + return Command(self.command) + :args(arguments) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() +end + +-- Function to extract the archive. +-- +-- Tar automatically decompresses and extracts the archive +-- in one command, so there's no need to run it twice to +-- extract compressed tarballs. +---@type ExtractorExtract +function Tar:extract(_) + -- + + -- Call the command to extract the archive + local output, error = self:extract_command() + + -- If there is no output, return the result + if not output then + return { + successful = false, + error = tostring(error), + } + end + + -- Otherwise, if the status code is not 0, + -- which means the extraction was not successful, + -- return the result + if output.status.code ~= 0 then + return { + successful = false, + output = output.stdout, + error = output.stderr, + } + end + + -- Otherwise, return the successful result + return { + successful = true, + output = output.stdout, + } +end + +-- Functions for the commands + +-- Function to get the extractor for the file type +---@param archive_path string The path to the archive file +---@param destination_path string The path to the destination directory +---@param config Configuration The configuration for the plugin +---@return ExtractionResult result The results of getting the extractor +---@return Extractor|nil extractor The extractor for the file type +local function get_extractor(archive_path, destination_path, config) + -- + + -- Get the mime type of the archive file + local mime_type = get_mime_type(archive_path) + + -- Get the extractor for the mime type + local extractor = ARCHIVE_MIME_TYPE_TO_EXTRACTOR_MAP[mime_type] + + -- If there is no extractor, + -- return that it is not successful, + -- but that it has been cancelled + -- as the mime type is not an archive + if not extractor then + return { + successful = false, + cancelled = true, + } + end + + -- Instantiate an instance of the extractor + local extractor_instance = + extractor:new(archive_path, destination_path, config) + + -- While the extractor instance failed to be created + while not extractor_instance do + -- + + -- If the extractor instance is the default extractor, + -- then return an error telling the user to install the + -- default extractor + if extractor.name == DefaultExtractor.name then + return { + successful = false, + error = table.concat({ + string.format( + "%s is not installed,", + DefaultExtractor.name + ), + "please install it before using the 'extract' command", + }, " "), + } + end + + -- Try instantiating the default extractor + extractor_instance = + DefaultExtractor:new(archive_path, destination_path, config) + end + + -- If the user wants to preserve file permissions, + -- and the target extractor for the mime type supports + -- preserving file permissions, but the extractor + -- instantiated does not, show a warning to the user + if + config.preserve_file_permissions + and extractor.supports_file_permissions + and not extractor_instance.supports_file_permissions + then + -- + + -- The warning to show the user + local warning = table.concat({ + string.format( + "%s is not installed, defaulting to %s.", + extractor.name, + extractor_instance.name + ), + string.format( + "However, %s does not support preserving file permissions.", + extractor_instance.name + ), + }, "\n") + + -- Show the warning to the user + show_warning(warning) + end + + -- Return the extractor instance + return { successful = true }, extractor_instance +end + +-- Function to move the extracted items out of the temporary directory +---@param archive_url Url The url of the archive +---@param destination_url Url The url of the destination +---@return ExtractionResult result The result of the move +local function move_extracted_items(archive_url, destination_url) + -- + + -- The function to clean up the destination directory + -- and return the extractor result in the event of an error + ---@param error string The error message to return + ---@param empty_dir_only boolean|nil Whether to remove the empty dir only + ---@return ExtractionResult + local function fail(error, empty_dir_only) + -- + + -- Clean up the destination path + fs.remove(empty_dir_only and "dir" or "dir_all", destination_url) + + -- Return the extractor result + ---@type ExtractionResult + return { + successful = false, + error = error, + } + end + + -- Get the extracted items in the destination. + -- There is a limit of 2 as we just need to + -- know if the destination contains only + -- a single item or not. + local extracted_items = fs.read_dir(destination_url, { limit = 2 }) + + -- If the extracted items doesn't exist, + -- clean up and return the error + if not extracted_items then + return fail( + string.format( + "Failed to read the destination directory: %s", + tostring(destination_url) + ) + ) + end + + -- If there are no extracted items, + -- clean up and return the error + if #extracted_items == 0 then + return fail("No files extracted from the archive", true) + end + + -- Get the parent directory of the destination + local parent_directory_url = destination_url:parent() + + -- If the parent directory doesn't exist, + -- clean up and return the error + if not parent_directory_url then + return fail("Destination path has no parent directory") + end + + -- Get the name of the archive without the extension + local archive_name = archive_url:stem() + + -- If the name of the archive doesn't exist, + -- clean up and return the error + if not archive_name then + return fail("Archive has no name without its extension") + end + + -- Get the first extracted item + local first_extracted_item = table.unpack(extracted_items) + + -- Initialise the variable to indicate whether the archive has only one item + local only_one_item = false + + -- Initialise the target directory url to move the extracted items to, + -- which is the parent directory of the archive + -- joined with the file name of the archive without the extension + local target_url = parent_directory_url:join(archive_name) + + -- If there is only one item in the archive + if #extracted_items == 1 then + -- + + -- Set the only one item variable to true + only_one_item = true + + -- Get the name of the first extracted item + local first_extracted_item_name = first_extracted_item.url:name() + + -- If the first extracted item has no name, + -- then clean up and return the error + if not first_extracted_item_name then + return fail("The only extracted item has no name") + end + + -- Otherwise, set the target url to the parent directory + -- of the destination joined with the file name of the extracted item + target_url = parent_directory_url:join(first_extracted_item_name) + end + + -- Get a unique name for the target url + local unique_target_url = fs.unique_name(target_url) + + -- If the unique target url is nil, + -- clean up and return the error + if not unique_target_url then + return fail( + "Failed to get a unique name to move the extracted items to" + ) + end + + -- Set the target path to the string of the target url + local target_path = tostring(unique_target_url) + + -- Initialise the move successful variable and the error message + local error_message, move_successful = nil, false + + -- If there is only one item in the archive + if only_one_item then + -- + + -- Move the item to the target path + move_successful, error_message = + os.rename(tostring(first_extracted_item.url), target_path) + + -- Otherwise + else + -- + + -- Rename the destination directory itself to the target path + move_successful, error_message = + os.rename(tostring(destination_url), target_path) + end + + -- Clean up the destination directory + fs.remove(move_successful and "dir" or "dir_all", destination_url) + + -- Return the extractor result with the target path as the + -- path to the extracted items + return { + successful = move_successful, + error = error_message, + extracted_items_path = target_path, + } +end + +-- Function to recursively extract archives +---@param archive_path string The path to the archive +---@param args Arguments The arguments passed to the plugin +---@param config Configuration The configuration object +---@param destination_path string|nil The destination path to extract to +---@return ExtractionResult extraction_result The extraction results +local function recursively_extract_archive( + archive_path, + args, + config, + destination_path +) + -- + + -- Get whether the destination path is given + local destination_path_given = destination_path ~= nil + + -- Initialise the destination path to the archive path if it is not given + local destination = destination_path or archive_path + + -- Get the temporary directory url + local temporary_directory_url = + get_temporary_directory_url(destination, destination_path_given) + + -- If the temporary directory can't be created + -- then return the result + if not temporary_directory_url then + return { + successful = false, + error = "Failed to create a temporary directory", + archive_path = archive_path, + destination_path = destination_path, + } + end + + -- Get an extractor for the archive + local get_extractor_result, extractor = + get_extractor(archive_path, tostring(temporary_directory_url), config) + + -- Function to add the archive and destination path to the result + ---@param result ExtractionResult The result to add the paths to + ---@return ExtractionResult modified_result The result with the paths added + local function add_paths_to_result(result) + return merge_tables(result, { + archive_path = archive_path, + destination_path = destination_path, + }) + end + + -- If there is no extractor, return the result + if not extractor then + return merge_tables(get_extractor_result, { + archive_path = archive_path, + destination_path = destination_path, + }) + end + + -- Get the list of archive files and directories, + -- the error message and the password + local archive_files, archive_directories, error = extractor:get_items() + + -- If there are no are no archive files and directories + if #archive_files == 0 and #archive_directories == 0 then + -- + + -- The extraction result + ---@type ExtractionResult + local extraction_result = { + successful = false, + error = error or "Archive is empty", + } + + -- Return the extraction result + return add_paths_to_result(extraction_result) + end + + -- Get if the archive has only one file + local archive_has_only_one_file = #archive_files == 1 + and #archive_directories == 0 + + -- Extract the given archive + local extraction_result = extractor:extract(archive_has_only_one_file) + + -- If the extraction result is not successful, return it + if not extraction_result.successful then + return add_paths_to_result(extraction_result) + end + + -- Get the result of moving the extracted items + local move_result = + move_extracted_items(Url(archive_path), temporary_directory_url) + + -- Get the extracted items path + local extracted_items_path = move_result.extracted_items_path + + -- If moving the extracted items isn't successful, + -- or if the extracted items path is nil, + -- or if the user does not want to extract archives recursively, + -- return the move results + if + not move_result.successful + or not extracted_items_path + or not config.recursively_extract_archives + then + return add_paths_to_result(move_result) + end + + -- Get the url of the extracted items path + ---@type Url + local extracted_items_url = Url(extracted_items_path) + + -- Initialise the base url for the extracted items + local base_url = extracted_items_url + + -- Get the parent directory of the extracted items path + local parent_directory_url = extracted_items_url:parent() + + -- If the parent directory doesn't exist + if not parent_directory_url then + -- + + -- Modify the move result with a custom error + ---@type ExtractionResult + local modified_move_result = merge_tables(move_result, { + error = "Archive has no parent directory", + archive_path = archive_path, + destination_path = destination_path, + }) + + -- Return the modified move result + return modified_move_result + end + + -- If the archive has only one file + if archive_has_only_one_file then + -- + + -- Set the base url to the parent directory of the extracted items path + base_url = parent_directory_url + end + + -- Iterate over the archive files + for _, file in ipairs(archive_files) do + -- + + -- Get the file extension of the file + local file_extension = file:match(file_extension_pattern) + + -- If the file extension is not found, then skip the file + if not file_extension then goto continue end + + -- If the file extension is not an archive file extension, skip the file + if not is_archive_file_extension(file_extension) then goto continue end + + -- Otherwise, get the full url to the archive + local full_archive_url = base_url:join(file) + + -- Get the full path to the archive + local full_archive_path = tostring(full_archive_url) + + -- Recursively extract the archive + emit_plugin_command( + "extract", + merge_tables(args, { + archive_path = ya.quote(full_archive_path), + remove = true, + }) + ) + + -- The label the continue the loop + ::continue:: + end + + -- Return the move result + return add_paths_to_result(move_result) +end + +-- Function to show an extraction error +---@param extraction_result ExtractionResult The extraction result +---@return nil +local function show_extraction_error(extraction_result) + return show_error(table.concat({ + string.format( + "Failed to extract archive at: %s", + extraction_result.archive_path + ), + string.format("Destination: %s", extraction_result.destination_path), + string.format("Error: %s", extraction_result.error), + }, "\n")) +end + +-- Function to handle the open command +---@type CommandFunction +local function handle_open(args, config) + -- + + -- Call the function to get the item group + local item_group = get_item_group() + + -- If no item group is returned, exit the function + if not item_group then return end + + -- If the item group is the selected items, + -- then execute the command and exit the function + if item_group == ItemGroup.Selected then + -- + + -- Emit the command and exit the function + return ya.manager_emit("open", args) + end + + -- If the hovered item is a directory + if hovered_item_is_dir() then + -- + + -- If smart enter is wanted, + -- calls the function to enter the directory + -- and exit the function + if config.smart_enter or table_pop(args, "smart", false) then + return emit_plugin_command("enter", args) + end + + -- Otherwise, just exit the function + return + end + + -- Otherwise, if the hovered item is not an archive, + -- or entering archives isn't wanted, + -- or the interactive flag is passed + if + not hovered_item_is_archive() + or not config.enter_archives + or args.interactive + then + -- + + -- Simply emit the open command, + -- opening only the hovered item + -- as the item group is the hovered item, + -- and exit the function + return ya.manager_emit("open", merge_tables(args, { hovered = true })) + end + + -- Otherwise, the hovered item is an archive + -- and entering archives is wanted, + -- so get the path of the hovered item + local archive_path = get_path_of_hovered_item() + + -- If the archive path somehow doesn't exist, then exit the function + if not archive_path then return end + + -- Get the parent directory of the hovered item + ---@type Url + local parent_directory_url = Url(archive_path):parent() + + -- If the parent directory doesn't exist, then exit the function + if not parent_directory_url then return end + + -- Emit the command to extract the archive + -- and reveal the extracted items + emit_plugin_command( + "extract", + merge_tables(args, { + archive_path = ya.quote(archive_path), + reveal = true, + parent_dir = ya.quote(tostring(parent_directory_url)), + }) + ) +end + +-- Function to get the archive paths for the extract command +---@param args Arguments The arguments passed to the plugin +---@return string|string[]|nil archive_paths The archive paths +local function get_archive_paths(args) + -- + + -- Get the archive path from the arguments given + local archive_path = table_pop(args, "archive_path") + + -- If the archive path is given, return it immediately + if archive_path then return archive_path end + + -- Otherwise, get the item group + local item_group = get_item_group() + + -- If there is no item group + if not item_group then return end + + -- If the item group is the hovered item + if item_group == ItemGroup.Hovered then + -- + + -- Get the hovered item path + local hovered_item_path = get_path_of_hovered_item(true) + + -- If the hovered item path is nil, exit the function + if not hovered_item_path then return end + + -- Otherwise, return the hovered item path + return hovered_item_path + end + + -- Otherwise, if the item group is the selected items + if item_group == ItemGroup.Selected then + -- + + -- Get the list of selected items + local selected_items = get_paths_of_selected_items(true) + + -- If there are no selected items, exit the function + if not selected_items then return end + + -- Otherwise, return the list of selected items + return selected_items + end +end + +-- Function to handle the extract command +---@type CommandFunction +local function handle_extract(args, config) + -- + + -- Get the archive paths + local archive_paths = get_archive_paths(args) + + -- Get the destination path from the arguments given + ---@type string + local destination_path = table_pop(args, "destination_path") + + -- If there are no archive paths, exit the function + if not archive_paths then return end + + -- If the archive path is a list + if type(archive_paths) == "table" then + -- + + -- Iterate over the archive paths + -- and call the extract command on them + for _, archive_path in ipairs(archive_paths) do + emit_plugin_command( + "extract", + merge_tables(args, { + archive_path = ya.quote(archive_path), + }) + ) + end + + -- Exit the function + return + end + + -- Otherwise the archive path is a string + ---@type string + local archive_path = archive_paths + + -- Call the function to recursively extract the archive + local extraction_result = recursively_extract_archive( + archive_path, + args, + config, + destination_path + ) + + -- If the extraction is cancelled, then just exit the function + if extraction_result.cancelled then return end + + -- Get the extracted items path + local extracted_items_path = extraction_result.extracted_items_path + + -- If the extraction is not successful, notify the user + if not extraction_result.successful or not extracted_items_path then + return show_extraction_error(extraction_result) + end + + -- Get the url of the archive + local archive_url = Url(archive_path) + + -- If the remove flag is passed, + -- then remove the archive after extraction + if table_pop(args, "remove", false) then fs.remove("file", archive_url) end + + -- If the reveal flag is passed + if table_pop(args, "reveal", false) then + -- + + -- Get the url of the extracted items + ---@type Url + local extracted_items_url = Url(extracted_items_path) + + -- Get the parent directory of the extracted items + local parent_directory_url = extracted_items_url:parent() + + -- If the parent directory doesn't exist, then exit the function + if not parent_directory_url then return end + + -- Get the given parent directory + local given_parent_directory = table_pop(args, "parent_dir") + + -- If there is a parent directory given but the parent directory + -- of the extracted items isn't the same as the given one, + -- exit the function + if + given_parent_directory + and given_parent_directory ~= tostring(parent_directory_url) + then + return + end + + -- Get the cha of the extracted item + local extracted_items_cha = fs.cha(extracted_items_url, false) + + -- If the cha of the extracted item doesn't exist, + -- exit the function + if not extracted_items_cha then return end + + -- If the extracted item is not a directory + if not extracted_items_cha.is_dir then + -- + + -- Reveal the item and exit the function + return ya.manager_emit("reveal", { extracted_items_url }) + end + + -- Otherwise, change the directory to the extracted item. + -- Note that extracted_items_url is destroyed here. + ya.manager_emit("cd", { extracted_items_url }) + + -- If the user wants to skip single subdirectories on enter, + -- and the no skip flag is not passed + if + config.skip_single_subdirectory_on_enter + and not table_pop(args, "no_skip", false) + then + -- + + -- Call the function to skip child directories + skip_single_child_directories(extracted_items_path) + end + end +end + +-- Function to handle the enter command +---@type CommandFunction +local function handle_enter(args, config) + -- + + -- If the hovered item is not a directory + if not hovered_item_is_dir() then + -- + + -- If smart enter is wanted, + -- call the function for the open command + -- and exit the function + if config.smart_enter or table_pop(args, "smart", false) then + return emit_plugin_command("open", args) + end + + -- Otherwise, just exit the function + return + end + + -- Otherwise, always emit the enter command, + ya.manager_emit("enter", args) + + -- If the user doesn't want to skip single subdirectories on enter, + -- or one of the arguments passed is no skip, + -- then exit the function + if + not config.skip_single_subdirectory_on_enter + or table_pop(args, "no_skip", false) + then + return + end + + -- Otherwise, call the function to skip child directories + -- with only a single directory inside + skip_single_child_directories(get_current_directory()) +end + +-- Function to handle the leave command +---@type CommandFunction +local function handle_leave(args, config) + -- + + -- Always emit the leave command + ya.manager_emit("leave", args) + + -- If the user doesn't want to skip single subdirectories on leave, + -- or one of the arguments passed is no skip, + -- then exit the function + if + not config.skip_single_subdirectory_on_leave + or table_pop(args, "no_skip", false) + then + return + end + + -- Otherwise, initialise the directory to the current directory + local directory = get_current_directory() + + -- Get the tab preferences + local tab_preferences = get_tab_preferences() + + -- Start an infinite loop + while true do + -- + + -- Get all the items in the current directory + local directory_items = + get_directory_items(directory, tab_preferences.show_hidden) + + -- If the number of directory items is not 1, + -- then break out of the loop. + if #directory_items ~= 1 then break end + + -- Get the parent directory of the current directory + ---@type Url|nil + local parent_directory = Url(directory):parent() + + -- If the parent directory is nil, + -- break the loop + if not parent_directory then break end + + -- Otherwise, set the new directory to the parent directory + directory = tostring(parent_directory) + end + + -- Emit the change directory command to change to the directory variable + ya.manager_emit("cd", { directory }) +end + +-- Function to handle a Yazi command +---@param command string A Yazi command +---@param args Arguments The arguments passed to the plugin +---@return nil +local function handle_yazi_command(command, args) + -- + + -- Call the function to get the item group + local item_group = get_item_group() + + -- If no item group is returned, exit the function + if not item_group then return end + + -- If the item group is the selected items + if item_group == ItemGroup.Selected then + -- + + -- Emit the command to operate on the selected items + ya.manager_emit(command, args) + + -- If the item group is the hovered item + elseif item_group == ItemGroup.Hovered then + -- + + -- Emit the command with the hovered option + ya.manager_emit(command, merge_tables(args, { hovered = true })) + end +end + +-- Function to enter or open the created file +---@param item_url Url The url of the item to create +---@param is_directory boolean|nil Whether the item to create is a directory +---@param args Arguments The arguments passed to the plugin +---@param config Configuration The configuration object +---@return nil +local function enter_or_open_created_item(item_url, is_directory, args, config) + -- + + -- If the item is a directory + if is_directory then + -- + + -- If user does not want to enter the directory + -- after creating it, exit the function + if + not ( + config.enter_directory_after_creation + or table_pop(args, "enter", false) + ) + then + return + end + + -- Otherwise, call the function change to the created directory + return ya.manager_emit("cd", { item_url }) + end + + -- Otherwise, the item is a file + + -- If the user does not want to open the file + -- after creating it, exit the function + if + not (config.open_file_after_creation or table_pop(args, "open", false)) + then + return + end + + -- Otherwise, call the function to reveal the created file + ya.manager_emit("reveal", { item_url }) + + -- Wait for Yazi to finish loading + wait_until_yazi_is_loaded() + + -- Call the function to open the file + return ya.manager_emit("open", { hovered = true }) +end + +-- Function to execute the create command +---@param item_url Url The url of the item to create +---@param args Arguments The arguments passed to the plugin +---@param config Configuration The configuration object +---@return nil +local function execute_create(item_url, is_directory, args, config) + -- + + -- Get the parent directory of the file to create + local parent_directory_url = item_url:parent() + + -- If the parent directory doesn't exist, + -- then show an error and exit the function + if not parent_directory_url then + return show_error( + "Parent directory of the item to create doesn't exist" + ) + end + + -- If the item to create is a directory + if is_directory then + -- + + -- Call the function to create the directory + local successful, error_message = fs.create("dir_all", item_url) + + -- If the function is not successful, + -- show the error message and exit the function + if not successful then return show_error(tostring(error_message)) end + + -- Otherwise, the item to create is a file + else + -- + + -- Otherwise, create the parent directory if it doesn't exist + if not fs.cha(parent_directory_url, false) then + -- + + -- Call the function to create the parent directory + local successful, error_message = + fs.create("dir_all", parent_directory_url) + + -- If the function is not successful, + -- show the error message and exit the function + if not successful then + return show_error(tostring(error_message)) + end + end + + -- Otherwise, create the file + local successful, error_message = fs.write(item_url, "") + + -- If the function is not successful, + -- show the error message and exit the function + if not successful then return show_error(tostring(error_message)) end + end + + -- Call the function to enter or open the created item + enter_or_open_created_item(item_url, is_directory, args, config) +end + +-- Function to handle the create command +---@type CommandFunction +local function handle_create(args, config) + -- + + -- Get the directory flag + local dir_flag = table_pop(args, "dir", false) + + -- Get the user's input for the item to create + local user_input, event = + get_user_input(dir_flag and "Create (dir):" or "Create:") + + -- If the user input is nil, + -- or if the user did not confirm the input, + -- exit the function + if not user_input or event ~= 1 then return end + + -- Get the current working directory as a url + ---@type Url + local current_working_directory = Url(get_current_directory()) + + -- Get whether the url ends with a path delimiter + local ends_with_path_delimiter = user_input:find("[/\\]$") + + -- Get the whether the given item is a directory or not based + -- on the default conditions for a directory + local is_directory = ends_with_path_delimiter or dir_flag + + -- Get the url from the user's input + ---@type Url + local item_url = Url(user_input) + + -- If the user does not want to use the default Yazi create behaviour + if + not ( + config.use_default_create_behaviour + or table_pop(args, "default_behaviour", false) + ) + then + -- + + -- Get the file extension from the user's input + local file_extension = user_input:match(file_extension_pattern) + + -- Set the is directory variable to the is directory condition + -- or if the file extension exists + is_directory = is_directory or not file_extension + end + + -- Get the full url of the item to create + local full_url = current_working_directory:join(item_url) + + -- If the path to the item to create already exists, + -- and the user did not pass the force flag + if fs.cha(full_url, false) and not table_pop(args, "force", false) then + -- + + -- Get the user's confirmation for + -- whether they want to overwrite the item + local user_confirmation = get_user_confirmation( + + -- TODO: Remove the line below + "The item already exists, overwrite? (y/N)", + "Overwrite file?", + ui.Text({ + ui.Line("Will overwrite the following file:") + :align(ui.Line.CENTER), + ui.Line(string.rep("─", DEFAULT_CONFIRM_OPTIONS.pos.w - 2)) + :align(ui.Line.LEFT), + ui.Line(tostring(full_url)):align(ui.Line.LEFT), + }):wrap(ui.Text.WRAP_TRIM) + ) + + -- If the user did not confirm the overwrite, + -- then exit the function + if not user_confirmation then return end + end + + -- Call the function to execute the create command + return execute_create(full_url, is_directory, args, config) +end + +-- Function to remove the F flag from the less command +---@param command string The shell command containing the less command +---@return string command The command with the F flag removed +---@return boolean f_flag_found Whether the F flag was found +local function remove_f_flag_from_less_command(command) + -- + + -- Initialise the variable to store if the F flag is found + local f_flag_found = false + + -- Initialise the variable to store the replacement count + local replacement_count = 0 + + -- Remove the F flag when it is passed at the start + -- of the flags given to the less command + command, replacement_count = command:gsub("(%f[%a]less%f[%A].*)%-F", "%1") + + -- If the replacement count is not 0, + -- set the f_flag_found variable to true + if replacement_count ~= 0 then f_flag_found = true end + + -- Remove the F flag when it is passed in the middle + -- or end of the flags given to the less command command + command, replacement_count = + command:gsub("(%f[%a]less%f[%A].*%-)(%a*)F(%a*)", "%1%2%3") + + -- If the replacement count is not 0, + -- set the f_flag_found variable to true + if replacement_count ~= 0 then f_flag_found = true end + + -- Return the command and whether or not the F flag was found + return command, f_flag_found +end + +-- Function to fix a command containing less. +-- All this function does is remove +-- the F flag from a command containing less. +---@param command string The shell command containing the less command +---@return string command The fixed shell command +local function fix_shell_command_containing_less(command) + -- + + -- Remove the F flag from the given command + local fixed_command = remove_f_flag_from_less_command(command) + + -- Get the LESS environment variable + local less_environment_variable = os.getenv("LESS") + + -- If the LESS environment variable is not set, + -- then return the given command with the F flag removed + if not less_environment_variable then return fixed_command end + + -- Otherwise, remove the F flag from the LESS environment variable + -- and check if the F flag was found + local less_command_with_modified_env_variables, f_flag_found = + remove_f_flag_from_less_command("less " .. less_environment_variable) + + -- If the F flag isn't found, + -- then return the given command with the F flag removed + if not f_flag_found then return fixed_command end + + -- Add the less environment variable flags to the less command + fixed_command = fixed_command:gsub( + "%f[%a]less%f[%A]", + escape_replacement_string(less_command_with_modified_env_variables) + ) + + -- Unset the LESS environment variable before calling the command + fixed_command = "unset LESS; " .. fixed_command + + -- Return the fixed command + return fixed_command +end + +-- Function to fix the bat default pager command +---@param command string The command containing the bat default pager command +---@return string command The fixed bat command +local function fix_bat_default_pager_shell_command(command) + -- + + -- Initialise the default pager command for bat without the F flag + local bat_default_pager_command_without_f_flag = "less -RX" + + -- Get the modified command and the replacement count + -- when replacing the less command when it is quoted + local modified_command, replacement_count = command:gsub( + "(" + .. bat_command_with_pager_pattern + .. "['\"]+%s*" + .. ")" + .. "less" + .. "(%s*['\"]+)", + "%1" .. bat_default_pager_command_without_f_flag .. "%2" + ) + + -- If the replacement count is not 0, + -- then return the modified command + if replacement_count ~= 0 then return modified_command end + + -- Otherwise, get the modified command and the replacement count + -- when replacing the less command when it is unquoted + modified_command, replacement_count = command:gsub( + "(" .. bat_command_with_pager_pattern .. ")" .. "less", + '%1"' .. bat_default_pager_command_without_f_flag .. '"' + ) + + -- If the replacement count is not 0, + -- then return the modified command + if replacement_count ~= 0 then return modified_command end + + -- Otherwise, return the given command + return command +end + +-- Function to fix the shell commands given to work properly with Yazi +---@param command string A shell command +---@return string command The fixed shell command +local function fix_shell_command(command) + -- + + -- If the given command includes the less command + if command:find("%f[%a]less%f[%A]") ~= nil then + -- + + -- Fix the command containing less + command = fix_shell_command_containing_less(command) + end + + -- If the given command contains the bat command with the pager + -- option passed + if command:find(bat_command_with_pager_pattern) ~= nil then + -- + + -- Calls the command to fix the bat command with the default pager + command = fix_bat_default_pager_shell_command(command) + end + + -- Return the modified command + return command +end + +-- Function to handle a shell command +---@type CommandFunction +local function handle_shell(args, _) + -- + + -- Get the first item of the arguments given + -- and set it to the command variable + local command = table.remove(args, 1) + + -- Get the type of the command variable + local command_type = type(command) + + -- If the command isn't a string, + -- show an error message and exit the function + if command_type ~= "string" then + return show_error( + string.format( + "Shell command given is not a string, " + .. "instead it is a '%s', " + .. "with value '%s'", + command_type, + tostring(command) + ) + ) + end + + -- Fix the given command + command = fix_shell_command(command) + + -- Call the function to get the item group + local item_group = get_item_group() + + -- If no item group is returned, exit the function + if not item_group then return end + + -- Get whether the exit if directory flag is passed + local exit_if_dir = table_pop(args, "exit_if_dir", false) + + -- If the item group is the selected items + if item_group == ItemGroup.Selected then + -- + + -- Get the paths of the selected items + local selected_items = get_paths_of_selected_items(true) + + -- If there are no selected items, exit the function + if not selected_items then return end + + -- If the exit if directory flag is passed + if exit_if_dir then + -- + + -- Initialise the number of files + local number_of_files = 0 + + -- Iterate over all of the selected items + for _, item in pairs(selected_items) do + -- + + -- Get the cha object of the item + local item_cha = fs.cha(Url(item), false) + + -- If the item isn't a directory + if not (item_cha or {}).is_dir then + -- + + -- Increment the number of files + number_of_files = number_of_files + 1 + end + end + + -- If the number of files is 0, then exit the function + if number_of_files == 0 then return end + end + + -- Replace the shell variable in the command + -- with the quoted paths of the selected items + command = command:gsub( + shell_variable_pattern, + escape_replacement_string(table.concat(selected_items, " ")) + ) + + -- If the item group is the hovered item + elseif item_group == ItemGroup.Hovered then + -- + + -- Get the hovered item path + local hovered_item_path = get_path_of_hovered_item(true) + + -- If the hovered item path is nil, exit the function + if not hovered_item_path then return end + + -- If the exit if directory flag is passed, + -- and the hovered item is a directory, + -- then exit the function + if exit_if_dir and hovered_item_is_dir() then return end + + -- Replace the shell variable in the command + -- with the quoted path of the hovered item + command = command:gsub( + shell_variable_pattern, + escape_replacement_string(hovered_item_path) + ) + + -- Otherwise, exit the function + else + return + end + + -- Merge the command back into the arguments given + args = merge_tables({ command }, args) + + -- Emit the command to operate on the hovered item + ya.manager_emit("shell", args) +end + +-- Function to handle the paste command +---@type CommandFunction +local function handle_paste(args, config) + -- + + -- If the hovered item is not a directory or smart paste is not wanted + if + not hovered_item_is_dir() + or not (config.smart_paste or table_pop(args, "smart", false)) + then + -- + + -- Just paste the items inside the current directory + -- and exit the function + return ya.manager_emit("paste", args) + end + + -- Otherwise, enter the directory + ya.manager_emit("enter", {}) + + -- Paste the items inside the directory + ya.manager_emit("paste", args) + + -- Leave the directory + ya.manager_emit("leave", {}) +end + +-- Function to execute the tab create command +---@type fun( +--- args: Arguments, -- The arguments passed to the plugin +---): nil +local execute_tab_create = ya.sync(function(state, args) + -- + + -- Get the hovered item + local hovered_item = cx.active.current.hovered + + -- If the hovered item is nil, + -- or if the hovered item is not a directory, + -- or if the user doesn't want to smartly + -- create a tab in the hovered directory + if + not hovered_item + or not hovered_item.cha.is_dir + or not ( + state.config.smart_tab_create + or table_pop(args, "smart", false) + ) + then + -- + + -- Emit the command to create a new tab with the arguments + -- and exit the function + return ya.manager_emit("tab_create", args) + end + + -- Otherwise, emit the command to create a new tab + -- with the hovered item's url + ya.manager_emit("tab_create", { hovered_item.url }) +end) + +-- Function to handle the tab create command +---@type CommandFunction +local function handle_tab_create(args) + -- + + -- Call the function to execute the tab create command + execute_tab_create(args) +end + +-- Function to execute the tab switch command +---@type fun( +--- args: Arguments, -- The arguments passed to the plugin +---): nil +local execute_tab_switch = ya.sync(function(state, args) + -- + + -- Get the tab index + local tab_index = args[1] + + -- If no tab index is given, exit the function + if not tab_index then return end + + -- If the user doesn't want to create tabs + -- when switching to a new tab, + -- or the tab index is not given, + -- then just call the tab switch command + -- and exit the function + if + not (state.config.smart_tab_switch or table_pop(args, "smart", false)) + then + return ya.manager_emit("tab_switch", args) + end + + -- Get the current tab + local current_tab = cx.active.current + + -- Get the number of tabs currently open + local number_of_open_tabs = #cx.tabs + + -- Iterate from the number of current open tabs + -- to the given tab number + for _ = number_of_open_tabs, tab_index do + -- + + -- Call the tab create command + ya.manager_emit("tab_create", { current_tab.cwd }) + + -- If there is a hovered item + if current_tab.hovered then + -- + + -- Reveal the hovered item + ya.manager_emit("reveal", { current_tab.hovered.url }) + end + end + + -- Switch to the given tab index + ya.manager_emit("tab_switch", args) +end) + +-- Function to handle the tab switch command +---@type CommandFunction +local function handle_tab_switch(args) + -- + + -- Call the function to execute the tab switch command + execute_tab_switch(args) +end + +-- Function to do the wraparound for the arrow command +---@type fun( +--- args: Arguments, -- The arguments passed to the plugin +---): nil +local wraparound_arrow = ya.sync(function(_, args) + -- + + -- Get the current tab + local current_tab = cx.active.current + + -- Get the step from the arguments given + local step = table.remove(args, 1) + + -- Get the number of files in the current tab + local number_of_files = #current_tab.files + + -- If there are no files in the current tab, exit the function + if number_of_files == 0 then return end + + -- Get the new cursor index, + -- which is the current cursor position plus the step given + -- to the arrow function, modulus the number of files in + -- the current tab + local new_cursor_index = (current_tab.cursor + step) % number_of_files + + -- Emit the arrow function with the new cursor index minus + -- the current cursor index to determine how to move the cursor + ya.manager_emit( + "arrow", + merge_tables(args, { new_cursor_index - current_tab.cursor }) + ) +end) + +-- Function to handle the arrow command +---@type CommandFunction +local function handle_arrow(args, config) + -- + + -- If wraparound file navigation isn't wanted, + -- then execute the arrow command + if not config.wraparound_file_navigation then + ya.manager_emit("arrow", args) + + -- Otherwise, call the wraparound arrow function + else + wraparound_arrow(args) + end +end + +-- Function to get the directory items in the parent directory +---@type fun( +--- directories_only: boolean, -- Whether to only get directories +---): string[] directory_items The list of paths to the directory items +local get_parent_directory_items = ya.sync(function(_, directories_only) + -- + + -- Initialise the list of directory items + local directory_items = {} + + -- Get the parent directory + local parent_directory = cx.active.parent + + -- If the parent directory doesn't exist, + -- return the empty list of directory items + if not parent_directory then return directory_items end + + -- Otherwise, iterate over the items in the parent directory + for _, item in ipairs(parent_directory.files) do + -- + + -- If the directories only flag is passed, + -- and the item is not a directory, + -- then skip the item + if directories_only and not item.cha.is_dir then goto continue end + + -- Otherwise, add the item to the list of directory items + table.insert(directory_items, item) + + -- The continue label to skip the item + ::continue:: + end + + -- Return the list of directory items + return directory_items +end) + +-- Function to execute the parent arrow command +---@type fun( +--- args: Arguments, -- The arguments passed to the plugin +---): nil +local execute_parent_arrow = ya.sync(function(state, args) + -- + + -- Gets the parent directory + local parent_directory = cx.active.parent + + -- If the parent directory doesn't exist, + -- then exit the function + if not parent_directory then return end + + -- Get the offset from the arguments given + local offset = table.remove(args, 1) + + -- Get the type of the offset + local offset_type = type(offset) + + -- If the offset is not a number, + -- then show an error that the offset is not a number + -- and exit the function + if offset_type ~= "number" then + return show_error( + string.format( + "The given offset is not of the type 'number', " + .. "instead it is a '%s', " + .. "with value '%s'", + offset_type, + tostring(offset) + ) + ) + end + + -- Get the number of items in the parent directory + local number_of_items = #parent_directory.files + + -- Initialise the new cursor index + -- to the current cursor index + local new_cursor_index = parent_directory.cursor + + -- Get whether the user wants to sort directories first + local sort_directories_first = cx.active.pref.sort_dir_first + + -- If wraparound file navigation is wanted + if state.config.wraparound_file_navigation then + -- + + -- If the user sorts their directories first + if sort_directories_first then + -- + + -- Get the directories in the parent directory + local directories = get_parent_directory_items(true) + + -- Get the number of directories in the parent directory + local number_of_directories = #directories + + -- If the number of directories is 0, then exit the function + if number_of_directories == 0 then return end + + -- Get the new cursor index by adding the offset, + -- and modding the whole thing by the number of directories + new_cursor_index = (parent_directory.cursor + offset) + % number_of_directories + + -- Otherwise, if the user doesn't sort their directories first + else + -- + + -- Get the new cursor index by adding the offset, + -- and modding the whole thing by the number of + -- items in the parent directory + new_cursor_index = (parent_directory.cursor + offset) + % number_of_items + end + + -- Otherwise, get the new cursor index normally + -- by adding the offset to the cursor index + else + new_cursor_index = parent_directory.cursor + offset + end + + -- Increment the cursor index by 1. + -- The cursor index needs to be increased by 1 + -- as the cursor index is 0-based, while Lua + -- tables are 1-based. + new_cursor_index = new_cursor_index + 1 + + -- Get the starting index of the loop + local start_index = new_cursor_index + + -- Get the ending index of the loop. + -- + -- If the offset given is negative, set the end index to 1, + -- as the loop will iterate backwards. + -- Otherwise, if the step given is positive, + -- set the end index to the number of items in the + -- parent directory. + local end_index = offset < 0 and 1 or number_of_items + + -- Get the step for the loop. + -- + -- If the offset given is negative, set the step to -1, + -- as the loop will iterate backwards. + -- Otherwise, if the step given is positive, set + -- the step to 1 to iterate forwards. + local step = offset < 0 and -1 or 1 + + -- Iterate over the parent directory items + for i = start_index, end_index, step do + -- + + -- Get the directory item + local directory_item = parent_directory.files[i] + + -- If the directory item exists and is a directory + if directory_item and directory_item.cha.is_dir then + -- + + -- Emit the command to change directory to + -- the directory item and exit the function + return ya.manager_emit("cd", { directory_item.url }) + end + end +end) + +-- Function to handle the parent arrow command +---@type CommandFunction +local function handle_parent_arrow(args) + -- + + -- Call the function to execute the parent arrow command + -- with the arguments given + execute_parent_arrow(args) +end + +-- Function to handle the editor command +---@type CommandFunction +local function handle_editor(args, config) + -- + + -- Get the editor environment variable + local editor = os.getenv("EDITOR") + + -- If the editor not set, exit the function + if not editor then return end + + -- Call the handle shell function + -- with the editor command + handle_shell( + merge_tables({ + editor .. " $@", + block = true, + exit_if_dir = true, + }, args), + config + ) +end + +-- Function to handle the pager command +---@type CommandFunction +local function handle_pager(args, config) + -- + + -- Get the pager environment variable + local pager = os.getenv("PAGER") + + -- If the pager is not set, exit the function + if not pager then return end + + -- Call the handle shell function + -- with the pager command + handle_shell( + merge_tables({ + pager .. " $@", + block = true, + exit_if_dir = true, + }, args), + config + ) +end + +-- Function to run the commands given +---@param command string The command passed to the plugin +---@param args Arguments The arguments passed to the plugin +---@param config Configuration The configuration object +---@return nil +local function run_command_func(command, args, config) + -- + + -- The command table + ---@type CommandTable + local command_table = { + [Commands.Open] = handle_open, + [Commands.Extract] = handle_extract, + [Commands.Enter] = handle_enter, + [Commands.Leave] = handle_leave, + [Commands.Rename] = function(_) handle_yazi_command("rename", args) end, + [Commands.Remove] = function(_) handle_yazi_command("remove", args) end, + [Commands.Create] = handle_create, + [Commands.Shell] = handle_shell, + [Commands.Paste] = handle_paste, + [Commands.TabCreate] = handle_tab_create, + [Commands.TabSwitch] = handle_tab_switch, + [Commands.Arrow] = handle_arrow, + [Commands.ParentArrow] = handle_parent_arrow, + [Commands.Editor] = handle_editor, + [Commands.Pager] = handle_pager, + } + + -- Get the function for the command + ---@type CommandFunction|nil + local command_func = command_table[command] + + -- If the function isn't found, notify the user and exit the function + if not command_func then + return show_error("Unknown command: " .. command) + end + + -- Otherwise, call the function for the command + command_func(args, config) +end + +-- The setup function to setup the plugin +---@param _ any +---@param opts Configuration|nil The options given to the plugin +---@return nil +local function setup(_, opts) + -- + + -- Initialise the plugin + initialise_plugin(opts) +end + +-- Function to be called to use the plugin +---@param _ any +---@param job { args: Arguments } The job object given by Yazi +---@return nil +local function entry(_, job) + -- + + -- Get the arguments to the plugin + ---@type Arguments + local args = parse_number_arguments(job.args) + + -- Get the command passed to the plugin + local command = table.remove(args, 1) + + -- If the command isn't given, exit the function + if not command then return end + + -- Get the configuration object + local config = get_config() + + -- If the configuration hasn't been initialised yet, + -- then initialise the plugin with the default configuration, + -- as it hasn't been initialised either + if not config then config = initialise_plugin() end + + -- Call the function to handle the commands + run_command_func(command, args, config) +end + +-- Returns the table required for Yazi to run the plugin +---@return { setup: fun(): nil, entry: fun(): nil } +return { + setup = setup, + entry = entry, +} diff --git a/config/yazi/plugins/chmod.yazi/main.lua b/config/yazi/plugins/chmod.yazi/main.lua new file mode 100644 index 00000000..183c31e2 --- /dev/null +++ b/config/yazi/plugins/chmod.yazi/main.lua @@ -0,0 +1,39 @@ +local selected_or_hovered = ya.sync(function() + local tab, paths = cx.active, {} + for _, u in pairs(tab.selected) do + paths[#paths + 1] = tostring(u) + end + if #paths == 0 and tab.current.hovered then + paths[1] = tostring(tab.current.hovered.url) + end + return paths +end) + +return { + entry = function() + ya.manager_emit("escape", { visual = true }) + + local urls = selected_or_hovered() + if #urls == 0 then + return ya.notify { title = "Chmod", content = "No file selected", level = "warn", timeout = 5 } + end + + local value, event = ya.input { + title = "Chmod:", + position = { "top-center", y = 3, w = 40 }, + } + if event ~= 1 then + return + end + + local status, err = Command("chmod"):arg(value):args(urls):spawn():wait() + if not status or not status.success then + ya.notify { + title = "Chmod", + content = string.format("Chmod on selected files failed, error: %s", status and status.code or err), + level = "error", + timeout = 5, + } + end + end, +} diff --git a/config/yazi/plugins/exifaudio.yazi/main.lua b/config/yazi/plugins/exifaudio.yazi/main.lua new file mode 100644 index 00000000..4e6980bd --- /dev/null +++ b/config/yazi/plugins/exifaudio.yazi/main.lua @@ -0,0 +1,231 @@ +local M = {} + +function GetPath(str) + local sep = '/' + if ya.target_family() == "windows" then + sep = '\\' + end + return str:match("(.*"..sep..")") +end + +function Exiftool(...) + local child = Command("exiftool") + :args({ + "-q", "-q", "-S", "-Title", "-SortName", + "-TitleSort", "-TitleSortOrder", "-Artist", + "-SortArtist", "-ArtistSort", "-PerformerSortOrder", + "-Album", "-SortAlbum", "-AlbumSort", "-AlbumSortOrder", + "-AlbumArtist", "-SortAlbumArtist", "-AlbumArtistSort", + "-AlbumArtistSortOrder", "-Genre", "-TrackNumber", + "-Year", "-Duration", "-SampleRate", + "-AudioSampleRate", "-AudioBitrate", "-AvgBitrate", + "-Channels", "-AudioChannels", tostring(...), + }) + :stdout(Command.PIPED) + :stderr(Command.NULL) + :spawn() + return child +end + +function Mediainfo(...) + local file, cache_dir = ... + local template = cache_dir.."mediainfo.txt" + local child = Command("mediainfo") + :args({ + "--Output=file://"..template, tostring(file) + }) + :stdout(Command.PIPED) + :stderr(Command.NULL) + :spawn() + return child +end + +function M:peek(job) + local cache = ya.file_cache(job) + if not cache then + return + end + + -- Get cache dir to find the mediainfo template file + local cache_dir = GetPath(tostring(cache)) + + -- Try mediainfo, otherwise use exiftool + local status, child = pcall(Mediainfo, job.file.url, cache_dir) + if not status or child == nil then + status, child = pcall(Exiftool, job.file.url) + if not status or child == nil then + local error = ui.Line { ui.Span("Make sure exiftool is installed and in your PATH") } + -- TODO)) Remove legacy method when v0.4 gets released + local function display_error_legacy() + local p = ui.Paragraph(job.area, { error }):wrap(ui.Paragraph.WRAP) + ya.preview_widgets(job, { p }) + end + local function display_error() + local p = ui.Text(error):area(job.area):wrap(ui.Text.WRAP) + ya.preview_widgets(job, { p }) + end + if pcall(display_error) then else pcall(display_error_legacy) end + return + end + end + + local limit = job.area.h + local i, metadata = 0, {} + repeat + local next, event = child:read_line() + if event == 1 then + return self:fallback_to_builtin() + elseif event ~= 0 then + break + end + + i = i + 1 + if i > job.skip then + local m_title, m_tag = Prettify(next) + if m_title ~= "" and m_tag ~= "" then + local ti = ui.Span(m_title):bold() + local ta = ui.Span(m_tag) + table.insert(metadata, ui.Line{ti, ta}) + table.insert(metadata, ui.Line{}) + end + end + until i >= job.skip + limit + + -- TODO)) Remove legacy method when v0.4 gets released + local function display_metadata_legacy() + local p = ui.Paragraph(job.area, metadata):wrap(ui.Paragraph.WRAP) + ya.preview_widgets(job, { p }) + end + local function display_metadata() + local p = ui.Text(metadata):area(job.area):wrap(ui.Text.WRAP) + ya.preview_widgets(job, { p }) + end + if pcall(display_metadata) then else pcall(display_metadata_legacy) end + + local cover_width = job.area.w / 2 - 5 + local cover_height = (job.area.h / 4) + 3 + + local bottom_right = ui.Rect { + x = job.area.right - cover_width, + y = job.area.bottom - cover_height, + w = cover_width, + h = cover_height, + } + + if self:preload(job) == 1 then + ya.image_show(cache, bottom_right) + end +end + +function Prettify(metadata) + local substitutions = { + Sortname = "Sort Title:", + SortName = "Sort Title:", + TitleSort = "Sort Title:", + TitleSortOrder = "Sort Title:", + ArtistSort = "Sort Artist:", + SortArtist = "Sort Artist:", + Artist = "Artist:", + ARTIST = "Artist:", + PerformerSortOrder = "Sort Artist:", + SortAlbumArtist = "Sort Album Artist:", + AlbumArtistSortOrder = "Sort Album Artist:", + AlbumArtistSort = "Sort Album Artist:", + AlbumSortOrder = "Sort Album:", + AlbumSort = "Sort Album:", + SortAlbum = "Sort Album:", + Album = "Album:", + ALBUM = "Album:", + AlbumArtist = "Album Artist:", + Genre = "Genre:", + GENRE = "Genre:", + TrackNumber = "Track Number:", + Year = "Year:", + Duration = "Duration:", + AudioBitrate = "Bitrate:", + AvgBitrate = "Average Bitrate:", + AudioSampleRate = "Sample Rate:", + SampleRate = "Sample Rate:", + AudioChannels = "Channels:" + } + + for k, v in pairs(substitutions) do + metadata = metadata:gsub(tostring(k)..":", v, 1) + end + + -- Separate the tag title from the tag data + local t={} + for str in string.gmatch(metadata , "([^"..":".."]+)") do + if str ~= "\n" then + table.insert(t, str) + else + table.insert(t, nil) + end + end + + -- Add back semicolon to title, rejoin tag data if it happened to contain a semicolon + local title, tag_data = "", "" + if t[1] ~= nil then + title, tag_data = t[1]..":", table.concat(t, ":", 2) + end + return title, tag_data + +end + +function M:seek(job) + local h = cx.active.current.hovered + if h and h.url == job.file.url then + ya.manager_emit("peek", { + tostring(math.max(0, cx.active.preview.skip + job.units)), + only_if = tostring(job.file.url), + }) + end +end + +function M:preload(job) + local cache = ya.file_cache(job) + if not cache or fs.cha(cache) then + return 1 + end + + local mediainfo_template = 'General;"\ +$if(%Track%,Title: %Track%,)\ +$if(%Track/Sort%,Sort Title: %Track/Sort%,)\ +$if(%Title/Sort%,Sort Title: %Title/Sort%,)\ +$if(%TITLESORT%,Sort Title: %TITLESORT%,)\ +$if(%Performer%,Artist: %Performer%,)\ +$if(%Performer/Sort%,Sort Artist: %Performer/Sort%,)\ +$if(%ARTISTSORT%,Sort Artist: %ARTISTSORT%,)\ +$if(%Album%,Album: %Album%,)\ +$if(%Album/Sort%,Sort Album: %Album/Sort%)\ +$if(%ALBUMSORT%,Sort Album: %ALBUMSORT%)\ +$if(%Album/Performer%,Album Artist: %Album/Performer%)\ +$if(%Album/Performer/Sort%,Sort Album Artist: %Album/Performer/Sort%)\ +$if(%Genre%,Genre: %Genre%)\ +$if(%Track/Position%,Track Number: %Track/Position%)\ +$if(%Recorded_Date%,Year: %Recorded_Date%)\ +$if(%Duration/String%,Duration: %Duration/String%)\ +$if(%BitRate/String%,Bitrate: %BitRate/String%)\ +"\ +Audio;"Sample Rate: %SamplingRate%\ +Channels: %Channel(s)%"\ +' + + -- Write the mediainfo template file into yazi's cache dir + local cache_dir = GetPath(tostring(cache)) + fs.write(Url(cache_dir.."mediainfo.txt"), mediainfo_template) + + local output = Command("exiftool") + :args({ "-b", "-CoverArt", "-Picture", tostring(job.file.url) }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :output() + + if not output then + return 0 + end + + return fs.write(cache, output.stdout) and 1 or 2 +end + +return M diff --git a/config/yazi/plugins/full-border.yazi/main.lua b/config/yazi/plugins/full-border.yazi/main.lua new file mode 100644 index 00000000..0de33b1d --- /dev/null +++ b/config/yazi/plugins/full-border.yazi/main.lua @@ -0,0 +1,41 @@ +local function setup(_, opts) + local type = opts and opts.type or ui.Border.ROUNDED + local old_build = Tab.build + + Tab.build = function(self, ...) + local bar = function(c, x, y) + if x <= 0 or x == self._area.w - 1 then + return ui.Bar(ui.Bar.TOP) + end + + return ui.Bar(ui.Bar.TOP) + :area( + ui.Rect { x = x, y = math.max(0, y), w = ya.clamp(0, self._area.w - x, 1), h = math.min(1, self._area.h) } + ) + :symbol(c) + end + + local c = self._chunks + self._chunks = { + c[1]:pad(ui.Pad.y(1)), + c[2]:pad(ui.Pad(1, c[3].w > 0 and 0 or 1, 1, c[1].w > 0 and 0 or 1)), + c[3]:pad(ui.Pad.y(1)), + } + + local style = THEME.manager.border_style + self._base = ya.list_merge(self._base or {}, { + ui.Border(ui.Border.ALL):area(self._area):type(type):style(style), + ui.Bar(ui.Bar.RIGHT):area(self._chunks[1]):style(style), + ui.Bar(ui.Bar.LEFT):area(self._chunks[3]):style(style), + + bar("┬", c[1].right - 1, c[1].y), + bar("┴", c[1].right - 1, c[1].bottom - 1), + bar("┬", c[2].right, c[2].y), + bar("┴", c[2].right, c[2].bottom - 1), + }) + + old_build(self, ...) + end +end + +return { setup = setup } diff --git a/config/yazi/plugins/git.yazi/main.lua b/config/yazi/plugins/git.yazi/main.lua new file mode 100644 index 00000000..64c3e8eb --- /dev/null +++ b/config/yazi/plugins/git.yazi/main.lua @@ -0,0 +1,208 @@ +local WIN = ya.target_family() == "windows" +local PATS = { + { "[MT]", 6 }, -- Modified + { "[AC]", 5 }, -- Added + { "?$", 4 }, -- Untracked + { "!$", 3 }, -- Ignored + { "D", 2 }, -- Deleted + { "U", 1 }, -- Updated + { "[AD][AD]", 1 }, -- Updated +} + +local function match(line) + local signs = line:sub(1, 2) + for _, p in ipairs(PATS) do + local path + if signs:find(p[1]) then + path = line:sub(4, 4) == '"' and line:sub(5, -2) or line:sub(4) + path = WIN and path:gsub("/", "\\") or path + end + if not path then + elseif path:find("[/\\]$") then + return p[2] == 3 and 30 or p[2], path:sub(1, -2) + else + return p[2], path + end + end +end + +local function root(cwd) + local is_worktree = function(url) + local file, head = io.open(tostring(url)), nil + if file then + head = file:read(8) + file:close() + end + return head == "gitdir: " + end + + repeat + local next = cwd:join(".git") + local cha = fs.cha(next) + if cha and (cha.is_dir or is_worktree(next)) then + return tostring(cwd) + end + cwd = cwd:parent() + until not cwd +end + +local function bubble_up(changed) + local new, empty = {}, Url("") + for k, v in pairs(changed) do + if v ~= 3 and v ~= 30 then + local url = Url(k):parent() + while url and url ~= empty do + local s = tostring(url) + new[s] = (new[s] or 0) > v and new[s] or v + url = url:parent() + end + end + end + return new +end + +local function propagate_down(ignored, cwd, repo) + local new, rel = {}, cwd:strip_prefix(repo) + for k, v in pairs(ignored) do + if v == 30 then + if rel:starts_with(k) then + new[tostring(repo:join(rel))] = 30 + elseif cwd == repo:join(k):parent() then + new[k] = 3 + end + end + end + return new +end + +local add = ya.sync(function(st, cwd, repo, changed) + st.dirs[cwd] = repo + st.repos[repo] = st.repos[repo] or {} + for k, v in pairs(changed) do + if v == 0 then + st.repos[repo][k] = nil + elseif v == 30 then + st.dirs[k] = "" + else + st.repos[repo][k] = v + end + end + ya.render() +end) + +local remove = ya.sync(function(st, cwd) + local dir = st.dirs[cwd] + if not dir then + return + end + + ya.render() + st.dirs[cwd] = nil + if not st.repos[dir] then + return + end + + for _, r in pairs(st.dirs) do + if r == dir then + return + end + end + st.repos[dir] = nil +end) + +local function setup(st, opts) + st.dirs = {} + st.repos = {} + + opts = opts or {} + opts.order = opts.order or 1500 + + -- Chosen by ChatGPT fairly, PRs are welcome to adjust them + local t = THEME.git or {} + local styles = { + [6] = t.modified and ui.Style(t.modified) or ui.Style():fg("#ffa500"), + [5] = t.added and ui.Style(t.added) or ui.Style():fg("#32cd32"), + [4] = t.untracked and ui.Style(t.untracked) or ui.Style():fg("#a9a9a9"), + [3] = t.ignored and ui.Style(t.ignored) or ui.Style():fg("#696969"), + [2] = t.deleted and ui.Style(t.deleted) or ui.Style():fg("#ff4500"), + [1] = t.updated and ui.Style(t.updated) or ui.Style():fg("#1e90ff"), + } + local signs = { + [6] = t.modified_sign and t.modified_sign or "", + [5] = t.added_sign and t.added_sign or "", + [4] = t.untracked_sign and t.untracked_sign or "", + [3] = t.ignored_sign and t.ignored_sign or "", + [2] = t.deleted_sign and t.deleted_sign or "", + [1] = t.updated_sign and t.updated_sign or "U", + } + + Linemode:children_add(function(self) + local url = self._file.url + local dir = st.dirs[tostring(url:parent())] + local change + if dir then + change = dir == "" and 3 or st.repos[dir][tostring(url):sub(#dir + 2)] + end + + if not change or signs[change] == "" then + return ui.Line("") + elseif self._file:is_hovered() then + return ui.Line { ui.Span(" "), ui.Span(signs[change]) } + else + return ui.Line { ui.Span(" "), ui.Span(signs[change]):style(styles[change]) } + end + end, opts.order) +end + +local function fetch(_, job) + local cwd = job.files[1].url:parent() + local repo = root(cwd) + if not repo then + remove(tostring(cwd)) + return 1 + end + + local paths = {} + for _, f in ipairs(job.files) do + paths[#paths + 1] = tostring(f.url) + end + + -- stylua: ignore + local output, err = Command("git") + :cwd(tostring(cwd)) + :args({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" }) + :args(paths) + :stdout(Command.PIPED) + :output() + if not output then + ya.err("Cannot spawn git command, error: " .. err) + return 0 + end + + local changed, ignored = {}, {} + for line in output.stdout:gmatch("[^\r\n]+") do + local sign, path = match(line) + if sign == 30 then + ignored[path] = sign + else + changed[path] = sign + end + end + + if job.files[1].cha.is_dir then + ya.dict_merge(changed, bubble_up(changed)) + ya.dict_merge(changed, propagate_down(ignored, cwd, Url(repo))) + else + ya.dict_merge(changed, propagate_down(ignored, cwd, Url(repo))) + end + + for _, p in ipairs(paths) do + local s = p:sub(#repo + 2) + changed[s] = changed[s] or 0 + end + add(tostring(cwd), repo, changed) + + return 3 +end + +return { setup = setup, fetch = fetch } diff --git a/config/yazi/plugins/glow.yazi/main.lua b/config/yazi/plugins/glow.yazi/main.lua new file mode 100644 index 00000000..cc813e9d --- /dev/null +++ b/config/yazi/plugins/glow.yazi/main.lua @@ -0,0 +1,76 @@ +local M = {} + +function M:peek(job) + -- Set a fixed width of 55 characters for the preview + local preview_width = 55 + + local child = Command("glow") + :args({ + "--style", + "dark", + "--width", + tostring(preview_width), -- Use fixed width instead of job.area.w + tostring(job.file.url), + }) + :env("CLICOLOR_FORCE", "1") + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + + if not child then + return require("code").peek(job) + end + + local limit = job.area.h + local i, lines = 0, "" + repeat + local next, event = child:read_line() + if event == 1 then + return require("code").peek(job) + elseif event ~= 0 then + break + end + + i = i + 1 + if i > job.skip then + lines = lines .. next + end + until i >= job.skip + limit + + child:start_kill() + if job.skip > 0 and i < job.skip + limit then + ya.manager_emit("peek", { + tostring(math.max(0, i - limit)), + only_if = job.file.url, + upper_bound = true + }) + else + lines = lines:gsub("\t", string.rep(" ", PREVIEW.tab_size)) + ya.preview_widgets(job, { ui.Text.parse(lines):area(job.area) }) + end +end + +function M:seek(job) + local h = cx.active.current.hovered + if not h or h.url ~= job.file.url then + return + end + + local scroll_amount = 1 + local scroll_offset = job.units + + if job.key == "ctrl-e" then + scroll_offset = scroll_amount + elseif job.key == "ctrl-y" then + scroll_offset = -scroll_amount + else + scroll_offset = job.units + end + + ya.manager_emit('peek', { + math.max(0, cx.active.preview.skip + scroll_offset), + only_if = job.file.url, + }) +end + +return M diff --git a/config/yazi/plugins/hexyl.yazi/main.lua b/config/yazi/plugins/hexyl.yazi/main.lua new file mode 100644 index 00000000..6fe0990e --- /dev/null +++ b/config/yazi/plugins/hexyl.yazi/main.lua @@ -0,0 +1,57 @@ +local M = {} + +function M:peek(job) + local child + local l = self.file.cha.len + if l == 0 then + child = Command("hexyl") + :args({ + tostring(job.file.url), + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + else + child = Command("hexyl") + :args({ + "--border", + "none", + "--terminal-width", + tostring(job.area.w), + tostring(job.file.url), + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + end + + local limit = job.area.h + local i, lines = 0, "" + repeat + local next, event = child:read_line() + if event == 1 then + ya.err(tostring(event)) + elseif event ~= 0 then + break + end + + i = i + 1 + if i > job.skip then + lines = lines .. next + end + until i >= job.skip + limit + + child:start_kill() + if job.skip > 0 and i < job.skip + limit then + ya.manager_emit("peek", { math.max(0, i - limit), only_if = job.file.url, upper_bound = true }) + else + lines = lines:gsub("\t", string.rep(" ", PREVIEW.tab_size)) + ya.preview_widgets(job, { ui.Text.parse(lines):area(job.area) }) + end +end + +function M:seek(units) + require("code").seek(job, units) +end + +return M diff --git a/config/yazi/plugins/hide-preview.yazi/main.lua b/config/yazi/plugins/hide-preview.yazi/main.lua new file mode 100644 index 00000000..fcf4094f --- /dev/null +++ b/config/yazi/plugins/hide-preview.yazi/main.lua @@ -0,0 +1,25 @@ +--- @sync entry + +local function entry(st) + if st.old then + Tab.layout, st.old = st.old, nil + else + st.old = Tab.layout + Tab.layout = function(self) + local all = MANAGER.ratio.parent + MANAGER.ratio.current + self._chunks = ui.Layout() + :direction(ui.Layout.HORIZONTAL) + :constraints({ + ui.Constraint.Ratio(MANAGER.ratio.parent, all), + ui.Constraint.Ratio(MANAGER.ratio.current, all), + ui.Constraint.Length(1), + }) + :split(self._area) + end + end + ya.app_emit("resize", {}) +end + +local function enabled(st) return st.old ~= nil end + +return { entry = entry, enabled = enabled } diff --git a/config/yazi/plugins/max-preview.yazi/main.lua b/config/yazi/plugins/max-preview.yazi/main.lua new file mode 100644 index 00000000..64edfed6 --- /dev/null +++ b/config/yazi/plugins/max-preview.yazi/main.lua @@ -0,0 +1,24 @@ +--- @sync entry + +local function entry(st) + if st.old then + Tab.layout, st.old = st.old, nil + else + st.old = Tab.layout + Tab.layout = function(self) + self._chunks = ui.Layout() + :direction(ui.Layout.HORIZONTAL) + :constraints({ + ui.Constraint.Percentage(0), + ui.Constraint.Percentage(0), + ui.Constraint.Percentage(100), + }) + :split(self._area) + end + end + ya.app_emit("resize", {}) +end + +local function enabled(st) return st.old ~= nil end + +return { entry = entry, enabled = enabled } diff --git a/config/yazi/plugins/mediainfo.yazi/main.lua b/config/yazi/plugins/mediainfo.yazi/main.lua new file mode 120000 index 00000000..aed737a5 --- /dev/null +++ b/config/yazi/plugins/mediainfo.yazi/main.lua @@ -0,0 +1 @@ +/home/kristofers/Nextcloud/repos/solorice/config/yazi/plugins/mediainfo.yazi/init.lua \ No newline at end of file diff --git a/config/yazi/plugins/miller.yazi/main.lua b/config/yazi/plugins/miller.yazi/main.lua new file mode 100644 index 00000000..9a4a7528 --- /dev/null +++ b/config/yazi/plugins/miller.yazi/main.lua @@ -0,0 +1,59 @@ +local M = {} + +function M:peek() + local child = Command("mlr") + :args({ + "--icsv", + "--opprint", + "-C", + "--key-color", + "darkcyan", + "--value-color", + "grey70", + "cat", + tostring(self.file.url), + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + + local limit = self.area.h + local i, lines = 0, "" + repeat + local next, event = child:read_line() + if event == 1 then + ya.err(tostring(event)) + elseif event ~= 0 then + break + end + + i = i + 1 + if i > self.skip then + lines = lines .. next + end + until i >= self.skip + limit + + child:start_kill() + if self.skip > 0 and i < self.skip + limit then + ya.manager_emit( + "peek", + { tostring(math.max(0, i - limit)), only_if = tostring(self.file.url), upper_bound = "" } + ) + else + lines = lines:gsub("\t", string.rep(" ", PREVIEW.tab_size)) + ya.preview_widgets(self, { ui.Paragraph.parse(self.area, lines) }) + end +end + +function M:seek(units) + local h = cx.active.current.hovered + if h and h.url == self.file.url then + local step = math.floor(units * self.area.h / 10) + ya.manager_emit("peek", { + tostring(math.max(0, cx.active.preview.skip + step)), + only_if = tostring(self.file.url), + }) + end +end + +return M diff --git a/config/yazi/plugins/nbpreview.yazi/main.lua b/config/yazi/plugins/nbpreview.yazi/main.lua new file mode 100644 index 00000000..2a41cc35 --- /dev/null +++ b/config/yazi/plugins/nbpreview.yazi/main.lua @@ -0,0 +1,58 @@ +local M = {} + +function M:peek(job) + local child = Command("nbpreview") + :args({ + -- DO NOT CHANGE -- + "--no-paging", + "--nerd-font", + "--decorated", + + -- OPTIONAL CHANGES -- + "--no-files", + "--unicode", + "--color", + "--images", + + -- SPECIAL CUSTOMIZATIONS -- + "--color-system=standard", + "--theme=ansi_dark", + tostring(job.file.url), + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + if not child then + return require("code"):peek(job) + end + + local limit = job.area.h + local i, lines = 0, "" + repeat + local next, event = child:read_line() + if event == 1 then + return require("code"):peek(job) + elseif event ~= 0 then + break + end + + i = i + 1 + if i > job.skip then + lines = lines .. next + end + until i >= job.skip + limit + + child:start_kill() + if job.skip > 0 and i < job.skip + limit then + ya.manager_emit("peek", { math.max(0, i - limit), only_if = job.file.url, upper_bound = true }) + else + lines = lines:gsub("\t", string.rep(" ", PREVIEW.tab_size)) + ya.preview_widgets(job, { ui.Text.parse(lines):area(job.area) }) + end +end + +function M:seek(job) + require("code"):seek(job) +end + +return M diff --git a/config/yazi/plugins/ouch.yazi/README.md b/config/yazi/plugins/ouch.yazi/README.md index ca2424dd..570b52b0 100644 --- a/config/yazi/plugins/ouch.yazi/README.md +++ b/config/yazi/plugins/ouch.yazi/README.md @@ -15,8 +15,11 @@ If you use latest Yazi from main branch # Linux/macOS git clone https://github.com/ndtoan96/ouch.yazi.git ~/.config/yazi/plugins/ouch.yazi -# Windows +# Windows with cmd git clone https://github.com/ndtoan96/ouch.yazi.git %AppData%\yazi\config\plugins\ouch.yazi + +# Windows with powershell +git clone https://github.com/ndtoan96/ouch.yazi.git "$($env:APPDATA)\yazi\config\plugins\ouch.yazi" ``` If you use Yazi <= 0.3.3 @@ -24,8 +27,11 @@ If you use Yazi <= 0.3.3 # Linux/macOS git clone --branch v0.2.1 --single-branch https://github.com/ndtoan96/ouch.yazi.git ~/.config/yazi/plugins/ouch.yazi -# Windows +# Windows with cmd git clone --branch v0.2.1 --single-branch https://github.com/ndtoan96/ouch.yazi.git %AppData%\yazi\config\plugins\ouch.yazi + +# Windows with powershell +git clone --branch v0.2.1 --single-branch https://github.com/ndtoan96/ouch.yazi.git "$($env:APPDATA)\yazi\config\plugins\ouch.yazi" ``` Make sure you have [ouch](https://github.com/ouch-org/ouch) installed and in your `PATH`. diff --git a/config/yazi/plugins/ouch.yazi/main.lua b/config/yazi/plugins/ouch.yazi/main.lua new file mode 100644 index 00000000..6e5c9a60 --- /dev/null +++ b/config/yazi/plugins/ouch.yazi/main.lua @@ -0,0 +1,145 @@ +local M = {} + +function M:peek(job) + local child = Command("ouch") + :args({ "l", "-t", "-y", tostring(job.file.url) }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + local limit = job.area.h + local file_name = string.match(tostring(job.file.url), ".*[/\\](.*)") + local lines = string.format("📁 \x1b[2m%s\x1b[0m\n", file_name) + local num_lines = 1 + local num_skip = 0 + repeat + local line, event = child:read_line() + if event == 1 then + ya.err(tostring(event)) + elseif event ~= 0 then + break + end + + if line:find('Archive', 1, true) ~= 1 and line:find('[INFO]', 1, true) ~= 1 then + if num_skip >= job.skip then + lines = lines .. line + num_lines = num_lines + 1 + else + num_skip = num_skip + 1 + end + end + until num_lines >= limit + + child:start_kill() + if job.skip > 0 and num_lines < limit then + ya.manager_emit( + "peek", + { tostring(math.max(0, job.skip - (limit - num_lines))), only_if = tostring(job.file.url), upper_bound = "" } + ) + else + ya.preview_widgets(job, { ui.Text(lines):area(job.area) }) + end +end + +function M:seek(job) + local h = cx.active.current.hovered + if h and h.url == job.file.url then + local step = math.floor(job.units * job.area.h / 10) + ya.manager_emit("peek", { + math.max(0, cx.active.preview.skip + step), + only_if = tostring(job.file.url), + }) + end +end + +-- Check if file exists +local function file_exists(name) + local f = io.open(name, "r") + if f ~= nil then + io.close(f) + return true + else + return false + end +end + +-- Get the files that need to be compressed and infer a default archive name +local get_compression_target = ya.sync(function() + local tab = cx.active + local default_name + local paths = {} + if #tab.selected == 0 then + if tab.current.hovered then + local name = tab.current.hovered.name + default_name = name + table.insert(paths, name) + else + return + end + else + default_name = tab.current.cwd:name() + for _, url in pairs(tab.selected) do + table.insert(paths, tostring(url)) + end + -- The compression targets are aquired, now unselect them + ya.manager_emit("escape", {}) + end + return paths, default_name +end) + +local function invoke_compress_command(paths, name) + local cmd_output, err_code = Command("ouch") + :args({ "c", "-y" }) + :args(paths) + :arg(name) + :stderr(Command.PIPED) + :output() + if err_code ~= nil then + ya.notify({ + title = "Failed to run ouch command", + content = "Status: " .. err_code, + timeout = 5.0, + level = "error", + }) + elseif not cmd_output.status.success then + ya.notify({ + title = "Compression failed: status code " .. cmd_output.status.code, + content = cmd_output.stderr, + timeout = 5.0, + level = "error", + }) + end +end + +function M:entry(job) + local default_fmt = job.args[1] + + ya.manager_emit("escape", { visual = true }) + + -- Get the files that need to be compressed and infer a default archive name + local paths, default_name = get_compression_target() + + -- Get archive name from user + local output_name, name_event = ya.input({ + title = "Create archive:", + value = default_name .. "." .. default_fmt, + position = { "top-center", y = 3, w = 40 }, + }) + if name_event ~= 1 then + return + end + + -- Get confirmation if file exists + if file_exists(output_name) then + local confirm, confirm_event = ya.input({ + title = "Overwrite " .. output_name .. "? (y/N)", + position = { "top-center", y = 3, w = 40 }, + }) + if not (confirm_event == 1 and confirm:lower() == "y") then + return + end + end + + invoke_compress_command(paths, output_name) +end + +return M diff --git a/config/yazi/plugins/relative-motions.yazi/main.lua b/config/yazi/plugins/relative-motions.yazi/main.lua new file mode 100644 index 00000000..be938098 --- /dev/null +++ b/config/yazi/plugins/relative-motions.yazi/main.lua @@ -0,0 +1,326 @@ +-- stylua: ignore +local MOTIONS_AND_OP_KEYS = { + { on = "0" }, { on = "1" }, { on = "2" }, { on = "3" }, { on = "4" }, + { on = "5" }, { on = "6" }, { on = "7" }, { on = "8" }, { on = "9" }, + -- commands + { on = "d" }, { on = "v" }, { on = "y" }, { on = "x" }, + -- tab commands + { on = "t" }, { on = "L" }, { on = "H" }, { on = "w" }, + { on = "W" }, { on = "<" }, { on = ">" }, { on = "~" }, + -- movement + { on = "g" }, { on = "j" }, { on = "k" }, { on = "" }, { on = "" } +} + +-- stylua: ignore +local MOTION_KEYS = { + { on = "0" }, { on = "1" }, { on = "2" }, { on = "3" }, { on = "4" }, + { on = "5" }, { on = "6" }, { on = "7" }, { on = "8" }, { on = "9" }, + -- movement + { on = "g" }, { on = "j" }, { on = "k" } +} + +-- stylua: ignore +local DIRECTION_KEYS = { + { on = "j" }, { on = "k" }, { on = "" }, { on = "" }, + -- tab movement + { on = "t" } +} + +local SHOW_NUMBERS_ABSOLUTE = 0 +local SHOW_NUMBERS_RELATIVE = 1 +local SHOW_NUMBERS_RELATIVE_ABSOLUTE = 2 + +----------------------------------------------- +----------------- R E N D E R ----------------- +----------------------------------------------- + +local render_motion_setup = ya.sync(function(_) + ya.render() + + Status.motion = function() return ui.Span("") end + + Status.children_redraw = function(self, side) + local lines = {} + if side == self.RIGHT then + lines[1] = self:motion(self) + end + for _, c in ipairs(side == self.RIGHT and self._right or self._left) do + lines[#lines + 1] = (type(c[1]) == "string" and self[c[1]] or c[1])(self) + end + return ui.Line(lines) + end + + -- TODO: check why it doesn't work line this + -- Status:children_add(function() return ui.Span("") end, 1000, Status.RIGHT) +end) + +local render_motion = ya.sync(function(_, motion_num, motion_cmd) + ya.render() + + Status.motion = function(self) + if not motion_num then + return ui.Span("") + end + + local style = self:style() + + local motion_span + if not motion_cmd then + motion_span = ui.Span(string.format(" %3d ", motion_num)) + else + motion_span = ui.Span(string.format(" %3d%s ", motion_num, motion_cmd)) + end + + return ui.Line { + ui.Span(THEME.status.separator_open):fg(style.main.bg), + motion_span:style(style.main), + ui.Span(THEME.status.separator_close):fg(style.main.bg), + ui.Span(" "), + } + end +end) + +local render_numbers = ya.sync(function(_, mode) + ya.render() + + Entity.number = function(_, index, file, hovered) + local idx + if mode == SHOW_NUMBERS_RELATIVE then + idx = math.abs(hovered - index) + elseif mode == SHOW_NUMBERS_ABSOLUTE then + idx = file.idx + else -- SHOW_NUMBERS_RELATIVE_ABSOLUTE + if hovered == index then + idx = file.idx + else + idx = math.abs(hovered - index) + end + end + + -- emulate vim's hovered offset + if idx >= 100 then + return ui.Span(string.format("%4d ", idx)) + elseif hovered == index then + return ui.Span(string.format("%3d ", idx)) + else + return ui.Span(string.format(" %3d ", idx)) + end + end + + Current.redraw = function(self) + local files = self._folder.window + if #files == 0 then + return self:empty() + end + + local hovered_index + for i, f in ipairs(files) do + if f:is_hovered() then + hovered_index = i + break + end + end + + local entities, linemodes = {}, {} + for i, f in ipairs(files) do + linemodes[#linemodes + 1] = Linemode:new(f):redraw() + + local entity = Entity:new(f) + entities[#entities + 1] = ui.Line({ Entity:number(i, f, hovered_index), entity:redraw() }):style(entity:style()) + end + + return { + ui.List(entities):area(self._area), + ui.Text(linemodes):area(self._area):align(ui.Text.RIGHT), + } + end +end) + +local function render_clear() render_motion() end + +----------------------------------------------- +--------- C O M M A N D P A R S E R --------- +----------------------------------------------- + +local get_keys = ya.sync(function(state) return state._only_motions and MOTION_KEYS or MOTIONS_AND_OP_KEYS end) + +local function normal_direction(dir) + if dir == "" then + return "j" + elseif dir == "" then + return "k" + end + return dir +end + +local function get_cmd(first_char, keys) + local last_key + local lines = first_char or "" + + while true do + render_motion(tonumber(lines)) + local key = ya.which { cands = keys, silent = true } + if not key then + return nil, nil, nil + end + + last_key = keys[key].on + if not tonumber(last_key) then + last_key = normal_direction(last_key) + break + end + + lines = lines .. last_key + end + + render_motion(tonumber(lines), last_key) + + -- command direction + local direction + if last_key == "g" or last_key == "v" or last_key == "d" or last_key == "y" or last_key == "x" then + DIRECTION_KEYS[#DIRECTION_KEYS + 1] = { + on = last_key, + } + local direction_key = ya.which { cands = DIRECTION_KEYS, silent = true } + if not direction_key then + return nil, nil, nil + end + + direction = DIRECTION_KEYS[direction_key].on + direction = normal_direction(direction) + end + + return tonumber(lines), last_key, direction +end + +local function is_tab_command(command) + local tab_commands = { "t", "L", "H", "w", "W", "<", ">", "~" } + for _, cmd in ipairs(tab_commands) do + if command == cmd then + return true + end + end + return false +end + +local get_active_tab = ya.sync(function(_) return cx.tabs.idx end) + +----------------------------------------------- +---------- E N T R Y / S E T U P ---------- +----------------------------------------------- + +return { + entry = function(_, job) + local initial_value + + local args = job.args + -- this is checking if the argument is a valid number + if #args > 0 then + initial_value = tostring(tonumber(args[1])) + if initial_value == "nil" then + return + end + end + + local lines, cmd, direction = get_cmd(initial_value, get_keys()) + if not lines or not cmd then + -- command was cancelled + render_clear() + return + end + + if cmd == "g" then + if direction == "g" then + ya.manager_emit("arrow", { -99999999 }) + ya.manager_emit("arrow", { lines - 1 }) + render_clear() + return + elseif direction == "j" then + cmd = "j" + elseif direction == "k" then + cmd = "k" + elseif direction == "t" then + ya.manager_emit("tab_switch", { lines - 1 }) + render_clear() + return + else + -- no valid direction + render_clear() + return + end + end + + if cmd == "j" then + ya.manager_emit("arrow", { lines }) + elseif cmd == "k" then + ya.manager_emit("arrow", { -lines }) + elseif is_tab_command(cmd) then + if cmd == "t" then + for _ = 1, lines do + ya.manager_emit("tab_create", {}) + end + elseif cmd == "H" then + ya.manager_emit("tab_switch", { -lines, relative = true }) + elseif cmd == "L" then + ya.manager_emit("tab_switch", { lines, relative = true }) + elseif cmd == "w" then + ya.manager_emit("tab_close", { lines - 1 }) + elseif cmd == "W" then + local curr_tab = get_active_tab() + local del_tab = curr_tab + lines - 1 + for _ = curr_tab, del_tab do + ya.manager_emit("tab_close", { curr_tab - 1 }) + end + ya.manager_emit("tab_switch", { curr_tab - 1 }) + elseif cmd == "<" then + ya.manager_emit("tab_swap", { -lines }) + elseif cmd == ">" then + ya.manager_emit("tab_swap", { lines }) + elseif cmd == "~" then + local jump = lines - get_active_tab() + ya.manager_emit("tab_swap", { jump }) + end + else + ya.manager_emit("visual_mode", {}) + -- invert direction when user specifies it + if direction == "k" then + ya.manager_emit("arrow", { -lines }) + elseif direction == "j" then + ya.manager_emit("arrow", { lines }) + else + ya.manager_emit("arrow", { lines - 1 }) + end + ya.manager_emit("escape", {}) + + if cmd == "d" then + ya.manager_emit("remove", {}) + elseif cmd == "y" then + ya.manager_emit("yank", {}) + elseif cmd == "x" then + ya.manager_emit("yank", { cut = true }) + end + end + + render_clear() + end, + setup = function(state, args) + if not args then + return + end + + -- initialize state variables + state._only_motions = args["only_motions"] or false + + if args["show_motion"] then + render_motion_setup() + end + + if args["show_numbers"] == "absolute" then + render_numbers(SHOW_NUMBERS_ABSOLUTE) + elseif args["show_numbers"] == "relative" then + render_numbers(SHOW_NUMBERS_RELATIVE) + elseif args["show_numbers"] == "relative_absolute" then + render_numbers(SHOW_NUMBERS_RELATIVE_ABSOLUTE) + end + end, +} diff --git a/config/yazi/plugins/starship.yazi/main.lua b/config/yazi/plugins/starship.yazi/main.lua new file mode 120000 index 00000000..2f825343 --- /dev/null +++ b/config/yazi/plugins/starship.yazi/main.lua @@ -0,0 +1 @@ +/home/kristofers/Nextcloud/repos/solorice/config/yazi/plugins/starship.yazi/init.lua \ No newline at end of file diff --git a/config/yazi/plugins/torrent-preview.yazi/main.lua b/config/yazi/plugins/torrent-preview.yazi/main.lua new file mode 100644 index 00000000..d692ce33 --- /dev/null +++ b/config/yazi/plugins/torrent-preview.yazi/main.lua @@ -0,0 +1,46 @@ +local M = {} + +function M:peek(job) + local child = Command("transmission-show") + :args({ + tostring(job.file.url), + }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + :spawn() + + if not child then + return require("code"):peek(job) + end + + local limit = job.area.h + local i, lines = 0, "" + repeat + local next, event = child:read_line() + if event == 1 then + return require("code"):peek(job) + elseif event ~= 0 then + break + end + + i = i + 1 + if i > job.skip then + lines = lines .. next + end + until i >= job.skip + limit + + child:start_kill() + if job.skip > 0 and i < job.skip + limit then + ya.manager_emit("peek", { math.max(0, i - limit), only_if = job.file.url, upper_bound = true }) + else + lines = lines:gsub("\t", string.rep(" ", PREVIEW.tab_size)) + ya.preview_widgets(job, { ui.Text.parse(lines):area(job.area) }) + end +end + +function M:seek(job) + require("code"):seek(job) +end + +return M + diff --git a/config/yazi/plugins/what-size.yazi/main.lua b/config/yazi/plugins/what-size.yazi/main.lua new file mode 100644 index 00000000..e84e1c12 --- /dev/null +++ b/config/yazi/plugins/what-size.yazi/main.lua @@ -0,0 +1,72 @@ + +-- function to get paths of selected elements or current directory +-- of no elements are selected +local get_paths = ya.sync(function() + local paths = {} + -- get selected files + for _, u in pairs(cx.active.selected) do + paths[#paths + 1] = tostring(u) + end + -- if no files are selected, get current directory + if #paths == 0 then + if cx.active.current.cwd then + paths[1] = tostring(cx.active.current.cwd) + else + ya.err("what-size would return nil paths") + end + end + return paths +end) + +-- Function to get total size from du output +local get_total_size = function(s) + local lines = {} + for line in s:gmatch("[^\n]+") do lines[#lines + 1] = line end + local last_line = lines[#lines] + local last_line_parts = {} + for part in last_line:gmatch("%S+") do last_line_parts[#last_line_parts + 1] = part end + local total_size = last_line_parts[1] + return total_size +end + +-- Function to format file size +local function format_size(size) + local units = { "B", "KB", "MB", "GB", "TB" } + local unit_index = 1 + + while size > 1024 and unit_index < #units do + size = size / 1024 + unit_index = unit_index + 1 + end + + return string.format("%.2f %s", size, units[unit_index]) +end + +return { + entry = function(self, job) + -- defaults not to use clipboard, use it only if required by the user + local clipboard = job.args.clipboard or job.args[1] == '-c' + local items = get_paths() + + local cmd = "du" + local output, err = Command(cmd):arg("-scb"):args(items):output() + if not output then + ya.err("Failed to run diff, error: " .. err) + else + local total_size = get_total_size(output.stdout) + local formatted_size = format_size(tonumber(total_size)) + + local notification_content = "Total size: " .. formatted_size + if clipboard then + ya.clipboard(formatted_size) + notification_content = notification_content .. "\nCopied to clipboard." + end + + ya.notify { + title = "What size", + content = notification_content, + timeout = 5, + } + end + end, +} diff --git a/config/yazi/plugins/yatline.yazi/main.lua b/config/yazi/plugins/yatline.yazi/main.lua new file mode 100644 index 00000000..712ae143 --- /dev/null +++ b/config/yazi/plugins/yatline.yazi/main.lua @@ -0,0 +1,1312 @@ +--- @diagnostic disable: undefined-global, undefined-field +--- @alias Mode Mode Comes from Yazi. +--- @alias Rect Rect Comes from Yazi. +--- @alias Paragraph Paragraph Comes from Yazi. +--- @alias Line Line Comes from Yazi. +--- @alias Span Span Comes from Yazi. +--- @alias Color Color Comes from Yazi. +--- @alias Config Config The config used for setup. +--- @alias Coloreds Coloreds The array returned by colorizer in {{string, Color}, {string, Color} ... } format +--- @alias Side # [ LEFT ... RIGHT ] +--- | `enums.LEFT` # The left side of either the header-line or status-line. [ LEFT ... ] +--- | `enums.RIGHT` # The right side of either the header-line or status-line. [ ... RIGHT] +--- @alias SeparatorType +--- | `enums.OUTER` # Separators on the outer side of sections. [ c o | c o | c o ... ] or [ ... o c | o c | o c ] +--- | `enums.INNER` # Separators on the inner side of sections. [ c i c | c i c | c i c ... ] or [ ... c i c | c i c | c i c ] +--- @alias ComponentType +--- | `enums.A` # Components on the first section. [ A | | ... ] or [ ... | | A ] +--- | `enums.B` # Components on the second section. [ | B | ... ] or [ ... | B | ] +--- | `enums.C` # Components on the third section. [ | | C ... ] or [ ... C | | ] + +--==================-- +-- Type Declaration -- +--==================-- + +Yatline = {} + +local Side = { LEFT = 0, RIGHT = 1 } +local SeparatorType = { OUTER = 0, INNER = 1 } +local ComponentType = { A = 0, B = 1, C = 2 } + +--=========================-- +-- Variable Initialization -- +--=========================-- + +local section_separator_open +local section_separator_close + +local inverse_separator_open +local inverse_separator_close + +local part_separator_open +local part_separator_close + +local separator_style = { bg = nil, fg = nil } + +local style_a +local style_b +local style_c + +local style_a_normal_bg +local style_a_select_bg +local style_a_un_set_bg + +local permissions_t_fg +local permissions_r_fg +local permissions_w_fg +local permissions_x_fg +local permissions_s_fg + +local tab_width + +local selected_icon +local copied_icon +local cut_icon + +local selected_fg +local copied_fg +local cut_fg + +local task_total_icon +local task_succ_icon +local task_fail_icon +local task_found_icon +local task_processed_icon + +local task_total_fg +local task_succ_fg +local task_fail_fg +local task_found_fg +local task_processed_fg + +local show_background + +local section_order = { "section_a", "section_b", "section_c" } + +--=================-- +-- Component Setup -- +--=================-- + +--- Sets the background of style_a according to the tab's mode. +--- @param mode Mode The mode of the active tab. +--- @see cx.active.mode To get the active tab's mode. +local function set_mode_style(mode) + if mode.is_select then + style_a.bg = style_a_select_bg + elseif mode.is_unset then + style_a.bg = style_a_un_set_bg + else + style_a.bg = style_a_normal_bg + end +end + +--- Sets the style of the component according to the its type. +--- @param component Span Component that will be styled. +--- @param component_type ComponentType Which section component will be in [ a | b | c ]. +--- @see Style To see how to style, in Yazi's documentation. +local function set_component_style(component, component_type) + if component_type == ComponentType.A then + component:style(style_a):bold() + elseif component_type == ComponentType.B then + component:style(style_b) + else + component:style(style_c) + end +end + +--- Connects component to a separator. +--- @param component Span Component that will be connected to separator. +--- @param side Side Left or right side of the either header-line or status-line. +--- @param separator_type SeparatorType Where will there be a separator in the section. +--- @return Line line A Line which has component and separator. +local function connect_separator(component, side, separator_type) + local open, close + if + separator_type == SeparatorType.OUTER and not (separator_style.bg == "reset" and separator_style.fg == "reset") + then + open = ui.Span(section_separator_open) + close = ui.Span(section_separator_close) + + if separator_style.fg == "reset" then + if separator_style.bg ~= "reset" and separator_style.bg ~= nil then + open = ui.Span(inverse_separator_open) + close = ui.Span(inverse_separator_close) + + separator_style.fg, separator_style.bg = separator_style.bg, separator_style.fg + else + return ui.Line({ component }) + end + end + else + open = ui.Span(part_separator_open) + close = ui.Span(part_separator_close) + end + + open:style(separator_style) + close:style(separator_style) + + if side == Side.LEFT then + return ui.Line({ component, close }) + else + return ui.Line({ open, component }) + end +end + +--==================-- +-- Helper Functions -- +--==================-- + +--- Gets the file name from given file extension. +--- @param file_name string The name of a file whose extension will be taken. +--- @return string file_extension Extension of a file. +local function get_file_extension(file_name) + local extension = file_name:match("^.+%.(.+)$") + + if extension == nil or extension == "" then + return "null" + else + return extension + end +end + +--- Reverse the order of given array +--- @param array Line Array which wants to be reversed. +--- @return table reversed Reversed ordered given array. +local function reverse_order(array) + local reversed = {} + for i = #array, 1, -1 do + table.insert(reversed, array[i]) + end + + return reversed +end + +--- the number of characters in a UTF-8 string +--- @param s string The string to process. +--- @return integer The number of characters in the string. +local function utf8len(s) + -- count the number of non-continuing bytes + return select(2, s:gsub("[^\128-\193]", "")) +end + +--- like string.sub() but i, j are utf8 strings +--- a utf8-safe string.sub() +--- @param s string The string to process. +--- @param i integer The start position. +--- @param j integer The end position. +--- @return string The substring. +local function utf8sub(s, i, j) + -- pattern for matching UTF-8 characters + local pattern = "[%z\1-\127\194-\244][\128-\191]*" + + -- helper function for position calculation + --- @param pos integer The position of the character. + --- @param len integer The length of the string. + --- @return integer The relative position of the character. + local function posrelat(pos, len) + if pos < 0 then + pos = len + pos + 1 + end + return pos + end + + -- helper function to iterate over UTF-8 chars + local function chars(_s, no_subs) + local function map(f) + local _i = 0 + if no_subs then + for b, e in _s:gmatch("()" .. pattern .. "()") do + _i = _i + 1 + local c = e - b + f(_i, c, b) + end + else + for b, c in _s:gmatch("()(" .. pattern .. ")") do + _i = _i + 1 + f(_i, c, b) + end + end + end + return coroutine.wrap(function() + return map(coroutine.yield) + end) + end + + local l = utf8len(s) + + i = posrelat(i, l) + j = j and posrelat(j, l) or l + + if i < 1 then + i = 1 + end + if j > l then + j = l + end + + if i > j then + return "" + end + + local diff = j - i + local iter = chars(s, true) + + -- advance up to i + for _ = 1, i - 1 do + iter() + end + + local c, b = select(2, iter()) + + -- becareful with the edge case of empty string + if not b then + return "" + end + + -- i and j are the same, single-character sub + if diff == 0 then + return string.sub(s, b, b + c - 1) + end + + i = b + + -- advance up to j + for _ = 1, diff - 1 do + iter() + end + + c, b = select(2, iter()) + + return string.sub(s, i, b + c - 1) +end + +--- Trims the filename if it is longer than the max_length. +--- @param filename string The name of a file which will be trimmed. +--- @param max_length integer Maximum length of the filename. +--- @param trim_length integer Length of the trimmed filename. +--- @return string trimmed_filename Trimmed filename. +local function trim_filename(filename, max_length, trim_length) + if not max_length or not trim_length then + return filename + end + + -- Count UTF-8 characters + local len = utf8len(filename) + + if len <= max_length then + return filename + end + + if len <= trim_length * 2 then + return filename + end + + return utf8sub(filename, 1, trim_length) .. "..." .. utf8sub(filename, len - trim_length + 1, len) +end + +--========================-- +-- Component String Group -- +--========================-- + +Yatline.string = {} +Yatline.string.get = {} +Yatline.string.has_separator = true + +--- Creates a component from given string according to other parameters. +--- @param string string The text which will be shown inside of the component. +--- @param component_type ComponentType Which section component will be in [ a | b | c ]. +--- @return Line line Customized Line which follows desired style of the parameters. +--- @see set_mode_style To know how mode style selected. +--- @see set_component_style To know how component style applied. +function Yatline.string.create(string, component_type) + local span = ui.Span(" " .. string .. " ") + set_mode_style(cx.active.mode) + set_component_style(span, component_type) + + return ui.Line({ span }) +end + +--- Configuration for getting hovered file's name +--- @class HoveredNameConfig +--- @field trimed? boolean Whether to trim the filename if it's too long (default: false) +--- @field max_length? integer Maximum length of the filename (default: 24) +--- @field trim_length? integer Length of each end when trimming (default: 10) +--- @field show_symlink? boolean Whether to show symlink target (default: false) +--- Gets the hovered file's name of the current active tab. +--- @param config? HoveredNameConfig Configuration for getting hovered file's name +--- @return string name Current active tab's hovered file's name +function Yatline.string.get:hovered_name(config) + local hovered = cx.active.current.hovered + if not hovered then + return "" + end + + if not config then + return hovered.name + end + + local trimed = config.trimed or false + local max_length = config.max_length or 24 + local trim_length = config.trim_length or 10 + local show_symlink = config.show_symlink or false + + local link_delimiter = " -> " + local linked = (show_symlink and hovered.link_to ~= nil) and (link_delimiter .. tostring(hovered.link_to)) or "" + + if trimed then + local trimmed_name = trim_filename(hovered.name, max_length, trim_length) + local trimmed_linked = #linked ~= 0 + and link_delimiter .. trim_filename( + string.sub(linked, #link_delimiter + 1, -1), + max_length, + trim_length + ) + or "" + return trimmed_name .. trimmed_linked + else + return hovered.name .. linked + end +end + +--- Configuration for getting hovered file's path +--- @class HoveredPathConfig +--- @field trimed? boolean Whether to trim the file path if it's too long (default: false) +--- @field max_length? integer Maximum length of the file path (default: 24) +--- @field trim_length? integer Length of each end when trimming (default: 10) +--- Gets the hovered file's path of the current active tab. +--- @param config? HoveredPathConfig Configuration for getting hovered file's path +--- @return string path Current active tab's hovered file's path. +function Yatline.string.get:hovered_path(config) + local hovered = cx.active.current.hovered + if not hovered then + return "" + end + + if not config then + return ya.readable_path(tostring(hovered.url)) + end + + local trimed = config.trimed or false + local max_length = config.max_length or 24 + local trim_length = config.trim_length or 10 + + if trimed then + return trim_filename(ya.readable_path(tostring(hovered.url)), max_length, trim_length) + else + return ya.readable_path(tostring(hovered.url)) + end +end + +--- Gets the hovered file's size of the current active tab. +--- @return string size Current active tab's hovered file's size. +function Yatline.string.get:hovered_size() + local hovered = cx.active.current.hovered + if hovered then + return ya.readable_size(hovered:size() or hovered.cha.len) + else + return "" + end +end + +--- Gets the hovered file's path of the current active tab. +--- @return string mime Current active tab's hovered file's mime. +function Yatline.string.get:hovered_mime() + local hovered = cx.active.current.hovered + if hovered then + return hovered:mime() + else + return "" + end +end + +--- Gets the hovered file's user and group ownership of the current active tab. +--- @return string ownership Current active tab's hovered file's user and group ownership. +function Yatline.string.get:hovered_ownership() + local hovered = cx.active.current.hovered + + if hovered then + return ya.user_name(hovered.cha.uid) .. ":" .. ya.group_name(hovered.cha.gid) + else + return "" + end +end + +--- Gets the hovered file's extension of the current active tab. +--- @param show_icon boolean Whether or not an icon will be shown. +--- @return string file_extension Current active tab's hovered file's extension. +function Yatline.string.get:hovered_file_extension(show_icon) + local hovered = cx.active.current.hovered + + if hovered then + local cha = hovered.cha + + local name + if cha.is_dir then + name = "dir" + else + name = get_file_extension(hovered.url:name()) + end + + if show_icon then + local icon = hovered:icon().text + return icon .. " " .. name + else + return name + end + else + return "" + end +end + +--- Configuration for getting curent active tab's path +--- @class TabPathConfig +--- @field trimed? boolean Whether to trim the current active tab's path if it's too long (default: false) +--- @field max_length? integer Maximum length of the current active tab's path (default: 24) +--- @field trim_length? integer Length of each end when trimming (default: 10) +--- Gets the path of the current active tab. +--- @param config? TabPathConfig Configuration for getting current active tab's path +--- @return string path Current active tab's path. +function Yatline.string.get:tab_path(config) + local cwd = cx.active.current.cwd + local filter = cx.active.current.files.filter + + local search = cwd.is_search and string.format(" (search: %s", cwd:frag()) or "" + + local suffix + if not filter then + suffix = search == "" and search or search .. ")" + elseif search == "" then + suffix = string.format(" (filter: %s)", tostring(filter)) + else + suffix = string.format("%s, filter: %s)", search, tostring(filter)) + end + + if not config then + return ya.readable_path(tostring(cwd)) .. suffix + end + + local trimed = config.trimed or false + local max_length = config.max_length or 24 + local trim_length = config.trim_length or 10 + + if trimed then + return trim_filename(ya.readable_path(tostring(cwd)), max_length, trim_length) .. suffix + else + return ya.readable_path(tostring(cwd)) .. suffix + end +end + +--- Gets the mode of active tab. +--- @return string mode Active tab's mode. +function Yatline.string.get:tab_mode() + local mode = tostring(cx.active.mode):upper() + if mode == "UNSET" then + mode = "UN-SET" + end + + return mode +end + +--- Gets the number of files in the current active tab. +--- @return string num_files Number of files in the current active tab. +function Yatline.string.get:tab_num_files() + return tostring(#cx.active.current.files) +end + +--- Gets the cursor position in the current active tab. +--- @return string cursor_position Current active tab's cursor position. +function Yatline.string.get:cursor_position() + local cursor = cx.active.current.cursor + local length = #cx.active.current.files + + if length ~= 0 then + return string.format(" %2d/%-2d", cursor + 1, length) + else + return "0" + end +end + +--- Gets the cursor position as percentage which is according to the number of files inside of current active tab. +--- @return string percentage Percentage of current active tab's cursor position and number of percentages. +function Yatline.string.get:cursor_percentage() + local percentage = 0 + local cursor = cx.active.current.cursor + local length = #cx.active.current.files + if cursor ~= 0 and length ~= 0 then + percentage = math.floor((cursor + 1) * 100 / length) + end + + if percentage == 0 then + return " Top " + elseif percentage == 100 then + return " Bot " + else + return string.format("%3d%% ", percentage) + end +end + +--- Gets the local date or time values. +--- @param format string Format for giving desired date or time values. +--- @return string date Date or time values. +--- @see os.date To see how format works. +function Yatline.string.get:date(format) + return tostring(os.date(format)) +end + +--======================-- +-- Component Line Group -- +--======================-- + +Yatline.line = {} +Yatline.line.get = {} +Yatline.line.has_separator = false + +--- To follow component group naming and functions, returns the given line without any changes. +--- @param line Line The line already pre-defined. +--- @param component_type ComponentType Which section component will be in [ a | b | c ]. Will not be used. +--- @return Line line The given line as an input. +function Yatline.line.create(line, component_type) + return line +end + +--- Creates and returns line component for tabs. +--- @param side Side Left or right side of the either header-line or status-line. +--- @return Line line Customized Line which contains tabs. +--- @see set_mode_style To know how mode style selected. +--- @see set_component_style To know how component style applied. +--- @see connect_separator To know how component and separator connected. +function Yatline.line.get:tabs(side) + local tabs = #cx.tabs + local lines = {} + + local in_side + if side == "left" then + in_side = Side.LEFT + else + in_side = Side.RIGHT + end + + for i = 1, tabs do + local text = i + if tab_width > 2 then + text = ya.truncate(text .. " " .. cx.tabs[i]:name(), { max = tab_width }) + end + + separator_style = { bg = nil, fg = nil } + if i == cx.tabs.idx then + local span = ui.Span(" " .. text .. " ") + set_mode_style(cx.tabs[i].mode) + set_component_style(span, ComponentType.A) + + if style_a.bg ~= "reset" or show_background then + separator_style.fg = style_a.bg + if show_background then + separator_style.bg = style_c.bg + end + + lines[#lines + 1] = connect_separator(span, in_side, SeparatorType.OUTER) + else + separator_style.fg = style_a.fg + + lines[#lines + 1] = connect_separator(span, in_side, SeparatorType.INNER) + end + else + local span = ui.Span(" " .. text .. " ") + if show_background then + set_component_style(span, ComponentType.C) + else + span:style({ fg = style_c.fg }) + end + + if i == cx.tabs.idx - 1 then + set_mode_style(cx.tabs[i + 1].mode) + + local open, close + if style_a.bg ~= "reset" or (show_background and style_c.bg ~= "reset") then + if not show_background or (show_background and style_c.bg == "reset") then + separator_style.fg = style_a.bg + if show_background then + separator_style.bg = style_c.bg + end + + open = ui.Span(inverse_separator_open) + close = ui.Span(inverse_separator_close) + else + separator_style.bg = style_a.bg + if show_background then + separator_style.fg = style_c.bg + end + + open = ui.Span(section_separator_open) + close = ui.Span(section_separator_close) + end + else + separator_style.fg = style_c.fg + + open = ui.Span(part_separator_open) + close = ui.Span(part_separator_close) + end + + open:style(separator_style) + close:style(separator_style) + + if in_side == Side.LEFT then + lines[#lines + 1] = ui.Line({ span, close }) + else + lines[#lines + 1] = ui.Line({ open, span }) + end + else + separator_style.fg = style_c.fg + if show_background then + separator_style.bg = style_c.bg + end + + lines[#lines + 1] = connect_separator(span, in_side, SeparatorType.INNER) + end + end + end + + if in_side == Side.RIGHT then + local lines_in_right = {} + for i = #lines, 1, -1 do + lines_in_right[#lines_in_right + 1] = lines[i] + end + + return ui.Line(lines_in_right) + else + return ui.Line(lines) + end +end + +--==========================-- +-- Component Coloreds Group -- +--==========================-- + +Yatline.coloreds = {} +Yatline.coloreds.get = {} +Yatline.coloreds.has_separator = true + +--- Creates a component from given Coloreds according to other parameters. +--- The component it created, can contain multiple strings with different foreground color. +--- @param coloreds Coloreds The array which contains an array which contains text which will be shown inside of the component and its foreground color. +--- @param component_type ComponentType Which section component will be in [ a | b | c ]. +--- @return Line line Customized Line which follows desired style of the parameters. +--- @see set_mode_style To know how mode style selected. +--- @see set_component_style To know how component style applied. +function Yatline.coloreds.create(coloreds, component_type) + set_mode_style(cx.active.mode) + + local spans = {} + for i, colored in ipairs(coloreds) do + local span = ui.Span(colored[1]) + set_component_style(span, component_type) + span:fg(colored[2]) + + spans[i] = span + end + + return ui.Line(spans) +end + +--- Gets the hovered file's permissions of the current active tab. +--- @return Coloreds coloreds Current active tab's hovered file's permissions +function Yatline.coloreds.get:permissions() + local hovered = cx.active.current.hovered + + if hovered then + local perm = hovered.cha:perm() + + if perm then + local coloreds = {} + coloreds[1] = { " ", "black" } + + for i = 1, #perm do + local c = perm:sub(i, i) + + local fg = permissions_t_fg + if c == "-" then + fg = permissions_s_fg + elseif c == "r" then + fg = permissions_r_fg + elseif c == "w" then + fg = permissions_w_fg + elseif c == "x" or c == "s" or c == "S" or c == "t" or c == "T" then + fg = permissions_x_fg + end + + coloreds[i + 1] = { c, fg } + end + + coloreds[#perm + 2] = { " ", "black" } + + return coloreds + else + return "" + end + else + return "" + end +end + +--- Gets the number of selected and yanked files of the active tab. +--- @return Coloreds coloreds Active tab's number of selected and yanked files. +function Yatline.coloreds.get:count() + local num_yanked = #cx.yanked + local num_selected = #cx.active.selected + + local yanked_fg, yanked_icon + if cx.yanked.is_cut then + yanked_fg = cut_fg + yanked_icon = cut_icon + else + yanked_fg = copied_fg + yanked_icon = copied_icon + end + + local coloreds = { + { string.format(" %s %d ", selected_icon, num_selected), selected_fg }, + { string.format(" %s %d ", yanked_icon, num_yanked), yanked_fg }, + } + + return coloreds +end + +--- Gets the number of task states. +--- @return Coloreds coloreds Number of task states. +function Yatline.coloreds.get:task_states() + local tasks = cx.tasks.progress + + local coloreds = { + { string.format(" %s %d ", task_total_icon, tasks.total), task_total_fg }, + { string.format(" %s %d ", task_succ_icon, tasks.succ), task_succ_fg }, + { string.format(" %s %d ", task_fail_icon, tasks.fail), task_fail_fg }, + } + + return coloreds +end + +--- Gets the number of task workloads. +--- @return Coloreds coloreds Number of task workloads. +function Yatline.coloreds.get:task_workload() + local tasks = cx.tasks.progress + + local coloreds = { + { string.format(" %s %d ", task_found_icon, tasks.found), task_found_fg }, + { string.format(" %s %d ", task_processed_icon, tasks.processed), task_processed_fg }, + } + + return coloreds +end + +--- Gets colored which contains string based component's string and desired foreground color. +--- @param component_name string String based component's name. +--- @param fg Color Desired foreground color. +--- @param params? table Array of parameters of string based component. It is optional. +--- @return Coloreds coloreds Array of solely array of string based component's string and desired foreground color. +function Yatline.coloreds.get:string_based_component(component_name, fg, params) + local getter = Yatline.string.get[component_name] + + if getter then + local output + if params then + output = getter(Yatline.string.get, table.unpack(params)) + else + output = getter() + end + + if output ~= nil and output ~= "" then + return { { " " .. output .. " ", fg } } + else + return "" + end + else + return "" + end +end + +--===============-- +-- Configuration -- +--===============-- + +--- Configure separators if it is need to be added to the components. +--- Connects them with each component. +--- @param section_components table Array of components in one of the sections. +--- @param component_type ComponentType Which section component will be in [ a | b | c ]. +--- @param in_side Side Left or right side of the either header-line or status-line. +--- @param num_section_b_components integer Number of components in section-b. +--- @param num_section_c_components integer Number of components in section-c. +--- @return table section_line_components Array of line components whether or not connected with separators. +--- @see connect_separator To know how component and separator connected. +local function config_components_separators( + section_components, + component_type, + in_side, + num_section_b_components, + num_section_c_components +) + local num_section_components = #section_components + local section_line_components = {} + for i, component in ipairs(section_components) do + if component[2] == true then + separator_style = { bg = nil, fg = nil } + + local separator_type + if i ~= num_section_components then + if component_type == ComponentType.A then + separator_style = style_a + elseif component_type == ComponentType.B then + separator_style = style_b + else + separator_style = style_c + end + + separator_type = SeparatorType.INNER + else + if component_type == ComponentType.A then + separator_style.fg = style_a.bg + elseif component_type == ComponentType.B then + separator_style.fg = style_b.bg + else + separator_style.fg = style_c.bg + end + + if component_type == ComponentType.A and num_section_b_components ~= 0 then + separator_style.bg = style_b.bg + else + if num_section_c_components == 0 or component_type == ComponentType.C then + if show_background then + separator_style.bg = style_c.bg + end + else + separator_style.bg = style_c.bg + end + end + + separator_type = SeparatorType.OUTER + end + + section_line_components[i] = connect_separator(component[1], in_side, separator_type) + else + if in_side == Side.LEFT then + section_line_components[i] = component[1] + else + section_line_components[i] = component[1] + end + end + end + + return section_line_components +end + +--- Leads the given parameters to the other functions. +--- @param section_a_components table Components array whose components are in section-a of either side. +--- @param section_b_components table Components array whose components are in section-b of either side. +--- @param section_c_components table Components array whose components are in section-c of either side. +--- @param in_side Side Left or right side of the either header-line or status-line. +--- @return table section_a_line_components Array of components whose components are connected to separator and are in section-a of either side. +--- @return table section_b_line_components Array of components whose components are connected to separator and are in section-b of either side. +--- @return table section_c_line_components Array of components whose components are connected to separator and are in section-c of either side. +--- @see config_components_separators To know how separators are configured. +local function config_components(section_a_components, section_b_components, section_c_components, in_side) + local num_section_b_components = #section_b_components + local num_section_c_components = #section_c_components + + local section_a_line_components = config_components_separators( + section_a_components, + ComponentType.A, + in_side, + num_section_b_components, + num_section_c_components + ) + local section_b_line_components = config_components_separators( + section_b_components, + ComponentType.B, + in_side, + num_section_b_components, + num_section_c_components + ) + local section_c_line_components = config_components_separators( + section_c_components, + ComponentType.C, + in_side, + num_section_b_components, + num_section_c_components + ) + + return section_a_line_components, section_b_line_components, section_c_line_components +end + +--- Automatically creates and configures either left or right side according to their config. +--- @param side Config Configuration of either left or right side. +--- @return table section_a_components Components array whose components are in section-a of either side. +--- @return table section_b_components Components array whose components are in section-b of either side. +--- @return table section_c_components Components array whose components are in section-c of either side. +local function config_side(side) + local section_a_components = {} + local section_b_components = {} + local section_c_components = {} + + for _, section in ipairs(section_order) do + local components = side[section] + + local in_section, section_components + if section == "section_a" then + in_section = ComponentType.A + section_components = section_a_components + elseif section == "section_b" then + in_section = ComponentType.B + section_components = section_b_components + else + in_section = ComponentType.C + section_components = section_c_components + end + + for _, component in ipairs(components) do + local component_group = Yatline[component.type] + + if component_group then + if component.custom then + section_components[#section_components + 1] = + { component_group.create(component.name, in_section), component_group.has_separator } + else + local getter = component_group.get[component.name] + + if getter then + local output + if component.params then + output = getter(component_group.get, table.unpack(component.params)) + else + output = getter() + end + + if output ~= nil and output ~= "" then + section_components[#section_components + 1] = + { component_group.create(output, in_section), component_group.has_separator } + end + end + end + end + end + end + + return section_a_components, section_b_components, section_c_components +end + +--- Automatically creates and configures either header-line or status-line. +--- @param side Config Configuration of either left or right side. +--- @return table left_components Components array whose components are in left side of the line. +--- @return table right_components Components array whose components are in right side of the line. +--- @see config_side To know how components are gotten from side's config. +--- @see config_components To know how components are configured. +local function config_line(side, in_side) + local section_a_components, section_b_components, section_c_components = config_side(side) + + local section_a_line_components, section_b_line_components, section_c_line_components = + config_components(section_a_components, section_b_components, section_c_components, in_side) + + if in_side == Side.RIGHT then + section_a_line_components = reverse_order(section_a_line_components) + section_b_line_components = reverse_order(section_b_line_components) + section_c_line_components = reverse_order(section_c_line_components) + end + + local section_a_line = ui.Line(section_a_line_components) + local section_b_line = ui.Line(section_b_line_components) + local section_c_line = ui.Line(section_c_line_components) + + if in_side == Side.LEFT then + return ui.Line({ section_a_line, section_b_line, section_c_line }) + else + return ui.Line({ section_c_line, section_b_line, section_a_line }) + end +end + +--- Checks if either header-line or status-line contains components. +--- @param line Config Configuration of either header-line or status-line. +--- @return boolean show_line Returns yes if it contains components, otherwise returns no. +local function show_line(line) + local total_components = 0 + + for _, side in pairs(line) do + for _, section in pairs(side) do + total_components = total_components + #section + end + end + + return total_components ~= 0 +end + +--- Creates and configures paragraph which is used as left or right of either +--- header-line or status-line. +--- @param area Rect The area where paragraph will be placed in. +--- @param line? Line The line which used in paragraph. It is optional. +--- @return Paragraph paragraph Configured parapgraph. +local function config_paragraph(area, line) + local line_array = { line } or {} + if show_background then + return ui.Text(line_array):area(area):style(style_c) + else + return ui.Text(line_array):area(area) + end +end + +return { + setup = function(_, config) + config = config or {} + + tab_width = config.tab_width or 20 + + local component_positions = config.component_positions or { "header", "tab", "status" } + + show_background = config.show_background or false + + local display_header_line = config.display_header_line + if display_header_line == nil then + display_header_line = true + end + + local display_status_line = config.display_status_line + if display_status_line == nil then + display_status_line = true + end + + local header_line = config.header_line + or { + left = { section_a = {}, section_b = {}, section_c = {} }, + right = { section_a = {}, section_b = {}, section_c = {} }, + } + local status_line = config.status_line + or { + left = { section_a = {}, section_b = {}, section_c = {} }, + right = { section_a = {}, section_b = {}, section_c = {} }, + } + + if config.theme then + config = config.theme + end + + if config.section_separator then + section_separator_open = config.section_separator.open + section_separator_close = config.section_separator.close + else + section_separator_open = "" + section_separator_close = "" + end + + if config.inverse_separator then + inverse_separator_open = config.inverse_separator.open + inverse_separator_close = config.inverse_separator.close + else + inverse_separator_open = "" + inverse_separator_close = "" + end + + if config.part_separator then + part_separator_open = config.part_separator.open + part_separator_close = config.part_separator.close + else + part_separator_open = "" + part_separator_close = "" + end + + if config.style_a then + style_a = { bg = config.style_a.bg_mode.normal, fg = config.style_a.fg } + + style_a_normal_bg = config.style_a.bg_mode.normal + style_a_select_bg = config.style_a.bg_mode.select + style_a_un_set_bg = config.style_a.bg_mode.un_set + else + style_a = { bg = "white", fg = "black" } + + style_a_normal_bg = "white" + style_a_select_bg = "brightyellow" + style_a_un_set_bg = "brightred" + end + + style_b = config.style_b or { bg = "brightblack", fg = "brightwhite" } + style_c = config.style_c or { bg = "black", fg = "brightwhite" } + + permissions_t_fg = config.permissions_t_fg or "green" + permissions_r_fg = config.permissions_r_fg or "yellow" + permissions_w_fg = config.permissions_w_fg or "red" + permissions_x_fg = config.permissions_x_fg or "cyan" + permissions_s_fg = config.permissions_s_fg or "white" + + if config.selected then + selected_fg = config.selected.fg + selected_icon = config.selected.icon + else + selected_fg = "yellow" + selected_icon = "󰻭" + end + + if config.copied then + copied_fg = config.copied.fg + copied_icon = config.copied.icon + else + copied_fg = "green" + copied_icon = "" + end + + if config.cut then + cut_icon = config.cut.icon + cut_fg = config.cut.fg + else + cut_icon = "" + cut_fg = "red" + end + + if config.total then + task_total_icon = config.total.icon + task_total_fg = config.total.fg + else + task_total_icon = "󰮍" + task_total_fg = "yellow" + end + + if config.succ then + task_succ_icon = config.succ.icon + task_succ_fg = config.succ.fg + else + task_succ_icon = "" + task_succ_fg = "green" + end + + if config.fail then + task_fail_icon = config.fail.icon + task_fail_fg = config.fail.fg + else + task_fail_icon = "" + task_fail_fg = "red" + end + + if config.found then + task_found_icon = config.found.icon + task_found_fg = config.found.fg + else + task_found_icon = "󰮕" + task_found_fg = "blue" + end + + if config.processed then + task_processed_icon = config.processed.icon + task_processed_fg = config.processed.fg + else + task_processed_icon = "󰐍" + task_processed_fg = "green" + end + + Progress.partial_render = function(self) + local progress = cx.tasks.progress + if progress.total == 0 then + return { config_paragraph(self._area) } + end + + local gauge = ui.Gauge():area(self._area) + if progress.fail == 0 then + gauge = gauge:gauge_style(THEME.status.progress_normal) + else + gauge = gauge:gauge_style(THEME.status.progress_error) + end + + local percent = 99 + if progress.found ~= 0 then + percent = math.min(99, ya.round(progress.processed * 100 / progress.found)) + end + + local left = progress.total - progress.succ + return { + gauge + :percent(percent) + :label(ui.Span(string.format("%3d%%, %d left", percent, left)):style(THEME.status.progress_label)), + } + end + + if display_header_line then + if show_line(header_line) then + Header.redraw = function(self) + local left_line = config_line(header_line.left, Side.LEFT) + local right_line = config_line(header_line.right, Side.RIGHT) + + return { + config_paragraph(self._area, left_line), + ui.Text(right_line):area(self._area):align(ui.Text.RIGHT), + } + end + + Header.children_add = function() + return {} + end + Header.children_remove = function() + return {} + end + end + else + Header.redraw = function() + return {} + end + end + + if display_status_line then + if show_line(status_line) then + Status.redraw = function(self) + local left_line = config_line(status_line.left, Side.LEFT) + local right_line = config_line(status_line.right, Side.RIGHT) + local right_width = right_line:width() + + return { + config_paragraph(self._area, left_line), + ui.Text(right_line):area(self._area):align(ui.Text.RIGHT), + table.unpack(Progress:new(self._area, right_width):redraw()), + } + end + + Status.children_add = function() + return {} + end + Status.children_remove = function() + return {} + end + end + else + Status.redraw = function() + return {} + end + end + + Root.layout = function(self) + local constraints = {} + for _, component in ipairs(component_positions) do + if + (component == "header" and display_header_line) or (component == "status" and display_status_line) + then + table.insert(constraints, ui.Constraint.Length(1)) + elseif component == "tab" then + table.insert(constraints, ui.Constraint.Fill(1)) + end + end + + self._chunks = ui.Layout():direction(ui.Layout.VERTICAL):constraints(constraints):split(self._area) + end + + Root.build = function(self) + local childrens = {} + + local i = 1 + for _, component in ipairs(component_positions) do + if component == "header" and display_header_line then + table.insert(childrens, Header:new(self._chunks[i], cx.active)) + i = i + 1 + elseif component == "tab" then + table.insert(childrens, Tab:new(self._chunks[i], cx.active)) + i = i + 1 + elseif component == "status" and display_status_line then + table.insert(childrens, Status:new(self._chunks[i], cx.active)) + i = i + 1 + end + end + + self._children = childrens + end + end, +} diff --git a/config/yazi/yazi.toml b/config/yazi/yazi.toml index 6e4ae8dd..a156c0a8 100644 --- a/config/yazi/yazi.toml +++ b/config/yazi/yazi.toml @@ -92,12 +92,12 @@ previewers = [ # JSON {mime = "application/json", run = "code"}, # Image - # {mime = "image/vnd.djvu", run = "noop"}, - # {mime = "image/*", run = "image"}, + {mime = "image/vnd.djvu", run = "noop"}, + {mime = "image/*", run = "image"}, # Video - # {mime = "video/*", run = "video"}, + {mime = "video/*", run = "video"}, # PDF - # {mime = "application/pdf", run = "pdf"}, + {mime = "application/pdf", run = "pdf"}, # Fallback {name = "*", run = "file"}, ] diff --git a/dotter b/dotter old mode 100644 new mode 100755 diff --git a/dotter.arm b/dotter.arm old mode 100644 new mode 100755 diff --git a/dotter.exe b/dotter.exe old mode 100644 new mode 100755