diff --git a/yazi-actor/src/input/close.rs b/yazi-actor/src/input/close.rs index 0c0a8eb55..4b4bcd3eb 100644 --- a/yazi-actor/src/input/close.rs +++ b/yazi-actor/src/input/close.rs @@ -20,7 +20,14 @@ impl Actor for Close { if let Some(tx) = input.tx.take() { let value = input.snap().value.clone(); - _ = tx.send(if form.submit { InputEvent::Submit(value) } else { InputEvent::Cancel(value) }); + if form.submit { + if !input.obscure { + input.history().push(value.clone()); + } + _ = tx.send(InputEvent::Submit(value)); + } else { + _ = tx.send(InputEvent::Cancel(value)); + } } act!(cmp:close, cx)?; diff --git a/yazi-actor/src/input/history.rs b/yazi-actor/src/input/history.rs new file mode 100644 index 000000000..1464e1725 --- /dev/null +++ b/yazi-actor/src/input/history.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use yazi_shared::data::Data; +use yazi_widgets::input::parser::HistoryOpt; + +use crate::{Actor, Ctx}; + +pub struct History; + +impl Actor for History { + type Form = HistoryOpt; + + const NAME: &str = "history"; + + fn act(cx: &mut Ctx, form: Self::Form) -> Result { + cx.input.navigate_history(form) + } +} \ No newline at end of file diff --git a/yazi-actor/src/input/mod.rs b/yazi-actor/src/input/mod.rs index 16e44b0ec..7dd25a819 100644 --- a/yazi-actor/src/input/mod.rs +++ b/yazi-actor/src/input/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(close complete escape show); +yazi_macro::mod_flat!(close complete escape history show); diff --git a/yazi-config/preset/keymap-default.toml b/yazi-config/preset/keymap-default.toml index 7d621ec01..3807d2fd2 100644 --- a/yazi-config/preset/keymap-default.toml +++ b/yazi-config/preset/keymap-default.toml @@ -304,6 +304,14 @@ keymap = [ { on = "p", run = "paste", desc = "Paste copied characters after the cursor" }, { on = "P", run = "paste --before", desc = "Paste copied characters before the cursor" }, + # History + { on = "", run = "history -1", desc = "Navigate to the previous history entry" }, + { on = "", run = "history +1", desc = "Navigate to the next history entry" }, + { on = "", run = "history -1", desc = "Navigate to the previous history entry" }, + { on = "", run = "history +1", desc = "Navigate to the next history entry" }, + { on = "k", run = "history -1", desc = "Navigate to the previous history entry" }, + { on = "j", run = "history +1", desc = "Navigate to the next history entry" }, + # Undo/Redo/Casefy { on = "u", run = [ "undo", "casefy lower" ], desc = "Undo, or lowercase if in visual mode" }, { on = "U", run = "casefy upper", desc = "Uppercase" }, diff --git a/yazi-config/src/popup/options.rs b/yazi-config/src/popup/options.rs index 6170bfd91..20a420b46 100644 --- a/yazi-config/src/popup/options.rs +++ b/yazi-config/src/popup/options.rs @@ -1,11 +1,12 @@ use ratatui::{text::{Line, Text}, widgets::{Paragraph, Wrap}}; -use yazi_shared::{scheme::Encode as EncodeScheme, strand::ToStrand, url::{Url, UrlBuf}}; +use yazi_shared::{SStr, scheme::Encode as EncodeScheme, strand::ToStrand, url::{Url, UrlBuf}}; use super::{Offset, Position}; use crate::{YAZI, popup::Origin}; #[derive(Clone, Debug, Default)] pub struct InputCfg { + pub id: SStr, pub title: String, pub value: String, pub cursor: Option, @@ -33,6 +34,7 @@ pub struct ConfirmCfg { impl InputCfg { pub fn cd(cwd: Url) -> Self { Self { + id: "cd".into(), title: YAZI.input.cd_title.clone(), value: if cwd.kind().is_local() { String::new() } else { EncodeScheme(cwd).to_string() }, position: Position::new(YAZI.input.cd_origin, YAZI.input.cd_offset), @@ -43,6 +45,7 @@ impl InputCfg { pub fn create(dir: bool) -> Self { Self { + id: "create".into(), title: YAZI.input.create_title[dir as usize].clone(), position: Position::new(YAZI.input.create_origin, YAZI.input.create_offset), ..Default::default() @@ -51,6 +54,7 @@ impl InputCfg { pub fn rename() -> Self { Self { + id: "rename".into(), title: YAZI.input.rename_title.clone(), position: Position::new(YAZI.input.rename_origin, YAZI.input.rename_offset), ..Default::default() @@ -59,6 +63,7 @@ impl InputCfg { pub fn filter() -> Self { Self { + id: "filter".into(), title: YAZI.input.filter_title.clone(), position: Position::new(YAZI.input.filter_origin, YAZI.input.filter_offset), realtime: true, @@ -78,6 +83,7 @@ impl InputCfg { pub fn search(name: &str) -> Self { Self { title: YAZI.input.search_title.replace("{n}", name), + id: "search".into(), position: Position::new(YAZI.input.search_origin, YAZI.input.search_offset), ..Default::default() } @@ -85,6 +91,7 @@ impl InputCfg { pub fn shell(block: bool) -> Self { Self { + id: "shell".into(), title: YAZI.input.shell_title[block as usize].clone(), position: Position::new(YAZI.input.shell_origin, YAZI.input.shell_offset), ..Default::default() @@ -93,6 +100,7 @@ impl InputCfg { pub fn tab_rename() -> Self { Self { + id: "tab_rename".into(), title: "Rename tab:".to_owned(), position: Position::new(Origin::TopCenter, Offset { x: 0, diff --git a/yazi-core/src/input/history.rs b/yazi-core/src/input/history.rs new file mode 100644 index 000000000..75d9c7cc9 --- /dev/null +++ b/yazi-core/src/input/history.rs @@ -0,0 +1,90 @@ +use std::{collections::VecDeque, mem}; + +use yazi_widgets::input::InputSnaps; + +// TODO: make configurable? +const MAX_LENGTH: usize = 20; + +#[derive(Default)] +pub struct InputHistory { + entries: VecDeque, + entry_snaps: VecDeque>, + idx: Option, + draft: Option, +} + +impl InputHistory { + pub const fn new() -> Self { + Self { + entries: VecDeque::new(), + entry_snaps: VecDeque::new(), + idx: None, + draft: None, + } + } + + pub fn push(&mut self, value: String) { + if value.is_empty() { + return; + } + if self.entries.back().map(String::as_str) != Some(&value) { + if self.entries.len() >= MAX_LENGTH { + self.entries.pop_front(); + self.entry_snaps.pop_front(); + } + self.entries.push_back(value); + self.entry_snaps.push_back(None); + } + self.reset(); + } + + pub fn reset(&mut self) { + self.idx = None; + self.draft = None; + } + + pub fn navigate(&mut self, step: i64, snaps: &mut InputSnaps, limit: usize) -> bool { + if self.entries.is_empty() || step == 0 { + return false; + } + + let len = self.entries.len() as i64; + let pos = self.idx.map_or(len, |i| i as i64); + let new_pos = (pos + step).clamp(0, len); + + if new_pos == pos { + return false; + } + + let mode = snaps.current().mode; + let cursor = snaps.current().cursor; + + // Save current snaps into draft or the slot we're leaving + let old = mem::take(snaps); + if let Some(old_idx) = self.idx { + self.entry_snaps[old_idx] = Some(old); + } else { + self.draft = Some(old); + } + + // Load target snaps + *snaps = if new_pos == len { + self.idx = None; + self.draft.take().unwrap_or_default() + } else { + let new_idx = new_pos as usize; + self.idx = Some(new_idx); + if self.entry_snaps[new_idx].is_none() { + let value = self.entries[new_idx].clone(); + // history does not trigger on obscured inputs + self.entry_snaps[new_idx] = Some(InputSnaps::new(value, false, limit)); + } + self.entry_snaps[new_idx].take().unwrap() + }; + + // Preserve mode and cursor position from before navigation + snaps.update_current(mode, cursor, limit); + + true + } +} diff --git a/yazi-core/src/input/input.rs b/yazi-core/src/input/input.rs index 130c4e5a3..ed0c17c02 100644 --- a/yazi-core/src/input/input.rs +++ b/yazi-core/src/input/input.rs @@ -1,16 +1,45 @@ use std::ops::{Deref, DerefMut}; +use anyhow::Result; use yazi_config::popup::Position; +use yazi_macro::{render, succ}; +use yazi_shared::{data::Data, SStr}; +use yazi_widgets::input::{InputOp, parser::HistoryOpt}; +use crate::input::InputHistory; #[derive(Default)] pub struct Input { pub(super) inner: yazi_widgets::input::Input, + pub history: std::collections::HashMap, pub visible: bool, pub title: String, pub position: Position, } +impl Input { + pub fn history(&mut self) -> &mut InputHistory { + if !self.history.contains_key(&self.inner.id) { + self.history.insert(self.inner.id.clone(), InputHistory::new()); + } + self.history.get_mut(&self.inner.id).unwrap() + } + pub fn navigate_history(&mut self, opt: HistoryOpt) -> Result { + if self.inner.snap().op != InputOp::None || self.inner.obscure { + succ!(); + } + match self.history.get_mut(&self.inner.id) { + Some(history) => { + if !history.navigate(opt.offset, &mut self.inner.snaps, self.inner.limit) { + succ!(); + } + } + None => succ!(), + } + succ!(render!()); + } +} + impl Deref for Input { type Target = yazi_widgets::input::Input; diff --git a/yazi-core/src/input/mod.rs b/yazi-core/src/input/mod.rs index 9ad1aafa7..29e51ec9b 100644 --- a/yazi-core/src/input/mod.rs +++ b/yazi-core/src/input/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(input); +yazi_macro::mod_flat!(history input); diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs index 3a8c461ff..d69d7c42c 100644 --- a/yazi-fm/src/executor.rs +++ b/yazi-fm/src/executor.rs @@ -270,6 +270,7 @@ impl<'a> Executor<'a> { on!(escape); on!(show); on!(close); + on!(history); match mode { InputMode::Normal => { diff --git a/yazi-parser/src/spark/spark.rs b/yazi-parser/src/spark/spark.rs index a6cbc703e..3fdbd4030 100644 --- a/yazi-parser/src/spark/spark.rs +++ b/yazi-parser/src/spark/spark.rs @@ -122,6 +122,7 @@ pub enum Spark<'a> { InputEscape(crate::VoidForm), InputForward(yazi_widgets::input::parser::ForwardOpt), InputInsert(yazi_widgets::input::parser::InsertOpt), + InputHistory(yazi_widgets::input::parser::HistoryOpt), InputKill(yazi_widgets::input::parser::KillOpt), InputMove(yazi_widgets::input::parser::MoveOpt), InputPaste(yazi_widgets::input::parser::PasteOpt), @@ -310,6 +311,7 @@ impl<'a> IntoLua for Spark<'a> { Self::InputEscape(b) => b.into_lua(lua), Self::InputForward(b) => b.into_lua(lua), Self::InputInsert(b) => b.into_lua(lua), + Self::InputHistory(b) => b.into_lua(lua), Self::InputKill(b) => b.into_lua(lua), Self::InputMove(b) => b.into_lua(lua), Self::InputPaste(b) => b.into_lua(lua), @@ -448,6 +450,7 @@ try_from_spark!(crate::which::ActivateForm, which:activate); try_from_spark!(yazi_dds::Payload<'a>, app:accept_payload); try_from_spark!(yazi_widgets::input::InputOpt, input:show); try_from_spark!(yazi_widgets::input::parser::BackspaceOpt, input:backspace); +try_from_spark!(yazi_widgets::input::parser::HistoryOpt, input:history); try_from_spark!(yazi_widgets::input::parser::BackwardOpt, input:backward); try_from_spark!(yazi_widgets::input::parser::CompleteOpt, input:complete); try_from_spark!(yazi_widgets::input::parser::DeleteOpt, input:delete); diff --git a/yazi-plugin/src/utils/layer.rs b/yazi-plugin/src/utils/layer.rs index 096a0aef9..e76214396 100644 --- a/yazi-plugin/src/utils/layer.rs +++ b/yazi-plugin/src/utils/layer.rs @@ -1,6 +1,6 @@ use std::{str::FromStr, time::Duration}; -use mlua::{ExternalError, ExternalResult, Function, IntoLuaMulti, Lua, Table, Value}; +use mlua::{ExternalError, ExternalResult, Function, IntoLuaMulti, Lua, Table, Value, prelude::LuaError}; use tokio_stream::wrappers::UnboundedReceiverStream; use yazi_binding::{InputRx, elements::{Line, Pos, Text}, runtime}; use yazi_config::{Platform, keymap::{Chord, ChordCow, Key}, popup::{ConfirmCfg, InputCfg}}; @@ -52,6 +52,7 @@ impl Utils { let realtime = t.raw_get("realtime")?; let rx = UnboundedReceiverStream::new(InputProxy::show(InputCfg { + id: (t.raw_get("id") as Result).unwrap_or_default().into(), title: t.raw_get("title")?, value: t.raw_get("value").unwrap_or_default(), cursor: None, // TODO diff --git a/yazi-widgets/src/input/input.rs b/yazi-widgets/src/input/input.rs index 936fbda61..54adecaa4 100644 --- a/yazi-widgets/src/input/input.rs +++ b/yazi-widgets/src/input/input.rs @@ -5,7 +5,7 @@ use crossterm::cursor::SetCursorStyle; use tokio::sync::mpsc; use yazi_config::YAZI; use yazi_macro::act; -use yazi_shared::Ids; +use yazi_shared::{Ids, SStr}; use yazi_shim::path::CROSS_SEPARATOR; use super::{InputSnap, InputSnaps, mode::InputMode, op::InputOp}; @@ -13,6 +13,7 @@ use crate::{CLIPBOARD, input::{InputEvent, InputOpt}}; #[derive(Default)] pub struct Input { + pub id: SStr, pub snaps: InputSnaps, pub limit: usize, pub obscure: bool, @@ -27,6 +28,7 @@ impl Input { pub fn new(opt: InputOpt) -> Result { let limit = opt.cfg.position.offset.width.saturating_sub(YAZI.input.border()) as usize; let mut input = Self { + id: opt.cfg.id, snaps: InputSnaps::new(opt.cfg.value, opt.cfg.obscure, limit), limit, obscure: opt.cfg.obscure, diff --git a/yazi-widgets/src/input/parser/history.rs b/yazi-widgets/src/input/parser/history.rs new file mode 100644 index 000000000..d1e35df0a --- /dev/null +++ b/yazi-widgets/src/input/parser/history.rs @@ -0,0 +1,29 @@ +use mlua::{ExternalError, FromLua, IntoLua, Lua, Value}; +use serde::Deserialize; +use yazi_shared::event::ActionCow; + +#[derive(Debug, Deserialize)] +pub struct HistoryOpt { + #[serde(alias = "0", default)] + pub offset: i64, +} + +impl TryFrom for HistoryOpt { + type Error = anyhow::Error; + + fn try_from(a: ActionCow) -> Result { + Ok(a.deserialize()?) + } +} + +impl FromLua for HistoryOpt { + fn from_lua(_: Value, _: &Lua) -> mlua::Result { + Err("unsupported".into_lua_err()) + } +} + +impl IntoLua for HistoryOpt { + fn into_lua(self, _: &Lua) -> mlua::Result { + Err("unsupported".into_lua_err()) + } +} diff --git a/yazi-widgets/src/input/parser/mod.rs b/yazi-widgets/src/input/parser/mod.rs index 924c5be3d..17b0fb13f 100644 --- a/yazi-widgets/src/input/parser/mod.rs +++ b/yazi-widgets/src/input/parser/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(backspace backward casefy complete delete forward insert kill paste r#move); +yazi_macro::mod_flat!(backspace backward casefy complete delete forward history insert kill paste r#move); diff --git a/yazi-widgets/src/input/snaps.rs b/yazi-widgets/src/input/snaps.rs index 0bfbd5397..9ec261f86 100644 --- a/yazi-widgets/src/input/snaps.rs +++ b/yazi-widgets/src/input/snaps.rs @@ -1,5 +1,6 @@ use std::mem; +use crate::input::InputMode; use super::InputSnap; #[derive(PartialEq, Eq)] @@ -76,4 +77,16 @@ impl InputSnaps { #[inline] pub(super) fn current_mut(&mut self) -> &mut InputSnap { &mut self.current } + + /// Updates the current snap. + /// + /// * `mode`: New mode to use. + /// * `cursor`: Cursor position. + /// * `limit`: New size of the snap. + pub fn update_current(&mut self, mode: InputMode, cursor: usize, limit: usize) { + let snap = self.current_mut(); + snap.mode = mode; + snap.cursor = cursor.min(snap.count().saturating_sub(mode.delta())); + snap.resize(limit); + } }