diff --git a/yazi-actor/src/lives/preview.rs b/yazi-actor/src/lives/preview.rs index ea87071c..bee57a44 100644 --- a/yazi-actor/src/lives/preview.rs +++ b/yazi-actor/src/lives/preview.rs @@ -25,6 +25,8 @@ impl Preview { impl UserData for Preview { fn add_fields>(fields: &mut F) { fields.add_field_method_get("skip", |_, me| Ok(me.skip)); + fields.add_field_method_get("search_idx", |_, me| Ok(me.search_idx)); + fields.add_static_field("folder", |_, me| { me.tab .hovered_folder() diff --git a/yazi-actor/src/mgr/peek.rs b/yazi-actor/src/mgr/peek.rs index c1d41d28..7ef9861e 100644 --- a/yazi-actor/src/mgr/peek.rs +++ b/yazi-actor/src/mgr/peek.rs @@ -37,13 +37,13 @@ impl Actor for Peek { succ!(); } + let preview = &mut cx.tab_mut().preview; if let Some(skip) = form.skip { - let preview = &mut cx.tab_mut().preview; - if form.upper_bound { - preview.skip = preview.skip.min(skip); - } else { - preview.skip = skip; - } + preview.skip = skip; + } + + if let Some(index) = form.search_idx { + preview.search_idx = Some(index); } if hovered.is_dir() { diff --git a/yazi-binding/src/elements/highlight.rs b/yazi-binding/src/elements/highlight.rs new file mode 100644 index 00000000..a4bb205b --- /dev/null +++ b/yazi-binding/src/elements/highlight.rs @@ -0,0 +1,44 @@ +use mlua::{ExternalError, FromLua, IntoLua, Lua, Result as LuaResult, Value}; + +const EXPECTED: &str = "expected a table containing a line and a length"; + +#[derive(Clone, Debug)] +pub struct HighlightPosition { + pub line: usize, + pub col: usize, + pub length: usize, +} + +impl HighlightPosition { + pub fn compose(lua: &Lua) -> LuaResult { + let new = lua.create_function(|_, (line, col, length): (usize, usize, usize)| { + Ok(Self { line, col, length }) + })?; + + let tbl = lua.create_table_from([("new", new)])?; + tbl.into_lua(lua) + } +} + +impl FromLua for HighlightPosition { + fn from_lua(value: Value, _: &Lua) -> mlua::Result { + match value { + Value::Table(tbl) => Ok(Self { + line: tbl.raw_get("line")?, + col: tbl.raw_get("col")?, + length: tbl.raw_get("length")?, + }), + _ => Err(EXPECTED.into_lua_err()), + } + } +} + +impl IntoLua for HighlightPosition { + fn into_lua(self, lua: &Lua) -> LuaResult { + let tbl = lua.create_table()?; + tbl.set("line", self.line)?; + tbl.set("col", self.col)?; + tbl.set("length", self.length)?; + Ok(Value::Table(tbl)) + } +} diff --git a/yazi-binding/src/elements/mod.rs b/yazi-binding/src/elements/mod.rs index 004b09ce..1cdbbe83 100644 --- a/yazi-binding/src/elements/mod.rs +++ b/yazi-binding/src/elements/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(align area bar border cell color constraint edge fill gauge layout line list pad rect row span spatial table text wrap); +yazi_macro::mod_flat!(align area bar border cell color constraint edge fill gauge highlight layout line list pad rect row span spatial table text wrap); diff --git a/yazi-core/src/highlighter.rs b/yazi-core/src/highlighter.rs index beedd79a..ad507970 100644 --- a/yazi-core/src/highlighter.rs +++ b/yazi-core/src/highlighter.rs @@ -3,6 +3,7 @@ use std::{io::{BufRead, BufReader, Cursor, Seek}, path::PathBuf, sync::OnceLock} use anyhow::{Result, anyhow, bail}; use ratatui_core::{layout::Size, text::{Line, Span, Text}}; use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Theme, ThemeSet}, parsing::{SyntaxReference, SyntaxSet}}; +use yazi_binding::elements::HighlightPosition; use yazi_config::{THEME, YAZI}; use yazi_runner::previewer::PeekError; use yazi_shared::{id::{Id, Ids}, replace_to_printable}; @@ -25,12 +26,17 @@ pub struct Highlighter { } impl Highlighter { - pub async fn oneshot

(path: P, skip: usize, size: Size) -> Result, PeekError> + pub async fn oneshot

( + path: P, + skip: usize, + size: Size, + position: Option, + ) -> Result, PeekError> where P: Into, { let path = path.into(); - tokio::task::spawn_blocking(move || Self::make(path, skip, size)?.highlight()).await? + tokio::task::spawn_blocking(move || Self::make(path, skip, size)?.highlight(position)).await? } fn make

(path: P, skip: usize, size: Size) -> Result @@ -59,7 +65,7 @@ impl Highlighter { pub fn abort() { INCR.next(); } - fn highlight(mut self) -> Result, PeekError> { + fn highlight(mut self, position: Option) -> Result, PeekError> { self.load_syntax()?; let mut plain = self.syntax.is_none(); @@ -78,9 +84,9 @@ impl Highlighter { } self.ensure_not_cancelled()?; - if plain && !self.process_plain(&buf, &mut i, &mut lines)? { + if plain && !self.process_plain(&buf, &mut i, &position, &mut lines)? { break; - } else if !plain && !self.process_hyper(&buf, &mut i, &mut lines)? { + } else if !plain && !self.process_hyper(&buf, &mut i, &position, &mut lines)? { break; } buf.clear(); @@ -89,11 +95,16 @@ impl Highlighter { if self.skip > 0 && i < self.skip + self.size.height as usize { return Err(PeekError::Exceeded(i.saturating_sub(self.size.height as _))); } - Ok(Text::from(lines)) } - fn process_plain(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec) -> Result { + fn process_plain( + &mut self, + buf: &[u8], + i: &mut usize, + position: &Option, + lines: &mut Vec, + ) -> Result { let b = replace_to_printable(buf, true, YAZI.preview.tab_size, false); let s = String::from_utf8_lossy(&b); @@ -107,20 +118,31 @@ impl Highlighter { if *i > self.skip + self.size.height as usize { return Ok(false); } else if *i > self.skip { - lines.push(spans.into_static_line()); + let mut static_line = spans.into_static_line(); + if let Some(pos) = position + && pos.line == *i + { + static_line.style = ratatui_core::style::Style::new().bg(ratatui_core::style::Color::Red); + } + lines.push(static_line); } self.ensure_not_cancelled()?; } Ok(true) } - fn process_hyper(&mut self, buf: &[u8], i: &mut usize, lines: &mut Vec) -> Result { + fn process_hyper( + &mut self, + buf: &[u8], + i: &mut usize, + position: &Option, + lines: &mut Vec, + ) -> Result { let Some(syntax) = self.syntax else { bail!("No syntax") }; let h = self.inner.get_or_insert_with(|| HighlightLines::new(syntax, self.theme)); - let s = String::from_utf8_lossy(buf); - let line = [Self::to_line_widget(h.highlight_line(&s, self.syntaxes)?)]; + let line = [Self::to_line_widget(h.highlight_line(&s, self.syntaxes)?)]; let mut it = LineIter::parsed(&line, YAZI.preview.tab_size); if let Some(wrap) = YAZI.preview.wrap.into() { it = it.wrapped(wrap, self.size.width); @@ -131,10 +153,17 @@ impl Highlighter { if *i > self.skip + self.size.height as usize { return Ok(false); } else if *i > self.skip { - lines.push(spans.into_static_line()); + let mut static_line = spans.into_static_line(); + if let Some(pos) = position + && pos.line == *i + { + static_line.style = ratatui_core::style::Style::new().bg(ratatui_core::style::Color::Red); + } + lines.push(static_line); } self.ensure_not_cancelled()?; } + Ok(true) } diff --git a/yazi-core/src/proxy.rs b/yazi-core/src/proxy.rs index 1547a9b3..88b069ce 100644 --- a/yazi-core/src/proxy.rs +++ b/yazi-core/src/proxy.rs @@ -48,9 +48,10 @@ impl MgrProxy { cha: job.file.cha, mime: job.mime, - skip: job.skip, - area: area.into(), - data: vec![ + skip: job.skip, + search_idx: job.search_idx, + area: area.into(), + data: vec![ Renderable::Clear(Default::default()).with_area(area), Renderable::from(Error::custom(error)).with_area(area), ], diff --git a/yazi-core/src/tab/preview.rs b/yazi-core/src/tab/preview.rs index da4a2f0b..3ab0cd61 100644 --- a/yazi-core/src/tab/preview.rs +++ b/yazi-core/src/tab/preview.rs @@ -12,10 +12,11 @@ use yazi_vfs::{VfsEntries, VfsFilesOp}; use crate::{AppProxy, Highlighter, MgrProxy, tab::PreviewLock}; -#[derive(Default)] +#[derive(Default, Debug)] pub struct Preview { - pub lock: Option, - pub skip: usize, + pub lock: Option, + pub skip: usize, + pub search_idx: Option, handle: Option>, pub folder_lock: Option, @@ -35,7 +36,8 @@ impl Preview { }; self.abort(); - let job = PeekJob { previewer, file, mime, skip: self.skip }; + + let job = PeekJob { previewer, file, mime, skip: self.skip, search_idx: self.search_idx }; self.handle = Some(tokio::spawn(async move { let mut rx = RUNNER.peek(&job).await; @@ -83,6 +85,7 @@ impl Preview { } pub fn reset(&mut self) { + self.search_idx = None; self.abort(); ADAPTOR.image_hide().ok(); render!(self.lock.take().is_some()) @@ -101,7 +104,8 @@ impl Preview { } pub fn same_lock(&self, file: &File, mime: &str) -> bool { - self.same_file(file, mime) && matches!(&self.lock, Some(l) if l.skip == self.skip) + self.same_file(file, mime) + && matches!(&self.lock, Some(l) if l.skip == self.skip && l.search_idx == self.search_idx) } pub fn same_folder(&self, url: &UrlBuf) -> bool { self.folder_lock.as_ref() == Some(url) } diff --git a/yazi-core/src/tab/preview_lock.rs b/yazi-core/src/tab/preview_lock.rs index a7a264f6..937c5be0 100644 --- a/yazi-core/src/tab/preview_lock.rs +++ b/yazi-core/src/tab/preview_lock.rs @@ -11,9 +11,10 @@ pub struct PreviewLock { pub cha: Cha, pub mime: Symbol, - pub skip: usize, - pub area: Rect, - pub data: Vec, + pub skip: usize, + pub search_idx: Option, + pub area: Rect, + pub data: Vec, } impl_data_any!(PreviewLock); @@ -29,9 +30,10 @@ impl TryFrom for PreviewLock { cha: f.cha, mime: t.raw_get::("mime")?.to_str()?.intern(), - skip: t.raw_get("skip")?, - area: t.raw_get("area")?, - data: Default::default(), + skip: t.raw_get("skip")?, + search_idx: t.raw_get("search_idx")?, + area: t.raw_get("area")?, + data: Default::default(), }) }) } diff --git a/yazi-parser/src/mgr/peek.rs b/yazi-parser/src/mgr/peek.rs index 4279bc44..432952a8 100644 --- a/yazi-parser/src/mgr/peek.rs +++ b/yazi-parser/src/mgr/peek.rs @@ -7,6 +7,7 @@ pub struct PeekForm { pub force: bool, pub only_if: Option, pub upper_bound: bool, + pub search_idx: Option, } impl From for PeekForm { @@ -16,6 +17,7 @@ impl From for PeekForm { force: a.bool("force"), only_if: a.take("only-if").ok(), upper_bound: a.bool("upper-bound"), + search_idx: a.take("search-idx").ok(), } } } diff --git a/yazi-plugin/preset/plugins/code.lua b/yazi-plugin/preset/plugins/code.lua index a9747d0e..168afc33 100644 --- a/yazi-plugin/preset/plugins/code.lua +++ b/yazi-plugin/preset/plugins/code.lua @@ -1,26 +1,104 @@ local M = {} +local function get_search_occurrences(url) + if not url.is_search then + return + end + -- Shoud we encode ~ too? + local subject_len, occurrences = tostring(url):match("^search://([^~]+)~(.-)//") + local lines = {} + for occurrence in occurrences:gmatch("[^,]+") do + local line, col = occurrence:match("(%d+)-(%d+)") + table.insert(lines, { tonumber(line), tonumber(col) }) + end + + if #lines == 0 then + return + end + + return tonumber(subject_len), lines +end + +local function get_next_occurrence_idx(search_idx, direction, occurrences_len) + local index + + if direction == "up" then + index = (search_idx or 1) - 1 + if index < 1 then + index = occurrences_len + end + else + index = (search_idx or 1) + 1 + if index > occurrences_len then + index = 1 + end + end + + return index +end + function M:peek(job) + local subject_len, search_occurrences = get_search_occurrences(job.file.url) + local search_idx = job.search_idx + + if search_idx == nil and search_occurrences then + local occurrence = search_occurrences[1] + local line = occurrence[1] + local skip = math.max(0, line - 1) + ya.emit("peek", { + skip, + only_if = job.file.url, + search_idx = 1, + }) + return + end + + local occurrence + if search_occurrences and search_idx then + occurrence = search_occurrences[search_idx] + job.position = { + line = occurrence[1], + col = occurrence[2], + length = subject_len, + } + end + local err, bound = ya.preview_code(job) if bound then - ya.emit("peek", { bound, only_if = job.file.url, upper_bound = true }) + ya.emit("peek", { bound, only_if = job.file.url, upper_bound = true, search_idx = search_idx }) elseif err and not err:find("cancelled", 1, true) then require("empty").msg(job, err) end end function M:seek(job) + local direction = job.units > 0 and "down" or "up" + local search_idx = cx.active.preview.search_idx + local h = cx.active.current.hovered if not h or h.url ~= job.file.url then return end + local _, search_occurrences = get_search_occurrences(job.file.url) + if search_occurrences then + local next_occurrence_idx = get_next_occurrence_idx(search_idx, direction, #search_occurrences) + local occurrence = search_occurrences[next_occurrence_idx] + local line = occurrence[1] + local skip = math.max(0, line - 1) + + ya.emit("peek", { skip, only_if = job.file.url, search_idx = next_occurrence_idx }) + return + end + local step = math.floor(job.units * job.area.h / 10) step = step == 0 and ya.clamp(-1, job.units, 1) or step + local skip = math.max(0, cx.active.preview.skip + step) ya.emit("peek", { - math.max(0, cx.active.preview.skip + step), + skip, only_if = job.file.url, + search_idx = search_idx, }) end diff --git a/yazi-plugin/src/external/rg.rs b/yazi-plugin/src/external/rg.rs index b6abf320..7a83c490 100644 --- a/yazi-plugin/src/external/rg.rs +++ b/yazi-plugin/src/external/rg.rs @@ -1,4 +1,4 @@ -use std::{path::Path, process::Stdio}; +use std::process::Stdio; use anyhow::Result; use tokio::{io::{AsyncBufReadExt, BufReader}, process::Command, sync::mpsc::{self, UnboundedReceiver}}; @@ -15,10 +15,10 @@ pub struct RgOpt { pub fn rg(opt: RgOpt) -> Result> { let mut child = Command::new("rg") - .args(["--color=never", "--files-with-matches", "--smart-case"]) + .args(["--color=never", "--no-heading", "--column", "--smart-case"]) .arg(if opt.hidden { "--hidden" } else { "--no-hidden" }) .args(opt.args) - .arg(opt.subject) + .arg(opt.subject.clone()) .current_dir(&*opt.cwd.as_url().unified_path()) .kill_on_drop(true) .stdout(Stdio::piped()) @@ -29,18 +29,67 @@ pub fn rg(opt: RgOpt) -> Result> { let (tx, rx) = mpsc::unbounded_channel(); tokio::spawn(async move { - while let Ok(Some(line)) = it.next_line().await { - if Path::new(&line).is_absolute() { - continue; + let mut current_file: String = String::new(); + let mut current_occurrences: Vec<(u32, u32)> = vec![]; + + while let Ok(Some(search_line)) = it.next_line().await { + let Some((file_path, line, col)) = parse_rg_line(&search_line) else { continue }; + + if current_file != file_path { + let Some(url) = + build_file_url(opt.cwd.clone(), opt.subject.clone(), ¤t_file, ¤t_occurrences) + else { + continue; + }; + + if let Ok(file) = File::new(url).await { + tx.send(file).ok(); + } + current_file = file_path.clone(); + current_occurrences = vec![]; } - let Ok(url) = opt.cwd.try_join(line) else { - continue; - }; - if let Ok(file) = File::new(url).await { - tx.send(file).ok(); + + current_occurrences.push((line as u32, col as u32)); + } + + if current_occurrences.len() > 0 { + match build_file_url(opt.cwd, opt.subject, ¤t_file, ¤t_occurrences) { + Some(url) => { + if let Ok(file) = File::new(url).await { + tx.send(file).ok(); + } + } + None => {} } } + child.wait().await.ok(); }); + Ok(rx) } + +fn build_file_url( + cwd: UrlBuf, + subject: String, + file_path: &str, + occurences: &Vec<(u32, u32)>, +) -> Option { + let occurrences_str = + occurences.iter().map(|(line, col)| format!("{line}-{col}")).collect::>().join(","); + let search_str = format!("{}~{}", subject.len(), occurrences_str); + let url = cwd.try_join(file_path).ok().and_then(|u| u.into_search(search_str).ok()); + + url +} + +fn parse_rg_line(line: &str) -> Option<(String, usize, usize)> { + let mut parts = line.split(':'); + + let (file, line, col) = (parts.next()?, parts.next()?, parts.next()?); + if file.is_empty() { + return None; + } + + Some((file.to_owned(), line.parse().ok()?, col.parse().ok()?)) +} diff --git a/yazi-plugin/src/utils/preview.rs b/yazi-plugin/src/utils/preview.rs index 001bfe04..d869d906 100644 --- a/yazi-plugin/src/utils/preview.rs +++ b/yazi-plugin/src/utils/preview.rs @@ -1,5 +1,5 @@ use mlua::{ExternalError, Function, IntoLuaMulti, Lua, Table, Value}; -use yazi_binding::{Error, elements::Area}; +use yazi_binding::{Error, elements::{Area, HighlightPosition}}; use yazi_core::{Highlighter, MgrProxy, tab::PreviewLock}; use yazi_fs::FsUrl; use yazi_runner::previewer::PeekError; @@ -15,10 +15,12 @@ impl Utils { pub(super) fn preview_code(lua: &Lua) -> mlua::Result { lua.create_async_function(|lua, t: Table| async move { let area: Area = t.raw_get("area")?; - let mut lock = PreviewLock::try_from(t)?; + let position: Option = t.raw_get("position").ok(); + let mut lock = PreviewLock::try_from(t)?; let path = lock.url.as_url().unified_path(); - let inner = match Highlighter::oneshot(path, lock.skip, area.size()).await { + + let inner = match Highlighter::oneshot(path, lock.skip, area.size(), position).await { Ok(text) => text, Err(e @ PeekError::Exceeded(max)) => return (e, max).into_lua_multi(&lua), Err(e) => { @@ -33,6 +35,9 @@ impl Utils { }) } + // Note: You need to implement or update Highlighter::oneshot_with_highlight to + // accept line/column and highlight accordingly. + pub(super) fn preview_widget(lua: &Lua) -> mlua::Result { lua.create_async_function(|_, (t, value): (Table, Value)| async move { let mut lock = PreviewLock::try_from(t)?; diff --git a/yazi-runner/src/previewer/job.rs b/yazi-runner/src/previewer/job.rs index 0002cfa4..5db06144 100644 --- a/yazi-runner/src/previewer/job.rs +++ b/yazi-runner/src/previewer/job.rs @@ -6,10 +6,11 @@ use yazi_shared::{data::Sendable, pool::Symbol}; #[derive(Clone, Debug)] pub struct PeekJob { - pub previewer: PreviewerArc, - pub file: File, - pub mime: Symbol, - pub skip: usize, + pub previewer: PreviewerArc, + pub file: File, + pub mime: Symbol, + pub skip: usize, + pub search_idx: Option, } impl IntoLua for PeekJob { @@ -21,6 +22,7 @@ impl IntoLua for PeekJob { ("file", self.file.into_lua(lua)?), ("mime", self.mime.into_lua(lua)?), ("skip", self.skip.into_lua(lua)?), + ("search_idx", self.search_idx.into_lua(lua)?), ])? .into_lua(lua) }