Repository: Canop/broot (language: rust)

Issue:
Context lines for content matches
When searching file content with `c/...`, I can open the preview pane and then cycle through matches, but I only get the exact line that matched. I'd really like to have the matching line centered in a regular preview and to be able to cycle through the matches that way. Alternatively, having *n* lines of context before and after the match (like `rg`) would be nice.


Discussion / hints:
In the same vein, this might require separate verbs for `previous_match_in_file` and `next_match_in_file`, as this would make `line_up` and `line_down` operate on file lines rather than match lines.


Right now, the way to cycle is to do another `ctrl-right` to see the text at the match position, then `ctrl-left` to see all matches. I describe it there: https://dystroy.org/blog/broot-c-search/

But cycling with a key is an interesting idea that I should try.
A configurable option for $n$ lines of context would really help. For my use case, I often deal with *Markdown* files, which have nested lists. Just seeing the child item isn't very helpful without also seeing the line above.
You mean when a file containing one or several matches is selected, you'd like to initially have in the right panel not just the matching lines but to have them surrounded by some context ?

(this may have been the initial request and I didn't understand it)
> You mean when a file containing one or several matches is selected, you'd like to initially have in the right panel not just the matching lines but to have them surrounded by some context ?

Yes that's what I mean. I believe this was the initial request. So see $n$ lines before and after the matching line.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41d49675..b6b9326a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,6 @@
 ### next
+- optionally display lines surrounding a matching one in display, with `lines_before_match_in_preview` and  `lines_after_match_in_preview` - Fix #756
+- filtered preview: jump between matches with `:next_match` (default: `tab`) and `:previous_match` (default `shift-tab`)
 - display setuid, setgid and sticky bits in permission - Fix #863
 
 ### v1.36.1 - 2024-03-11
diff --git a/bacon.toml b/bacon.toml
index 7f703107..ee7b1065 100644
--- a/bacon.toml
+++ b/bacon.toml
@@ -43,6 +43,7 @@ command = [
 	"-A", "clippy::match_like_matches_macro",
 	"-A", "clippy::module_inception",
 	"-A", "clippy::needless_bool",
+	"-A", "clippy::needless_range_loop",
 	"-A", "clippy::neg_multiply",
 	"-A", "clippy::vec_init_then_push",
 ]
diff --git a/resources/default-conf/conf.hjson b/resources/default-conf/conf.hjson
index b5f1b3d7..29f8909a 100644
--- a/resources/default-conf/conf.hjson
+++ b/resources/default-conf/conf.hjson
@@ -206,6 +206,16 @@ content_search_max_file_size: 10MB
 #
 enable_kitty_keyboard: false
 
+###############################################################
+# lines around matching line in filtered preview
+#
+# When searching the content of a file, you can have either
+# only the matching lines displayed, or some of the surrounding
+# ones too.
+#
+lines_before_match_in_preview: 1
+lines_after_match_in_preview: 1
+
 ###############################################################
 # Imports
 #
diff --git a/resources/default-conf/skins/catppuccin-macchiato.hjson b/resources/default-conf/skins/catppuccin-macchiato.hjson
index 897faec0..21a3bf18 100644
--- a/resources/default-conf/skins/catppuccin-macchiato.hjson
+++ b/resources/default-conf/skins/catppuccin-macchiato.hjson
@@ -59,6 +59,8 @@ skin: {
     preview: rgb(202, 211, 245) rgb(30, 32, 48)
     # fg:$overlay0
     preview_line_number: rgb(110, 115, 141) none
+    # fg:$overlay0
+    preview_separator: rgb(110, 115, 141) none
 #
 ### MATCH
 #
diff --git a/resources/default-conf/skins/catppuccin-mocha.hjson b/resources/default-conf/skins/catppuccin-mocha.hjson
index 514ac3a0..6ac1b92d 100644
--- a/resources/default-conf/skins/catppuccin-mocha.hjson
+++ b/resources/default-conf/skins/catppuccin-mocha.hjson
@@ -59,6 +59,8 @@ skin: {
     preview: rgb(205, 214, 244) rgb(24, 24, 37)
     # fg:$overlay0
     preview_line_number: rgb(108, 112, 134) none
+    # fg:$overlay0
+    preview_separator: rgb(108, 112, 134) none
 #
 ### MATCH
 #
diff --git a/resources/default-conf/skins/dark-blue.hjson b/resources/default-conf/skins/dark-blue.hjson
index 0ceb2b95..7dbc7f49 100644
--- a/resources/default-conf/skins/dark-blue.hjson
+++ b/resources/default-conf/skins/dark-blue.hjson
@@ -79,6 +79,7 @@ skin: {
     preview: gray(20) gray(1)  / gray(18) gray(2) 
     preview_title: gray(23) gray(2)  / gray(21) gray(2) 
     preview_line_number: gray(12) gray(3) 
+    preview_separator: gray(5) None
     preview_match: None ansi(29) 
     hex_null: gray(8) None 
     hex_ascii_graphic: gray(18) None 
diff --git a/resources/default-conf/skins/dark-gruvbox.hjson b/resources/default-conf/skins/dark-gruvbox.hjson
index 28047292..e62d5831 100644
--- a/resources/default-conf/skins/dark-gruvbox.hjson
+++ b/resources/default-conf/skins/dark-gruvbox.hjson
@@ -66,6 +66,7 @@ skin: {
     preview_title: rgb(235, 219, 178) rgb(40, 40, 40) / rgb(189, 174, 147) rgb(40, 40, 40)
     preview: rgb(235, 219, 178) rgb(40, 40, 40) / rgb(235, 219, 178) rgb(40, 40, 40)
     preview_line_number: rgb(124, 111, 100) None / rgb(124, 111, 100) rgb(40, 40, 40)
+    preview_separator: rgb(70, 70, 80) None / rgb(60, 60, 60) None
     preview_match: None ansi(29) Bold
     hex_null: rgb(189, 174, 147) None
     hex_ascii_graphic: rgb(213, 196, 161) None
diff --git a/resources/default-conf/skins/dark-orange.hjson b/resources/default-conf/skins/dark-orange.hjson
index 9fb0eeed..8e2f8455 100644
--- a/resources/default-conf/skins/dark-orange.hjson
+++ b/resources/default-conf/skins/dark-orange.hjson
@@ -75,6 +75,7 @@ skin: {
     help_table_border: ansi(239) None
     preview: gray(20) gray(1) / gray(18) gray(2)
     preview_line_number: gray(12) gray(3)
+    preview_separator: ansi(94) None / gray(3) None
     preview_match: None ansi(29)
     hex_null: gray(11) None
     hex_ascii_graphic: gray(18) None
diff --git a/resources/default-conf/skins/solarized-light.hjson b/resources/default-conf/skins/solarized-light.hjson
index b9d79076..6e192754 100644
--- a/resources/default-conf/skins/solarized-light.hjson
+++ b/resources/default-conf/skins/solarized-light.hjson
@@ -102,6 +102,7 @@ skin: {
 	preview_title: "rgb(147, 161, 161) rgb(238, 232, 213)"
 	preview: "rgb(101, 123, 131) rgb(253, 246, 227) / rgb(147, 161, 161) rgb(238, 232, 213)"
 	preview_line_number: "rgb(147, 161, 161) rgb(238, 232, 213)"
+	preview_separator: "rgb(147, 161, 161) rgb(238, 232, 213)"
 	preview_match: "None ansi(29)"
 	staging_area_title: "gray(22) rgb(253, 246, 227)"
 	good_to_bad_0: ansi(28)
diff --git a/resources/default-conf/skins/white.hjson b/resources/default-conf/skins/white.hjson
index 1e8d9b92..908e6481 100644
--- a/resources/default-conf/skins/white.hjson
+++ b/resources/default-conf/skins/white.hjson
@@ -62,6 +62,7 @@ skin: {
 	preview_title: gray(3) None / gray(5) None
 	preview: gray(5) gray(23) / gray(7) gray(23)
 	preview_line_number: gray(6) gray(20)
+	preview_separator: gray(7) None / gray(18) None
 	preview_match: None ansi(29) Underlined
 	hex_null: gray(15) None
 	hex_ascii_graphic: gray(2) None
diff --git a/src/app/app_context.rs b/src/app/app_context.rs
index 35c1afa5..b772c02a 100644
--- a/src/app/app_context.rs
+++ b/src/app/app_context.rs
@@ -121,6 +121,12 @@ pub struct AppContext {
     pub keyboard_enhanced: bool,
 
     pub kitty_graphics_transmission: TransmissionMedium,
+
+    /// Number of lines to display after a match in the preview
+    pub lines_after_match_in_preview: usize,
+
+    /// Number of lines to display before a match in the preview
+    pub lines_before_match_in_preview: usize,
 }
 
 impl AppContext {
@@ -221,6 +227,8 @@ impl AppContext {
             keyboard_enhanced: false,
             kitty_graphics_transmission: config.kitty_graphics_transmission
                 .unwrap_or_default(),
+            lines_after_match_in_preview: config.lines_after_match_in_preview.unwrap_or(0),
+            lines_before_match_in_preview: config.lines_before_match_in_preview.unwrap_or(0),
         })
     }
     /// Return the --cmd argument, coming from the launch arguments (prefered)
diff --git a/src/conf/conf.rs b/src/conf/conf.rs
index aaeb147e..42dd56ec 100644
--- a/src/conf/conf.rs
+++ b/src/conf/conf.rs
@@ -43,88 +43,94 @@ macro_rules! overwrite_map {
 /// The configuration read from conf.toml or conf.hjson file(s)
 #[derive(Default, Clone, Debug, Deserialize)]
 pub struct Conf {
-    /// the files used to load this configuration
-    #[serde(skip)]
-    pub files: Vec<PathBuf>,
+    #[serde(alias="capture-mouse")]
+    pub capture_mouse: Option<bool>,
 
-    #[serde(alias="default-flags")]
-    pub default_flags: Option<String>, // the flags to apply before cli ones
+    #[serde(alias="cols-order")]
+    pub cols_order: Option<ColsConf>,
+
+    #[serde(alias="content-search-max-file-size", deserialize_with="file_size::deserialize", default)]
+    pub content_search_max_file_size: Option<u64>,
 
     #[serde(alias="date-time-format")]
     pub date_time_format: Option<String>,
 
-    #[serde(default)]
-    pub verbs: Vec<VerbConf>,
-
-    pub skin: Option<AHashMap<String, SkinEntry>>,
-
-    #[serde(default, alias="special-paths")]
-    pub special_paths: HashMap<GlobConf, SpecialHandlingConf>,
-
-    #[serde(alias="search-modes")]
-    pub search_modes: Option<FnvHashMap<String, String>>,
+    #[serde(alias="default-flags")]
+    pub default_flags: Option<String>, // the flags to apply before cli ones
 
     /// Obsolete, kept for compatibility: you should now use capture_mouse
     #[serde(alias="disable-mouse-capture")]
     pub disable_mouse_capture: Option<bool>,
 
-    #[serde(alias="capture-mouse")]
-    pub capture_mouse: Option<bool>,
-
-    #[serde(alias="cols-order")]
-    pub cols_order: Option<ColsConf>,
-
-    #[serde(alias="show-selection-mark")]
-    pub show_selection_mark: Option<bool>,
+    #[serde(alias="enable-keyboard-enhancements")]
+    pub enable_kitty_keyboard: Option<bool>,
 
     #[serde(default, alias="ext-colors")]
     pub ext_colors: AHashMap<String, String>,
 
-    #[serde(alias="syntax-theme")]
-    pub syntax_theme: Option<SyntaxTheme>,
+    pub file_sum_threads_count: Option<usize>,
 
-    #[serde(alias="true-colors")]
-    pub true_colors: Option<bool>,
+    /// the files used to load this configuration
+    #[serde(skip)]
+    pub files: Vec<PathBuf>,
 
     #[serde(alias="icon-theme")]
     pub icon_theme: Option<String>,
 
-    pub modal: Option<bool>,
+    #[serde(default)]
+    pub imports: Vec<Import>,
 
     /// the initial mode (only relevant when modal is true)
     #[serde(alias="initial-mode")]
     pub initial_mode: Option<Mode>,
 
-    pub max_panels_count: Option<usize>,
+    #[serde(alias="kitty-graphics-transmission")]
+    pub kitty_graphics_transmission: Option<TransmissionMedium>,
 
-    #[serde(alias="quit-on-last-cancel")]
-    pub quit_on_last_cancel: Option<bool>,
+    #[serde(alias="lines-after-match-in-preview")]
+    pub lines_after_match_in_preview: Option<usize>,
 
-    pub file_sum_threads_count: Option<usize>,
+    #[serde(alias="lines-before-match-in-preview")]
+    pub lines_before_match_in_preview: Option<usize>,
+
+    pub max_panels_count: Option<usize>,
 
     #[serde(alias="max_staged_count")]
     pub max_staged_count: Option<usize>,
 
-    #[serde(default)]
-    pub imports: Vec<Import>,
+    pub modal: Option<bool>,
+
+    #[serde(alias="quit-on-last-cancel")]
+    pub quit_on_last_cancel: Option<bool>,
+
+    #[serde(alias="search-modes")]
+    pub search_modes: Option<FnvHashMap<String, String>>,
 
     #[serde(alias="show-matching-characters-on-path-searches")]
     pub show_matching_characters_on_path_searches: Option<bool>,
 
-    #[serde(alias="content-search-max-file-size", deserialize_with="file_size::deserialize", default)]
-    pub content_search_max_file_size: Option<u64>,
+    #[serde(alias="show-selection-mark")]
+    pub show_selection_mark: Option<bool>,
+
+    pub skin: Option<AHashMap<String, SkinEntry>>,
+
+    #[serde(default, alias="special-paths")]
+    pub special_paths: HashMap<GlobConf, SpecialHandlingConf>,
+
+    #[serde(alias="syntax-theme")]
+    pub syntax_theme: Option<SyntaxTheme>,
 
     #[serde(alias="terminal-title")]
     pub terminal_title: Option<ExecPattern>,
 
+    #[serde(alias="true-colors")]
+    pub true_colors: Option<bool>,
+
     #[serde(alias="update-work-dir")]
     pub update_work_dir: Option<bool>,
 
-    #[serde(alias="enable-keyboard-enhancements")]
-    pub enable_kitty_keyboard: Option<bool>,
-
-    #[serde(alias="kitty-graphics-transmission")]
-    pub kitty_graphics_transmission: Option<TransmissionMedium>,
+    #[serde(default)]
+    pub verbs: Vec<VerbConf>,
 
     // BEWARE: entries added here won't be usable unless also
     // added in read_file!
@@ -213,6 +219,8 @@ impl Conf {
         overwrite!(self, update_work_dir, conf);
         overwrite!(self, enable_kitty_keyboard, conf);
         overwrite!(self, kitty_graphics_transmission, conf);
+        overwrite!(self, lines_after_match_in_preview, conf);
+        overwrite!(self, lines_before_match_in_preview, conf);
         self.verbs.append(&mut conf.verbs);
         // the following maps are "additive": we can add entries from several
         // config files and they still make sense
diff --git a/src/preview/preview.rs b/src/preview/preview.rs
index b2af52c5..be19570f 100644
--- a/src/preview/preview.rs
+++ b/src/preview/preview.rs
@@ -256,6 +256,22 @@ impl Preview {
             _ => {}
         }
     }
+
+    pub fn previous_match(&mut self) {
+        if let Self::Syntactic(sv) = self {
+            sv.previous_match();
+        } else {
+            self.move_selection(-1, true);
+        }
+    }
+    pub fn next_match(&mut self) {
+        if let Self::Syntactic(sv) = self {
+            sv.next_match();
+        } else {
+            self.move_selection(1, true);
+        }
+    }
+
     pub fn select_first(&mut self) {
         match self {
             Self::Dir(dv) => dv.select_first(),
diff --git a/src/preview/preview_state.rs b/src/preview/preview_state.rs
index ca27d95a..470169af 100644
--- a/src/preview/preview_state.rs
+++ b/src/preview/preview_state.rs
@@ -393,6 +393,14 @@ impl PanelState for PreviewState {
                 self.mut_preview().select_last();
                 Ok(CmdResult::Keep)
             }
+            Internal::previous_match => {
+                self.mut_preview().previous_match();
+                Ok(CmdResult::Keep)
+            }
+            Internal::next_match => {
+                self.mut_preview().next_match();
+                Ok(CmdResult::Keep)
+            }
             Internal::preview_image => self.set_mode(PreviewMode::Image, con),
             Internal::preview_text => self.set_mode(PreviewMode::Text, con),
             Internal::preview_binary => self.set_mode(PreviewMode::Hex, con),
diff --git a/src/skin/style_map.rs b/src/skin/style_map.rs
index 20bb2caa..91d14f91 100644
--- a/src/skin/style_map.rs
+++ b/src/skin/style_map.rs
@@ -214,6 +214,7 @@ StyleMap! {
     preview: gray(20), gray(1), [] / gray(18), gray(2), []
     preview_title: gray(23), gray(2), [] / gray(21), gray(2), []
     preview_line_number: gray(12), gray(3), []
+    preview_separator: gray(7), None, []
     preview_match: None, ansi(29), []
     hex_null: gray(8), None, []
     hex_ascii_graphic: gray(18), None, []
diff --git a/src/syntactic/syntactic_view.rs b/src/syntactic/syntactic_view.rs
index b23b7732..12cc168e 100644
--- a/src/syntactic/syntactic_view.rs
+++ b/src/syntactic/syntactic_view.rs
@@ -23,9 +23,12 @@ use {
         str,
     },
     syntect::highlighting::Style,
-    termimad::{Area, CropWriter, SPACE_FILLING},
+    termimad::{Area, CropWriter, Filling, SPACE_FILLING},
 };
 
+pub static SEPARATOR_FILLING: Lazy<Filling> = Lazy::new(|| { Filling::from_char('─') });
+
+
 /// Homogeneously colored piece of a line
 #[derive(Debug)]
 pub struct Region {
@@ -50,6 +53,12 @@ impl Region {
     }
 }
 
+#[derive(Debug)]
+pub enum DisplayLine {
+    Content(Line),
+    Separator,
+}
+
 #[derive(Debug)]
 pub struct Line {
     pub number: LineNumber,   // starting at 1
@@ -62,13 +71,29 @@ pub struct Line {
 pub struct SyntacticView {
     pub path: PathBuf,
     pub pattern: InputPattern,
-    lines: Vec<Line>,
+    lines: Vec<DisplayLine>,
     scroll: usize,
     page_height: usize,
     selection_idx: Option<usize>, // index in lines of the selection, if any
+    content_lines_count: usize,  // number of lines excluding separators
     total_lines_count: usize,     // including lines not filtered out
 }
 
+impl DisplayLine {
+    pub fn line_number(&self) -> Option<LineNumber> {
+        match self {
+            DisplayLine::Content(line) => Some(line.number),
+            DisplayLine::Separator => None,
+        }
+    }
+    pub fn is_match(&self) -> bool {
+        match self {
+            DisplayLine::Content(line) => line.name_match.is_some(),
+            DisplayLine::Separator => false,
+        }
+    }
+}
+
 impl SyntacticView {
 
     /// Return a prepared text view with syntax coloring if possible.
@@ -88,6 +113,7 @@ impl SyntacticView {
             scroll: 0,
             page_height: 0,
             selection_idx: None,
+            content_lines_count: 0,
             total_lines_count: 0,
         };
         if sv.read_lines(dam, con, no_style)? {
@@ -120,7 +146,7 @@ impl SyntacticView {
         }
         let with_style = !no_style && md.len() < MAX_SIZE_FOR_STYLING;
         let mut reader = BufReader::new(f);
-        self.lines.clear();
+        let mut content_lines = Vec::new();
         let mut line = String::new();
         self.total_lines_count = 0;
         let mut offset = 0;
@@ -146,32 +172,66 @@ impl SyntacticView {
             // We don't remove '\n' or '\r' at this point because some syntax sets
             // need them for correct detection of comments. See #477
             // Those chars are removed on printing
-            if pattern.is_empty() || pattern.score_of_string(&line).is_some() {
-                let name_match = pattern.search_string(&line);
-                let regions = if let Some(highlighter) = highlighter.as_mut() {
-                    highlighter
-                        .highlight(&line, &SYNTAXER.syntax_set)
-                        .map_err(|e| ProgramError::SyntectCrashed { details: e.to_string() })?
-                        .iter()
-                        .map(Region::from_syntect)
-                        .collect()
-                } else {
-                    Vec::new()
-                };
-                self.lines.push(Line {
-                    regions,
-                    start,
-                    len: line.len(),
-                    name_match,
-                    number,
-                });
-            }
+            let name_match = pattern.search_string(&line);
+            let regions = if let Some(highlighter) = highlighter.as_mut() {
+                highlighter
+                    .highlight(&line, &SYNTAXER.syntax_set)
+                    .map_err(|e| ProgramError::SyntectCrashed { details: e.to_string() })?
+                    .iter()
+                    .map(Region::from_syntect)
+                    .collect()
+            } else {
+                Vec::new()
+            };
+            content_lines.push(Line {
+                regions,
+                start,
+                len: line.len(),
+                name_match,
+                number,
+            });
             line.clear();
             if dam.has_event() {
                 info!("event interrupted preview filtering");
                 return Ok(false);
             }
         }
+        let mut must_add_separators = false;
+        if !pattern.is_empty() {
+            let lines_before = con.lines_before_match_in_preview;
+            let lines_after = con.lines_after_match_in_preview;
+            if lines_before + lines_after > 0 {
+                let mut kept = vec![false; content_lines.len()];
+                for (i, line) in content_lines.iter().enumerate() {
+                    if line.name_match.is_some() {
+                        for j in i.saturating_sub(lines_before)..(i + lines_after + 1).min(content_lines.len()) {
+                            kept[j] = true;
+                        }
+                    }
+                }
+                for i in 1..kept.len() - 1 {
+                    if !kept[i] && kept[i - 1] && kept[i + 1] {
+                        kept[i] = true;
+                    }
+                }
+                content_lines.retain(|line| kept[line.number - 1]);
+                must_add_separators = true;
+            } else {
+                content_lines.retain(|line| line.name_match.is_some());
+            }
+        }
+        self.lines.clear();
+        self.content_lines_count = content_lines.len();
+        for line in content_lines {
+            if must_add_separators {
+                if let Some(last_number) = self.lines.last().and_then(|l| l.line_number()) {
+                    if line.number > last_number + 1 {
+                        self.lines.push(DisplayLine::Separator);
+                    }
+                }
+            }
+            self.lines.push(DisplayLine::Content(line));
+        }
         Ok(true)
     }
 
@@ -207,6 +267,10 @@ impl SyntacticView {
     pub fn get_selected_line(&self) -> Option<String> {
         self.selection_idx
             .and_then(|idx| self.lines.get(idx))
+            .and_then(|line| match line {
+                DisplayLine::Content(line) => Some(line),
+                DisplayLine::Separator => None,
+            })
             .and_then(|line| {
                 File::open(&self.path)
                     .and_then(|file| unsafe { Mmap::map(&file) })
@@ -222,7 +286,7 @@ impl SyntacticView {
 
     pub fn get_selected_line_number(&self) -> Option<LineNumber> {
         self.selection_idx
-            .map(|idx| self.lines[idx].number)
+            .and_then(|idx| self.lines[idx].line_number())
     }
     pub fn unselect(&mut self) {
         self.selection_idx = None;
@@ -253,7 +317,7 @@ impl SyntacticView {
     pub fn try_select_line_number(&mut self, number: LineNumber) -> bool {
         // this could obviously be optimized
         for (idx, line) in self.lines.iter().enumerate() {
-            if line.number == number {
+            if line.line_number() == Some(number) {
                 self.selection_idx = Some(idx);
                 self.ensure_selection_is_visible();
                 return true;
@@ -271,6 +335,29 @@ impl SyntacticView {
         self.ensure_selection_is_visible();
     }
 
+    pub fn previous_match(&mut self) {
+        let s = self.selection_idx.unwrap_or(0);
+        for d in  1..self.lines.len() {
+            let idx = (self.lines.len() + s - d) % self.lines.len();
+            if self.lines[idx].is_match() {
+                self.selection_idx = Some(idx);
+                self.ensure_selection_is_visible();
+                return;
+            }
+        }
+    }
+    pub fn next_match(&mut self) {
+        let s = self.selection_idx.unwrap_or(0);
+        for d in  1..self.lines.len() {
+            let idx = (s + d) % self.lines.len();
+            if self.lines[idx].is_match() {
+                self.selection_idx = Some(idx);
+                self.ensure_selection_is_visible();
+                return;
+            }
+        }
+    }
+
     pub fn try_scroll(
         &mut self,
         cmd: ScrollCommand,
@@ -299,6 +386,22 @@ impl SyntacticView {
         self.scroll != old_scroll
     }
 
+    pub fn max_line_number(&self) -> Option<LineNumber> {
+        for line in self.lines.iter().rev() {
+            if let Some(n) = line.line_number() {
+                return Some(n);
+            }
+        }
+        None
+    }
+
+    pub fn get_content_line(&self, idx: usize) -> Option<&Line> {
+        self.lines.get(idx).and_then(|line| match line {
+            DisplayLine::Content(line) => Some(line),
+            DisplayLine::Separator => None,
+        })
+    }
+
     pub fn display(
         &mut self,
         w: &mut W,
@@ -311,7 +414,10 @@ impl SyntacticView {
             self.page_height = area.height as usize;
             self.ensure_selection_is_visible();
         }
-        let max_number_len = self.lines.last().map_or(0, |l|l.number).to_string().len();
+        let max_number_len = self.max_line_number()
+            .unwrap_or(0)
+            .to_string()
+            .len();
         let show_line_number = area.width > 55 || ( self.pattern.is_some() && area.width > 8 );
         let line_count = area.height as usize;
         let styles = &panel_skin.styles;
@@ -336,73 +442,81 @@ impl SyntacticView {
             let selected = self.selection_idx == Some(line_idx);
             let bg = if selected { selection_bg } else { normal_bg };
             let mut op_mmap: Option<Mmap> = None;
-            if let Some(line) = self.lines.get(line_idx) {
-                let mut regions = &line.regions;
-                let regions_ur;
-                if regions.is_empty() && line.len > 0 {
-                    if op_mmap.is_none() {
-                        let file = File::open(&self.path)?;
-                        let mmap = unsafe { Mmap::map(&file)? };
-                        op_mmap = Some(mmap);
+            match self.lines.get(line_idx) {
+                Some(DisplayLine::Separator) => {
+                    cw.w.queue(SetBackgroundColor(bg))?;
+                    cw.queue_unstyled_str(" ")?;
+                    cw.fill(&styles.preview_separator, &SEPARATOR_FILLING)?;
+                }
+                Some(DisplayLine::Content(line)) => {
+                    let mut regions = &line.regions;
+                    let regions_ur;
+                    if regions.is_empty() && line.len > 0 {
+                        if op_mmap.is_none() {
+                            let file = File::open(&self.path)?;
+                            let mmap = unsafe { Mmap::map(&file)? };
+                            op_mmap = Some(mmap);
+                        }
+                        if op_mmap.as_ref().unwrap().len() < line.start + line.len {
+                            warn!("file truncated since parsing");
+                        } else {
+                            // an UTF8 error can only happen if file modified during display
+                            let string = String::from_utf8(
+                                // we copy the memmap slice, as it's not immutable
+                                (op_mmap.unwrap()[line.start..line.start + line.len]).to_vec(),
+                            )
+                            .unwrap_or_else(|_| "Bad UTF8".to_string());
+                            regions_ur = vec![Region {
+                                fg: normal_fg,
+                                string,
+                            }];
+                            regions = &regions_ur;
+                        }
                     }
-                    if op_mmap.as_ref().unwrap().len() < line.start + line.len {
-                        warn!("file truncated since parsing");
+                    cw.w.queue(SetBackgroundColor(bg))?;
+                    if show_line_number {
+                        cw.queue_g_string(
+                            &styles.preview_line_number,
+                            format!(" {:w$} ", line.number, w = max_number_len),
+                        )?;
                     } else {
-                        // an UTF8 error can only happen if file modified during display
-                        let string = String::from_utf8(
-                            // we copy the memmap slice, as it's not immutable
-                            (op_mmap.unwrap()[line.start..line.start + line.len]).to_vec(),
-                        )
-                        .unwrap_or_else(|_| "Bad UTF8".to_string());
-                        regions_ur = vec![Region {
-                            fg: normal_fg,
-                            string,
-                        }];
-                        regions = &regions_ur;
+                        cw.queue_unstyled_str(" ")?;
                     }
-                }
-                cw.w.queue(SetBackgroundColor(bg))?;
-                if show_line_number {
-                    cw.queue_g_string(
-                        &styles.preview_line_number,
-                        format!(" {:w$} ", line.number, w = max_number_len),
-                    )?;
-                } else {
-                    cw.queue_unstyled_str(" ")?;
-                }
-                cw.w.queue(SetBackgroundColor(bg))?;
-                if con.show_selection_mark {
-                    cw.queue_unstyled_char(if selected { '▶' } else { ' ' })?;
-                }
-                if let Some(nm) = &line.name_match {
-                    let mut dec = 0;
-                    let pos = &nm.pos;
-                    let mut pos_idx: usize = 0;
-                    for content in regions {
-                        let s = content.string.trim_end_matches(is_char_end_of_line);
-                        cw.w.queue(SetForegroundColor(content.fg))?;
-                        if pos_idx < pos.len() {
-                            for (cand_idx, cand_char) in s.chars().enumerate() {
-                                if pos_idx < pos.len() && pos[pos_idx] == cand_idx + dec {
-                                    cw.w.queue(SetBackgroundColor(match_bg))?;
-                                    cw.queue_unstyled_char(cand_char)?;
-                                    cw.w.queue(SetBackgroundColor(bg))?;
-                                    pos_idx += 1;
-                                } else {
-                                    cw.queue_unstyled_char(cand_char)?;
+                    cw.w.queue(SetBackgroundColor(bg))?;
+                    if con.show_selection_mark {
+                        cw.queue_unstyled_char(if selected { '▶' } else { ' ' })?;
+                    }
+                    if let Some(nm) = &line.name_match {
+                        let mut dec = 0;
+                        let pos = &nm.pos;
+                        let mut pos_idx: usize = 0;
+                        for content in regions {
+                            let s = content.string.trim_end_matches(is_char_end_of_line);
+                            cw.w.queue(SetForegroundColor(content.fg))?;
+                            if pos_idx < pos.len() {
+                                for (cand_idx, cand_char) in s.chars().enumerate() {
+                                    if pos_idx < pos.len() && pos[pos_idx] == cand_idx + dec {
+                                        cw.w.queue(SetBackgroundColor(match_bg))?;
+                                        cw.queue_unstyled_char(cand_char)?;
+                                        cw.w.queue(SetBackgroundColor(bg))?;
+                                        pos_idx += 1;
+                                    } else {
+                                        cw.queue_unstyled_char(cand_char)?;
+                                    }
                                 }
+                                dec += s.chars().count();
+                            } else {
+                                cw.queue_unstyled_str(s)?;
                             }
-                            dec += s.chars().count();
-                        } else {
-                            cw.queue_unstyled_str(s)?;
                         }
-                    }
-                } else {
-                    for content in regions {
-                        cw.w.queue(SetForegroundColor(content.fg))?;
-                        cw.queue_unstyled_str(content.string.trim_end_matches(is_char_end_of_line))?;
+                    } else {
+                        for content in regions {
+                            cw.w.queue(SetForegroundColor(content.fg))?;
+                            cw.queue_unstyled_str(content.string.trim_end_matches(is_char_end_of_line))?;
+                        }
                     }
                 }
+                None => {}
             }
             cw.fill(
                 if selected { &styles.selected_line } else { &styles.preview },
@@ -428,7 +542,7 @@ impl SyntacticView {
     ) -> Result<(), ProgramError> {
         let width = area.width as usize;
         let mut s = if self.pattern.is_some() {
-            format!("{}/{}", self.lines.len(), self.total_lines_count)
+            format!("{}/{}", self.content_lines_count, self.total_lines_count)
         } else {
             format!("{}", self.total_lines_count)
         };
diff --git a/src/verb/execution_builder.rs b/src/verb/execution_builder.rs
index 524936db..54e33cee 100644
--- a/src/verb/execution_builder.rs
+++ b/src/verb/execution_builder.rs
@@ -170,7 +170,7 @@ impl<'b> ExecutionStringBuilder<'b> {
                     }))
             }
             "file-git-relative" => { // file path relative to git repo workdir
-                let Some(sel) = sel else { return None; };
+                let sel = sel?;
                 let path = git2::Repository::discover(self.root).ok()
                     .and_then(|repo| repo.workdir().map(path_to_string))
                     .and_then(|gitroot| sel.path.strip_prefix(gitroot).ok())
diff --git a/website/docs/conf_file.md b/website/docs/conf_file.md
index 45d1ba57..339657c4 100644
--- a/website/docs/conf_file.md
+++ b/website/docs/conf_file.md
@@ -388,3 +388,16 @@ show_matching_characters_on_path_searches = false
 which gives this:
 
 ![not shown](img/subpath-match-not-shown.png)
+
+## Lines surrounding a match in preview
+
+If you want to have more than just the matching lines displayed in preview, you may changes those parameters in config:
+
+```Hjson
+lines_before_match_in_preview: 0
+lines_after_match_in_preview: 0
+```
+```TOML
+lines_before_match_in_preview = 0
+lines_after_match_in_preview = 0
+```
diff --git a/website/docs/skins.md b/website/docs/skins.md
index fa80a565..ae240ce9 100644
--- a/website/docs/skins.md
+++ b/website/docs/skins.md
@@ -77,6 +77,7 @@ skin: {
 	help_table_border: ansi(239) None
 	preview_title: gray(23) None / gray(21) None
 	preview: gray(20) gray(1) / gray(18) gray(2)
+	preview_separator: ansi(94) None / gray(3) None
 	preview_line_number: gray(12) gray(3)
 	preview_match: None ansi(29)
 	hex_null: gray(11) None
@@ -159,6 +160,7 @@ help_table_border = "ansi(239) None"
 preview_title = "gray(23) None / gray(21) None"
 preview = "gray(20) gray(1) / gray(18) gray(2)"
 preview_line_number = "gray(12) gray(3)"
+preview_separator: "ansi(94) None / gray(3) None"
 preview_match = "None ansi(29)"
 hex_null = "gray(11) None"
 hex_ascii_graphic = "gray(18) None"


Test patch: