mirror of
https://github.com/sxyazi/yazi.git
synced 2024-09-11 10:26:35 +03:00
first commit
This commit is contained in:
commit
52aa00ed23
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
2396
Cargo.lock
generated
Normal file
2396
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
Normal file
38
Cargo.toml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
[package]
|
||||||
|
name = "ranger-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ansi-to-tui = "3.0.0"
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
async-channel = "1.8.0"
|
||||||
|
base64 = "0.21.2"
|
||||||
|
console-subscriber = "0.1.9"
|
||||||
|
crossterm = { version = "0.26.1", features = [ "event-stream" ] }
|
||||||
|
futures = "0.3.28"
|
||||||
|
glob = "0.3.1"
|
||||||
|
image = "0.24.6"
|
||||||
|
indexmap = "2.0.0"
|
||||||
|
libc = "0.2.146"
|
||||||
|
md5 = "0.7.0"
|
||||||
|
notify = { version = "6.0.1", default-features = false, features = [ "macos_fsevent" ] }
|
||||||
|
once_cell = "1.18.0"
|
||||||
|
parking_lot = "0.12.1"
|
||||||
|
ratatui = "0.21.0"
|
||||||
|
serde = { version = "1.0.164", features = [ "derive" ] }
|
||||||
|
signal-hook-tokio = { version = "0.3.1", features = [ "futures-v0_3" ] }
|
||||||
|
syntect = "5.0.0"
|
||||||
|
tokio = { version = "1.28.2", features = [ "parking_lot", "macros", "rt-multi-thread", "sync", "fs", "process", "io-std", "io-util", "time" ] }
|
||||||
|
toml = "0.7.4"
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-appender = "0.2.2"
|
||||||
|
tracing-subscriber = "0.3.17"
|
||||||
|
trash = "3.0.3"
|
||||||
|
unicode-width = "0.1.10"
|
||||||
|
xdg = "2.5.0"
|
||||||
|
|
||||||
|
# [profile.release]
|
||||||
|
# strip = true
|
||||||
|
# lto = true
|
||||||
|
# panic = "abort"
|
21
LICENCE
Normal file
21
LICENCE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 - sxyazi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
40
README.md
Normal file
40
README.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
## Yazi - ⚡️ Blazing Fast Terminal File Manager
|
||||||
|
|
||||||
|
Yazi ("duck" in Chinese) is a terminal file manager written in Rust, based on non-blocking async I/O. It aims to provide an efficient, user-friendly, and configurable file management experience.
|
||||||
|
|
||||||
|
⚠️ Note: Yazi is currently in active development and may be unstable. The API is subject to change without prior notice. Please use it with caution in a non-production environment.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Before getting started, ensure that the following dependencies are installed on your system:
|
||||||
|
|
||||||
|
- jq (optional, for JSON preview)
|
||||||
|
- ffmpegthumbnailer (optional, for video thumbnails)
|
||||||
|
- fzf (optional, for fuzzy search)
|
||||||
|
- rg (optional, for fuzzy search)
|
||||||
|
- zoxide (optional, for directory jumping)
|
||||||
|
|
||||||
|
Execute the following commands to clone the project and build Yazi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/sxyazi/yazi.git
|
||||||
|
cd yazi
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/yazi
|
||||||
|
```
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Integration with zoxide for fast directory navigation
|
||||||
|
- Integration with fzf, rg for fuzzy file searching
|
||||||
|
- Support for Überzug++ for image previews with X11/wayland environment
|
||||||
|
- Batch renaming support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Yazi is MIT licensed.
|
66
src/config/keymap/exec.rs
Normal file
66
src/config/keymap/exec.rs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
use std::{collections::BTreeMap, fmt};
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserializer};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Exec {
|
||||||
|
pub cmd: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub named: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Exec {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
let mut exec = Self::default();
|
||||||
|
for x in value.split_whitespace() {
|
||||||
|
if x.starts_with("--") {
|
||||||
|
let mut it = x[2..].splitn(2, '=');
|
||||||
|
let name = it.next().unwrap();
|
||||||
|
let value = it.next().unwrap_or("");
|
||||||
|
exec.named.insert(name.to_string(), value.to_string());
|
||||||
|
} else if exec.cmd.is_empty() {
|
||||||
|
exec.cmd = x.to_string();
|
||||||
|
} else {
|
||||||
|
exec.args.push(x.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exec {
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Exec>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct ExecVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for ExecVisitor {
|
||||||
|
type Value = Vec<Exec>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a command string, e.g. tab_switch 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::SeqAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut execs = Vec::new();
|
||||||
|
while let Some(value) = &seq.next_element::<String>()? {
|
||||||
|
execs.push(Exec::from(value.as_str()));
|
||||||
|
}
|
||||||
|
Ok(execs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(value.split(';').map(Exec::from).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(ExecVisitor)
|
||||||
|
}
|
||||||
|
}
|
112
src/config/keymap/key.rs
Normal file
112
src/config/keymap/key.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Key {
|
||||||
|
pub code: KeyCode,
|
||||||
|
pub shift: bool,
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Key {
|
||||||
|
fn default() -> Self { Self { code: KeyCode::Null, shift: false, ctrl: false, alt: false } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Key {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
let mut key = Default::default();
|
||||||
|
if value.is_empty() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !value.starts_with("<") || !value.ends_with(">") {
|
||||||
|
let c = value.chars().next().unwrap();
|
||||||
|
key.code = KeyCode::Char(c);
|
||||||
|
key.shift = c.is_ascii_uppercase();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut it = value[1..value.len() - 1].split_inclusive('-').peekable();
|
||||||
|
while let Some(x) = it.next() {
|
||||||
|
match x {
|
||||||
|
"S-" => key.shift = true,
|
||||||
|
"C-" => key.ctrl = true,
|
||||||
|
"A-" => key.alt = true,
|
||||||
|
|
||||||
|
"Space" => key.code = KeyCode::Char(' '),
|
||||||
|
"Backspace" => key.code = KeyCode::Backspace,
|
||||||
|
"Enter" => key.code = KeyCode::Enter,
|
||||||
|
"Left" => key.code = KeyCode::Left,
|
||||||
|
"Right" => key.code = KeyCode::Right,
|
||||||
|
"Up" => key.code = KeyCode::Up,
|
||||||
|
"Down" => key.code = KeyCode::Down,
|
||||||
|
"Home" => key.code = KeyCode::Home,
|
||||||
|
"End" => key.code = KeyCode::End,
|
||||||
|
"PageUp" => key.code = KeyCode::PageUp,
|
||||||
|
"PageDown" => key.code = KeyCode::PageDown,
|
||||||
|
"Tab" => key.code = KeyCode::Tab,
|
||||||
|
"Delete" => key.code = KeyCode::Delete,
|
||||||
|
"Insert" => key.code = KeyCode::Insert,
|
||||||
|
"F1" => key.code = KeyCode::F(1),
|
||||||
|
"F2" => key.code = KeyCode::F(2),
|
||||||
|
"F3" => key.code = KeyCode::F(3),
|
||||||
|
"F4" => key.code = KeyCode::F(4),
|
||||||
|
"F5" => key.code = KeyCode::F(5),
|
||||||
|
"F6" => key.code = KeyCode::F(6),
|
||||||
|
"F7" => key.code = KeyCode::F(7),
|
||||||
|
"F8" => key.code = KeyCode::F(8),
|
||||||
|
"F9" => key.code = KeyCode::F(9),
|
||||||
|
"F10" => key.code = KeyCode::F(10),
|
||||||
|
"F11" => key.code = KeyCode::F(11),
|
||||||
|
"F12" => key.code = KeyCode::F(12),
|
||||||
|
"Esc" => key.code = KeyCode::Esc,
|
||||||
|
|
||||||
|
c if it.peek().is_none() => {
|
||||||
|
key.code = KeyCode::Char(c.chars().next().unwrap());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<KeyEvent> for Key {
|
||||||
|
fn from(value: KeyEvent) -> Self {
|
||||||
|
let shift = if let KeyCode::Char(c) = value.code { c.is_ascii_uppercase() } else { false };
|
||||||
|
|
||||||
|
Self {
|
||||||
|
code: value.code,
|
||||||
|
shift: shift || value.modifiers.contains(KeyModifiers::SHIFT),
|
||||||
|
ctrl: value.modifiers.contains(KeyModifiers::CONTROL),
|
||||||
|
alt: value.modifiers.contains(KeyModifiers::ALT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Key {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct KeyVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for KeyVisitor {
|
||||||
|
type Value = Key;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a key string, e.g. <C-a>")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(Key::from(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_str(KeyVisitor)
|
||||||
|
}
|
||||||
|
}
|
41
src/config/keymap/keymap.rs
Normal file
41
src/config/keymap/keymap.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
use super::{Exec, Key};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Single {
|
||||||
|
pub on: Vec<Key>,
|
||||||
|
#[serde(deserialize_with = "Exec::deserialize")]
|
||||||
|
pub exec: Vec<Exec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Keymap {
|
||||||
|
pub manager: Vec<Single>,
|
||||||
|
pub tasks: Vec<Single>,
|
||||||
|
pub input: Vec<Single>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keymap {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Inner {
|
||||||
|
keymap: Vec<Single>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct All {
|
||||||
|
manager: Inner,
|
||||||
|
tasks: Inner,
|
||||||
|
input: Inner,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = BaseDirectories::new().unwrap().get_config_file("yazi/keymap.toml");
|
||||||
|
|
||||||
|
let all: All = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap();
|
||||||
|
Self { manager: all.manager.keymap, tasks: all.tasks.keymap, input: all.input.keymap }
|
||||||
|
}
|
||||||
|
}
|
7
src/config/keymap/mod.rs
Normal file
7
src/config/keymap/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
mod exec;
|
||||||
|
mod key;
|
||||||
|
mod keymap;
|
||||||
|
|
||||||
|
pub use exec::*;
|
||||||
|
pub use key::*;
|
||||||
|
pub use keymap::*;
|
41
src/config/manager/manager.rs
Normal file
41
src/config/manager/manager.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use std::{env, fs, path::PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
use super::SortBy;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Manager {
|
||||||
|
#[serde(skip)]
|
||||||
|
pub cwd: PathBuf,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub cache: PathBuf,
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
pub sort_by: SortBy,
|
||||||
|
pub sort_reverse: bool,
|
||||||
|
|
||||||
|
// Display
|
||||||
|
pub show_hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Outer {
|
||||||
|
manager: Manager,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
|
||||||
|
let mut manager = toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap().manager;
|
||||||
|
|
||||||
|
manager.cwd = env::current_dir().unwrap_or("/".into());
|
||||||
|
manager.cache = "/tmp/yazi".into();
|
||||||
|
if !manager.cache.is_dir() {
|
||||||
|
fs::create_dir(&manager.cache).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
}
|
||||||
|
}
|
5
src/config/manager/mod.rs
Normal file
5
src/config/manager/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod manager;
|
||||||
|
mod sorting;
|
||||||
|
|
||||||
|
pub use manager::*;
|
||||||
|
pub use sorting::*;
|
46
src/config/manager/sorting.rs
Normal file
46
src/config/manager/sorting.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum SortBy {
|
||||||
|
Alphabetical,
|
||||||
|
Created,
|
||||||
|
Modified,
|
||||||
|
Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for SortBy {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"created" => Self::Created,
|
||||||
|
"modified" => Self::Modified,
|
||||||
|
"size" => Self::Size,
|
||||||
|
_ => Self::Alphabetical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SortBy {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<SortBy, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct SortByVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for SortByVisitor {
|
||||||
|
type Value = SortBy;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a sort_by string, e.g. modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(SortBy::from(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_str(SortByVisitor)
|
||||||
|
}
|
||||||
|
}
|
24
src/config/mod.rs
Normal file
24
src/config/mod.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
|
pub mod keymap;
|
||||||
|
pub mod manager;
|
||||||
|
pub mod open;
|
||||||
|
mod pattern;
|
||||||
|
pub mod preview;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
pub use pattern::*;
|
||||||
|
|
||||||
|
pub static KEYMAP: Lazy<keymap::Keymap> = Lazy::new(|| keymap::Keymap::new());
|
||||||
|
pub static MANAGER: Lazy<manager::Manager> = Lazy::new(|| manager::Manager::new());
|
||||||
|
pub static OPEN: Lazy<open::Open> = Lazy::new(|| open::Open::new());
|
||||||
|
pub static PREVIEW: Lazy<preview::Preview> = Lazy::new(|| preview::Preview::new());
|
||||||
|
pub static THEME: Lazy<theme::Theme> = Lazy::new(|| theme::Theme::new());
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
Lazy::force(&KEYMAP);
|
||||||
|
Lazy::force(&MANAGER);
|
||||||
|
Lazy::force(&OPEN);
|
||||||
|
Lazy::force(&PREVIEW);
|
||||||
|
Lazy::force(&THEME);
|
||||||
|
}
|
3
src/config/open/mod.rs
Normal file
3
src/config/open/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod open;
|
||||||
|
|
||||||
|
pub use open::*;
|
65
src/config/open/open.rs
Normal file
65
src/config/open/open.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use std::{collections::BTreeMap, fs, path::Path};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
use crate::config::Pattern;
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct Opener {
|
||||||
|
pub cmd: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block: bool,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub spread: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Open {
|
||||||
|
#[serde(skip)]
|
||||||
|
openers: BTreeMap<String, Vec<Opener>>,
|
||||||
|
|
||||||
|
rules: Vec<OpenRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct OpenRule {
|
||||||
|
name: Option<Pattern>,
|
||||||
|
mime: Option<Pattern>,
|
||||||
|
#[serde(rename = "use")]
|
||||||
|
use_: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Open {
|
||||||
|
pub fn new() -> Open {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Outer {
|
||||||
|
opener: BTreeMap<String, Vec<Opener>>,
|
||||||
|
open: Open,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
|
||||||
|
let mut outer = toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap();
|
||||||
|
|
||||||
|
for opener in outer.opener.values_mut() {
|
||||||
|
for one in opener.iter_mut() {
|
||||||
|
one.spread = one.args.iter().any(|a| a == "$*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { openers: outer.opener, rules: outer.open.rules }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn opener(&self, path: &Path, mime: &str) -> Option<&Opener> {
|
||||||
|
self.rules.iter().find_map(|rule| {
|
||||||
|
if rule.name.as_ref().map_or(false, |e| e.match_path(path, Some(false)))
|
||||||
|
|| rule.mime.as_ref().map_or(false, |m| m.matches(mime))
|
||||||
|
{
|
||||||
|
self.openers.get(&rule.use_).and_then(|v| v.first())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
50
src/config/pattern.rs
Normal file
50
src/config/pattern.rs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Pattern {
|
||||||
|
inner: glob::Pattern,
|
||||||
|
is_folder: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
pub fn matches(&self, str: &str) -> bool { self.inner.matches(str) }
|
||||||
|
|
||||||
|
pub fn match_path(&self, path: &Path, is_folder: Option<bool>) -> bool {
|
||||||
|
is_folder.map_or(true, |f| f == self.is_folder) && self.inner.matches_path(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for Pattern {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
let is_folder = value.ends_with('/');
|
||||||
|
Self { inner: glob::Pattern::new(value.trim_end_matches('/')).unwrap_or_default(), is_folder }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Pattern {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct PatternVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for PatternVisitor {
|
||||||
|
type Value = Pattern;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str("a glob pattern, e.g. *.rs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(Pattern::from(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_str(PatternVisitor)
|
||||||
|
}
|
||||||
|
}
|
3
src/config/preview/mod.rs
Normal file
3
src/config/preview/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod preview;
|
||||||
|
|
||||||
|
pub use preview::*;
|
30
src/config/preview/preview.rs
Normal file
30
src/config/preview/preview.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Manager {
|
||||||
|
pub sort_by: String,
|
||||||
|
pub sort_reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Preview {
|
||||||
|
pub tab_size: u32,
|
||||||
|
|
||||||
|
pub max_width: u32,
|
||||||
|
pub max_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preview {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Outer {
|
||||||
|
preview: Preview,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
|
||||||
|
toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap().preview
|
||||||
|
}
|
||||||
|
}
|
39
src/config/theme/color.rs
Normal file
39
src/config/theme/color.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use ratatui::style;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Color {
|
||||||
|
pub fg: String,
|
||||||
|
pub bg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Color {
|
||||||
|
pub fn fg_rgb(&self) -> style::Color {
|
||||||
|
if self.fg.len() < 7 {
|
||||||
|
return style::Color::Reset;
|
||||||
|
}
|
||||||
|
let convert = || -> Result<style::Color> {
|
||||||
|
Ok(style::Color::Rgb(
|
||||||
|
u8::from_str_radix(&self.fg[1..3], 16)?,
|
||||||
|
u8::from_str_radix(&self.fg[3..5], 16)?,
|
||||||
|
u8::from_str_radix(&self.fg[5..7], 16)?,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
convert().unwrap_or(style::Color::Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bg_rgb(&self) -> style::Color {
|
||||||
|
if self.bg.len() < 7 {
|
||||||
|
return style::Color::Reset;
|
||||||
|
}
|
||||||
|
let convert = || -> Result<style::Color> {
|
||||||
|
Ok(style::Color::Rgb(
|
||||||
|
u8::from_str_radix(&self.bg[1..3], 16)?,
|
||||||
|
u8::from_str_radix(&self.bg[3..5], 16)?,
|
||||||
|
u8::from_str_radix(&self.bg[5..7], 16)?,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
convert().unwrap_or(style::Color::Reset)
|
||||||
|
}
|
||||||
|
}
|
45
src/config/theme/icon.rs
Normal file
45
src/config/theme/icon.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserializer};
|
||||||
|
|
||||||
|
use crate::config::Pattern;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Icon {
|
||||||
|
pub name: Pattern,
|
||||||
|
pub display: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Icon {
|
||||||
|
pub fn new(name: String, display: String) -> Self {
|
||||||
|
Self { name: Pattern::from(name.as_ref()), display }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Icon>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct IconVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for IconVisitor {
|
||||||
|
type Value = Vec<Icon>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a icon rule, e.g. \"*.md\" = \"\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut icons = Vec::new();
|
||||||
|
while let Some((key, value)) = &map.next_entry::<String, String>()? {
|
||||||
|
icons.push(Icon::new(key.clone(), value.clone()));
|
||||||
|
}
|
||||||
|
Ok(icons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_map(IconVisitor)
|
||||||
|
}
|
||||||
|
}
|
7
src/config/theme/mod.rs
Normal file
7
src/config/theme/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
mod color;
|
||||||
|
mod icon;
|
||||||
|
mod theme;
|
||||||
|
|
||||||
|
pub use color::*;
|
||||||
|
pub use icon::*;
|
||||||
|
pub use theme::*;
|
56
src/config/theme/theme.rs
Normal file
56
src/config/theme/theme.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use xdg::BaseDirectories;
|
||||||
|
|
||||||
|
use super::{Color, Icon};
|
||||||
|
use crate::misc::absolute_path;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Mode {
|
||||||
|
pub normal: Color,
|
||||||
|
pub select: Color,
|
||||||
|
pub unselect: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Tab {
|
||||||
|
pub active: Color,
|
||||||
|
pub inactive: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Selection {
|
||||||
|
pub normal: Color,
|
||||||
|
pub hovered: Color,
|
||||||
|
pub selected: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Filetype {}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Syntect {
|
||||||
|
pub theme: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub mode: Mode,
|
||||||
|
pub tab: Tab,
|
||||||
|
pub selection: Selection,
|
||||||
|
pub filetype: Filetype,
|
||||||
|
pub syntect: Syntect,
|
||||||
|
#[serde(deserialize_with = "Icon::deserialize")]
|
||||||
|
pub icons: Vec<Icon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let path = BaseDirectories::new().unwrap().get_config_file("yazi/theme.toml");
|
||||||
|
|
||||||
|
let mut parsed: Self = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap();
|
||||||
|
parsed.syntect.theme = absolute_path(&parsed.syntect.theme);
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
}
|
49
src/core/adapter/kitty.rs
Normal file
49
src/core/adapter/kitty.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
|
use image::DynamicImage;
|
||||||
|
|
||||||
|
pub struct Kitty;
|
||||||
|
|
||||||
|
impl Kitty {
|
||||||
|
pub fn image_show(img: DynamicImage) -> Result<Vec<u8>> {
|
||||||
|
fn output(raw: Vec<u8>, format: u8, size: (u32, u32)) -> Result<Vec<u8>> {
|
||||||
|
let b64 = general_purpose::STANDARD.encode(raw).chars().collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut it = b64.chunks(4096).peekable();
|
||||||
|
let mut buf = Vec::with_capacity(b64.len() + it.len() * 50);
|
||||||
|
if let Some(first) = it.next() {
|
||||||
|
write!(
|
||||||
|
buf,
|
||||||
|
"\x1b_Ga=d\x1b\\\x1b_Ga=T,f={},s={},v={},m={};{}\x1b\\",
|
||||||
|
format,
|
||||||
|
size.0,
|
||||||
|
size.1,
|
||||||
|
it.peek().is_some() as u8,
|
||||||
|
first.iter().collect::<String>(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(chunk) = it.next() {
|
||||||
|
write!(
|
||||||
|
buf,
|
||||||
|
"\x1b_Gm={};{}\x1b\\",
|
||||||
|
it.peek().is_some() as u8,
|
||||||
|
chunk.iter().collect::<String>()
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = (img.width(), img.height());
|
||||||
|
match img {
|
||||||
|
DynamicImage::ImageRgb8(v) => output(v.into_raw(), 24, size),
|
||||||
|
DynamicImage::ImageRgba8(v) => output(v.into_raw(), 32, size),
|
||||||
|
v => output(v.to_rgb8().into_raw(), 24, size),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn image_hide() -> &'static [u8; 8] { b"\x1b_Ga=d\x1b\\" }
|
||||||
|
}
|
1
src/core/adapter/mod.rs
Normal file
1
src/core/adapter/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod kitty;
|
95
src/core/event.rs
Normal file
95
src/core/event.rs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use tokio::sync::{mpsc::Sender, oneshot};
|
||||||
|
|
||||||
|
use super::{FolderItem, InputOpt, PreviewData};
|
||||||
|
|
||||||
|
static mut TX: Option<Sender<Event>> = None;
|
||||||
|
|
||||||
|
pub enum Event {
|
||||||
|
Quit,
|
||||||
|
Stop(bool, oneshot::Sender<()>),
|
||||||
|
Key(KeyEvent),
|
||||||
|
Render(String),
|
||||||
|
Resize(u16, u16),
|
||||||
|
|
||||||
|
Refresh,
|
||||||
|
Files(PathBuf, IndexMap<PathBuf, FolderItem>),
|
||||||
|
Hover,
|
||||||
|
Mimetype(PathBuf, String),
|
||||||
|
Preview(PathBuf, PreviewData),
|
||||||
|
|
||||||
|
Input(InputOpt, oneshot::Sender<Result<String>>),
|
||||||
|
|
||||||
|
Open(Vec<PathBuf>),
|
||||||
|
Progress(u8, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
#[inline]
|
||||||
|
pub fn init(tx: Sender<Event>) {
|
||||||
|
unsafe {
|
||||||
|
TX.replace(tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn emit(self) {
|
||||||
|
let tx = unsafe { TX.as_ref().unwrap() };
|
||||||
|
tokio::spawn(async {
|
||||||
|
tx.send(self).await.ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait<T>(self, rx: oneshot::Receiver<T>) -> T {
|
||||||
|
let tx = unsafe { TX.as_ref().unwrap() };
|
||||||
|
tx.send(self).await.ok();
|
||||||
|
rx.await.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! emit {
|
||||||
|
(Stop($state:expr)) => {{
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
$crate::core::Event::Stop($state, tx).wait(rx)
|
||||||
|
}};
|
||||||
|
(Key($key:expr)) => {
|
||||||
|
$crate::core::Event::Key($key).emit();
|
||||||
|
};
|
||||||
|
(Render) => {
|
||||||
|
$crate::core::Event::Render(format!("{}:{}", file!(), line!())).emit();
|
||||||
|
};
|
||||||
|
(Resize($cols:expr, $rows:expr)) => {
|
||||||
|
$crate::core::Event::Resize($cols, $rows).emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
(Files($path:expr, $items:expr)) => {
|
||||||
|
$crate::core::Event::Files($path, $items).emit();
|
||||||
|
};
|
||||||
|
(Mimetype($path:expr, $mime:expr)) => {
|
||||||
|
$crate::core::Event::Mimetype($path, $mime).emit();
|
||||||
|
};
|
||||||
|
(Preview($path:expr, $data:expr)) => {
|
||||||
|
$crate::core::Event::Preview($path, $data).emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
(Input($opt:expr)) => {{
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
$crate::core::Event::Input($opt, tx).wait(rx)
|
||||||
|
}};
|
||||||
|
|
||||||
|
(Open($files:expr)) => {
|
||||||
|
$crate::core::Event::Open($files).emit();
|
||||||
|
};
|
||||||
|
(Progress($percent:expr, $tasks:expr)) => {
|
||||||
|
$crate::core::Event::Progress($percent, $tasks).emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
($event:ident) => {
|
||||||
|
$crate::core::Event::$event.emit();
|
||||||
|
};
|
||||||
|
}
|
312
src/core/input/input.rs
Normal file
312
src/core/input/input.rs
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use tokio::sync::oneshot::Sender;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::misc::{tty_size, CharKind};
|
||||||
|
|
||||||
|
pub struct Input {
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
position: (u16, u16),
|
||||||
|
|
||||||
|
op: InputOp,
|
||||||
|
range: Option<(usize, usize)>,
|
||||||
|
|
||||||
|
mode: InputMode,
|
||||||
|
offset: usize,
|
||||||
|
cursor: usize,
|
||||||
|
callback: Option<Sender<Result<String>>>,
|
||||||
|
|
||||||
|
pub visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InputOpt {
|
||||||
|
pub title: String,
|
||||||
|
pub value: String,
|
||||||
|
pub position: (u16, u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum InputMode {
|
||||||
|
Normal,
|
||||||
|
#[default]
|
||||||
|
Insert,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum InputOp {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Delete(bool),
|
||||||
|
Yank,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
pub fn show(&mut self, opt: InputOpt, tx: Sender<Result<String>>) {
|
||||||
|
self.title = opt.title;
|
||||||
|
self.value = opt.value;
|
||||||
|
self.position = opt.position;
|
||||||
|
|
||||||
|
self.mode = InputMode::Insert;
|
||||||
|
self.cursor = self.count();
|
||||||
|
self.offset = self.value.width().saturating_sub(50);
|
||||||
|
self.callback = Some(tx);
|
||||||
|
self.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self, submit: bool) -> bool {
|
||||||
|
self.visible = false;
|
||||||
|
if let Some(cb) = self.callback.take() {
|
||||||
|
let _ = cb.send(if submit { Ok(self.value.clone()) } else { Err(anyhow!("canceled")) });
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn escape(&mut self) -> bool {
|
||||||
|
match self.mode {
|
||||||
|
InputMode::Normal => {
|
||||||
|
self.range = None;
|
||||||
|
}
|
||||||
|
InputMode::Insert => {
|
||||||
|
self.mode = InputMode::Normal;
|
||||||
|
self.move_(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, append: bool) -> bool {
|
||||||
|
if self.mode != InputMode::Normal {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.mode = InputMode::Insert;
|
||||||
|
if append {
|
||||||
|
self.move_(1);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visual(&mut self) -> bool {
|
||||||
|
if self.mode != InputMode::Normal {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.range = Some((self.cursor, self.cursor));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_(&mut self, step: isize) -> bool {
|
||||||
|
let old = self.cursor;
|
||||||
|
let mut include = false;
|
||||||
|
|
||||||
|
if step <= 0 {
|
||||||
|
self.cursor = self.cursor.saturating_sub(step.abs() as usize);
|
||||||
|
} else {
|
||||||
|
let count = self.count();
|
||||||
|
self.cursor += step as usize;
|
||||||
|
|
||||||
|
if self.cursor >= count {
|
||||||
|
include = true;
|
||||||
|
self.cursor = if self.mode == InputMode::Insert { count } else { count.saturating_sub(1) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.cursor != old {
|
||||||
|
if self.cursor < self.offset {
|
||||||
|
self.offset = self.cursor;
|
||||||
|
} else if self.cursor > self.offset + 50 {
|
||||||
|
self.offset = self.cursor.saturating_sub(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.handle_op(include) || self.cursor != old
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backward(&mut self) -> bool {
|
||||||
|
if self.cursor == 0 {
|
||||||
|
return self.handle_op(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = self.idx(self.cursor).unwrap_or(self.value.len());
|
||||||
|
let mut it = self.value[..idx].chars().rev().enumerate();
|
||||||
|
let mut prev = CharKind::new(it.next().unwrap().1);
|
||||||
|
for (i, c) in it {
|
||||||
|
let c = CharKind::new(c);
|
||||||
|
if prev != CharKind::Space && prev != c {
|
||||||
|
return self.move_(-(i as isize));
|
||||||
|
}
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev != CharKind::Space {
|
||||||
|
return self.move_(-(self.value.len() as isize));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(&mut self, end: bool) -> bool {
|
||||||
|
if self.value.is_empty() {
|
||||||
|
return self.handle_op(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut it = self.value.chars().skip(self.cursor).enumerate();
|
||||||
|
let mut prev = CharKind::new(it.next().unwrap().1);
|
||||||
|
for (i, c) in it {
|
||||||
|
let c = CharKind::new(c);
|
||||||
|
let b = if end {
|
||||||
|
prev != CharKind::Space && prev != c && i != 1
|
||||||
|
} else {
|
||||||
|
c != CharKind::Space && c != prev
|
||||||
|
};
|
||||||
|
if b {
|
||||||
|
return self.move_(if end { i - 1 } else { i } as isize);
|
||||||
|
}
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.move_(self.value.len() as isize)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn type_(&mut self, c: char) -> bool {
|
||||||
|
if self.cursor < 1 {
|
||||||
|
self.value.insert(0, c);
|
||||||
|
} else if self.cursor == self.count() {
|
||||||
|
self.value.push(c);
|
||||||
|
} else {
|
||||||
|
self.value.insert(self.idx(self.cursor).unwrap(), c);
|
||||||
|
}
|
||||||
|
self.move_(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self) -> bool {
|
||||||
|
if self.cursor < 1 {
|
||||||
|
return false;
|
||||||
|
} else if self.cursor == self.count() {
|
||||||
|
self.value.pop();
|
||||||
|
} else {
|
||||||
|
self.value.remove(self.idx(self.cursor - 1).unwrap());
|
||||||
|
}
|
||||||
|
self.move_(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&mut self, insert: bool) -> bool {
|
||||||
|
match self.op {
|
||||||
|
InputOp::None => {
|
||||||
|
self.op = InputOp::Delete(insert);
|
||||||
|
if self.range.is_some() {
|
||||||
|
return self.handle_op(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.range = Some((self.cursor, self.cursor));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
InputOp::Delete(_) => {
|
||||||
|
self.move_(-(self.value.len() as isize));
|
||||||
|
self.value.clear();
|
||||||
|
|
||||||
|
self.op = InputOp::None;
|
||||||
|
self.range = None;
|
||||||
|
|
||||||
|
self.mode = if insert { InputMode::Insert } else { InputMode::Normal };
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_op(&mut self, include: bool) -> bool {
|
||||||
|
if let Some(ref mut range) = self.range {
|
||||||
|
*range = (range.0.min(self.cursor), range.0.max(self.cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.op {
|
||||||
|
InputOp::None => return false,
|
||||||
|
InputOp::Delete(insert) => {
|
||||||
|
let range = self.range.take().unwrap();
|
||||||
|
if !self.value.is_empty() {
|
||||||
|
let (start, end) = (self.idx(range.0), self.idx(range.1 + include as usize));
|
||||||
|
self.value.drain(start.unwrap()..end.unwrap());
|
||||||
|
}
|
||||||
|
self.mode = if insert {
|
||||||
|
self.cursor = range.0.min(self.count());
|
||||||
|
InputMode::Insert
|
||||||
|
} else {
|
||||||
|
self.cursor = range.0.min(self.count().saturating_sub(1));
|
||||||
|
InputMode::Normal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
InputOp::Yank => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.op = InputOp::None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
#[inline]
|
||||||
|
pub fn title(&self) -> String { self.title.clone() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn value(&self) -> String { self.value.clone() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn area(&self) -> Rect {
|
||||||
|
Rect { x: self.position.0, y: self.position.1 + 2, width: 50, height: 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn mode(&self) -> InputMode { self.mode }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn cursor(&self) -> (u16, u16) {
|
||||||
|
let width = self
|
||||||
|
.value
|
||||||
|
.chars()
|
||||||
|
.enumerate()
|
||||||
|
.take_while(|(i, _)| *i < self.cursor)
|
||||||
|
.map(|(_, c)| c)
|
||||||
|
.collect::<String>()
|
||||||
|
.width() as u16;
|
||||||
|
|
||||||
|
let area = self.area();
|
||||||
|
(area.x + width + 1, area.y + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn top_position() -> (u16, u16) { ((tty_size().ws_col / 2).saturating_sub(25), 2) }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn count(&self) -> usize { self.value.chars().count() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn idx(&self, n: usize) -> Option<usize> {
|
||||||
|
self
|
||||||
|
.value
|
||||||
|
.char_indices()
|
||||||
|
.nth(n)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.or_else(|| if n == self.count() { Some(self.value.len()) } else { None })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Input {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
title: "".to_string(),
|
||||||
|
value: "".to_string(),
|
||||||
|
position: Default::default(),
|
||||||
|
|
||||||
|
op: Default::default(),
|
||||||
|
mode: Default::default(),
|
||||||
|
cursor: 0,
|
||||||
|
offset: 0,
|
||||||
|
range: None,
|
||||||
|
|
||||||
|
visible: false,
|
||||||
|
callback: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
src/core/input/mod.rs
Normal file
3
src/core/input/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod input;
|
||||||
|
|
||||||
|
pub use input::*;
|
254
src/core/manager/folder.rs
Normal file
254
src/core/manager/folder.rs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
use std::{fs::Metadata, path::{Path, PathBuf}, usize};
|
||||||
|
|
||||||
|
use indexmap::{map::Slice, IndexMap};
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use super::{ALL_RATIO, CURRENT_RATIO, DIR_PADDING, PARENT_RATIO};
|
||||||
|
use crate::{config::{manager::SortBy, MANAGER}, emit, misc::tty_size};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Folder {
|
||||||
|
pub cwd: PathBuf,
|
||||||
|
items: IndexMap<PathBuf, FolderItem>,
|
||||||
|
offset: usize,
|
||||||
|
cursor: usize,
|
||||||
|
|
||||||
|
sort: FolderSort,
|
||||||
|
show_hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FolderItem {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub meta: Metadata,
|
||||||
|
pub length: Option<u64>,
|
||||||
|
pub is_link: bool,
|
||||||
|
pub is_hidden: bool,
|
||||||
|
pub is_selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FolderSort {
|
||||||
|
pub by: SortBy,
|
||||||
|
pub reverse: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FolderSort {
|
||||||
|
fn default() -> Self { Self { by: MANAGER.sort_by, reverse: MANAGER.sort_reverse } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Folder {
|
||||||
|
pub fn new(cwd: &Path) -> Self {
|
||||||
|
Self { cwd: cwd.to_path_buf(), show_hidden: MANAGER.show_hidden, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn limit() -> usize { tty_size().ws_row.saturating_sub(DIR_PADDING) as usize }
|
||||||
|
|
||||||
|
pub async fn read(path: &Path) {
|
||||||
|
let mut iter = match fs::read_dir(path).await {
|
||||||
|
Ok(it) => it,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = IndexMap::new();
|
||||||
|
while let Ok(Some(item)) = iter.next_entry().await {
|
||||||
|
let mut meta = if let Ok(meta) = item.metadata().await { meta } else { continue };
|
||||||
|
let is_link = meta.is_symlink();
|
||||||
|
if is_link {
|
||||||
|
meta = fs::metadata(&path).await.unwrap_or(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = item.path();
|
||||||
|
let name = item.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let length = if meta.is_dir() { None } else { Some(meta.len()) };
|
||||||
|
let is_hidden = name.starts_with('.');
|
||||||
|
|
||||||
|
items.insert(path.clone(), FolderItem {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
meta,
|
||||||
|
length,
|
||||||
|
is_link,
|
||||||
|
is_hidden,
|
||||||
|
is_selected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emit!(Files(path.to_path_buf(), items));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort(&mut self) {
|
||||||
|
fn cmp<T: Ord>(a: T, b: T, reverse: bool) -> std::cmp::Ordering {
|
||||||
|
if reverse { b.cmp(&a) } else { a.cmp(&b) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let reverse = self.sort.reverse;
|
||||||
|
match self.sort.by {
|
||||||
|
SortBy::Alphabetical => self.items.sort_by(|_, a, _, b| cmp(&a.name, &b.name, reverse)),
|
||||||
|
SortBy::Created => self.items.sort_by(|_, a, _, b| {
|
||||||
|
if let (Ok(a), Ok(b)) = (a.meta.created(), b.meta.created()) {
|
||||||
|
return cmp(a, b, reverse);
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}),
|
||||||
|
SortBy::Modified => self.items.sort_by(|_, a, _, b| {
|
||||||
|
if let (Ok(a), Ok(b)) = (a.meta.modified(), b.meta.modified()) {
|
||||||
|
return cmp(a, b, reverse);
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}),
|
||||||
|
SortBy::Size => {
|
||||||
|
self.items.sort_by(|_, a, _, b| cmp(a.length.unwrap_or(0), b.length.unwrap_or(0), reverse))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, mut items: IndexMap<PathBuf, FolderItem>) -> bool {
|
||||||
|
if !self.show_hidden {
|
||||||
|
items.retain(|_, item| !item.is_hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, item) in &mut items {
|
||||||
|
if let Some(old) = self.items.get(path) {
|
||||||
|
item.length = old.length;
|
||||||
|
item.is_selected = old.is_selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = items.len();
|
||||||
|
self.items = items;
|
||||||
|
self.cursor = self.cursor.min(len.saturating_sub(1));
|
||||||
|
self.offset = self.offset.min(len);
|
||||||
|
self.sort();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self, step: usize) -> bool {
|
||||||
|
let len = self.items.len();
|
||||||
|
if len == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let old = self.cursor;
|
||||||
|
self.cursor = (self.cursor + step).min(len - 1);
|
||||||
|
|
||||||
|
let limit = Self::limit();
|
||||||
|
if self.cursor >= (self.offset + limit).min(len).saturating_sub(5) {
|
||||||
|
self.offset = len.saturating_sub(limit).min(self.offset + self.cursor - old);
|
||||||
|
}
|
||||||
|
|
||||||
|
old != self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self, step: usize) -> bool {
|
||||||
|
let old = self.cursor;
|
||||||
|
self.cursor = self.cursor.saturating_sub(step);
|
||||||
|
|
||||||
|
if self.cursor < self.offset + 5 {
|
||||||
|
self.offset = self.offset.saturating_sub(old - self.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
old != self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hidden(&mut self, show: Option<bool>) -> bool {
|
||||||
|
if show.is_none() || self.show_hidden != show.unwrap() {
|
||||||
|
self.show_hidden = !self.show_hidden;
|
||||||
|
emit!(Refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paginate(&self) -> &Slice<PathBuf, FolderItem> {
|
||||||
|
let end = (self.offset + Self::limit()).min(self.items.len());
|
||||||
|
self.items.get_range(self.offset..end).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, idx: Option<usize>, state: Option<bool>) -> bool {
|
||||||
|
let len = self.items.len();
|
||||||
|
let mut apply = |idx: usize, state: Option<bool>| -> bool {
|
||||||
|
if state.is_none() {
|
||||||
|
self.items[idx].is_selected = !self.items[idx].is_selected;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = state.unwrap();
|
||||||
|
if state != self.items[idx].is_selected {
|
||||||
|
self.items[idx].is_selected = state;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(idx) = idx {
|
||||||
|
if idx < len {
|
||||||
|
return apply(idx, state);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut applied = false;
|
||||||
|
for i in 0..len {
|
||||||
|
if apply(i, state) {
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<Vec<PathBuf>> {
|
||||||
|
let v = self
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, item)| item.is_selected)
|
||||||
|
.map(|(path, _)| path.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if v.is_empty() { None } else { Some(v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hover(&mut self, path: &Path) -> bool {
|
||||||
|
if self.hovered().map(|h| h.path.as_path()) == Some(path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new = self.position(path).unwrap_or(self.cursor);
|
||||||
|
if new > self.cursor { self.next(new - self.cursor) } else { self.prev(self.cursor - new) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Folder {
|
||||||
|
#[inline]
|
||||||
|
pub fn hovered(&self) -> Option<&FolderItem> {
|
||||||
|
self.items.get_index(self.cursor).map(|(_, item)| item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn cursor(&self) -> usize { self.cursor }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn rel_cursor(&self) -> usize { self.cursor - self.offset }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn position(&self, path: &Path) -> Option<usize> {
|
||||||
|
self.items.iter().position(|(p, _)| p == path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn rect_current(&self, path: &Path) -> Option<Rect> {
|
||||||
|
let pos = self.position(path)? - self.offset;
|
||||||
|
let s = tty_size();
|
||||||
|
|
||||||
|
Some(Rect {
|
||||||
|
x: (s.ws_col as u32 * PARENT_RATIO / ALL_RATIO) as u16,
|
||||||
|
y: pos as u16,
|
||||||
|
width: (s.ws_col as u32 * CURRENT_RATIO / ALL_RATIO) as u16,
|
||||||
|
height: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
252
src/core/manager/manager.rs
Normal file
252
src/core/manager/manager.rs
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
use std::{collections::{BTreeSet, HashMap, HashSet}, path::PathBuf};
|
||||||
|
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
|
use super::{FolderItem, PreviewData, Tab, Tabs, Watcher};
|
||||||
|
use crate::{core::{Folder, Input, InputOpt, Precache}, emit};
|
||||||
|
|
||||||
|
pub struct Manager {
|
||||||
|
tabs: Tabs,
|
||||||
|
yanked: (bool, HashSet<PathBuf>),
|
||||||
|
|
||||||
|
watcher: Watcher,
|
||||||
|
mimetype: HashMap<PathBuf, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tabs: Tabs::new(),
|
||||||
|
yanked: Default::default(),
|
||||||
|
|
||||||
|
watcher: Watcher::init(),
|
||||||
|
mimetype: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
self.watcher.trigger(&self.current().cwd);
|
||||||
|
if let Some(p) = self.parent() {
|
||||||
|
self.watcher.trigger(&p.cwd);
|
||||||
|
}
|
||||||
|
emit!(Hover);
|
||||||
|
|
||||||
|
let mut to_watch = BTreeSet::new();
|
||||||
|
for tab in self.tabs.iter() {
|
||||||
|
to_watch.insert(tab.current.cwd.clone());
|
||||||
|
if let Some(ref p) = tab.parent {
|
||||||
|
to_watch.insert(p.cwd.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref h) = tab.current.hovered() {
|
||||||
|
to_watch.insert(h.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.watcher.watch(to_watch);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview(&mut self) -> bool {
|
||||||
|
let hovered = if let Some(h) = self.hovered() {
|
||||||
|
h.clone()
|
||||||
|
} else {
|
||||||
|
return self.active_mut().preview.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if hovered.meta.is_dir() {
|
||||||
|
self.active_mut().preview.go(&hovered.path, "inode/directory");
|
||||||
|
if self.active().history(&hovered.path).is_some() {
|
||||||
|
emit!(Preview(hovered.path, PreviewData::Folder));
|
||||||
|
}
|
||||||
|
} else if let Some(mime) = self.mimetype.get(&hovered.path).cloned() {
|
||||||
|
self.active_mut().preview.go(&hovered.path, &mime);
|
||||||
|
} else {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(mime) = Precache::mimetype(&vec![hovered.path.clone()]).await {
|
||||||
|
if let Some(Some(mime)) = mime.first() {
|
||||||
|
emit!(Mimetype(hovered.path, mime.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self) -> bool {
|
||||||
|
if self.tabs.len() > 1 {
|
||||||
|
return self.tabs.close(self.tabs.idx());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit!(Quit);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn yank(&mut self, cut: bool) -> bool {
|
||||||
|
self.yanked.0 = cut;
|
||||||
|
self.yanked.1.clear();
|
||||||
|
self.yanked.1.extend(self.selected());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn yanked(&self) -> &(bool, HashSet<PathBuf>) { &self.yanked }
|
||||||
|
|
||||||
|
pub fn create(&self) -> bool {
|
||||||
|
let pos = Input::top_position();
|
||||||
|
let cwd = self.current().cwd.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = emit!(Input(InputOpt {
|
||||||
|
title: "Create:".to_string(),
|
||||||
|
value: "".to_string(),
|
||||||
|
position: pos,
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(name) = result {
|
||||||
|
let path = cwd.join(&name);
|
||||||
|
if name.ends_with('/') {
|
||||||
|
fs::create_dir_all(path).await.ok();
|
||||||
|
} else {
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).await.ok();
|
||||||
|
fs::File::create(path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename(&self) -> bool {
|
||||||
|
let selected = self.selected();
|
||||||
|
if selected.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected.len() > 1 {
|
||||||
|
return self.bulk_rename();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect = self.current().rect_current(&selected[0]).unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = emit!(Input(InputOpt {
|
||||||
|
title: "Rename:".to_string(),
|
||||||
|
value: selected[0].file_name().unwrap().to_string_lossy().to_string(),
|
||||||
|
position: (rect.x, rect.y),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Ok(new) = result {
|
||||||
|
let to = selected[0].parent().unwrap().join(new);
|
||||||
|
fs::rename(&selected[0], to).await.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_rename(&self) -> bool { false }
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Vec<PathBuf> {
|
||||||
|
self
|
||||||
|
.current()
|
||||||
|
.selected()
|
||||||
|
.or_else(|| self.hovered().map(|h| vec![h.path.clone()]))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mimetype(&mut self, files: &Vec<PathBuf>) -> Vec<Option<String>> {
|
||||||
|
let todo =
|
||||||
|
files.iter().filter(|&p| !self.mimetype.contains_key(p)).cloned().collect::<Vec<_>>();
|
||||||
|
if let Ok(mime) = Precache::mimetype(&todo).await {
|
||||||
|
let mut it = todo.iter().zip(mime);
|
||||||
|
while let Some((p, Some(m))) = it.next() {
|
||||||
|
self.mimetype.insert(p.clone(), m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.into_iter().map(|p| self.mimetype.get(p).cloned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_files(&mut self, path: PathBuf, items: IndexMap<PathBuf, FolderItem>) -> bool {
|
||||||
|
let cwd = self.current().cwd.clone();
|
||||||
|
let hovered = self.hovered().map(|h| h.path.clone());
|
||||||
|
|
||||||
|
let mut b = if self.current().cwd == path {
|
||||||
|
self.current_mut().update(items)
|
||||||
|
} else if matches!(self.parent(), Some(p) if p.cwd == path) {
|
||||||
|
self.active_mut().parent.as_mut().unwrap().update(items)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
.active_mut()
|
||||||
|
.history
|
||||||
|
.entry(path.clone())
|
||||||
|
.or_insert_with(|| Folder::new(&path))
|
||||||
|
.update(items);
|
||||||
|
|
||||||
|
matches!(self.hovered(), Some(h) if h.path == path)
|
||||||
|
};
|
||||||
|
|
||||||
|
b |= self.active_mut().parent.as_mut().map_or(false, |p| p.hover(&cwd));
|
||||||
|
b |= hovered.as_ref().map_or(false, |h| self.current_mut().hover(h));
|
||||||
|
|
||||||
|
if hovered != self.hovered().map(|h| h.path.clone()) {
|
||||||
|
emit!(Hover);
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_mimetype(&mut self, path: PathBuf, mimetype: String) -> bool {
|
||||||
|
if matches!(self.mimetype.get(&path), Some(m) if m == &mimetype) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.mimetype.insert(path, mimetype);
|
||||||
|
self.preview();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_preview(&mut self, path: PathBuf, data: PreviewData) -> bool {
|
||||||
|
let hovered = if let Some(h) = self.current().hovered() {
|
||||||
|
h.path.clone()
|
||||||
|
} else {
|
||||||
|
return self.active_mut().preview.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if hovered != path {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview = &mut self.active_mut().preview;
|
||||||
|
preview.path = path;
|
||||||
|
preview.data = data;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manager {
|
||||||
|
#[inline]
|
||||||
|
pub fn tabs(&self) -> &Tabs { &self.tabs }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn tabs_mut(&mut self) -> &mut Tabs { &mut self.tabs }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn active(&self) -> &Tab { self.tabs.active() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn active_mut(&mut self) -> &mut Tab { self.tabs.active_mut() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn current(&self) -> &Folder { &self.tabs.active().current }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn current_mut(&mut self) -> &mut Folder { &mut self.tabs.active_mut().current }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn parent(&self) -> &Option<Folder> { &self.tabs.active().parent }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn hovered(&self) -> Option<&FolderItem> { self.tabs.active().current.hovered() }
|
||||||
|
}
|
25
src/core/manager/mod.rs
Normal file
25
src/core/manager/mod.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
mod folder;
|
||||||
|
mod manager;
|
||||||
|
mod mode;
|
||||||
|
mod preview;
|
||||||
|
mod tab;
|
||||||
|
mod tabs;
|
||||||
|
mod watcher;
|
||||||
|
|
||||||
|
pub use folder::*;
|
||||||
|
pub use manager::*;
|
||||||
|
pub use mode::*;
|
||||||
|
pub use preview::*;
|
||||||
|
pub use tab::*;
|
||||||
|
pub use tabs::*;
|
||||||
|
pub use watcher::*;
|
||||||
|
|
||||||
|
pub const PARENT_RATIO: u32 = 1;
|
||||||
|
pub const CURRENT_RATIO: u32 = 4;
|
||||||
|
pub const PREVIEW_RATIO: u32 = 3;
|
||||||
|
pub const ALL_RATIO: u32 = PARENT_RATIO + CURRENT_RATIO + PREVIEW_RATIO;
|
||||||
|
|
||||||
|
pub const DIR_PADDING: u16 = 2;
|
||||||
|
|
||||||
|
pub const PREVIEW_BORDER: u16 = 2;
|
||||||
|
pub const PREVIEW_PADDING: u16 = 2;
|
41
src/core/manager/mode.rs
Normal file
41
src/core/manager/mode.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use crate::config::{theme, THEME};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
Select(usize),
|
||||||
|
Unselect(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
#[inline]
|
||||||
|
pub fn color(&self) -> &theme::Color {
|
||||||
|
match *self {
|
||||||
|
Mode::Normal => &THEME.mode.normal,
|
||||||
|
Mode::Select(_) => &THEME.mode.select,
|
||||||
|
Mode::Unselect(_) => &THEME.mode.unselect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn start(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Mode::Normal => None,
|
||||||
|
Mode::Select(n) => Some(*n),
|
||||||
|
Mode::Unselect(n) => Some(*n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Mode {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match *self {
|
||||||
|
Mode::Normal => write!(f, "NORMAL"),
|
||||||
|
Mode::Select(_) => write!(f, "SELECT"),
|
||||||
|
Mode::Unselect(_) => write!(f, "UN-SEL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
157
src/core/manager/preview.rs
Normal file
157
src/core/manager/preview.rs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
use std::{fs::File, io::BufReader, path::{Path, PathBuf}, sync::OnceLock};
|
||||||
|
|
||||||
|
use adapter::kitty::Kitty;
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use syntect::{easy::HighlightLines, highlighting::{Theme, ThemeSet}, parsing::SyntaxSet, util::as_24_bit_terminal_escaped};
|
||||||
|
use tokio::{fs, task::JoinHandle};
|
||||||
|
|
||||||
|
use super::{Folder, ALL_RATIO, PREVIEW_BORDER, PREVIEW_PADDING, PREVIEW_RATIO};
|
||||||
|
use crate::{config::{PREVIEW, THEME}, core::{adapter, Precache}, emit, misc::{first_n_lines, tty_ratio, tty_size}};
|
||||||
|
|
||||||
|
static SYNTECT_SYNTAX: OnceLock<SyntaxSet> = OnceLock::new();
|
||||||
|
static SYNTECT_THEME: OnceLock<Theme> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Preview {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub data: PreviewData,
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub enum PreviewData {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Folder,
|
||||||
|
Text(String),
|
||||||
|
Image(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Preview {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { path: Default::default(), data: Default::default(), handle: Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size() -> (u16, u16) {
|
||||||
|
let s = tty_size();
|
||||||
|
let col = (s.ws_col as u32 * PREVIEW_RATIO / ALL_RATIO) as u16;
|
||||||
|
(col.saturating_sub(PREVIEW_BORDER), s.ws_row.saturating_sub(PREVIEW_PADDING))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go(&mut self, path: &Path, mime: &str) {
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, mime) = (path.to_path_buf(), mime.to_string());
|
||||||
|
self.handle = Some(tokio::spawn(async move {
|
||||||
|
let result = if mime == "inode/directory" {
|
||||||
|
Self::folder(&path).await
|
||||||
|
} else if mime == "application/json" {
|
||||||
|
Self::json(&path).await.map(PreviewData::Text)
|
||||||
|
} else if mime.starts_with("text/") || mime.ends_with("/xml") {
|
||||||
|
Self::highlight(&path).await.map(PreviewData::Text)
|
||||||
|
} else if mime.starts_with("image/") {
|
||||||
|
Self::image(&path).await.map(PreviewData::Image)
|
||||||
|
} else if mime.starts_with("video/") {
|
||||||
|
Self::video(&path).await.map(PreviewData::Image)
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Unsupported mimetype: {}", mime))
|
||||||
|
};
|
||||||
|
|
||||||
|
emit!(Preview(path, result.unwrap_or_default()));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) -> bool {
|
||||||
|
if self.path == PathBuf::default() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.path = Default::default();
|
||||||
|
self.data = Default::default();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn folder(path: &Path) -> Result<PreviewData> {
|
||||||
|
Folder::read(&path).await;
|
||||||
|
Ok(PreviewData::Folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn image(mut path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let cache = Precache::cache(path);
|
||||||
|
if cache.exists() {
|
||||||
|
path = cache.as_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (w, h) = {
|
||||||
|
let r = tty_ratio();
|
||||||
|
let (w, h) = Self::size();
|
||||||
|
let (w, h) = ((w as f64 * r.0) as u32, (h as f64 * r.1) as u32);
|
||||||
|
(w.min(PREVIEW.max_width), h.min(PREVIEW.max_height))
|
||||||
|
};
|
||||||
|
|
||||||
|
let file = fs::read(path).await?;
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
|
||||||
|
let img = image::load_from_memory(&file)?;
|
||||||
|
Kitty::image_show(if img.width() > w || img.height() > h {
|
||||||
|
img.resize(w, h, FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn video(path: &Path) -> Result<Vec<u8>> {
|
||||||
|
Precache::video(path).await?;
|
||||||
|
|
||||||
|
let cache = Precache::cache(path);
|
||||||
|
Self::image(&cache).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn json(path: &Path) -> Result<String> {
|
||||||
|
Ok(
|
||||||
|
Precache::json(path)
|
||||||
|
.await?
|
||||||
|
.lines()
|
||||||
|
.take(Self::size().1 as usize)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn highlight(path: &Path) -> Result<String> {
|
||||||
|
let syntax = SYNTECT_SYNTAX.get_or_init(|| SyntaxSet::load_defaults_newlines());
|
||||||
|
|
||||||
|
let theme = SYNTECT_THEME.get_or_init(|| {
|
||||||
|
let from_file = || -> Result<Theme> {
|
||||||
|
let file = File::open(&THEME.syntect.theme)?;
|
||||||
|
Ok(ThemeSet::load_from_reader(&mut BufReader::new(file))?)
|
||||||
|
};
|
||||||
|
from_file().unwrap_or_else(|_| ThemeSet::load_defaults().themes["base16-ocean.dark"].clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
let ext = path.extension().context("no extension found")?.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let lines = first_n_lines(path, Self::size().1 as usize).await?;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<String> {
|
||||||
|
let mut buf = "".to_string();
|
||||||
|
if let Some(syn) = syntax.find_syntax_by_extension(&ext) {
|
||||||
|
let mut h = HighlightLines::new(syn, theme);
|
||||||
|
let tab = " ".repeat(PREVIEW.tab_size as usize);
|
||||||
|
for line in lines {
|
||||||
|
let line = line.replace('\t', &tab);
|
||||||
|
let ranges = h.highlight_line(&line, &syntax)?;
|
||||||
|
buf.push_str(&as_24_bit_terminal_escaped(&ranges, false));
|
||||||
|
buf.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
}
|
144
src/core/manager/tab.rs
Normal file
144
src/core/manager/tab.rs
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
use std::{collections::BTreeMap, mem, path::{Path, PathBuf}};
|
||||||
|
|
||||||
|
use super::{Folder, Mode, Preview};
|
||||||
|
use crate::emit;
|
||||||
|
|
||||||
|
pub struct Tab {
|
||||||
|
pub(super) current: Folder,
|
||||||
|
pub(super) parent: Option<Folder>,
|
||||||
|
|
||||||
|
pub(super) mode: Mode,
|
||||||
|
|
||||||
|
pub(super) history: BTreeMap<PathBuf, Folder>,
|
||||||
|
pub(super) preview: Preview,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
pub fn new(path: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
current: Folder::new(path),
|
||||||
|
parent: path.parent().map(|p| Folder::new(p)),
|
||||||
|
|
||||||
|
mode: Default::default(),
|
||||||
|
|
||||||
|
history: Default::default(),
|
||||||
|
preview: Preview::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn escape(&mut self) -> bool {
|
||||||
|
if matches!(self.mode, Mode::Select(_) | Mode::Unselect(_)) {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.select_all(Some(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arrow(&mut self, step: isize) -> bool {
|
||||||
|
let before = self.current.cursor();
|
||||||
|
let ok = if step > 0 {
|
||||||
|
self.current.next(step as usize)
|
||||||
|
} else {
|
||||||
|
self.current.prev(step.abs() as usize)
|
||||||
|
};
|
||||||
|
if !ok {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual selection
|
||||||
|
if let Some(start) = self.mode.start() {
|
||||||
|
let after = self.current.cursor();
|
||||||
|
if (after > before && before < start) || (after < before && before > start) {
|
||||||
|
for i in before.min(start)..=start.max(before) {
|
||||||
|
self.current.select(Some(i), Some(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in start.min(after)..=after.max(start) {
|
||||||
|
self.current.select(Some(i), Some(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit!(Hover);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter(&mut self) -> bool {
|
||||||
|
let hovered = if let Some(h) = self.current.hovered() {
|
||||||
|
h.clone()
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !hovered.meta.is_dir() {
|
||||||
|
emit!(Open(self.current.selected().unwrap_or(vec![hovered.path])));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = self.history.remove(&hovered.path).unwrap_or_else(|| Folder::new(&hovered.path));
|
||||||
|
let parent = mem::replace(&mut self.current, current);
|
||||||
|
|
||||||
|
if self.parent.is_none() {
|
||||||
|
self.parent = Some(parent);
|
||||||
|
} else {
|
||||||
|
let cwd = self.parent.as_ref().unwrap().cwd.clone();
|
||||||
|
let pparent = mem::replace(self.parent.as_mut().unwrap(), parent);
|
||||||
|
self.history.insert(cwd, pparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit!(Refresh);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(&mut self) -> bool {
|
||||||
|
let parent = if let Some(p) = &self.parent {
|
||||||
|
p.cwd.clone()
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pparent = parent.parent().map(|p| self.history.remove(p).unwrap_or_else(|| Folder::new(p)));
|
||||||
|
|
||||||
|
let cwd = self.current.cwd.clone();
|
||||||
|
let parent = mem::replace(&mut self.parent, pparent).unwrap();
|
||||||
|
let current = mem::replace(&mut self.current, parent);
|
||||||
|
self.history.insert(cwd, current);
|
||||||
|
|
||||||
|
emit!(Refresh);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn back(&mut self) -> bool { todo!() }
|
||||||
|
|
||||||
|
pub fn forward(&mut self) -> bool { todo!() }
|
||||||
|
|
||||||
|
pub fn select(&mut self, state: Option<bool>) -> bool {
|
||||||
|
let idx = Some(self.current.cursor());
|
||||||
|
self.current.select(idx, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_all(&mut self, state: Option<bool>) -> bool { self.current.select(None, state) }
|
||||||
|
|
||||||
|
pub fn visual_mode(&mut self, unsel: bool) -> bool {
|
||||||
|
let idx = self.current.cursor();
|
||||||
|
|
||||||
|
if unsel {
|
||||||
|
self.mode = Mode::Unselect(idx);
|
||||||
|
self.current.select(Some(idx), Some(false));
|
||||||
|
} else {
|
||||||
|
self.mode = Mode::Select(idx);
|
||||||
|
self.current.select(Some(idx), Some(true));
|
||||||
|
};
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
#[inline]
|
||||||
|
pub fn mode(&self) -> &Mode { &self.mode }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn history(&self, path: &Path) -> Option<&Folder> { self.history.get(path) }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn preview(&self) -> &Preview { &self.preview }
|
||||||
|
}
|
98
src/core/manager/tabs.rs
Normal file
98
src/core/manager/tabs.rs
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::Tab;
|
||||||
|
use crate::{config::MANAGER, emit};
|
||||||
|
|
||||||
|
const MAX_TABS: usize = 9;
|
||||||
|
|
||||||
|
pub struct Tabs {
|
||||||
|
idx: usize,
|
||||||
|
items: Vec<Tab>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tabs {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let tabs = Self { idx: 0, items: vec![Tab::new(&MANAGER.cwd)] };
|
||||||
|
|
||||||
|
emit!(Refresh);
|
||||||
|
tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&mut self, path: &Path) -> bool {
|
||||||
|
if self.items.len() >= MAX_TABS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.items.insert(self.idx + 1, Tab::new(path));
|
||||||
|
self.set_idx(self.idx + 1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch(&mut self, idx: isize, rel: bool) -> bool {
|
||||||
|
let idx = if rel { self.absolute(idx) } else { idx as usize };
|
||||||
|
|
||||||
|
if idx == self.idx || idx >= self.items.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_idx(idx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn swap(&mut self, rel: isize) -> bool {
|
||||||
|
let idx = self.absolute(rel);
|
||||||
|
if idx == self.idx {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.items.swap(self.idx, idx);
|
||||||
|
self.set_idx(idx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self, idx: usize) -> bool {
|
||||||
|
let len = self.items.len();
|
||||||
|
if len <= 1 || idx as usize >= len {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.items.remove(idx);
|
||||||
|
if idx == self.idx {
|
||||||
|
self.set_idx(self.absolute(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn absolute(&self, rel: isize) -> usize {
|
||||||
|
if rel > 0 {
|
||||||
|
(self.idx + rel as usize).min(self.items.len() - 1)
|
||||||
|
} else {
|
||||||
|
self.idx.saturating_sub(rel.abs() as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn set_idx(&mut self, idx: usize) {
|
||||||
|
self.idx = idx;
|
||||||
|
emit!(Refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tabs {
|
||||||
|
#[inline]
|
||||||
|
pub fn idx(&self) -> usize { self.idx }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn len(&self) -> usize { self.items.len() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &Tab> { self.items.iter() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn active(&self) -> &Tab { &self.items[self.idx] }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn active_mut(&mut self) -> &mut Tab { &mut self.items[self.idx] }
|
||||||
|
}
|
81
src/core/manager/watcher.rs
Normal file
81
src/core/manager/watcher.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
use std::{collections::BTreeSet, path::{Path, PathBuf}};
|
||||||
|
|
||||||
|
use notify::{RecommendedWatcher, Watcher as _Watcher};
|
||||||
|
use tokio::sync::mpsc::{self, Sender};
|
||||||
|
|
||||||
|
use super::Folder;
|
||||||
|
use crate::emit;
|
||||||
|
|
||||||
|
pub struct Watcher {
|
||||||
|
tx: Sender<PathBuf>,
|
||||||
|
|
||||||
|
watcher: RecommendedWatcher,
|
||||||
|
watched: BTreeSet<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Watcher {
|
||||||
|
pub fn init() -> Self {
|
||||||
|
let (watcher, tx) = Self::start();
|
||||||
|
|
||||||
|
Self { tx, watcher, watched: Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start() -> (RecommendedWatcher, Sender<PathBuf>) {
|
||||||
|
let (tx, mut rx) = mpsc::channel(50);
|
||||||
|
|
||||||
|
let watcher = RecommendedWatcher::new(
|
||||||
|
{
|
||||||
|
let tx = tx.clone();
|
||||||
|
move |res: Result<notify::Event, notify::Error>| {
|
||||||
|
if res.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = res.unwrap();
|
||||||
|
match event.kind {
|
||||||
|
notify::EventKind::Create(_) => {}
|
||||||
|
notify::EventKind::Modify(_) => {}
|
||||||
|
notify::EventKind::Remove(_) => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = if event.paths.len() > 0 {
|
||||||
|
event.paths[0].parent().unwrap_or(&event.paths[0])
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.blocking_send(path.to_path_buf()).ok();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notify::Config::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(path) = rx.recv().await {
|
||||||
|
Folder::read(&path).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(watcher, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn watch(&mut self, to_watch: BTreeSet<PathBuf>) {
|
||||||
|
for p in to_watch.difference(&self.watched) {
|
||||||
|
self.watcher.watch(&p, notify::RecursiveMode::NonRecursive).ok();
|
||||||
|
}
|
||||||
|
for p in self.watched.difference(&to_watch) {
|
||||||
|
self.watcher.unwatch(p).ok();
|
||||||
|
}
|
||||||
|
self.watched = to_watch;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn trigger(&self, path: &Path) {
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tx.send(path).await.ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
11
src/core/mod.rs
Normal file
11
src/core/mod.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
mod adapter;
|
||||||
|
mod event;
|
||||||
|
mod input;
|
||||||
|
mod manager;
|
||||||
|
mod tasks;
|
||||||
|
|
||||||
|
pub use adapter::*;
|
||||||
|
pub use event::*;
|
||||||
|
pub use input::*;
|
||||||
|
pub use manager::*;
|
||||||
|
pub use tasks::*;
|
315
src/core/tasks/file.rs
Normal file
315
src/core/tasks/file.rs
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
use std::{collections::VecDeque, fs::Metadata, path::{Path, PathBuf}};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures::{future::BoxFuture, FutureExt};
|
||||||
|
use tokio::{fs, io::{self, ErrorKind::{AlreadyExists, NotFound}}, sync::mpsc};
|
||||||
|
use tracing::{info, trace};
|
||||||
|
use trash::{macos::{DeleteMethod, TrashContextExtMacos}, TrashContext};
|
||||||
|
|
||||||
|
use super::TaskOp;
|
||||||
|
use crate::misc::{calculate_size, copy_with_progress};
|
||||||
|
|
||||||
|
pub(super) struct File {
|
||||||
|
rx: async_channel::Receiver<FileOp>,
|
||||||
|
tx: async_channel::Sender<FileOp>,
|
||||||
|
|
||||||
|
sch: mpsc::UnboundedSender<TaskOp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) enum FileOp {
|
||||||
|
Paste(FileOpPaste),
|
||||||
|
Link(FileOpLink),
|
||||||
|
Delete(FileOpDelete),
|
||||||
|
Trash(FileOpTrash),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct FileOpPaste {
|
||||||
|
pub id: usize,
|
||||||
|
pub from: PathBuf,
|
||||||
|
pub to: PathBuf,
|
||||||
|
pub cut: bool,
|
||||||
|
pub follow: bool,
|
||||||
|
pub retry: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct FileOpLink {
|
||||||
|
pub id: usize,
|
||||||
|
pub from: PathBuf,
|
||||||
|
pub to: PathBuf,
|
||||||
|
pub cut: bool,
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct FileOpDelete {
|
||||||
|
pub id: usize,
|
||||||
|
pub target: PathBuf,
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct FileOpTrash {
|
||||||
|
pub id: usize,
|
||||||
|
pub target: PathBuf,
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl File {
|
||||||
|
pub(super) fn new(sch: mpsc::UnboundedSender<TaskOp>) -> Self {
|
||||||
|
let (tx, rx) = async_channel::unbounded();
|
||||||
|
Self { tx, rx, sch }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) async fn recv(&self) -> Result<(usize, FileOp)> {
|
||||||
|
Ok(match self.rx.recv().await? {
|
||||||
|
FileOp::Paste(t) => (t.id, FileOp::Paste(t)),
|
||||||
|
FileOp::Link(t) => (t.id, FileOp::Link(t)),
|
||||||
|
FileOp::Delete(t) => (t.id, FileOp::Delete(t)),
|
||||||
|
FileOp::Trash(t) => (t.id, FileOp::Trash(t)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn work(&self, task: &mut FileOp) -> Result<()> {
|
||||||
|
match task {
|
||||||
|
FileOp::Paste(task) => {
|
||||||
|
match fs::remove_file(&task.to).await {
|
||||||
|
Err(e) if e.kind() != NotFound => Err(e)?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut it = copy_with_progress(&task.from, &task.to);
|
||||||
|
while let Some(res) = it.recv().await {
|
||||||
|
match res {
|
||||||
|
Ok(0) => {
|
||||||
|
if task.cut {
|
||||||
|
fs::remove_file(&task.from).await.ok();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
trace!("Paste task advanced {}: {:?}", n, task);
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 0, n))?
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == NotFound => {
|
||||||
|
trace!("Paste task partially done: {:?}", task);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Operation not permitted (os error 1)
|
||||||
|
// Attribute not found (os error 93)
|
||||||
|
Err(e) if task.retry < 3 && matches!(e.raw_os_error(), Some(1) | Some(93)) => {
|
||||||
|
trace!("Paste task retry: {:?}", task);
|
||||||
|
task.retry += 1;
|
||||||
|
return Ok(self.tx.send(FileOp::Paste(task.clone())).await?);
|
||||||
|
}
|
||||||
|
Err(e) => Err(e)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 1, 0))?;
|
||||||
|
}
|
||||||
|
FileOp::Link(task) => {
|
||||||
|
let src = match fs::read_link(&task.from).await {
|
||||||
|
Ok(src) => src,
|
||||||
|
Err(e) if e.kind() == NotFound => {
|
||||||
|
trace!("Link task partially done: {:?}", task);
|
||||||
|
return Ok(self.sch.send(TaskOp::Adv(task.id, 1, task.length))?);
|
||||||
|
}
|
||||||
|
Err(e) => Err(e)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
match fs::remove_file(&task.to).await {
|
||||||
|
Err(e) if e.kind() != NotFound => Err(e)?,
|
||||||
|
_ => fs::symlink(src, &task.to).await?,
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.cut {
|
||||||
|
fs::remove_file(&task.from).await.ok();
|
||||||
|
}
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?;
|
||||||
|
}
|
||||||
|
FileOp::Delete(task) => {
|
||||||
|
if let Err(e) = fs::remove_file(&task.target).await {
|
||||||
|
if e.kind() != NotFound && fs::symlink_metadata(&task.target).await.is_ok() {
|
||||||
|
info!("Delete task failed: {:?}, {}", task, e);
|
||||||
|
Err(e)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?
|
||||||
|
}
|
||||||
|
FileOp::Trash(task) => {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let mut ctx = TrashContext::default();
|
||||||
|
ctx.set_delete_method(DeleteMethod::NsFileManager);
|
||||||
|
ctx.delete(&task.target)?;
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
trash::delete(&task.target)?;
|
||||||
|
}
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done(&self, id: usize) -> Result<()> { Ok(self.sch.send(TaskOp::Done(id))?) }
|
||||||
|
|
||||||
|
pub(super) async fn paste(&self, mut task: FileOpPaste) -> Result<()> {
|
||||||
|
if task.cut {
|
||||||
|
match fs::rename(&task.from, &task.to).await {
|
||||||
|
Ok(_) => return self.done(task.id),
|
||||||
|
Err(e) if e.kind() == NotFound => return self.done(task.id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = Self::metadata(&task.from, task.follow).await?;
|
||||||
|
if !meta.is_dir() {
|
||||||
|
let id = task.id;
|
||||||
|
self.sch.send(TaskOp::New(id, meta.len()))?;
|
||||||
|
|
||||||
|
if meta.is_file() {
|
||||||
|
self.tx.send(FileOp::Paste(task)).await?;
|
||||||
|
} else if meta.is_symlink() {
|
||||||
|
self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?;
|
||||||
|
}
|
||||||
|
return self.done(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = task.to.clone();
|
||||||
|
let skip = task.from.components().count();
|
||||||
|
let mut dirs = VecDeque::from([task.from]);
|
||||||
|
|
||||||
|
while let Some(src) = dirs.pop_front() {
|
||||||
|
let dest = root.join(src.components().skip(skip).collect::<PathBuf>());
|
||||||
|
match fs::create_dir(&dest).await {
|
||||||
|
Err(e) if e.kind() != AlreadyExists => {
|
||||||
|
info!("Create dir failed: {:?}, {}", dest, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut it = match fs::read_dir(&src).await {
|
||||||
|
Ok(it) => it,
|
||||||
|
Err(e) => {
|
||||||
|
info!("Read dir failed: {:?}, {}", src, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = it.next_entry().await {
|
||||||
|
let src = entry.path();
|
||||||
|
let meta = if let Ok(meta) = Self::metadata(&src, task.follow).await {
|
||||||
|
meta
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
dirs.push_back(src);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.to = dest.join(src.file_name().unwrap());
|
||||||
|
task.from = src;
|
||||||
|
self.sch.send(TaskOp::New(task.id, meta.len()))?;
|
||||||
|
|
||||||
|
if meta.is_file() {
|
||||||
|
trace!("Paste: {:?} -> {:?}", task.from, task.to);
|
||||||
|
self.tx.send(FileOp::Paste(task.clone())).await?;
|
||||||
|
} else if meta.is_symlink() {
|
||||||
|
trace!("Link: {:?} -> {:?}", task.from, task.to);
|
||||||
|
self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.done(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn delete(&self, mut task: FileOpDelete) -> Result<()> {
|
||||||
|
let meta = fs::symlink_metadata(&task.target).await?;
|
||||||
|
if !meta.is_dir() {
|
||||||
|
let id = task.id;
|
||||||
|
task.length = meta.len();
|
||||||
|
self.sch.send(TaskOp::New(id, meta.len()))?;
|
||||||
|
self.tx.send(FileOp::Delete(task)).await?;
|
||||||
|
return self.done(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dirs = VecDeque::from([task.target]);
|
||||||
|
while let Some(target) = dirs.pop_front() {
|
||||||
|
let mut it = match fs::read_dir(target).await {
|
||||||
|
Ok(it) => it,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = it.next_entry().await {
|
||||||
|
let meta = match entry.metadata().await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
dirs.push_front(entry.path());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
task.target = entry.path();
|
||||||
|
task.length = meta.len();
|
||||||
|
self.sch.send(TaskOp::New(task.id, meta.len()))?;
|
||||||
|
self.tx.send(FileOp::Delete(task.clone())).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.done(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn trash(&self, mut task: FileOpTrash) -> Result<()> {
|
||||||
|
let id = task.id;
|
||||||
|
task.length = calculate_size(&task.target).await;
|
||||||
|
|
||||||
|
self.sch.send(TaskOp::New(id, task.length))?;
|
||||||
|
self.tx.send(FileOp::Trash(task)).await?;
|
||||||
|
self.done(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata(path: &Path, follow: bool) -> io::Result<Metadata> {
|
||||||
|
if !follow {
|
||||||
|
return fs::symlink_metadata(path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = fs::metadata(path).await;
|
||||||
|
if meta.is_ok() { meta } else { fs::symlink_metadata(path).await }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_empty_dirs(dir: &Path) -> BoxFuture<()> {
|
||||||
|
trace!("Remove empty dirs: {:?}", dir);
|
||||||
|
async move {
|
||||||
|
let mut it = match fs::read_dir(dir).await {
|
||||||
|
Ok(it) => it,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = it.next_entry().await {
|
||||||
|
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
|
||||||
|
let path = entry.path();
|
||||||
|
Self::remove_empty_dirs(&path).await;
|
||||||
|
fs::remove_dir(path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir(dir).await.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileOpPaste {
|
||||||
|
fn to_link(&self, length: u64) -> FileOpLink {
|
||||||
|
FileOpLink { id: self.id, from: self.from.clone(), to: self.to.clone(), cut: self.cut, length }
|
||||||
|
}
|
||||||
|
}
|
14
src/core/tasks/mod.rs
Normal file
14
src/core/tasks/mod.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
mod file;
|
||||||
|
mod precache;
|
||||||
|
mod process;
|
||||||
|
mod scheduler;
|
||||||
|
mod tasks;
|
||||||
|
|
||||||
|
pub(crate) use file::*;
|
||||||
|
pub use precache::*;
|
||||||
|
pub(crate) use process::*;
|
||||||
|
pub use scheduler::*;
|
||||||
|
pub use tasks::*;
|
||||||
|
|
||||||
|
pub const TASKS_PADDING: u16 = 2;
|
||||||
|
pub const TASKS_PERCENT: u16 = 80;
|
94
src/core/tasks/precache.rs
Normal file
94
src/core/tasks/precache.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
use tokio::{fs, process::Command};
|
||||||
|
|
||||||
|
use crate::{config::PREVIEW, misc::valid_mimetype};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Precache;
|
||||||
|
|
||||||
|
impl Precache {
|
||||||
|
pub fn cache(path: &Path) -> PathBuf {
|
||||||
|
PathBuf::from(format!("/tmp/yazi/{:x}", md5::compute(path.to_string_lossy().as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mimetype(files: &Vec<PathBuf>) -> Result<Vec<Option<String>>> {
|
||||||
|
if files.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("file")
|
||||||
|
.args(["-bL", "--mime-type"])
|
||||||
|
.args(files)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("failed to get mimetype: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.trim()
|
||||||
|
.lines()
|
||||||
|
.map(|s| if valid_mimetype(s) { Some(s.to_string()) } else { None })
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn json(path: &Path) -> Result<String> {
|
||||||
|
let output = Command::new("jq")
|
||||||
|
.args(["-C", "--indent", &PREVIEW.tab_size.to_string(), "."])
|
||||||
|
.arg(path)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("failed to get json: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn image(path: &Path) -> Result<()> {
|
||||||
|
let cache = Self::cache(path);
|
||||||
|
if cache.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let img = image::load_from_memory(&fs::read(path).await?)?;
|
||||||
|
let (w, h) = (PREVIEW.max_width, PREVIEW.max_height);
|
||||||
|
|
||||||
|
let img = if img.width() > w || img.height() > h {
|
||||||
|
img.resize(w, h, FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
Ok(img.save(cache)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn video(path: &Path) -> Result<()> {
|
||||||
|
let cache = Self::cache(path);
|
||||||
|
if cache.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new("ffmpegthumbnailer")
|
||||||
|
.arg("-i")
|
||||||
|
.arg(path)
|
||||||
|
.arg("-o")
|
||||||
|
.arg(cache)
|
||||||
|
.args(["-q", "6", "-c", "jpeg", "-s", &PREVIEW.max_width.to_string()])
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("failed to generate video thumbnail: {}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
85
src/core/tasks/process.rs
Normal file
85
src/core/tasks/process.rs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use tokio::{process::Command, select, sync::{mpsc, oneshot, Semaphore}};
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
|
use super::TaskOp;
|
||||||
|
use crate::emit;
|
||||||
|
|
||||||
|
pub(super) struct Process {
|
||||||
|
rx: async_channel::Receiver<ProcessOp>,
|
||||||
|
tx: async_channel::Sender<ProcessOp>,
|
||||||
|
|
||||||
|
sch: mpsc::UnboundedSender<TaskOp>,
|
||||||
|
|
||||||
|
blocker: Semaphore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) enum ProcessOp {
|
||||||
|
Open(ProcessOpOpen),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct ProcessOpOpen {
|
||||||
|
pub id: usize,
|
||||||
|
pub cmd: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub block: bool,
|
||||||
|
pub cancel: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Process {
|
||||||
|
pub(super) fn new(sch: mpsc::UnboundedSender<TaskOp>) -> Self {
|
||||||
|
let (tx, rx) = async_channel::unbounded();
|
||||||
|
Self { tx, rx, sch, blocker: Semaphore::new(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) async fn recv(&self) -> Result<(usize, ProcessOp)> {
|
||||||
|
Ok(match self.rx.recv().await? {
|
||||||
|
ProcessOp::Open(t) => (t.id, ProcessOp::Open(t)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn work(&self, task: &mut ProcessOp) -> Result<()> {
|
||||||
|
match task {
|
||||||
|
ProcessOp::Open(task) => {
|
||||||
|
trace!("Open task: {:?}", task);
|
||||||
|
if !task.block {
|
||||||
|
select! {
|
||||||
|
_ = task.cancel.closed() => {},
|
||||||
|
Ok(status) = Command::new(&task.cmd).args(&task.args).kill_on_drop(true).status() => {
|
||||||
|
trace!("{} exited with {:?}", task.cmd, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(self.sch.send(TaskOp::Adv(task.id, 1, 0))?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = self.blocker.acquire().await.unwrap();
|
||||||
|
emit!(Stop(true)).await;
|
||||||
|
|
||||||
|
match Command::new(&task.cmd).args(&task.args).kill_on_drop(true).spawn() {
|
||||||
|
Ok(mut child) => {
|
||||||
|
child.wait().await.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
trace!("Failed to spawn {}: {}", task.cmd, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit!(Stop(false)).await;
|
||||||
|
self.sch.send(TaskOp::Adv(task.id, 1, 0))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done(&self, id: usize) -> Result<()> { Ok(self.sch.send(TaskOp::Done(id))?) }
|
||||||
|
|
||||||
|
pub(super) async fn open(&self, task: ProcessOpOpen) -> Result<()> {
|
||||||
|
let id = task.id;
|
||||||
|
self.sch.send(TaskOp::New(id, 0))?;
|
||||||
|
self.tx.send(ProcessOp::Open(task)).await?;
|
||||||
|
self.done(id)
|
||||||
|
}
|
||||||
|
}
|
368
src/core/tasks/scheduler.rs
Normal file
368
src/core/tasks/scheduler.rs
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use async_channel::{Receiver, Sender};
|
||||||
|
use futures::{future::BoxFuture, FutureExt};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use tokio::{fs, select, sync::{mpsc::{self, UnboundedReceiver}, oneshot}, time::sleep};
|
||||||
|
use tracing::{info, trace};
|
||||||
|
|
||||||
|
use super::{File, FileOpDelete, FileOpPaste, FileOpTrash, Process, ProcessOpOpen, Task, TaskOp, TaskStage};
|
||||||
|
use crate::{config::open::Opener, emit, misc::unique_path};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Running {
|
||||||
|
incer: usize,
|
||||||
|
|
||||||
|
hooks: BTreeMap<usize, Box<dyn (FnOnce(bool) -> BoxFuture<'static, ()>) + Send + Sync>>,
|
||||||
|
all: BTreeMap<usize, Task>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Running {
|
||||||
|
fn add(&mut self, name: String) -> usize {
|
||||||
|
self.incer += 1;
|
||||||
|
self.all.insert(self.incer, Task::new(self.incer, name));
|
||||||
|
self.incer
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn get(&mut self, id: usize) -> Option<&mut Task> { self.all.get_mut(&id) }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn len(&self) -> usize { self.all.len() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn exists(&self, id: usize) -> bool { self.all.contains_key(&id) }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn values(&self) -> impl Iterator<Item = &Task> { self.all.values() }
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_empty(&self) -> bool { self.all.is_empty() }
|
||||||
|
|
||||||
|
fn try_remove(&mut self, id: usize, stage: TaskStage) -> Option<BoxFuture<'static, ()>> {
|
||||||
|
if let Some(task) = self.get(id) {
|
||||||
|
if stage > task.stage {
|
||||||
|
task.stage = stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
match task.stage {
|
||||||
|
TaskStage::Pending => return None,
|
||||||
|
TaskStage::Dispatched => {
|
||||||
|
if task.processed < task.found {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Some(hook) = self.hooks.remove(&id) {
|
||||||
|
return Some(hook(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TaskStage::Hooked => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.all.remove(&id);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Scheduler {
|
||||||
|
file: Arc<File>,
|
||||||
|
process: Arc<Process>,
|
||||||
|
|
||||||
|
todo: Sender<BoxFuture<'static, ()>>,
|
||||||
|
pub(super) running: Arc<RwLock<Running>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scheduler {
|
||||||
|
pub(super) fn start() -> Self {
|
||||||
|
let (todo_tx, todo_rx) = async_channel::unbounded();
|
||||||
|
let (prog_tx, prog_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let scheduler = Self {
|
||||||
|
file: Arc::new(File::new(prog_tx.clone())),
|
||||||
|
process: Arc::new(Process::new(prog_tx)),
|
||||||
|
|
||||||
|
todo: todo_tx,
|
||||||
|
running: Default::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
scheduler.schedule_micro(todo_rx.clone());
|
||||||
|
}
|
||||||
|
for _ in 0..5 {
|
||||||
|
scheduler.schedule_macro(todo_rx.clone());
|
||||||
|
}
|
||||||
|
scheduler.progress(prog_rx);
|
||||||
|
scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_micro(&self, rx: Receiver<BoxFuture<'static, ()>>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if let Ok(fut) = rx.recv().await {
|
||||||
|
fut.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_macro(&self, rx: Receiver<BoxFuture<'static, ()>>) {
|
||||||
|
let file = self.file.clone();
|
||||||
|
let process = self.process.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if let Ok(fut) = rx.try_recv() {
|
||||||
|
fut.await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
select! {
|
||||||
|
Ok(fut) = rx.recv() => {
|
||||||
|
fut.await;
|
||||||
|
}
|
||||||
|
Ok((id, mut task)) = file.recv() => {
|
||||||
|
if !running.read().exists(id) {
|
||||||
|
trace!("Skipping task {:?} as it was removed", task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = file.work(&mut task).await {
|
||||||
|
info!("Failed to work on task {:?}: {}", task, e);
|
||||||
|
} else {
|
||||||
|
trace!("Finished task {:?}", task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((id, mut task)) = process.recv() => {
|
||||||
|
if !running.read().exists(id) {
|
||||||
|
trace!("Skipping task {:?} as it was removed", task);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = process.work(&mut task).await {
|
||||||
|
info!("Failed to work on task {:?}: {}", task, e);
|
||||||
|
} else {
|
||||||
|
trace!("Finished task {:?}", task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress(&self, mut rx: UnboundedReceiver<TaskOp>) {
|
||||||
|
let todo = self.todo.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(task) = rx.recv().await {
|
||||||
|
match task {
|
||||||
|
TaskOp::New(id, size) => {
|
||||||
|
if let Some(task) = running.write().get(id) {
|
||||||
|
task.found += 1;
|
||||||
|
task.todo += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TaskOp::Adv(id, processed, size) => {
|
||||||
|
let mut running = running.write();
|
||||||
|
if let Some(task) = running.get(id) {
|
||||||
|
task.processed += processed;
|
||||||
|
task.done += size;
|
||||||
|
}
|
||||||
|
if processed > 0 {
|
||||||
|
if let Some(fut) = running.try_remove(id, TaskStage::Pending) {
|
||||||
|
todo.send_blocking(fut).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TaskOp::Done(id) => {
|
||||||
|
if let Some(fut) = running.write().try_remove(id, TaskStage::Dispatched) {
|
||||||
|
todo.send_blocking(fut).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let running = self.running.clone();
|
||||||
|
let mut last = 100;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
if running.read().is_empty() {
|
||||||
|
if last != 100 {
|
||||||
|
last = 100;
|
||||||
|
emit!(Progress(100, 0));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tasks = 0u32;
|
||||||
|
let mut left = 0;
|
||||||
|
let mut progress = (0, 0);
|
||||||
|
for task in running.read().values() {
|
||||||
|
tasks += 1;
|
||||||
|
left += task.found.saturating_sub(task.processed);
|
||||||
|
progress = (progress.0 + task.done, progress.1 + task.todo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new = match progress.1 {
|
||||||
|
0 => 100u8,
|
||||||
|
_ => 100.min(progress.0 * 100 / progress.1) as u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
if tasks != 0 {
|
||||||
|
new = new.min(99);
|
||||||
|
left = left.max(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if new != last {
|
||||||
|
last = new;
|
||||||
|
emit!(Progress(new, left));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cancel(&self, id: usize) -> bool {
|
||||||
|
let mut running = self.running.write();
|
||||||
|
let b = running.all.remove(&id).is_some();
|
||||||
|
|
||||||
|
if let Some(hook) = running.hooks.remove(&id) {
|
||||||
|
self.todo.send_blocking(hook(true)).ok();
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_cut(&self, from: PathBuf, mut to: PathBuf, force: bool) {
|
||||||
|
let mut running = self.running.write();
|
||||||
|
let id = running.add(format!("Cut {:?} to {:?}", from, to));
|
||||||
|
|
||||||
|
running.hooks.insert(id, {
|
||||||
|
let from = from.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
|
||||||
|
Box::new(move |canceled: bool| {
|
||||||
|
async move {
|
||||||
|
if !canceled {
|
||||||
|
File::remove_empty_dirs(&from).await;
|
||||||
|
}
|
||||||
|
running.write().try_remove(id, TaskStage::Hooked);
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = self.todo.send_blocking({
|
||||||
|
let file = self.file.clone();
|
||||||
|
async move {
|
||||||
|
if !force {
|
||||||
|
to = unique_path(to).await;
|
||||||
|
}
|
||||||
|
file.paste(FileOpPaste { id, from, to, cut: true, follow: false, retry: 0 }).await.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_copy(&self, from: PathBuf, mut to: PathBuf, force: bool, follow: bool) {
|
||||||
|
let name = format!("Copy {:?} to {:?}", from, to);
|
||||||
|
let id = self.running.write().add(name);
|
||||||
|
|
||||||
|
let _ = self.todo.send_blocking({
|
||||||
|
let file = self.file.clone();
|
||||||
|
async move {
|
||||||
|
if !force {
|
||||||
|
to = unique_path(to).await;
|
||||||
|
}
|
||||||
|
file.paste(FileOpPaste { id, from, to, cut: false, follow, retry: 0 }).await.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_delete(&self, target: PathBuf) {
|
||||||
|
let mut running = self.running.write();
|
||||||
|
let id = running.add(format!("Delete {:?}", target));
|
||||||
|
|
||||||
|
running.hooks.insert(id, {
|
||||||
|
let target = target.clone();
|
||||||
|
let running = self.running.clone();
|
||||||
|
|
||||||
|
Box::new(move |canceled: bool| {
|
||||||
|
async move {
|
||||||
|
if !canceled {
|
||||||
|
fs::remove_dir_all(target).await.ok();
|
||||||
|
}
|
||||||
|
running.write().try_remove(id, TaskStage::Hooked);
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = self.todo.send_blocking({
|
||||||
|
let file = self.file.clone();
|
||||||
|
async move {
|
||||||
|
file.delete(FileOpDelete { id, target, length: 0 }).await.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn file_trash(&self, target: PathBuf) {
|
||||||
|
let name = format!("Trash {:?}", target);
|
||||||
|
let id = self.running.write().add(name);
|
||||||
|
|
||||||
|
let _ = self.todo.send_blocking({
|
||||||
|
let file = self.file.clone();
|
||||||
|
async move {
|
||||||
|
file.trash(FileOpTrash { id, target, length: 0 }).await.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn process_open(&self, opener: &Opener, args: &[String]) {
|
||||||
|
let args = opener
|
||||||
|
.args
|
||||||
|
.iter()
|
||||||
|
.map_while(|a| {
|
||||||
|
if !a.starts_with('$') {
|
||||||
|
return Some(vec![a.clone()]);
|
||||||
|
}
|
||||||
|
if a == "$*" {
|
||||||
|
return Some(args.to_vec());
|
||||||
|
}
|
||||||
|
a[1..].parse().ok().and_then(|n: usize| args.get(n)).map(|a| vec![a.clone()])
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut running = self.running.write();
|
||||||
|
let id = running.add(format!("Exec `{} {}`", opener.cmd, args.join(" ")));
|
||||||
|
|
||||||
|
let (cancel_tx, mut cancel_rx) = oneshot::channel();
|
||||||
|
running.hooks.insert(id, {
|
||||||
|
let running = self.running.clone();
|
||||||
|
Box::new(move |canceled: bool| {
|
||||||
|
async move {
|
||||||
|
if canceled {
|
||||||
|
cancel_rx.close();
|
||||||
|
}
|
||||||
|
running.write().try_remove(id, TaskStage::Hooked);
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = self.todo.send_blocking({
|
||||||
|
let process = self.process.clone();
|
||||||
|
let opener = opener.clone();
|
||||||
|
async move {
|
||||||
|
process
|
||||||
|
.open(ProcessOpOpen { id, cmd: opener.cmd, args, block: opener.block, cancel: cancel_tx })
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
.boxed()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
164
src/core/tasks/tasks.rs
Normal file
164
src/core/tasks/tasks.rs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
use std::{collections::{BTreeMap, HashSet}, path::PathBuf};
|
||||||
|
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
|
use super::{Scheduler, TASKS_PADDING, TASKS_PERCENT};
|
||||||
|
use crate::{config::OPEN, misc::tty_size};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Task {
|
||||||
|
pub id: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub stage: TaskStage,
|
||||||
|
|
||||||
|
pub found: u32,
|
||||||
|
pub processed: u32,
|
||||||
|
|
||||||
|
pub todo: u64,
|
||||||
|
pub done: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Task {
|
||||||
|
pub fn new(id: usize, name: String) -> Self {
|
||||||
|
Self { id, name, stage: Default::default(), found: 0, processed: 0, todo: 0, done: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TaskOp {
|
||||||
|
// task_id, size
|
||||||
|
New(usize, u64),
|
||||||
|
// task_id, processed, size
|
||||||
|
Adv(usize, u32, u64),
|
||||||
|
// task_id
|
||||||
|
Done(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd)]
|
||||||
|
pub enum TaskStage {
|
||||||
|
#[default]
|
||||||
|
Pending,
|
||||||
|
Dispatched,
|
||||||
|
Hooked,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Tasks {
|
||||||
|
scheduler: Scheduler,
|
||||||
|
|
||||||
|
pub visible: bool,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub progress: (u8, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tasks {
|
||||||
|
pub fn start() -> Self {
|
||||||
|
Self { scheduler: Scheduler::start(), visible: false, cursor: 0, progress: (100, 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn limit() -> usize {
|
||||||
|
(tty_size().ws_row * TASKS_PERCENT / 100).saturating_sub(TASKS_PADDING) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(&mut self) -> bool {
|
||||||
|
self.visible = !self.visible;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) -> bool {
|
||||||
|
let limit = Self::limit().min(self.scheduler.running.read().len());
|
||||||
|
|
||||||
|
let old = self.cursor;
|
||||||
|
self.cursor = limit.saturating_sub(1).min(self.cursor + 1);
|
||||||
|
|
||||||
|
old != self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self) -> bool {
|
||||||
|
let old = self.cursor;
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
old != self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paginate(&self) -> Vec<Task> {
|
||||||
|
let running = self.scheduler.running.read();
|
||||||
|
running.values().take(Self::limit()).cloned().collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) -> bool {
|
||||||
|
let id = self.scheduler.running.read().values().skip(self.cursor).next().map(|t| t.id);
|
||||||
|
id.map(|id| self.scheduler.cancel(id)).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_open(&self, targets: Vec<(PathBuf, String)>) -> bool {
|
||||||
|
let mut openers = BTreeMap::new();
|
||||||
|
for target in targets {
|
||||||
|
if let Some(opener) = OPEN.opener(&target.0, &target.1) {
|
||||||
|
openers
|
||||||
|
.entry(opener.clone())
|
||||||
|
.or_insert_with(|| vec![])
|
||||||
|
.push(target.0.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (opener, args) in openers {
|
||||||
|
if opener.spread {
|
||||||
|
self.scheduler.process_open(&opener, &args);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for target in args {
|
||||||
|
self.scheduler.process_open(&opener, &[target]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_cut(&self, src: &HashSet<PathBuf>, dest: PathBuf, force: bool) -> bool {
|
||||||
|
for p in src {
|
||||||
|
let to = dest.join(p.file_name().unwrap());
|
||||||
|
if force && *p == to {
|
||||||
|
trace!("file_cut: same file, skipping {:?}", to);
|
||||||
|
} else {
|
||||||
|
self.scheduler.file_cut(p.clone(), to, force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_copy(
|
||||||
|
&self,
|
||||||
|
src: &HashSet<PathBuf>,
|
||||||
|
dest: PathBuf,
|
||||||
|
force: bool,
|
||||||
|
follow: bool,
|
||||||
|
) -> bool {
|
||||||
|
for p in src {
|
||||||
|
let to = dest.join(p.file_name().unwrap());
|
||||||
|
if force && *p == to {
|
||||||
|
trace!("file_copy: same file, skipping {:?}", to);
|
||||||
|
} else {
|
||||||
|
self.scheduler.file_copy(p.clone(), to, force, follow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_remove(&self, targets: Vec<PathBuf>, permanently: bool) -> bool {
|
||||||
|
for p in targets {
|
||||||
|
if permanently {
|
||||||
|
self.scheduler.file_delete(p);
|
||||||
|
} else {
|
||||||
|
self.scheduler.file_trash(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_progress(&mut self, percent: u8, left: u32) -> bool {
|
||||||
|
if self.progress.0 == percent {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.progress = (percent, left);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
15
src/main.rs
Normal file
15
src/main.rs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
use ui::App;
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod core;
|
||||||
|
mod misc;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// console_subscriber::init();
|
||||||
|
|
||||||
|
config::init();
|
||||||
|
|
||||||
|
App::run().await
|
||||||
|
}
|
18
src/misc/chars.rs
Normal file
18
src/misc/chars.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CharKind {
|
||||||
|
Space,
|
||||||
|
Punct,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharKind {
|
||||||
|
pub fn new(c: char) -> Self {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
Self::Space
|
||||||
|
} else if c.is_ascii_punctuation() {
|
||||||
|
Self::Punct
|
||||||
|
} else {
|
||||||
|
Self::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
src/misc/defer.rs
Normal file
13
src/misc/defer.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
pub struct Defer<F: FnOnce()>(Option<F>);
|
||||||
|
|
||||||
|
impl<F: FnOnce()> Defer<F> {
|
||||||
|
pub fn new(f: F) -> Self { Defer(Some(f)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FnOnce()> Drop for Defer<F> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(f) = self.0.take() {
|
||||||
|
f();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
198
src/misc/fns.rs
Normal file
198
src/misc/fns.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
use std::{collections::VecDeque, env, path::{Path, PathBuf}};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use libc::{ioctl, winsize, STDOUT_FILENO, TIOCGWINSZ};
|
||||||
|
use tokio::{fs::{self, File}, io::{self, AsyncBufReadExt, BufReader}, select, sync::{mpsc, oneshot}, time};
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn tty_size() -> winsize {
|
||||||
|
unsafe {
|
||||||
|
let s: winsize = std::mem::zeroed();
|
||||||
|
ioctl(STDOUT_FILENO, TIOCGWINSZ, &s);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn tty_ratio() -> (f64, f64) {
|
||||||
|
let s = tty_size();
|
||||||
|
(f64::from(s.ws_xpixel) / f64::from(s.ws_col), f64::from(s.ws_ypixel) / f64::from(s.ws_row))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn absolute_path(p: &Path) -> PathBuf {
|
||||||
|
if p.starts_with("~") {
|
||||||
|
if let Ok(home) = env::var("HOME") {
|
||||||
|
let mut expanded = PathBuf::new();
|
||||||
|
expanded.push(home);
|
||||||
|
expanded.push(p.strip_prefix("~").unwrap());
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn readable_path(p: &Path) -> String {
|
||||||
|
if let Ok(home) = env::var("HOME") {
|
||||||
|
if let Ok(p) = p.strip_prefix(home) {
|
||||||
|
return format!("~/{}", p.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unique_path(mut p: PathBuf) -> PathBuf {
|
||||||
|
let name = if let Some(name) = p.file_name() {
|
||||||
|
name.to_os_string()
|
||||||
|
} else {
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while fs::symlink_metadata(&p).await.is_ok() {
|
||||||
|
i += 1;
|
||||||
|
let mut name = name.clone();
|
||||||
|
name.push(format!("_{}", i));
|
||||||
|
p.set_file_name(name);
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn optinal_bool(s: &str) -> Option<bool> {
|
||||||
|
if s == "true" {
|
||||||
|
Some(true)
|
||||||
|
} else if s == "false" {
|
||||||
|
Some(false)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn first_n_lines(path: &Path, n: usize) -> Result<Vec<String>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut it = BufReader::new(File::open(path).await?).lines();
|
||||||
|
for _ in 0..n {
|
||||||
|
if let Some(line) = it.next_line().await? {
|
||||||
|
lines.push(line);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn calculate_size(path: &Path) -> u64 {
|
||||||
|
let mut total = 0;
|
||||||
|
let mut stack = VecDeque::from([path.to_path_buf()]);
|
||||||
|
while let Some(path) = stack.pop_front() {
|
||||||
|
let meta = if let Ok(meta) = fs::symlink_metadata(&path).await {
|
||||||
|
meta
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !meta.is_dir() {
|
||||||
|
total += meta.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut it = if let Ok(it) = fs::read_dir(path).await {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = it.next_entry().await {
|
||||||
|
let meta = if let Ok(m) = entry.metadata().await {
|
||||||
|
m
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if meta.is_dir() {
|
||||||
|
stack.push_back(entry.path());
|
||||||
|
} else {
|
||||||
|
total += meta.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_with_progress(from: &Path, to: &Path) -> mpsc::Receiver<Result<u64, io::Error>> {
|
||||||
|
let (tx, rx) = mpsc::channel(1);
|
||||||
|
let (tick_tx, mut tick_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let (from, to) = (from.to_path_buf(), to.to_path_buf());
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let _ = match fs::copy(from, to).await {
|
||||||
|
Ok(len) => tick_tx.send(Ok(len)),
|
||||||
|
Err(e) => tick_tx.send(Err(e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let tx = tx.clone();
|
||||||
|
let to = to.to_path_buf();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
let mut last = 0;
|
||||||
|
let mut exit = None;
|
||||||
|
loop {
|
||||||
|
select! {
|
||||||
|
res = &mut tick_rx => exit = Some(res.unwrap()),
|
||||||
|
_ = tx.closed() => break,
|
||||||
|
_ = time::sleep(time::Duration::from_secs(1)) => (),
|
||||||
|
}
|
||||||
|
|
||||||
|
match exit {
|
||||||
|
Some(Ok(len)) => {
|
||||||
|
if len > last {
|
||||||
|
tx.send(Ok(len - last)).await.ok();
|
||||||
|
}
|
||||||
|
tx.send(Ok(0)).await.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tx.send(Err(e)).await.ok();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = fs::symlink_metadata(&to).await.map(|m| m.len()).unwrap_or(0);
|
||||||
|
if len > last {
|
||||||
|
tx.send(Ok(len - last)).await.ok();
|
||||||
|
last = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_mimetype(str: &str) -> bool {
|
||||||
|
let parts = str.split('/').collect::<Vec<_>>();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let b = match parts[0] {
|
||||||
|
"application" => true,
|
||||||
|
"audio" => true,
|
||||||
|
"example" => true,
|
||||||
|
"font" => true,
|
||||||
|
"image" => true,
|
||||||
|
"message" => true,
|
||||||
|
"model" => true,
|
||||||
|
"multipart" => true,
|
||||||
|
"text" => true,
|
||||||
|
"video" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
b && !parts[1].is_empty()
|
||||||
|
}
|
7
src/misc/mod.rs
Normal file
7
src/misc/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
mod chars;
|
||||||
|
mod defer;
|
||||||
|
mod fns;
|
||||||
|
|
||||||
|
pub use chars::*;
|
||||||
|
pub use defer::*;
|
||||||
|
pub use fns::*;
|
115
src/ui/app.rs
Normal file
115
src/ui/app.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
use anyhow::{Ok, Result};
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use tokio::sync::oneshot::{self};
|
||||||
|
|
||||||
|
use super::{root::Root, Ctx, Executor, Logs, Signals, Term};
|
||||||
|
use crate::{config::keymap::Key, core::Event, emit};
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
cx: Ctx,
|
||||||
|
term: Option<Term>,
|
||||||
|
signals: Signals,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
let _log = Logs::init()?;
|
||||||
|
let term = Term::start()?;
|
||||||
|
|
||||||
|
let signals = Signals::start()?;
|
||||||
|
let mut app = Self { cx: Ctx::new(), term: Some(term), signals };
|
||||||
|
|
||||||
|
while let Some(event) = app.signals.rx.recv().await {
|
||||||
|
match event {
|
||||||
|
Event::Quit => break,
|
||||||
|
Event::Stop(state, tx) => app.dispatch_stop(state, tx),
|
||||||
|
Event::Key(key) => app.dispatch_key(key),
|
||||||
|
Event::Render(_) => app.dispatch_render(),
|
||||||
|
Event::Resize(..) => app.dispatch_resize(),
|
||||||
|
event => app.dispatch_module(event).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_stop(&mut self, state: bool, tx: oneshot::Sender<()>) {
|
||||||
|
if state {
|
||||||
|
self.signals.stop_term(true);
|
||||||
|
self.term = None;
|
||||||
|
} else {
|
||||||
|
self.term = Some(Term::start().unwrap());
|
||||||
|
self.signals.stop_term(false);
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
tx.send(()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_key(&mut self, key: KeyEvent) {
|
||||||
|
let key = Key::from(key);
|
||||||
|
if Executor::handle(&mut self.cx, key) {
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_render(&mut self) {
|
||||||
|
if let Some(term) = &mut self.term {
|
||||||
|
let _ = term.draw(|f| {
|
||||||
|
f.render_widget(Root::new(&mut self.cx), f.size());
|
||||||
|
|
||||||
|
if let Some((x, y)) = self.cx.cursor {
|
||||||
|
f.set_cursor(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_resize(&mut self) {
|
||||||
|
self.cx.manager.preview();
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_module(&mut self, event: Event) {
|
||||||
|
let manager = &mut self.cx.manager;
|
||||||
|
match event {
|
||||||
|
Event::Refresh => {
|
||||||
|
manager.refresh();
|
||||||
|
}
|
||||||
|
Event::Files(path, files) => {
|
||||||
|
if manager.update_files(path, files) {
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Hover => {
|
||||||
|
if manager.preview() {
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mimetype(file, mime) => {
|
||||||
|
if manager.update_mimetype(file, mime) {
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Preview(file, data) => {
|
||||||
|
manager.update_preview(file, data);
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::Input(opt, tx) => {
|
||||||
|
self.cx.input.show(opt, tx);
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::Open(files) => {
|
||||||
|
let mime = self.cx.manager.mimetype(&files).await;
|
||||||
|
let targets = files.into_iter().zip(mime).map_while(|(f, m)| m.map(|m| (f, m))).collect();
|
||||||
|
self.cx.tasks.file_open(targets);
|
||||||
|
}
|
||||||
|
Event::Progress(percent, left) => {
|
||||||
|
self.cx.tasks.update_progress(percent, left);
|
||||||
|
emit!(Render);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
src/ui/context.rs
Normal file
21
src/ui/context.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use crate::core::{Input, Manager, Tasks};
|
||||||
|
|
||||||
|
pub struct Ctx {
|
||||||
|
pub cursor: Option<(u16, u16)>,
|
||||||
|
|
||||||
|
pub manager: Manager,
|
||||||
|
pub input: Input,
|
||||||
|
pub tasks: Tasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cursor: None,
|
||||||
|
|
||||||
|
manager: Manager::new(),
|
||||||
|
input: Input::default(),
|
||||||
|
tasks: Tasks::start(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
186
src/ui/dispatcher.rs
Normal file
186
src/ui/dispatcher.rs
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use super::Ctx;
|
||||||
|
use crate::{config::{keymap::{Exec, Key, Single}, KEYMAP}, core::InputMode, emit, misc::optinal_bool};
|
||||||
|
|
||||||
|
pub struct Executor;
|
||||||
|
|
||||||
|
impl Executor {
|
||||||
|
pub fn handle(cx: &mut Ctx, key: Key) -> bool {
|
||||||
|
let layer = if cx.input.visible {
|
||||||
|
2
|
||||||
|
} else if cx.tasks.visible {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut render = false;
|
||||||
|
let mut matched = false;
|
||||||
|
let keymap = [&KEYMAP.manager, &KEYMAP.tasks, &KEYMAP.input][layer];
|
||||||
|
|
||||||
|
for Single { on, exec } in keymap {
|
||||||
|
if on.len() < 1 || on[0] != key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matched = true;
|
||||||
|
for e in exec {
|
||||||
|
if layer == 0 {
|
||||||
|
render = Self::manager(cx, e) || render;
|
||||||
|
} else if layer == 1 {
|
||||||
|
render = Self::tasks(cx, e) || render;
|
||||||
|
} else if layer == 2 {
|
||||||
|
render = Self::input(cx, Some(e), key.code) || render;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if layer == 2 && !matched {
|
||||||
|
render = Self::input(cx, None, key.code);
|
||||||
|
}
|
||||||
|
render
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager(cx: &mut Ctx, exec: &Exec) -> bool {
|
||||||
|
match exec.cmd.as_str() {
|
||||||
|
"escape" => cx.manager.active_mut().escape(),
|
||||||
|
"quit" => {
|
||||||
|
emit!(Quit);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
"close" => cx.manager.close(),
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"arrow" => {
|
||||||
|
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
cx.manager.active_mut().arrow(step)
|
||||||
|
}
|
||||||
|
"leave" => cx.manager.active_mut().leave(),
|
||||||
|
"enter" => cx.manager.active_mut().enter(),
|
||||||
|
"back" => cx.manager.active_mut().back(),
|
||||||
|
"forward" => cx.manager.active_mut().forward(),
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
"select" => {
|
||||||
|
let state = exec.named.get("state").cloned().unwrap_or("none".to_string());
|
||||||
|
cx.manager.active_mut().select(optinal_bool(&state))
|
||||||
|
}
|
||||||
|
"visual_mode" => cx.manager.active_mut().visual_mode(exec.named.contains_key("unselect")),
|
||||||
|
"select_all" => {
|
||||||
|
let state = exec.named.get("state").cloned().unwrap_or("none".to_string());
|
||||||
|
cx.manager.active_mut().select_all(optinal_bool(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation
|
||||||
|
"yank" => cx.manager.yank(exec.named.contains_key("cut")),
|
||||||
|
"paste" => {
|
||||||
|
let dest = cx.manager.current().cwd.clone();
|
||||||
|
let (cut, src) = cx.manager.yanked();
|
||||||
|
|
||||||
|
let force = exec.named.contains_key("force");
|
||||||
|
if *cut {
|
||||||
|
cx.tasks.file_cut(src, dest, force)
|
||||||
|
} else {
|
||||||
|
cx.tasks.file_copy(src, dest, force, exec.named.contains_key("follow"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"remove" => {
|
||||||
|
cx.tasks.file_remove(cx.manager.selected(), exec.named.contains_key("permanently"))
|
||||||
|
}
|
||||||
|
"create" => cx.manager.create(),
|
||||||
|
"rename" => cx.manager.rename(),
|
||||||
|
"hidden" => cx.manager.current_mut().hidden(match exec.args.get(0).map(|s| s.as_str()) {
|
||||||
|
Some("show") => Some(true),
|
||||||
|
Some("hide") => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
"tab_create" => {
|
||||||
|
let path = if exec.named.contains_key("current") {
|
||||||
|
cx.manager.current().cwd.clone()
|
||||||
|
} else {
|
||||||
|
exec.args.get(0).map(|p| p.into()).unwrap_or("/".into())
|
||||||
|
};
|
||||||
|
cx.manager.tabs_mut().create(&path)
|
||||||
|
}
|
||||||
|
"tab_close" => {
|
||||||
|
let idx = exec.args.get(0).and_then(|i| i.parse().ok()).unwrap_or(0);
|
||||||
|
cx.manager.tabs_mut().close(idx)
|
||||||
|
}
|
||||||
|
"tab_switch" => {
|
||||||
|
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let rel = exec.named.contains_key("relative");
|
||||||
|
cx.manager.tabs_mut().switch(step, rel)
|
||||||
|
}
|
||||||
|
"tab_swap" => {
|
||||||
|
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
cx.manager.tabs_mut().swap(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
"tasks_show" => cx.tasks.toggle(),
|
||||||
|
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tasks(cx: &mut Ctx, exec: &Exec) -> bool {
|
||||||
|
match exec.cmd.as_str() {
|
||||||
|
"close" => cx.tasks.toggle(),
|
||||||
|
|
||||||
|
"arrow" => {
|
||||||
|
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
if step > 0 { cx.tasks.next() } else { cx.tasks.prev() }
|
||||||
|
}
|
||||||
|
|
||||||
|
"cancel" => cx.tasks.cancel(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input(cx: &mut Ctx, exec: Option<&Exec>, code: KeyCode) -> bool {
|
||||||
|
let exec = if let Some(e) = exec {
|
||||||
|
e
|
||||||
|
} else {
|
||||||
|
if cx.input.mode() == InputMode::Insert {
|
||||||
|
if let KeyCode::Char(c) = code {
|
||||||
|
return cx.input.type_(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
match cx.input.mode() {
|
||||||
|
InputMode::Normal => match exec.cmd.as_str() {
|
||||||
|
"close" => cx.input.close(exec.named.contains_key("submit")),
|
||||||
|
"escape" => cx.input.escape(),
|
||||||
|
|
||||||
|
"insert" => cx.input.insert(exec.named.contains_key("append")),
|
||||||
|
"visual" => cx.input.visual(),
|
||||||
|
|
||||||
|
"move" => {
|
||||||
|
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
cx.input.move_(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
"backward" => cx.input.backward(),
|
||||||
|
"forward" => cx.input.forward(exec.named.contains_key("end-of-word")),
|
||||||
|
"delete" => cx.input.delete(exec.named.contains_key("insert")),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
InputMode::Insert => match exec.cmd.as_str() {
|
||||||
|
"close" => cx.input.close(exec.named.contains_key("submit")),
|
||||||
|
"escape" => cx.input.escape(),
|
||||||
|
"backspace" => cx.input.backspace(),
|
||||||
|
_ => {
|
||||||
|
if let KeyCode::Char(c) = code {
|
||||||
|
return cx.input.type_(c);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
src/ui/header/layout.rs
Normal file
28
src/ui/header/layout.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, style::{Color, Style}, widgets::{Paragraph, Widget}};
|
||||||
|
|
||||||
|
use super::tabs::Tabs;
|
||||||
|
use crate::{misc::readable_path, ui::Ctx};
|
||||||
|
|
||||||
|
pub struct Layout<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Layout<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Layout<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let chunks = layout::Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let manager = &self.cx.manager;
|
||||||
|
Paragraph::new(readable_path(&manager.current().cwd))
|
||||||
|
.style(Style::default().fg(Color::Cyan))
|
||||||
|
.render(chunks[0], buf);
|
||||||
|
|
||||||
|
Tabs::new(self.cx).render(chunks[1], buf);
|
||||||
|
}
|
||||||
|
}
|
5
src/ui/header/mod.rs
Normal file
5
src/ui/header/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod layout;
|
||||||
|
mod tabs;
|
||||||
|
|
||||||
|
pub use layout::*;
|
||||||
|
pub use tabs::*;
|
33
src/ui/header/tabs.rs
Normal file
33
src/ui/header/tabs.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{Alignment, Rect}, style::{Color, Style}, text::{Line, Span}, widgets::{Paragraph, Widget}};
|
||||||
|
|
||||||
|
use crate::ui::Ctx;
|
||||||
|
|
||||||
|
pub struct Tabs<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Tabs<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Tabs<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let tabs = self.cx.manager.tabs();
|
||||||
|
|
||||||
|
let spans = Line::from(
|
||||||
|
tabs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, _)| {
|
||||||
|
if i == tabs.idx() {
|
||||||
|
Span::styled(format!(" {} ", i + 1), Style::default().fg(Color::Black).bg(Color::Blue))
|
||||||
|
} else {
|
||||||
|
Span::styled(format!(" {} ", i + 1), Style::default().fg(Color::Gray).bg(Color::Black))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Paragraph::new(spans).alignment(Alignment::Right).render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
40
src/ui/input.rs
Normal file
40
src/ui/input.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Style}, text::Line, widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}};
|
||||||
|
|
||||||
|
use super::{Ctx, Term};
|
||||||
|
use crate::core::InputMode;
|
||||||
|
|
||||||
|
pub struct Input<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Input<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Input<'a> {
|
||||||
|
fn render(self, _: Rect, buf: &mut Buffer) {
|
||||||
|
let input = &self.cx.input;
|
||||||
|
let area = input.area();
|
||||||
|
|
||||||
|
Clear.render(area, buf);
|
||||||
|
Paragraph::new(input.value())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Blue))
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.title({
|
||||||
|
let mut line = Line::from(input.title());
|
||||||
|
line.patch_style(Style::default().fg(Color::White));
|
||||||
|
line
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::White))
|
||||||
|
.render(area, buf);
|
||||||
|
|
||||||
|
let _ = match input.mode() {
|
||||||
|
InputMode::Insert => Term::set_cursor_bar(),
|
||||||
|
_ => Term::set_cursor_block(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
24
src/ui/logs.rs
Normal file
24
src/ui/logs.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing_appender::non_blocking::WorkerGuard;
|
||||||
|
use tracing_subscriber::{fmt, prelude::__tracing_subscriber_SubscriberExt, Registry};
|
||||||
|
|
||||||
|
pub struct Logs {}
|
||||||
|
|
||||||
|
impl Logs {
|
||||||
|
pub fn init() -> Result<WorkerGuard> {
|
||||||
|
let root = xdg::BaseDirectories::with_prefix("yazi")
|
||||||
|
.context("failed to get XDG base directories")?
|
||||||
|
.get_state_home();
|
||||||
|
|
||||||
|
let appender = tracing_appender::rolling::hourly(root, "yazi.log");
|
||||||
|
let (handle, guard) = tracing_appender::non_blocking(appender);
|
||||||
|
|
||||||
|
// let filter = EnvFilter::from_default_env();
|
||||||
|
let subscriber = Registry::default().with(fmt::layer().compact().with_writer(handle));
|
||||||
|
|
||||||
|
tracing::subscriber::set_global_default(subscriber)
|
||||||
|
.context("setting default subscriber failed")?;
|
||||||
|
|
||||||
|
Ok(guard)
|
||||||
|
}
|
||||||
|
}
|
75
src/ui/manager/folder.rs
Normal file
75
src/ui/manager/folder.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::{List, ListItem, Widget}};
|
||||||
|
|
||||||
|
use crate::{config::THEME, core};
|
||||||
|
|
||||||
|
pub struct Folder<'a> {
|
||||||
|
folder: &'a core::Folder,
|
||||||
|
is_preview: bool,
|
||||||
|
is_selection: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Folder<'a> {
|
||||||
|
pub fn new(folder: &'a core::Folder) -> Self {
|
||||||
|
Self { folder, is_preview: false, is_selection: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn with_preview(mut self, state: bool) -> Self {
|
||||||
|
self.is_preview = state;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn with_selection(mut self, state: bool) -> Self {
|
||||||
|
self.is_selection = state;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Folder<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let items = self
|
||||||
|
.folder
|
||||||
|
.paginate()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (_, v))| {
|
||||||
|
let icon = THEME
|
||||||
|
.icons
|
||||||
|
.iter()
|
||||||
|
.find(|x| x.name.match_path(&v.path, Some(v.meta.is_dir())))
|
||||||
|
.map(|x| x.display.as_ref())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let item = ListItem::new(if v.is_selected {
|
||||||
|
format!("> {} {}", icon, v.name)
|
||||||
|
} else {
|
||||||
|
format!("{} {}", icon, v.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut style = Style::default();
|
||||||
|
if self.is_selection {
|
||||||
|
if i == self.folder.rel_cursor() {
|
||||||
|
style = style.fg(Color::Black).bg(Color::Red);
|
||||||
|
} else if v.is_selected {
|
||||||
|
style = style.fg(Color::Red);
|
||||||
|
}
|
||||||
|
} else if self.is_preview {
|
||||||
|
if i == self.folder.rel_cursor() {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if i == self.folder.rel_cursor() {
|
||||||
|
style = style.fg(Color::Black).bg(Color::Yellow);
|
||||||
|
} else if v.is_selected {
|
||||||
|
style = style.fg(Color::Red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.style(style)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
List::new(items).render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
47
src/ui/manager/layout.rs
Normal file
47
src/ui/manager/layout.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, widgets::{Block, Borders, Widget}};
|
||||||
|
|
||||||
|
use super::{Folder, Preview};
|
||||||
|
use crate::{core::{Mode, ALL_RATIO, CURRENT_RATIO, PARENT_RATIO, PREVIEW_RATIO}, ui::Ctx};
|
||||||
|
|
||||||
|
pub struct Layout<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Layout<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Layout<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let manager = &self.cx.manager;
|
||||||
|
|
||||||
|
let chunks = layout::Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Ratio(PARENT_RATIO, ALL_RATIO),
|
||||||
|
Constraint::Ratio(CURRENT_RATIO, ALL_RATIO),
|
||||||
|
Constraint::Ratio(PREVIEW_RATIO, ALL_RATIO),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Parent
|
||||||
|
let block = Block::default().borders(Borders::RIGHT);
|
||||||
|
if let Some(ref parent) = manager.parent() {
|
||||||
|
Folder::new(parent).render(block.inner(chunks[0]), buf);
|
||||||
|
}
|
||||||
|
block.render(chunks[0], buf);
|
||||||
|
|
||||||
|
// Current
|
||||||
|
Folder::new(&manager.current())
|
||||||
|
.with_selection(matches!(manager.active().mode(), Mode::Select(_)))
|
||||||
|
.render(chunks[1], buf);
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
let block = Block::default().borders(Borders::LEFT);
|
||||||
|
Preview::new(self.cx).render(block.inner(chunks[2]), buf);
|
||||||
|
block.render(chunks[2], buf);
|
||||||
|
}
|
||||||
|
}
|
8
src/ui/manager/mod.rs
Normal file
8
src/ui/manager/mod.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
mod folder;
|
||||||
|
mod layout;
|
||||||
|
mod preview;
|
||||||
|
|
||||||
|
pub use folder::*;
|
||||||
|
pub use layout::*;
|
||||||
|
pub use preview::*;
|
||||||
|
|
58
src/ui/manager/preview.rs
Normal file
58
src/ui/manager/preview.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use std::io::{stdout, Write};
|
||||||
|
|
||||||
|
use ansi_to_tui::IntoText;
|
||||||
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::{Paragraph, Widget}};
|
||||||
|
|
||||||
|
use super::Folder;
|
||||||
|
use crate::{core::{kitty::Kitty, PreviewData}, ui::{Ctx, Term}};
|
||||||
|
|
||||||
|
pub struct Preview<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Preview<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Preview<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if self.cx.input.visible || self.cx.tasks.visible {
|
||||||
|
stdout().write(Kitty::image_hide()).ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manager = &self.cx.manager;
|
||||||
|
let hovered = if let Some(h) = manager.hovered() {
|
||||||
|
h.clone()
|
||||||
|
} else {
|
||||||
|
stdout().write(Kitty::image_hide()).ok();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let preview = manager.active().preview();
|
||||||
|
if preview.path != hovered.path {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(preview.data, PreviewData::Image(_)) {
|
||||||
|
stdout().write(Kitty::image_hide()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
match &preview.data {
|
||||||
|
PreviewData::None => {}
|
||||||
|
PreviewData::Folder => {
|
||||||
|
if let Some(folder) = manager.active().history(&hovered.path) {
|
||||||
|
Folder::new(folder).with_preview(true).render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PreviewData::Text(s) => {
|
||||||
|
let p = Paragraph::new(s.as_bytes().into_text().unwrap());
|
||||||
|
p.render(area, buf);
|
||||||
|
}
|
||||||
|
PreviewData::Image(b) => {
|
||||||
|
Term::move_to(area.x, area.y).ok();
|
||||||
|
stdout().write(b).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
20
src/ui/mod.rs
Normal file
20
src/ui/mod.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
mod app;
|
||||||
|
mod context;
|
||||||
|
mod dispatcher;
|
||||||
|
mod header;
|
||||||
|
mod input;
|
||||||
|
mod logs;
|
||||||
|
mod manager;
|
||||||
|
mod root;
|
||||||
|
mod signals;
|
||||||
|
mod status;
|
||||||
|
mod tasks;
|
||||||
|
mod term;
|
||||||
|
|
||||||
|
pub use app::*;
|
||||||
|
pub use context::*;
|
||||||
|
pub use dispatcher::*;
|
||||||
|
pub use input::*;
|
||||||
|
pub use logs::*;
|
||||||
|
pub use signals::*;
|
||||||
|
pub use term::*;
|
35
src/ui/root.rs
Normal file
35
src/ui/root.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, widgets::Widget};
|
||||||
|
|
||||||
|
use super::{header, manager, status, tasks, Ctx, Input};
|
||||||
|
|
||||||
|
pub struct Root<'a> {
|
||||||
|
cx: &'a mut Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Root<'a> {
|
||||||
|
pub fn new(cx: &'a mut Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Root<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
header::Layout::new(self.cx).render(chunks[0], buf);
|
||||||
|
manager::Layout::new(self.cx).render(chunks[1], buf);
|
||||||
|
status::Layout::new(self.cx).render(chunks[2], buf);
|
||||||
|
|
||||||
|
if self.cx.tasks.visible {
|
||||||
|
tasks::Layout::new(self.cx).render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.cx.input.visible {
|
||||||
|
Input::new(self.cx).render(area, buf);
|
||||||
|
self.cx.cursor = Some(self.cx.input.cursor());
|
||||||
|
} else {
|
||||||
|
self.cx.cursor = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
88
src/ui/signals.rs
Normal file
88
src/ui/signals.rs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::{Event as CrosstermEvent, EventStream};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use libc::{SIGHUP, SIGINT, SIGQUIT, SIGTERM};
|
||||||
|
use tokio::{select, sync::{mpsc::{self, Receiver, Sender}, oneshot}, task::JoinHandle};
|
||||||
|
|
||||||
|
use crate::core::Event;
|
||||||
|
|
||||||
|
pub struct Signals {
|
||||||
|
pub tx: Sender<Event>,
|
||||||
|
pub rx: Receiver<Event>,
|
||||||
|
|
||||||
|
term_stop_tx: Option<oneshot::Sender<()>>,
|
||||||
|
term_stop_rx: Option<oneshot::Receiver<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Signals {
|
||||||
|
pub fn start() -> Result<Self> {
|
||||||
|
let (tx, rx) = mpsc::channel(500);
|
||||||
|
let (term_tx, term_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let mut signals =
|
||||||
|
Self { tx: tx.clone(), rx, term_stop_tx: Some(term_tx), term_stop_rx: Some(term_rx) };
|
||||||
|
|
||||||
|
signals.spawn_system_task()?;
|
||||||
|
signals.spawn_crossterm_task();
|
||||||
|
|
||||||
|
Event::init(tx);
|
||||||
|
Ok(signals)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_system_task(&self) -> Result<JoinHandle<()>> {
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let mut signals = signal_hook_tokio::Signals::new([SIGHUP, SIGTERM, SIGINT, SIGQUIT])?;
|
||||||
|
|
||||||
|
Ok(tokio::spawn(async move {
|
||||||
|
while let Some(signal) = signals.next().await {
|
||||||
|
match signal {
|
||||||
|
SIGHUP | SIGTERM | SIGINT | SIGQUIT => {
|
||||||
|
if tx.send(Event::Quit).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_crossterm_task(&mut self) -> JoinHandle<()> {
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let mut stop_rx = self.term_stop_rx.take().unwrap();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut reader = EventStream::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
select! {
|
||||||
|
_ = &mut stop_rx => break,
|
||||||
|
Some(Ok(event)) = reader.next() => {
|
||||||
|
let event = match event {
|
||||||
|
CrosstermEvent::Key(key) => Event::Key(key),
|
||||||
|
CrosstermEvent::Resize(cols, rows) => Event::Resize(cols, rows),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if tx.send(event).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_term(&mut self, state: bool) {
|
||||||
|
if state == self.term_stop_tx.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tx) = self.term_stop_tx.take() {
|
||||||
|
tx.send(()).ok();
|
||||||
|
} else {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
(self.term_stop_tx, self.term_stop_rx) = (Some(tx), Some(rx));
|
||||||
|
self.spawn_crossterm_task();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
51
src/ui/status/layout.rs
Normal file
51
src/ui/status/layout.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, style::{Color, Modifier, Style}, widgets::{Paragraph, Widget}};
|
||||||
|
|
||||||
|
use super::Progress;
|
||||||
|
use crate::ui::Ctx;
|
||||||
|
|
||||||
|
pub struct Layout<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Layout<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Layout<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mode = self.cx.manager.active().mode();
|
||||||
|
|
||||||
|
let chunks = layout::Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Paragraph::new("").style(Style::default().fg(mode.color().bg_rgb())).render(chunks[0], buf);
|
||||||
|
|
||||||
|
Paragraph::new(format!(" {} ", mode))
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(mode.color().fg_rgb())
|
||||||
|
.bg(mode.color().bg_rgb())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.render(chunks[1], buf);
|
||||||
|
|
||||||
|
Paragraph::new(" master ")
|
||||||
|
.style(Style::default().fg(mode.color().bg_rgb()).bg(Color::Rgb(72, 77, 102)))
|
||||||
|
.render(chunks[2], buf);
|
||||||
|
|
||||||
|
Paragraph::new("").style(Style::default().fg(Color::Rgb(72, 77, 102))).render(chunks[3], buf);
|
||||||
|
|
||||||
|
Progress::new(self.cx).render(chunks[4], buf);
|
||||||
|
}
|
||||||
|
}
|
5
src/ui/status/mod.rs
Normal file
5
src/ui/status/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod layout;
|
||||||
|
mod progress;
|
||||||
|
|
||||||
|
pub use layout::*;
|
||||||
|
pub use progress::*;
|
27
src/ui/status/progress.rs
Normal file
27
src/ui/status/progress.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use ratatui::{style::{Color, Style}, widgets::{Gauge, Widget}};
|
||||||
|
|
||||||
|
use crate::ui::Ctx;
|
||||||
|
|
||||||
|
pub struct Progress<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Progress<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Progress<'a> {
|
||||||
|
fn render(self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||||
|
let progress = &self.cx.tasks.progress;
|
||||||
|
if progress.0 >= 100 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Gauge::default()
|
||||||
|
.gauge_style(Style::default().fg(Color::Yellow))
|
||||||
|
.percent(progress.0 as u16)
|
||||||
|
.label(format!("{}%, {} left", progress.0, progress.1))
|
||||||
|
.use_unicode(true)
|
||||||
|
.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
19
src/ui/tasks/clear.rs
Normal file
19
src/ui/tasks/clear.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::Rect, widgets::{self, Widget}};
|
||||||
|
|
||||||
|
pub struct Clear;
|
||||||
|
|
||||||
|
impl Widget for Clear {
|
||||||
|
fn render(self, mut area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.x > 0 {
|
||||||
|
area.x -= 1;
|
||||||
|
area.width += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if area.y > 0 {
|
||||||
|
area.y -= 1;
|
||||||
|
area.height += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets::Clear.render(area, buf)
|
||||||
|
}
|
||||||
|
}
|
72
src/ui/tasks/layout.rs
Normal file
72
src/ui/tasks/layout.rs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
use ratatui::{buffer::Buffer, layout::{self, Alignment, Constraint, Direction, Rect}, style::{Color, Modifier, Style}, widgets::{Block, BorderType, Borders, List, ListItem, Padding, Widget}};
|
||||||
|
|
||||||
|
use super::Clear;
|
||||||
|
use crate::{core::TASKS_PERCENT, ui::Ctx};
|
||||||
|
|
||||||
|
pub struct Layout<'a> {
|
||||||
|
cx: &'a Ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Layout<'a> {
|
||||||
|
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
|
||||||
|
|
||||||
|
pub fn area(area: Rect) -> Rect {
|
||||||
|
let chunk = layout::Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
|
||||||
|
Constraint::Percentage(TASKS_PERCENT),
|
||||||
|
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(area)[1];
|
||||||
|
|
||||||
|
let chunk = layout::Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
|
||||||
|
Constraint::Percentage(TASKS_PERCENT),
|
||||||
|
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(chunk)[1];
|
||||||
|
|
||||||
|
chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Widget for Layout<'a> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let area = Self::area(area);
|
||||||
|
|
||||||
|
Clear.render(area, buf);
|
||||||
|
let block = Block::default()
|
||||||
|
.title("Tasks")
|
||||||
|
.title_alignment(Alignment::Center)
|
||||||
|
.padding(Padding::new(0, 0, 1, 1))
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(Color::Rgb(128, 174, 250)));
|
||||||
|
block.clone().render(area, buf);
|
||||||
|
|
||||||
|
let tasks = &self.cx.tasks;
|
||||||
|
let items = tasks
|
||||||
|
.paginate()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| {
|
||||||
|
let mut item = ListItem::new(v.name.clone());
|
||||||
|
if i == tasks.cursor {
|
||||||
|
item = item.style(Style::default().add_modifier(Modifier::UNDERLINED));
|
||||||
|
}
|
||||||
|
item
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
List::new(items).render(block.inner(area), buf);
|
||||||
|
}
|
||||||
|
}
|
5
src/ui/tasks/mod.rs
Normal file
5
src/ui/tasks/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mod clear;
|
||||||
|
mod layout;
|
||||||
|
|
||||||
|
pub use clear::*;
|
||||||
|
pub use layout::*;
|
67
src/ui/term.rs
Normal file
67
src/ui/term.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
use std::{io::{stdout, Stdout}, ops::{Deref, DerefMut}};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{cursor::{MoveTo, SetCursorStyle}, event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, queue, terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen, LeaveAlternateScreen}};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
|
||||||
|
pub struct Term {
|
||||||
|
inner: Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
csi_u: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Term {
|
||||||
|
pub fn start() -> Result<Self> {
|
||||||
|
let mut term = Self { inner: Terminal::new(CrosstermBackend::new(stdout()))?, csi_u: false };
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
execute!(stdout(), EnterAlternateScreen, EnableBracketedPaste, EnableFocusChange)?;
|
||||||
|
|
||||||
|
term.csi_u = matches!(supports_keyboard_enhancement(), Ok(true));
|
||||||
|
if term.csi_u {
|
||||||
|
queue!(
|
||||||
|
stdout(),
|
||||||
|
PushKeyboardEnhancementFlags(
|
||||||
|
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||||
|
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||||
|
)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
term.hide_cursor()?;
|
||||||
|
term.clear()?;
|
||||||
|
Ok(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_to(x: u16, y: u16) -> Result<()> { Ok(execute!(stdout(), MoveTo(x, y))?) }
|
||||||
|
|
||||||
|
pub fn set_cursor_block() -> Result<()> { Ok(execute!(stdout(), SetCursorStyle::BlinkingBlock)?) }
|
||||||
|
|
||||||
|
pub fn set_cursor_bar() -> Result<()> { Ok(execute!(stdout(), SetCursorStyle::BlinkingBar)?) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Term {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut f = || -> Result<()> {
|
||||||
|
if self.csi_u {
|
||||||
|
execute!(stdout(), PopKeyboardEnhancementFlags)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
execute!(stdout(), DisableFocusChange, DisableBracketedPaste, LeaveAlternateScreen)?;
|
||||||
|
|
||||||
|
self.show_cursor()?;
|
||||||
|
Ok(disable_raw_mode()?)
|
||||||
|
};
|
||||||
|
|
||||||
|
f().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for Term {
|
||||||
|
type Target = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target { &self.inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for Term {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner }
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user