Skip to content

Commit 37f0d2b

Browse files
authored
Merge pull request #1 from digicrafts/fix/merge
feat: fix merge
2 parents 59fa3ee + 059defc commit 37f0d2b

11 files changed

Lines changed: 793 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Changelog
2+
3+
## [0.1.4] - 2026-03-06
4+
5+
### Added
6+
7+
- **Mouse selection & copy in preview pane** — click and drag to select text, copied to clipboard on release via OSC 52 with tmux passthrough support. Writes to `/dev/tty` for reliable SSH clipboard forwarding.
8+
- **Horizontal scrolling** — scroll preview content horizontally when word wrap is disabled:
9+
- `[` / `]` keys to scroll left/right
10+
- Shift+Left / Shift+Right arrow keys
11+
- Left/Right arrows in fullscreen preview mode
12+
- Shift+MouseWheel for horizontal mouse scroll
13+
- **Shift+Up/Down** scrolls preview vertically from any pane (not just when preview is focused)
14+
- **Git status in directory preview** — when a directory is selected, the preview pane shows git status indicators (`[M]`, `[A]`, `[D]`, `[R]`, `[?]`, etc.) next to each file entry
15+
- **"Copy Completed" overlay** — a bold inverted-color indicator appears at the top-right of the preview pane after a successful copy, disappears on next action
16+
- `PreviewScrollLeft` / `PreviewScrollRight` actions available for custom keybinding
17+
- **Auto-refresh** — tree list and preview automatically update when files/directories change on disk (polling every 2 seconds)
18+
- **Manual refresh** — press `F5` to force refresh the tree and preview immediately
19+
20+
### Changed
21+
22+
- Selection highlight uses light blue background (instead of REVERSED modifier) for better visibility across terminals
23+
- Clipboard copy now always sends OSC 52 via `/dev/tty` first, then also tries local tools (`xclip`, `xsel`, `wl-copy`, `pbcopy`) as a bonus
24+
- OSC 52 uses proper `ESC \` string terminator instead of BEL for wider terminal compatibility
25+
- tmux `allow-passthrough` is automatically enabled per-pane during copy and restored afterward
26+
27+
## [0.1.3] - 2025-01-01
28+
29+
- Initial public release

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fpv"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
edition = "2021"
55
description = "A minimal, keyboard-first TUI file previewer with syntax highlighting"
66
license = "MIT"

src/app/preview_controller.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::app::state::{LoadState, NodeType, PreviewDocument, SessionState, TreeNode};
22
use crate::fs::current_dir::{list_current_directory_with_visibility, selected_entry_metadata};
3+
use crate::fs::git::{git_repo_status_for_path, GitFileStatus, GitRepoStatus};
34
use crate::fs::preview::load_preview;
45
use crate::highlight::syntax::HighlightContext;
56
use std::path::Path;
@@ -16,14 +17,74 @@ fn directory_entry_label(node: &TreeNode) -> String {
1617
}
1718
}
1819

20+
fn git_status_for_entry(
21+
node: &TreeNode,
22+
dir_path: &Path,
23+
git: &GitRepoStatus,
24+
) -> Option<GitFileStatus> {
25+
let rel = node.path.strip_prefix(&git.repo_root).ok()?;
26+
27+
// Direct match for files
28+
if let Some(status) = git.file_statuses.get(rel) {
29+
return Some(*status);
30+
}
31+
32+
// For directories, check if any child has a status
33+
if node.node_type == NodeType::Directory {
34+
for (path, status) in &git.file_statuses {
35+
if path.starts_with(rel) && *status != GitFileStatus::Ignored {
36+
return Some(GitFileStatus::Modified);
37+
}
38+
}
39+
return None;
40+
}
41+
42+
// Try relative to the directory being previewed
43+
let dir_rel = dir_path
44+
.strip_prefix(&git.repo_root)
45+
.ok()
46+
.map(|d| d.join(&node.name));
47+
if let Some(dr) = dir_rel {
48+
if let Some(status) = git.file_statuses.get(&dr) {
49+
return Some(*status);
50+
}
51+
}
52+
53+
None
54+
}
55+
56+
fn format_git_label(status: GitFileStatus) -> &'static str {
57+
match status {
58+
GitFileStatus::Added => "[A]",
59+
GitFileStatus::Modified => "[M]",
60+
GitFileStatus::Deleted => "[D]",
61+
GitFileStatus::Renamed => "[R]",
62+
GitFileStatus::Copied => "[C]",
63+
GitFileStatus::Untracked => "[?]",
64+
GitFileStatus::Conflicted => "[U]",
65+
GitFileStatus::Ignored => "[!]",
66+
}
67+
}
68+
1969
fn directory_preview(path: &Path, show_hidden: bool) -> PreviewDocument {
2070
match list_current_directory_with_visibility(path, DIRECTORY_PREVIEW_MAX_ENTRIES, show_hidden) {
2171
Ok(entries) => {
72+
let git = git_repo_status_for_path(path);
2273
let mut lines = Vec::with_capacity(entries.len().saturating_add(1));
2374
if entries.is_empty() {
2475
lines.push("(empty directory)".to_string());
2576
} else {
26-
lines.extend(entries.iter().map(directory_entry_label));
77+
for node in &entries {
78+
let name = directory_entry_label(node);
79+
if let Some(git_status) = git
80+
.as_ref()
81+
.and_then(|g| git_status_for_entry(node, path, g))
82+
{
83+
lines.push(format!("{} {}", format_git_label(git_status), name));
84+
} else {
85+
lines.push(format!(" {}", name));
86+
}
87+
}
2788
}
2889
PreviewDocument {
2990
source_path: path.to_path_buf(),

src/app/run.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ use ratatui::Terminal;
2828
use std::env;
2929
use std::io;
3030
use std::path::PathBuf;
31+
use std::time::Instant;
32+
33+
fn dir_mtime(path: &PathBuf) -> Option<std::time::SystemTime> {
34+
std::fs::metadata(path)
35+
.and_then(|m| m.modified())
36+
.ok()
37+
}
3138

3239
fn parse_args() -> (PathBuf, Option<PathBuf>) {
3340
let mut root = PathBuf::from(".");
@@ -125,7 +132,30 @@ pub fn run() -> Result<()> {
125132
let backend = CrosstermBackend::new(stdout);
126133
let mut terminal = Terminal::new(backend)?;
127134

135+
let mut last_auto_refresh = Instant::now();
136+
let auto_refresh_interval = std::time::Duration::from_secs(2);
137+
let mut last_dir_mtime = dir_mtime(&state.current_path);
138+
128139
loop {
140+
// Auto-refresh: check if directory mtime changed
141+
if last_auto_refresh.elapsed() >= auto_refresh_interval {
142+
last_auto_refresh = Instant::now();
143+
let current_mtime = dir_mtime(&state.current_path);
144+
if current_mtime != last_dir_mtime {
145+
last_dir_mtime = current_mtime;
146+
let prev_path = state.selected_path.clone();
147+
nodes = list_current_directory_with_visibility(
148+
&state.current_path,
149+
2000,
150+
state.show_hidden,
151+
)?;
152+
state.restore_or_default_selection(&nodes, Some(&prev_path));
153+
state.update_selected_path(&nodes);
154+
state.git_status = git_repo_status_for_path(&state.current_path);
155+
preview = refresh_preview(&mut state, &nodes, &highlight, 1024 * 1024);
156+
}
157+
}
158+
129159
let frame_size = terminal.size()?;
130160
state.normalize_preview_width(frame_size.width);
131161
let preview_viewport_rows = frame_size.height.saturating_sub(4) as usize;
@@ -149,7 +179,7 @@ pub fn run() -> Result<()> {
149179
}
150180

151181
if state.preview_fullscreen {
152-
draw_preview(f, chunks[1], &preview, &state, &theme);
182+
draw_preview(f, chunks[1], &preview, &mut state, &theme);
153183
} else {
154184
let main = Layout::default()
155185
.direction(Direction::Horizontal)
@@ -162,7 +192,7 @@ pub fn run() -> Result<()> {
162192
})
163193
.split(chunks[1]);
164194
draw_tree(f, main[0], &nodes, &state, &theme);
165-
draw_preview(f, main[1], &preview, &state, &theme);
195+
draw_preview(f, main[1], &preview, &mut state, &theme);
166196
}
167197
draw_status(f, chunks[2], &state, &bindings);
168198

@@ -183,21 +213,37 @@ pub fn run() -> Result<()> {
183213
})?;
184214

185215
let previous_path = state.current_path.clone();
186-
let (should_quit, should_refresh_preview) = process_once(
216+
let (should_quit, should_refresh_preview, should_refresh_tree) = process_once(
187217
&mut state,
188218
&mut nodes,
189219
&bindings,
190220
total_preview_lines,
191221
preview_viewport_rows,
222+
&preview,
192223
)?;
193224
if should_quit {
194225
break;
195226
}
196-
if state.current_path != previous_path {
227+
if should_refresh_tree {
228+
let prev_selected = state.selected_path.clone();
229+
nodes = list_current_directory_with_visibility(
230+
&state.current_path,
231+
2000,
232+
state.show_hidden,
233+
)?;
234+
state.restore_or_default_selection(&nodes, Some(&prev_selected));
235+
state.update_selected_path(&nodes);
197236
state.git_status = git_repo_status_for_path(&state.current_path);
198-
}
199-
if should_refresh_preview {
237+
last_dir_mtime = dir_mtime(&state.current_path);
200238
preview = refresh_preview(&mut state, &nodes, &highlight, 1024 * 1024);
239+
} else {
240+
if state.current_path != previous_path {
241+
state.git_status = git_repo_status_for_path(&state.current_path);
242+
last_dir_mtime = dir_mtime(&state.current_path);
243+
}
244+
if should_refresh_preview {
245+
preview = refresh_preview(&mut state, &nodes, &highlight, 1024 * 1024);
246+
}
201247
}
202248
}
203249

src/app/state.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@ pub enum PreviewFallbackReason {
4646
DecodeUncertain,
4747
}
4848

49+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50+
pub struct ContentPosition {
51+
pub row: usize,
52+
pub col: usize,
53+
}
54+
55+
#[derive(Debug, Clone, PartialEq, Eq)]
56+
pub struct PreviewSelection {
57+
pub anchor: ContentPosition,
58+
pub cursor: ContentPosition,
59+
}
60+
61+
impl PreviewSelection {
62+
pub fn ordered(&self) -> (ContentPosition, ContentPosition) {
63+
if self.anchor.row < self.cursor.row
64+
|| (self.anchor.row == self.cursor.row && self.anchor.col <= self.cursor.col)
65+
{
66+
(self.anchor, self.cursor)
67+
} else {
68+
(self.cursor, self.anchor)
69+
}
70+
}
71+
}
72+
4973
#[derive(Debug, Clone)]
5074
pub struct StyledPreviewSegment {
5175
pub text: String,
@@ -153,6 +177,13 @@ pub struct SessionState {
153177
pub preview_wrap_enabled: bool,
154178
pub preview_fullscreen: bool,
155179
pub divider_drag_active: bool,
180+
pub preview_scroll_col: usize,
181+
pub preview_selection: Option<PreviewSelection>,
182+
pub preview_selecting: bool,
183+
pub preview_inner_rect: (u16, u16, u16, u16),
184+
pub preview_line_number_cols: usize,
185+
pub preview_copy_indicator: bool,
186+
pub preview_copying_indicator: bool,
156187
pub help_overlay_visible: bool,
157188
pub status_display_mode: StatusDisplayMode,
158189
pub git_status: Option<GitRepoStatus>,
@@ -185,6 +216,13 @@ impl SessionState {
185216
preview_wrap_enabled: false,
186217
preview_fullscreen: false,
187218
divider_drag_active: false,
219+
preview_scroll_col: 0,
220+
preview_selection: None,
221+
preview_selecting: false,
222+
preview_inner_rect: (0, 0, 0, 0),
223+
preview_line_number_cols: 0,
224+
preview_copy_indicator: false,
225+
preview_copying_indicator: false,
188226
help_overlay_visible: false,
189227
status_display_mode: StatusDisplayMode::Bar,
190228
git_status: None,
@@ -207,6 +245,9 @@ impl SessionState {
207245

208246
pub fn reset_preview_scroll(&mut self) {
209247
self.preview_scroll_row = 0;
248+
self.preview_scroll_col = 0;
249+
self.preview_selection = None;
250+
self.preview_selecting = false;
210251
}
211252

212253
pub fn clamp_preview_scroll(&mut self, total_lines: usize, viewport_rows: usize) {
@@ -228,6 +269,18 @@ impl SessionState {
228269
}
229270
}
230271

272+
pub fn scroll_preview_cols(&mut self, delta: isize, max_width: usize, viewport_cols: usize) {
273+
let max_scroll = max_width.saturating_sub(viewport_cols);
274+
if delta < 0 {
275+
self.preview_scroll_col = self.preview_scroll_col.saturating_sub((-delta) as usize);
276+
} else {
277+
self.preview_scroll_col = self
278+
.preview_scroll_col
279+
.saturating_add(delta as usize)
280+
.min(max_scroll);
281+
}
282+
}
283+
231284
pub fn page_scroll_preview_down(&mut self, total_lines: usize, viewport_rows: usize) {
232285
let page = viewport_rows.max(1) as isize;
233286
self.scroll_preview_lines(page, total_lines, viewport_rows);

src/config/keymap.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ pub enum Action {
1616
PageDown,
1717
PreviewScrollUp,
1818
PreviewScrollDown,
19+
PreviewScrollLeft,
20+
PreviewScrollRight,
1921
TogglePreviewLineNumbers,
2022
TogglePreviewWrap,
2123
ToggleHelp,
2224
ToggleHidden,
2325
ResizePreviewNarrower,
2426
ResizePreviewWider,
27+
Refresh,
2528
Quit,
2629
}
2730

@@ -78,6 +81,14 @@ pub fn default_keymap() -> HashMap<Action, KeyEvent> {
7881
Action::PreviewScrollDown,
7982
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
8083
),
84+
(
85+
Action::PreviewScrollLeft,
86+
KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE),
87+
),
88+
(
89+
Action::PreviewScrollRight,
90+
KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE),
91+
),
8192
(
8293
Action::TogglePreviewLineNumbers,
8394
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
@@ -102,6 +113,10 @@ pub fn default_keymap() -> HashMap<Action, KeyEvent> {
102113
Action::ResizePreviewWider,
103114
KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL),
104115
),
116+
(
117+
Action::Refresh,
118+
KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE),
119+
),
105120
(
106121
Action::Quit,
107122
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
@@ -122,12 +137,15 @@ pub fn action_from_name(name: &str) -> Option<Action> {
122137
"page_down" => Some(Action::PageDown),
123138
"preview_scroll_up" => Some(Action::PreviewScrollUp),
124139
"preview_scroll_down" => Some(Action::PreviewScrollDown),
140+
"preview_scroll_left" => Some(Action::PreviewScrollLeft),
141+
"preview_scroll_right" => Some(Action::PreviewScrollRight),
125142
"toggle_preview_line_numbers" => Some(Action::TogglePreviewLineNumbers),
126143
"toggle_preview_wrap" => Some(Action::TogglePreviewWrap),
127144
"toggle_help" => Some(Action::ToggleHelp),
128145
"toggle_hidden" => Some(Action::ToggleHidden),
129146
"resize_preview_narrower" => Some(Action::ResizePreviewNarrower),
130147
"resize_preview_wider" => Some(Action::ResizePreviewWider),
148+
"refresh" => Some(Action::Refresh),
131149
"quit" => Some(Action::Quit),
132150
_ => None,
133151
}
@@ -153,6 +171,13 @@ pub fn parse_key_combo(value: &str) -> Result<KeyEvent> {
153171
"pageup" => code = Some(KeyCode::PageUp),
154172
"pagedown" => code = Some(KeyCode::PageDown),
155173
"esc" => code = Some(KeyCode::Esc),
174+
s if s.starts_with('f') && s.len() > 1 => {
175+
if let Ok(n) = s[1..].parse::<u8>() {
176+
code = Some(KeyCode::F(n));
177+
} else {
178+
return Err(anyhow!("invalid key combo: {value}"));
179+
}
180+
}
156181
single if single.len() == 1 => {
157182
code = Some(KeyCode::Char(single.chars().next().unwrap_or(' ')));
158183
}

0 commit comments

Comments
 (0)