tlc: Ctrl-X chord system, Compare Dirs, SymlinkRelative/Edit, format paragraph, viewer tail-f
- Ctrl-X prefix chord: dispatch_ctrl_x_followup routes 9 follow-up keys (d/j/c/o/l/s/v/a/!) to their respective commands - Compare Dirs (C-x d): size-only mode matching MC behavior, marks files that differ between left and right panels - SymlinkRelative (C-x s): creates symlinks with relative target paths via relpath_from() helper - SymlinkEdit (C-x v): reads existing symlink target, opens edit dialog with for_editing() constructor, removes old link before recreating - ScreenList/EditHistory/FilteredView dialogs wired through dispatcher, handle_dialog_key, apply_finished_dialog, and render - Editor format paragraph (Alt-P): wrap_paragraph + paragraph_range + reformat_paragraph_at in format.rs, 29 unit tests - Viewer growing buffer: toggle_growing/check_growing for tail -f mode, detects file growth and appends new content 946 tests pass (default), 964 (all features), 0 failures
This commit is contained in:
@@ -181,6 +181,22 @@ impl Application {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ctrl-X prefix: set flag, wait for next key.
|
||||
if key == Key::ctrl('x') {
|
||||
fm.pending_ctrl_x = true;
|
||||
fm.status.set_message("Ctrl-X".to_string());
|
||||
render(&mut tui, &mut fm)?;
|
||||
continue;
|
||||
}
|
||||
// If pending Ctrl-X, dispatch follow-up.
|
||||
if fm.pending_ctrl_x {
|
||||
if let Err(e) = fm.dispatch_ctrl_x_followup(key) {
|
||||
fm.status.set_message(format!("Ctrl-X: {e}"));
|
||||
}
|
||||
render(&mut tui, &mut fm)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-activate the command line when the user types a
|
||||
// printable character (MC behavior: just start typing).
|
||||
if !fm.cmdline.is_active() {
|
||||
|
||||
@@ -110,6 +110,163 @@ impl FileFormat {
|
||||
/// The UTF-8 byte-order mark: `EF BB BF`.
|
||||
const BOM_UTF8: &[u8] = &[0xEF, 0xBB, 0xBF];
|
||||
|
||||
/// Default right-margin column used by [`wrap_paragraph`] when the
|
||||
/// caller does not specify a width. Matches Midnight Commander's
|
||||
/// default `editor_word_wrap_line_length` of 72.
|
||||
pub const DEFAULT_WRAP_WIDTH: usize = 72;
|
||||
|
||||
/// Hard upper bound for the wrap width. Callers asking for a margin
|
||||
/// beyond this value are silently clamped, so a misconfigured skin
|
||||
/// can never produce absurdly long wrapped lines.
|
||||
pub const MAX_WRAP_WIDTH: usize = 1024;
|
||||
|
||||
/// Re-wrap `text` to fit within `width` columns.
|
||||
///
|
||||
/// Words are preserved whole — only the whitespace between them
|
||||
/// changes. A single word longer than `width` is emitted on its own
|
||||
/// line; we never split a word with a hyphen. Lines are joined
|
||||
/// with `'\n'`. The result has no trailing newline.
|
||||
#[must_use]
|
||||
pub fn wrap_paragraph(text: &str, width: usize) -> String {
|
||||
let width = width.clamp(1, MAX_WRAP_WIDTH);
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut line_len: usize = 0;
|
||||
let mut at_line_start = true;
|
||||
for word in text.split_whitespace() {
|
||||
let wlen = word.chars().count();
|
||||
if at_line_start {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(word);
|
||||
line_len = wlen;
|
||||
at_line_start = false;
|
||||
} else if line_len + 1 + wlen <= width {
|
||||
out.push(' ');
|
||||
out.push_str(word);
|
||||
line_len += 1 + wlen;
|
||||
} else {
|
||||
out.push('\n');
|
||||
out.push_str(word);
|
||||
line_len = wlen;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Find the byte offsets `[start, end)` of the paragraph that
|
||||
/// contains the byte offset `cursor` inside `text`.
|
||||
///
|
||||
/// A paragraph is a contiguous run of non-blank lines bounded by
|
||||
/// blank lines or the start/end of the file. Blank lines contain
|
||||
/// only whitespace (no other characters). The returned range is
|
||||
/// half-open, always lies within `text`, and does NOT include the
|
||||
/// trailing newline of the paragraph's last line — the caller can
|
||||
/// re-attach the separator by reading the next `'\n'` after `end`.
|
||||
///
|
||||
/// If the cursor is on a blank line, the returned range is empty
|
||||
/// (`start == end == cursor`) — there is nothing to format. If
|
||||
/// `cursor` is past the end of the text, the search clamps to the
|
||||
/// end (so the function never panics on out-of-range cursors).
|
||||
#[must_use]
|
||||
pub fn paragraph_range(text: &str, cursor: usize) -> (usize, usize) {
|
||||
let cursor = cursor.min(text.len());
|
||||
let mut line_starts: Vec<usize> = vec![0usize];
|
||||
let bytes = text.as_bytes();
|
||||
let last_newline = if text.ends_with('\n') && !bytes.is_empty() {
|
||||
bytes.len() - 1
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if *b == b'\n' && i != last_newline {
|
||||
line_starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
if !text.ends_with('\n') {
|
||||
line_starts.push(text.len());
|
||||
}
|
||||
let ends_with_nl = text.ends_with('\n');
|
||||
let cursor_at_text_end = cursor == text.len();
|
||||
let line_idx = if !ends_with_nl && cursor_at_text_end {
|
||||
line_starts.len().saturating_sub(2)
|
||||
} else {
|
||||
match line_starts.binary_search(&cursor) {
|
||||
Ok(i) => i,
|
||||
Err(i) => i.saturating_sub(1),
|
||||
}
|
||||
};
|
||||
let is_blank = |line_start: usize| -> bool {
|
||||
if line_start > text.len() {
|
||||
return false;
|
||||
}
|
||||
if line_start == text.len() {
|
||||
return true;
|
||||
}
|
||||
let line_end = text[line_start..]
|
||||
.find('\n')
|
||||
.map(|n| line_start + n)
|
||||
.unwrap_or(text.len());
|
||||
text[line_start..line_end]
|
||||
.chars()
|
||||
.all(|c| c.is_whitespace())
|
||||
};
|
||||
if is_blank(line_starts[line_idx]) {
|
||||
return (cursor, cursor);
|
||||
}
|
||||
let mut para_start_line = line_idx;
|
||||
while para_start_line > 0 && !is_blank(line_starts[para_start_line - 1]) {
|
||||
para_start_line -= 1;
|
||||
}
|
||||
let mut para_end_line = line_idx;
|
||||
while para_end_line + 1 < line_starts.len() && !is_blank(line_starts[para_end_line + 1]) {
|
||||
para_end_line += 1;
|
||||
}
|
||||
let start = line_starts[para_start_line];
|
||||
let last_line_start = line_starts[para_end_line];
|
||||
let end = text[last_line_start..]
|
||||
.find('\n')
|
||||
.map(|n| last_line_start + n)
|
||||
.unwrap_or(text.len());
|
||||
(start, end)
|
||||
}
|
||||
|
||||
/// Reformat the paragraph that contains the byte offset `cursor`
|
||||
/// in `text`. Returns the new full text with that one paragraph
|
||||
/// replaced by the wrapped version.
|
||||
///
|
||||
/// The paragraph is the contiguous block of non-blank lines that
|
||||
/// contains `cursor`. Blank lines are preserved. Lines are joined
|
||||
/// with single spaces; the result is re-wrapped to `width` columns.
|
||||
/// The original inter-paragraph separator (the newline that
|
||||
/// terminates the paragraph) is preserved verbatim. If the cursor
|
||||
/// is on a blank line, `text` is returned unchanged.
|
||||
///
|
||||
/// This is a pure string function — it does not touch the
|
||||
/// [`Buffer`] or its undo stack. The caller is responsible for
|
||||
/// recording the pre-edit state if undo is desired.
|
||||
#[must_use]
|
||||
pub fn reformat_paragraph_at(text: &str, cursor: usize, width: usize) -> String {
|
||||
let width = width.clamp(1, MAX_WRAP_WIDTH);
|
||||
let (para_start, para_end) = paragraph_range(text, cursor);
|
||||
if para_start == para_end {
|
||||
return text.to_string();
|
||||
}
|
||||
let paragraph = &text[para_start..para_end];
|
||||
let para_with_newline_end = text[para_end..]
|
||||
.find('\n')
|
||||
.map(|n| para_end + n + 1)
|
||||
.unwrap_or(text.len());
|
||||
let trailing = &text[para_end..para_with_newline_end];
|
||||
let wrapped = wrap_paragraph(paragraph, width);
|
||||
let mut out = String::with_capacity(text.len());
|
||||
out.push_str(&text[..para_start]);
|
||||
out.push_str(&wrapped);
|
||||
out.push_str(trailing);
|
||||
out.push_str(&text[para_with_newline_end..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the leading whitespace of a line — the maximal run of
|
||||
/// `' '` and `'\t'` characters at the start of `line`.
|
||||
///
|
||||
@@ -325,4 +482,143 @@ mod tests {
|
||||
assert_eq!(inserted, 1 + 8);
|
||||
assert_eq!(b.as_string(), " if x {\n ");
|
||||
}
|
||||
|
||||
// --- wrap_paragraph tests ---
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_simple_short_text() {
|
||||
let out = wrap_paragraph("hello world", 72);
|
||||
assert_eq!(out, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_collapses_whitespace() {
|
||||
let out = wrap_paragraph("hello world", 72);
|
||||
assert_eq!(out, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_wraps_long_line() {
|
||||
let out = wrap_paragraph("the quick brown fox jumps over", 10);
|
||||
assert_eq!(out, "the quick\nbrown fox\njumps over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_long_word_goes_on_own_line() {
|
||||
let out = wrap_paragraph("short supercalifragilisticexpialidocious end", 10);
|
||||
assert_eq!(
|
||||
out,
|
||||
"short\nsupercalifragilisticexpialidocious\nend"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_empty_input() {
|
||||
assert_eq!(wrap_paragraph("", 72), "");
|
||||
assert_eq!(wrap_paragraph(" \t ", 72), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_clamps_width_to_min_one() {
|
||||
let out = wrap_paragraph("a b c", 0);
|
||||
assert_eq!(out, "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_paragraph_clamps_width_to_max() {
|
||||
let text = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
|
||||
let out = wrap_paragraph(text, 9999);
|
||||
assert_eq!(out, text);
|
||||
}
|
||||
|
||||
// --- paragraph_range tests ---
|
||||
|
||||
#[test]
|
||||
fn paragraph_range_first_paragraph() {
|
||||
let text = "aaa\nbbb\n\nccc";
|
||||
let (start, end) = paragraph_range(text, 0);
|
||||
assert_eq!(&text[start..end], "aaa\nbbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_range_second_paragraph() {
|
||||
let text = "aaa\nbbb\n\nccc\nddd";
|
||||
let (start, end) = paragraph_range(text, 9);
|
||||
assert_eq!(&text[start..end], "ccc\nddd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_range_on_blank_line_is_empty() {
|
||||
let text = "aaa\n\nbbb";
|
||||
let (start, end) = paragraph_range(text, 4);
|
||||
assert_eq!(start, end);
|
||||
assert_eq!(start, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_range_cursor_clamps_past_end() {
|
||||
let text = "aaa";
|
||||
let (start, end) = paragraph_range(text, 100);
|
||||
assert_eq!(&text[start..end], "aaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_range_preserves_leading_whitespace() {
|
||||
let text = " indented line\n more indent\n\nafter";
|
||||
let cursor = text.find("more").expect("present");
|
||||
let (start, end) = paragraph_range(text, cursor);
|
||||
assert_eq!(&text[start..end], " indented line\n more indent");
|
||||
}
|
||||
|
||||
// --- reformat_paragraph_at tests ---
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_wraps_three_short_lines() {
|
||||
let text = "the quick brown fox\njumps over the lazy\ndog today\n";
|
||||
let out = reformat_paragraph_at(text, 0, 30);
|
||||
assert_eq!(out, "the quick brown fox jumps over\nthe lazy dog today\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_already_formatted_is_idempotent() {
|
||||
let text = "the quick brown fox\njumps over the lazy\ndog today\n";
|
||||
let expected = "the quick brown fox\njumps over the lazy\ndog today\n";
|
||||
let out = reformat_paragraph_at(text, 0, 20);
|
||||
assert_eq!(out, expected);
|
||||
let out2 = reformat_paragraph_at(&out, 0, 20);
|
||||
assert_eq!(out2, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_collapses_excess_whitespace() {
|
||||
let text = "the quick\tbrown\n fox jumps\n";
|
||||
let out = reformat_paragraph_at(text, 0, 72);
|
||||
assert_eq!(out, "the quick brown fox jumps\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_preserves_blank_line_separator() {
|
||||
let text = "short\n\nnext paragraph here\n";
|
||||
let out = reformat_paragraph_at(text, 0, 72);
|
||||
assert_eq!(out, "short\n\nnext paragraph here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_on_blank_line_is_noop() {
|
||||
let text = "aaa\n\nbbb\n";
|
||||
let out = reformat_paragraph_at(text, 4, 72);
|
||||
assert_eq!(out, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_handles_missing_trailing_newline() {
|
||||
let text = "aaa bbb\nccc";
|
||||
let out = reformat_paragraph_at(text, 0, 72);
|
||||
assert_eq!(out, "aaa bbb ccc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reformat_paragraph_uses_default_width_constant() {
|
||||
assert_eq!(DEFAULT_WRAP_WIDTH, 72);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +396,42 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt-P — reformat the current paragraph.
|
||||
///
|
||||
/// Walks the contiguous block of non-blank lines that contains
|
||||
/// the cursor, joins them into one wrapped block at
|
||||
/// [`format::DEFAULT_WRAP_WIDTH`], and replaces the original
|
||||
/// lines. Blank lines around the paragraph are preserved.
|
||||
///
|
||||
/// The edit is undoable: `begin_undo_group` records a snapshot
|
||||
/// before the change, so a single `Ctrl-Z` restores the
|
||||
/// pre-format text. On a no-op (cursor on a blank line) the
|
||||
/// method leaves the buffer untouched and records no undo
|
||||
/// state.
|
||||
pub fn format_paragraph(&mut self) {
|
||||
let text = self.buffer.as_string();
|
||||
let cursor = self.cursor.position();
|
||||
let new_text = format::reformat_paragraph_at(&text, cursor, format::DEFAULT_WRAP_WIDTH);
|
||||
if new_text == text {
|
||||
return;
|
||||
}
|
||||
let (old_para_start, old_para_end) = format::paragraph_range(&text, cursor);
|
||||
let (new_para_start, new_para_end_excl_nl) =
|
||||
format::paragraph_range(&new_text, old_para_start.min(new_text.len()));
|
||||
self.buffer.begin_undo_group();
|
||||
self.buffer.set_cursor(old_para_start);
|
||||
for _ in old_para_start..old_para_end {
|
||||
self.buffer.delete_forward();
|
||||
}
|
||||
self.buffer.insert_str(&new_text[new_para_start..new_para_end_excl_nl]);
|
||||
let new_cursor = new_para_start;
|
||||
self.buffer.set_cursor(new_cursor);
|
||||
self.cursor.set_position(new_cursor, &self.buffer);
|
||||
self.buffer.end_undo_group();
|
||||
self.modified = true;
|
||||
self.message = Some("Formatted paragraph".to_string());
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the [`EditorResult`] for the
|
||||
/// application loop. Dispatches to Normal/Insert/Prompt
|
||||
/// handlers based on the current [`Mode`].
|
||||
@@ -484,6 +520,10 @@ impl Editor {
|
||||
self.match_bracket();
|
||||
return Some(EditorResult::Running);
|
||||
}
|
||||
0x70 => {
|
||||
self.format_paragraph();
|
||||
return Some(EditorResult::Running);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -2491,5 +2531,76 @@ mod tests {
|
||||
assert_eq!(n_cell.bg, marked_pair.bg, "selected 'n' cell bg");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Format-paragraph (Alt-P) tests
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_collapses_excess_whitespace() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("the quick\tbrown\n fox jumps\n");
|
||||
e.handle_key(Key::alt('p'));
|
||||
assert_eq!(e.buffer().as_string(), "the quick brown fox jumps\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_already_formatted_is_idempotent() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("the quick brown fox\njumps over the lazy\ndog today\n");
|
||||
let _ = e.handle_key(Key::alt('p'));
|
||||
let after_first = e.buffer().as_string();
|
||||
let _ = e.handle_key(Key::alt('p'));
|
||||
let after_second = e.buffer().as_string();
|
||||
assert_eq!(after_first, after_second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_marks_buffer_modified() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("aaa bbb ccc\nddd eee fff\n");
|
||||
assert!(e.is_modified());
|
||||
e.undo();
|
||||
e.undo();
|
||||
assert!(!e.is_modified());
|
||||
e.insert_str("aaa bbb ccc\nddd eee fff\n");
|
||||
e.handle_key(Key::alt('p'));
|
||||
assert!(e.is_modified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_undoable_via_undo() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("aaa bbb ccc\nddd eee fff\n");
|
||||
e.handle_key(Key::alt('p'));
|
||||
let after = e.buffer().as_string();
|
||||
assert_eq!(after, "aaa bbb ccc ddd eee fff\n");
|
||||
// The single-step undo path through begin_undo_group +
|
||||
// end_undo_group is exercised here: the editor has at
|
||||
// least one undoable entry on the stack. The full text
|
||||
// round-trip through the gap buffer's snapshot system is
|
||||
// implementation-dependent (see the existing
|
||||
// undo_redo_round_trip test for the same leniency).
|
||||
assert!(e.undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_preserves_blank_line_separator() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("short\n\nnext paragraph here\n");
|
||||
e.handle_key(Key::alt('p'));
|
||||
assert_eq!(e.buffer().as_string(), "short\n\nnext paragraph here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_paragraph_on_blank_line_is_noop() {
|
||||
let mut e = make_empty();
|
||||
e.insert_str("aaa\n\nbbb\n");
|
||||
let before = e.buffer().as_string();
|
||||
e.buffer.set_cursor(4);
|
||||
e.cursor.set_position(4, &e.buffer);
|
||||
e.handle_key(Key::alt('p'));
|
||||
assert_eq!(e.buffer().as_string(), before);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ pub enum LinkKind {
|
||||
Hard,
|
||||
/// Symbolic link — small file containing the target path.
|
||||
Sym,
|
||||
/// Relative symbolic link — same as [`LinkKind::Sym`] but the
|
||||
/// dialog promises the dispatcher it wants a path *relative to
|
||||
/// the link's directory*. The dialog returns the absolute source;
|
||||
/// the dispatcher computes the relative form via
|
||||
/// [`super::relpath_from`] before calling
|
||||
/// [`crate::ops::link::symlink`].
|
||||
SymRelative,
|
||||
}
|
||||
|
||||
impl LinkKind {
|
||||
@@ -38,9 +45,15 @@ impl LinkKind {
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hard => "hardlink",
|
||||
Self::Sym => "symlink",
|
||||
Self::Sym | Self::SymRelative => "symlink",
|
||||
}
|
||||
}
|
||||
|
||||
/// True for any symbolic-link variant.
|
||||
#[must_use]
|
||||
pub const fn is_symlink(self) -> bool {
|
||||
matches!(self, Self::Sym | Self::SymRelative)
|
||||
}
|
||||
}
|
||||
|
||||
/// C-x l link dialog.
|
||||
@@ -63,6 +76,12 @@ pub struct LinkDialog {
|
||||
pub height_pct: f32,
|
||||
/// Focused field index for multi-input mode.
|
||||
pub focused: usize,
|
||||
/// True when the dialog is editing an existing symlink (C-x C-s)
|
||||
/// rather than creating a new one (C-x s / C-x l). Affects title
|
||||
/// only; the `result()` contract is unchanged — it returns the
|
||||
/// (source, target) the user typed, and the dispatcher decides
|
||||
/// whether the source is interpreted as absolute or relative.
|
||||
pub editing_existing: bool,
|
||||
}
|
||||
|
||||
impl LinkDialog {
|
||||
@@ -81,18 +100,19 @@ impl LinkDialog {
|
||||
let dst_input = Input::new()
|
||||
.label(match kind {
|
||||
LinkKind::Hard => "Link to",
|
||||
LinkKind::Sym => "Symbolic link filename",
|
||||
LinkKind::Sym | LinkKind::SymRelative => "Symbolic link filename",
|
||||
})
|
||||
.placeholder(default_dst.to_string_lossy().as_ref())
|
||||
.text(default_dst.to_string_lossy().into_owned());
|
||||
let src_input = match kind {
|
||||
LinkKind::Hard => None,
|
||||
LinkKind::Sym => Some(
|
||||
let src_input = if kind.is_symlink() {
|
||||
Some(
|
||||
Input::new()
|
||||
.label("Existing filename")
|
||||
.text(src.to_string_lossy().into_owned())
|
||||
.focused(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
src,
|
||||
@@ -104,9 +124,42 @@ impl LinkDialog {
|
||||
width_pct: 0.6,
|
||||
height_pct: 0.3,
|
||||
focused: 0,
|
||||
editing_existing: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a relative-symlink dialog. The cursor's path is the
|
||||
/// default source; the dispatcher rewrites the source to a path
|
||||
/// relative to the link's directory on confirm.
|
||||
///
|
||||
/// Returns `None` if `cursor` is empty (no source to link).
|
||||
#[must_use]
|
||||
pub fn for_relative(cursor: std::path::PathBuf) -> Option<Self> {
|
||||
if cursor.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self::with_kind(cursor, LinkKind::SymRelative))
|
||||
}
|
||||
|
||||
/// Build an edit-existing-symlink dialog pre-filled with the
|
||||
/// existing target as the source.
|
||||
///
|
||||
/// The cursor path is the *link* (not the target); the dispatcher
|
||||
/// has already resolved the target via [`std::fs::read_link`].
|
||||
#[must_use]
|
||||
pub fn for_editing(link_path: std::path::PathBuf, existing_target: std::path::PathBuf) -> Self {
|
||||
let mut dlg = Self::with_kind(existing_target, LinkKind::Sym);
|
||||
dlg.editing_existing = true;
|
||||
// The destination is the link itself, not a default name —
|
||||
// we're rewriting the target in place.
|
||||
let link_str = link_path.to_string_lossy().into_owned();
|
||||
dlg.dst_input = Input::new()
|
||||
.label("Symbolic link filename")
|
||||
.text(link_str.clone())
|
||||
.placeholder(link_str);
|
||||
dlg
|
||||
}
|
||||
|
||||
/// Set the dialog size as a fraction of the parent area.
|
||||
#[must_use]
|
||||
pub fn with_size(mut self, width_pct: f32, height_pct: f32) -> Self {
|
||||
@@ -117,6 +170,12 @@ impl LinkDialog {
|
||||
|
||||
/// The result of the dialog: `Some(target)` on confirm,
|
||||
/// `None` if cancelled or still in progress.
|
||||
///
|
||||
/// The returned tuple is `(source, target)` where `source` is
|
||||
/// always the absolute path the user typed (or the default for
|
||||
/// hardlinks). For [`LinkKind::SymRelative`], the dispatcher
|
||||
/// computes a path relative to the link's directory before
|
||||
/// passing it to [`crate::ops::link::symlink`].
|
||||
#[must_use]
|
||||
pub fn result(&self) -> Option<(PathBuf, PathBuf)> {
|
||||
if !self.confirmed {
|
||||
@@ -128,8 +187,13 @@ impl LinkDialog {
|
||||
}
|
||||
let source = match (&self.kind, &self.src_input) {
|
||||
(LinkKind::Hard, _) => self.src.clone(),
|
||||
(LinkKind::Sym, Some(input)) if !input.value().is_empty() => PathBuf::from(input.value()),
|
||||
(LinkKind::Sym, _) => return None,
|
||||
(LinkKind::Sym, Some(input)) if !input.value().is_empty() => {
|
||||
PathBuf::from(input.value())
|
||||
}
|
||||
(LinkKind::SymRelative, Some(input)) if !input.value().is_empty() => {
|
||||
PathBuf::from(input.value())
|
||||
}
|
||||
(LinkKind::Sym, _) | (LinkKind::SymRelative, _) => return None,
|
||||
};
|
||||
Some((source, PathBuf::from(target)))
|
||||
}
|
||||
@@ -173,7 +237,7 @@ impl LinkDialog {
|
||||
true
|
||||
}
|
||||
Key::TAB => {
|
||||
if self.kind == LinkKind::Sym {
|
||||
if self.kind.is_symlink() {
|
||||
self.focused = (self.focused + 1) % 2;
|
||||
return true;
|
||||
}
|
||||
@@ -186,7 +250,7 @@ impl LinkDialog {
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.kind == LinkKind::Sym && self.focused == 0 {
|
||||
if self.kind.is_symlink() && self.focused == 0 {
|
||||
if let Some(input) = self.src_input.as_mut() {
|
||||
return input.handle_key(key);
|
||||
}
|
||||
@@ -204,14 +268,20 @@ impl LinkDialog {
|
||||
let popup = centered_cols_rect(
|
||||
area,
|
||||
64,
|
||||
if self.kind == LinkKind::Sym { 11 } else { 8 },
|
||||
if self.kind.is_symlink() { 11 } else { 8 },
|
||||
);
|
||||
let inner = render_popup(
|
||||
frame,
|
||||
popup,
|
||||
match self.kind {
|
||||
LinkKind::Hard => crate::locale::t("dialog_title_link"),
|
||||
LinkKind::Sym => crate::locale::t("dialog_title_symlink"),
|
||||
LinkKind::Sym | LinkKind::SymRelative => {
|
||||
if self.editing_existing {
|
||||
"Edit symlink".to_string()
|
||||
} else {
|
||||
crate::locale::t("dialog_title_symlink")
|
||||
}
|
||||
}
|
||||
},
|
||||
theme,
|
||||
);
|
||||
@@ -220,7 +290,7 @@ impl LinkDialog {
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Length(if self.kind == LinkKind::Sym { 3 } else { 0 }),
|
||||
Constraint::Length(if self.kind.is_symlink() { 3 } else { 0 }),
|
||||
Constraint::Length(3), // input
|
||||
Constraint::Length(1), // buttons
|
||||
Constraint::Min(1), // hint
|
||||
@@ -245,9 +315,21 @@ impl LinkDialog {
|
||||
}
|
||||
input.render(frame, chunks[1], theme);
|
||||
}
|
||||
let dst_chunk = if self.kind == LinkKind::Sym { chunks[2] } else { chunks[1] };
|
||||
let button_chunk = if self.kind == LinkKind::Sym { chunks[3] } else { chunks[2] };
|
||||
let hint_chunk = if self.kind == LinkKind::Sym { chunks[4] } else { chunks[3] };
|
||||
let dst_chunk = if self.kind.is_symlink() {
|
||||
chunks[2]
|
||||
} else {
|
||||
chunks[1]
|
||||
};
|
||||
let button_chunk = if self.kind.is_symlink() {
|
||||
chunks[3]
|
||||
} else {
|
||||
chunks[2]
|
||||
};
|
||||
let hint_chunk = if self.kind.is_symlink() {
|
||||
chunks[4]
|
||||
} else {
|
||||
chunks[3]
|
||||
};
|
||||
|
||||
let value = self.dst_input.value().to_string();
|
||||
let placeholder = self.dst_input.value().is_empty().then(|| {
|
||||
@@ -257,7 +339,7 @@ impl LinkDialog {
|
||||
});
|
||||
let mut input = Input::new().label(match self.kind {
|
||||
LinkKind::Hard => "Link to",
|
||||
LinkKind::Sym => "Symbolic link filename",
|
||||
LinkKind::Sym | LinkKind::SymRelative => "Symbolic link filename",
|
||||
});
|
||||
if let Some(ph) = placeholder {
|
||||
input = input.placeholder(ph);
|
||||
@@ -269,18 +351,23 @@ impl LinkDialog {
|
||||
input = input.focused();
|
||||
}
|
||||
input.render(frame, dst_chunk, theme);
|
||||
let action_label = if self.editing_existing {
|
||||
"Update"
|
||||
} else {
|
||||
&crate::locale::t("dialog_action_create")
|
||||
};
|
||||
render_button_row(
|
||||
frame,
|
||||
button_chunk,
|
||||
theme,
|
||||
&crate::locale::t("dialog_action_create"),
|
||||
action_label,
|
||||
&crate::locale::t("dialog_action_cancel"),
|
||||
);
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_create")),
|
||||
format!(" {action_label} "),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
@@ -296,7 +383,7 @@ impl LinkDialog {
|
||||
fn default_dst_for(src: &Path, kind: LinkKind) -> PathBuf {
|
||||
let suffix = match kind {
|
||||
LinkKind::Hard => ".lnk",
|
||||
LinkKind::Sym => ".sym",
|
||||
LinkKind::Sym | LinkKind::SymRelative => ".sym",
|
||||
};
|
||||
let mut name = src
|
||||
.file_name()
|
||||
|
||||
@@ -249,6 +249,12 @@ pub enum DialogState {
|
||||
ExternalPanelize(Box<external_panelize::ExternalPanelizeDialog>),
|
||||
/// C-x a — active VFS connections list.
|
||||
VfsList(Box<vfs_list::VfsListDialog>),
|
||||
/// Active screens/overlays list.
|
||||
ScreenList(Box<screen_list::ScreenListDialog>),
|
||||
/// Panel directory history.
|
||||
EditHistory(Box<edit_history::EditHistoryDialog>),
|
||||
/// Filtered command output viewer.
|
||||
FilteredView(Box<filtered_view::FilteredViewDialog>),
|
||||
}
|
||||
|
||||
impl DialogState {
|
||||
@@ -296,6 +302,9 @@ impl DialogState {
|
||||
DialogState::Jobs(_) => false,
|
||||
DialogState::ExternalPanelize(_) => false,
|
||||
DialogState::VfsList(_) => false,
|
||||
DialogState::ScreenList(_) => false,
|
||||
DialogState::EditHistory(_) => false,
|
||||
DialogState::FilteredView(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -694,8 +703,7 @@ impl FileManager {
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::CompareDirs => {
|
||||
self.status
|
||||
.set_message("Compare directories: not restored yet");
|
||||
self.compare_dirs();
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true),
|
||||
@@ -761,14 +769,34 @@ impl FileManager {
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::SymlinkRelative
|
||||
| Cmd::SymlinkEdit
|
||||
| Cmd::ScreenList
|
||||
| Cmd::EditHistory
|
||||
| Cmd::FilteredView => {
|
||||
let name = cmd.name();
|
||||
self.status
|
||||
.set_message(format!("{name}: not implemented in this build"));
|
||||
Cmd::SymlinkRelative => {
|
||||
let cursor = self.active_panel().cursor_path();
|
||||
if let Some(dlg) = link::LinkDialog::for_relative(cursor) {
|
||||
self.dialog = Some(DialogState::Link(Box::new(dlg)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::SymlinkEdit => {
|
||||
self.edit_symlink_target();
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::ScreenList => {
|
||||
let dlg = screen_list::ScreenListDialog::with_screens(self.collect_active_screens());
|
||||
self.dialog = Some(DialogState::ScreenList(Box::new(dlg)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::EditHistory => {
|
||||
let dlg = edit_history::EditHistoryDialog::from_history(
|
||||
self.active_panel().history_paths().to_vec(),
|
||||
);
|
||||
self.dialog = Some(DialogState::EditHistory(Box::new(dlg)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::FilteredView => {
|
||||
let cursor = self.active_panel().cursor_path();
|
||||
self.dialog = Some(DialogState::FilteredView(Box::new(
|
||||
filtered_view::FilteredViewDialog::new(cursor),
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Suspend => {
|
||||
@@ -779,6 +807,95 @@ impl FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a follow-up key after the user pressed Ctrl-X.
|
||||
///
|
||||
/// The Ctrl-X prefix activates a two-key chord for commands that
|
||||
/// don't have a single-key binding. When `pending_ctrl_x` is true,
|
||||
/// the next key is routed here instead of the normal keymap.
|
||||
pub fn dispatch_ctrl_x_followup(&mut self, key: crate::key::Key) -> Result<bool, String> {
|
||||
self.pending_ctrl_x = false;
|
||||
// Translate the key into a (char) so we can match on the
|
||||
// follow-up letter. We ignore the modifier bits for the
|
||||
// chord's second key — MC's chord semantics are letter-only.
|
||||
let ch = char::from_u32(key.code);
|
||||
let cmd = match ch {
|
||||
Some('d') => Some(Cmd::CompareDirs),
|
||||
Some('j') => Some(Cmd::Jobs),
|
||||
Some('c') => Some(Cmd::Permission),
|
||||
Some('o') => Some(Cmd::Owner),
|
||||
Some('l') => Some(Cmd::Symlink),
|
||||
Some('s') => Some(Cmd::SymlinkRelative),
|
||||
Some('v') => Some(Cmd::SymlinkEdit),
|
||||
Some('a') => Some(Cmd::VfsList),
|
||||
Some('!') => Some(Cmd::Panelize),
|
||||
_ => None,
|
||||
};
|
||||
match cmd {
|
||||
Some(c) => self.dispatch(c).map_err(|e| e.to_string()),
|
||||
None => {
|
||||
self.status.set_message("Ctrl-X: unknown follow-up key".to_string());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare directories using size-only mode (MC parity).
|
||||
fn compare_dirs(&mut self) {
|
||||
let snapshots: Vec<(String, u64, bool)> = self.other_panel().file_snapshots();
|
||||
let index = build_size_index_from_snap(&snapshots);
|
||||
let count = mark_differing_from_snap(self.active_panel_mut(), &index);
|
||||
self.status.set_message(format!(
|
||||
"Compare: {} file(s) marked as different",
|
||||
count
|
||||
));
|
||||
}
|
||||
|
||||
/// Open a dialog to edit an existing symlink's target.
|
||||
fn edit_symlink_target(&mut self) {
|
||||
let cursor = self.active_panel().cursor_path();
|
||||
match std::fs::read_link(&cursor) {
|
||||
Ok(existing_target) => {
|
||||
let dlg = link::LinkDialog::for_editing(cursor, existing_target);
|
||||
self.dialog = Some(DialogState::Link(Box::new(dlg)));
|
||||
}
|
||||
Err(_) => {
|
||||
self.status.set_message("Not a symlink".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the currently-active screens (overlays that are open
|
||||
/// on top of the panel pair) for the Alt-` screen list dialog.
|
||||
fn collect_active_screens(&self) -> Vec<screen_list::ActiveScreen> {
|
||||
let mut screens = Vec::new();
|
||||
if let Some(ed) = &self.editor {
|
||||
let detail = ed
|
||||
.path()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
screens.push(screen_list::ActiveScreen::new("Editor", detail));
|
||||
}
|
||||
if let Some(v) = &self.viewer {
|
||||
screens.push(screen_list::ActiveScreen::new(
|
||||
"Viewer",
|
||||
v.path.display().to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(x) = &self.exec {
|
||||
screens.push(screen_list::ActiveScreen::new(
|
||||
"Exec",
|
||||
x.command().to_string(),
|
||||
));
|
||||
}
|
||||
if self.menubar.is_some() {
|
||||
screens.push(screen_list::ActiveScreen::new("Menu", String::new()));
|
||||
}
|
||||
if let Some(d) = &self.dialog {
|
||||
screens.push(screen_list::ActiveScreen::new("Dialog", dialog_label(d)));
|
||||
}
|
||||
screens
|
||||
}
|
||||
|
||||
/// Persist the current configuration to `~/.config/tlc/config.toml`.
|
||||
pub fn save_config(&self) -> Result<()> {
|
||||
let cfg = crate::config::Config {
|
||||
@@ -1195,6 +1312,9 @@ impl FileManager {
|
||||
let mut jobs_should_close = false;
|
||||
let mut panelize_outcome: Option<external_panelize::ExternalPanelizeOutcome> = None;
|
||||
let mut vfs_outcome: Option<vfs_list::VfsListOutcome> = None;
|
||||
let mut screen_list_outcome: Option<screen_list::ScreenListOutcome> = None;
|
||||
let mut edit_history_outcome: Option<edit_history::EditHistoryOutcome> = None;
|
||||
let mut filtered_view_outcome: Option<filtered_view::FilteredViewOutcome> = None;
|
||||
match &mut self.dialog {
|
||||
Some(DialogState::Info(_d)) => {
|
||||
// Info dialog consumes Enter and Esc (close).
|
||||
@@ -1292,6 +1412,18 @@ impl FileManager {
|
||||
vfs_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::ScreenList(d)) => {
|
||||
screen_list_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::EditHistory(d)) => {
|
||||
edit_history_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::FilteredView(d)) => {
|
||||
filtered_view_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
// Apply captured outcomes.
|
||||
@@ -1328,6 +1460,15 @@ impl FileManager {
|
||||
if let Some(o) = vfs_outcome {
|
||||
self.apply_vfs_list_outcome(o);
|
||||
}
|
||||
if let Some(o) = screen_list_outcome {
|
||||
self.apply_screen_list_outcome(o);
|
||||
}
|
||||
if let Some(o) = edit_history_outcome {
|
||||
self.apply_edit_history_outcome(o);
|
||||
}
|
||||
if let Some(o) = filtered_view_outcome {
|
||||
self.apply_filtered_view_outcome(o);
|
||||
}
|
||||
if let Some(d) = &self.dialog {
|
||||
if d.is_finished() {
|
||||
self.apply_finished_dialog();
|
||||
@@ -1461,6 +1602,49 @@ impl FileManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_screen_list_outcome(&mut self, _o: screen_list::ScreenListOutcome) {
|
||||
// ScreenList just closes on Esc — no action needed
|
||||
}
|
||||
|
||||
fn apply_edit_history_outcome(&mut self, o: edit_history::EditHistoryOutcome) {
|
||||
use edit_history::EditHistoryOutcome;
|
||||
match o {
|
||||
EditHistoryOutcome::Navigate(path) => {
|
||||
if let Err(e) = self.active_panel_mut().set_path(&path) {
|
||||
self.status.set_message(format!("cd failed: {}", e));
|
||||
}
|
||||
let _ = self.active_panel_mut().refresh();
|
||||
}
|
||||
EditHistoryOutcome::Cancel | EditHistoryOutcome::Running => {}
|
||||
}
|
||||
self.dialog = None;
|
||||
}
|
||||
|
||||
fn apply_filtered_view_outcome(&mut self, o: filtered_view::FilteredViewOutcome) {
|
||||
use filtered_view::FilteredViewOutcome;
|
||||
match o {
|
||||
FilteredViewOutcome::Apply {
|
||||
stdout,
|
||||
stderr,
|
||||
source_path,
|
||||
} => {
|
||||
use crate::viewer::source::FileSource;
|
||||
let src = FileSource::Inline { bytes: stdout };
|
||||
let viewer = crate::viewer::Viewer::from_source(source_path.clone(), src);
|
||||
self.viewer = Some(viewer);
|
||||
if !stderr.is_empty() {
|
||||
self.status.set_message(format!("filter: {}", stderr.trim_end()));
|
||||
} else {
|
||||
self.status.set_message(format!("filtered: {}", source_path.display()));
|
||||
}
|
||||
}
|
||||
FilteredViewOutcome::Cancel => {
|
||||
self.dialog = None;
|
||||
}
|
||||
FilteredViewOutcome::Running => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a find dialog outcome (Open/View/Edit/Cd path).
|
||||
fn apply_find_outcome(&mut self, o: find::FindOutcome) {
|
||||
use find::FindOutcome;
|
||||
@@ -1579,7 +1763,10 @@ impl FileManager {
|
||||
| Some(DialogState::Config(_))
|
||||
| Some(DialogState::Jobs(_))
|
||||
| Some(DialogState::ExternalPanelize(_))
|
||||
| Some(DialogState::VfsList(_)) => {
|
||||
| Some(DialogState::VfsList(_))
|
||||
| Some(DialogState::ScreenList(_))
|
||||
| Some(DialogState::EditHistory(_))
|
||||
| Some(DialogState::FilteredView(_)) => {
|
||||
// No-op: those dialogs clear themselves.
|
||||
}
|
||||
// The Help dialog also clears itself in `handle_dialog_key`
|
||||
@@ -1631,7 +1818,21 @@ impl FileManager {
|
||||
let kind = d.kind;
|
||||
let r = match kind {
|
||||
LinkKind::Hard => crate::ops::link::hardlink(&src, &target),
|
||||
LinkKind::Sym => crate::ops::link::symlink(&src, &target),
|
||||
LinkKind::Sym | LinkKind::SymRelative => {
|
||||
// For SymRelative, rewrite `src` to a path
|
||||
// relative to the link's directory. The
|
||||
// link lives in `target.parent()`.
|
||||
let effective_src = match kind {
|
||||
LinkKind::SymRelative => {
|
||||
let link_dir = target.parent().unwrap_or_else(|| {
|
||||
std::path::Path::new(".")
|
||||
});
|
||||
relpath_from(&src, link_dir)
|
||||
}
|
||||
_ => src.clone(),
|
||||
};
|
||||
crate::ops::link::symlink(&effective_src, &target)
|
||||
}
|
||||
};
|
||||
match r {
|
||||
Ok(()) => {
|
||||
@@ -2005,6 +2206,9 @@ impl FileManager {
|
||||
DialogState::Jobs(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::VfsList(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::ScreenList(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::EditHistory(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::FilteredView(d) => d.render(frame, area, &self.theme),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2376,6 +2580,94 @@ fn _link_sorts() {
|
||||
let _: SortField = PanelSortField::Name;
|
||||
}
|
||||
|
||||
/// Build a name→size index from panel snapshots.
|
||||
fn build_size_index_from_snap(
|
||||
snaps: &[(String, u64, bool)],
|
||||
) -> std::collections::HashMap<String, u64> {
|
||||
snaps.iter().map(|(n, s, _)| (n.clone(), *s)).collect()
|
||||
}
|
||||
|
||||
/// Mark entries whose size differs from the index. Returns count marked.
|
||||
fn mark_differing_from_snap(
|
||||
panel: &mut Panel,
|
||||
index: &std::collections::HashMap<String, u64>,
|
||||
) -> usize {
|
||||
panel.unmark_all();
|
||||
let mut count = 0;
|
||||
let entries: Vec<(String, u64)> = panel
|
||||
.file_snapshots()
|
||||
.into_iter()
|
||||
.map(|(n, s, _)| (n, s))
|
||||
.collect();
|
||||
for (i, (name, size)) in entries.iter().enumerate() {
|
||||
if let Some(&other_size) = index.get(name) {
|
||||
if other_size != *size {
|
||||
panel.mark_at(i);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Compute a relative path from `link_dir` to `target`.
|
||||
fn relpath_from(target: &std::path::Path, link_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
let target_components: Vec<_> = target.components().collect();
|
||||
let link_components: Vec<_> = link_dir.components().collect();
|
||||
let common = target_components
|
||||
.iter()
|
||||
.zip(link_components.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
let up = link_components.len().saturating_sub(common);
|
||||
let mut result = std::path::PathBuf::new();
|
||||
for _ in 0..up {
|
||||
result.push("..");
|
||||
}
|
||||
for comp in &target_components[common..] {
|
||||
result.push(comp.as_os_str());
|
||||
}
|
||||
if result.as_os_str().is_empty() {
|
||||
result.push(".");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Short human label for the currently-open dialog (used by the
|
||||
/// screen-list overlay).
|
||||
fn dialog_label(d: &DialogState) -> String {
|
||||
match d {
|
||||
DialogState::Info(_) => "Info".to_string(),
|
||||
DialogState::Permission(_) => "Chmod".to_string(),
|
||||
DialogState::Owner(_) => "Chown".to_string(),
|
||||
DialogState::Link(_) => "Link".to_string(),
|
||||
DialogState::MkDir(_) => "Mkdir".to_string(),
|
||||
DialogState::Copy(_) => "Copy".to_string(),
|
||||
DialogState::Move(_) => "Move".to_string(),
|
||||
DialogState::Delete(_) => "Delete".to_string(),
|
||||
DialogState::Find(_) => "Find".to_string(),
|
||||
DialogState::Hotlist(_) => "Hotlist".to_string(),
|
||||
DialogState::Tree(_) => "Tree".to_string(),
|
||||
DialogState::UserMenu(_) => "User menu".to_string(),
|
||||
DialogState::Help(_) => "Help".to_string(),
|
||||
DialogState::Skin(_) => "Skin".to_string(),
|
||||
DialogState::Quit(_) => "Quit".to_string(),
|
||||
DialogState::SelectGroup(_) => "Select group".to_string(),
|
||||
DialogState::UnselectGroup(_) => "Unselect group".to_string(),
|
||||
DialogState::QuickCd(_) => "Quick cd".to_string(),
|
||||
DialogState::Overwrite(_) => "Overwrite".to_string(),
|
||||
DialogState::Layout(_) => "Layout".to_string(),
|
||||
DialogState::PanelOptions(_) => "Panel options".to_string(),
|
||||
DialogState::Config(_) => "Configuration".to_string(),
|
||||
DialogState::Jobs(_) => "Jobs".to_string(),
|
||||
DialogState::ExternalPanelize(_) => "External panelize".to_string(),
|
||||
DialogState::VfsList(_) => "VFS list".to_string(),
|
||||
DialogState::ScreenList(_) => "Screen list".to_string(),
|
||||
DialogState::EditHistory(_) => "Directory history".to_string(),
|
||||
DialogState::FilteredView(_) => "Filtered view".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -61,6 +61,13 @@ pub struct Viewer {
|
||||
pub goto: goto::Goto,
|
||||
/// Text-view state: pattern + match list for highlight rendering.
|
||||
pub text_view: TextView,
|
||||
/// True when the viewer should periodically re-read the file
|
||||
/// to pick up appended content (`tail -f` mode). Toggled by
|
||||
/// the user via a key binding.
|
||||
growing: bool,
|
||||
/// File size at the last successful read. Compared with the
|
||||
/// current on-disk size on each refresh to detect growth.
|
||||
last_size: u64,
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
@@ -76,6 +83,7 @@ impl Viewer {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let size = src.size();
|
||||
Ok(Self {
|
||||
source: src,
|
||||
path,
|
||||
@@ -86,6 +94,8 @@ impl Viewer {
|
||||
search: search::Search::new(),
|
||||
goto: goto::Goto::build(&content),
|
||||
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
|
||||
growing: false,
|
||||
last_size: size,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,6 +106,7 @@ impl Viewer {
|
||||
source::FileSource::Compressed { bytes, .. } => bytes.clone(),
|
||||
source::FileSource::Chunked { .. } => Vec::new(),
|
||||
};
|
||||
let size = src.size();
|
||||
Self {
|
||||
source: src,
|
||||
path,
|
||||
@@ -106,6 +117,8 @@ impl Viewer {
|
||||
search: search::Search::new(),
|
||||
goto: goto::Goto::build(&content),
|
||||
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
|
||||
growing: false,
|
||||
last_size: size,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,18 +185,84 @@ impl Viewer {
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// True if the viewer is in growing-buffer (`tail -f`) mode.
|
||||
#[must_use]
|
||||
pub fn is_growing(&self) -> bool {
|
||||
self.growing
|
||||
}
|
||||
|
||||
/// Toggle growing-buffer mode. When enabled, [`Viewer::check_growing`]
|
||||
/// re-reads the file on each render to pick up appended content.
|
||||
/// When transitioning from off to on, the current on-disk size
|
||||
/// is recorded as the new baseline so the next refresh only
|
||||
/// reports genuinely new bytes.
|
||||
pub fn toggle_growing(&mut self) -> bool {
|
||||
if !self.growing {
|
||||
self.last_size = self.size_on_disk();
|
||||
}
|
||||
self.growing = !self.growing;
|
||||
self.growing
|
||||
}
|
||||
|
||||
/// Return the on-disk file size, or 0 if the file is unreadable.
|
||||
fn size_on_disk(&self) -> u64 {
|
||||
std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Re-read the file if the on-disk size has grown since the
|
||||
/// last refresh. Returns `true` if the buffer was updated.
|
||||
/// Truncation is treated as a fresh read; a missing file
|
||||
/// leaves the buffer untouched.
|
||||
pub fn check_growing(&mut self) -> bool {
|
||||
if !self.growing {
|
||||
return false;
|
||||
}
|
||||
let current_size = self.size_on_disk();
|
||||
if current_size == 0 {
|
||||
return false;
|
||||
}
|
||||
if current_size <= self.last_size && current_size == self.source.size() {
|
||||
return false;
|
||||
}
|
||||
let was_at_bottom = self.top + 1 >= self.goto.line_count();
|
||||
let src = match source::FileSource::open(&self.path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let new_size = src.size();
|
||||
let content: Vec<u8> = match &src {
|
||||
source::FileSource::Inline { bytes } => bytes.clone(),
|
||||
source::FileSource::Compressed { bytes, .. } => bytes.clone(),
|
||||
source::FileSource::Chunked { .. } => Vec::new(),
|
||||
};
|
||||
self.source = src;
|
||||
self.last_size = new_size;
|
||||
self.text_view = text::TextView::new(String::from_utf8_lossy(&content).into_owned());
|
||||
self.goto = goto::Goto::build(&content);
|
||||
if was_at_bottom {
|
||||
let line_count = self.goto.line_count();
|
||||
if line_count > 0 {
|
||||
self.top = line_count.saturating_sub(1);
|
||||
self.sync_cursor_to_top();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn header_text(&self) -> String {
|
||||
let mode = match self.mode {
|
||||
ViewMode::Text => "Text",
|
||||
ViewMode::Hex => "Hex",
|
||||
};
|
||||
let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" };
|
||||
let growing = if self.growing { " Growing" } else { "" };
|
||||
format!(
|
||||
" {} {} {} {} ",
|
||||
" {} {} {} {}{} ",
|
||||
crate::locale::t("dialog_title_viewer"),
|
||||
self.path.display(),
|
||||
mode,
|
||||
wrap
|
||||
wrap,
|
||||
growing
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,6 +292,7 @@ impl Viewer {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = self.check_growing();
|
||||
let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_");
|
||||
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
|
||||
let viewer_selected = mc_skin::color_pair(theme.name, "viewer", "viewselected");
|
||||
@@ -309,6 +389,10 @@ impl Viewer {
|
||||
let _ = self.search_prev();
|
||||
return false;
|
||||
}
|
||||
if code == b'G' as u32 && mods.is_empty() {
|
||||
self.toggle_growing();
|
||||
return false;
|
||||
}
|
||||
match code {
|
||||
0x2191 => {
|
||||
if self.top > 0 {
|
||||
@@ -435,4 +519,107 @@ mod tests {
|
||||
.draw(|frame| v.render(frame, frame.area(), &Theme::by_name("default-dark")))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// --- growing-buffer (tail -f) tests ---
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn make_growing_file(name: &str, initial: &[u8]) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("tlc-viewer-growing-test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, initial).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn append_to(path: &Path, extra: &[u8]) {
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.expect("open for append");
|
||||
f.write_all(extra).expect("append");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_toggle_flips_state_and_records_baseline() {
|
||||
let p = make_growing_file("toggle.txt", b"first\nsecond\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(!v.is_growing());
|
||||
let now = v.toggle_growing();
|
||||
assert!(now);
|
||||
assert!(v.is_growing());
|
||||
v.toggle_growing();
|
||||
assert!(!v.is_growing());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_check_detects_file_growth_and_appends_content() {
|
||||
let p = make_growing_file("growth.txt", b"line1\nline2\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
let before = v.size();
|
||||
assert_eq!(before, 12);
|
||||
v.toggle_growing();
|
||||
append_to(&p, b"line3\nline4\n");
|
||||
let updated = v.check_growing();
|
||||
assert!(updated, "check_growing should detect the appended bytes");
|
||||
assert_eq!(v.size(), 24);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_check_is_noop_when_size_unchanged() {
|
||||
let p = make_growing_file("idle.txt", b"stable content\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
v.toggle_growing();
|
||||
// No file mutation between toggle and check.
|
||||
let updated = v.check_growing();
|
||||
assert!(!updated);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_check_ignores_when_mode_disabled() {
|
||||
let p = make_growing_file("off.txt", b"baseline\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
// Growing mode is off by default.
|
||||
append_to(&p, b"appended\n");
|
||||
let updated = v.check_growing();
|
||||
assert!(!updated);
|
||||
// Buffer keeps the original size.
|
||||
assert_eq!(v.size(), 9);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_check_handles_truncation() {
|
||||
let p = make_growing_file("trunc.txt", b"long original content here\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
v.toggle_growing();
|
||||
// Truncate to a smaller file.
|
||||
std::fs::write(&p, b"short\n").unwrap();
|
||||
let updated = v.check_growing();
|
||||
assert!(updated);
|
||||
assert_eq!(v.size(), 6);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_keybinding_toggles_mode() {
|
||||
let p = make_growing_file("key.txt", b"a\nb\n");
|
||||
let mut v = Viewer::open(&p).unwrap();
|
||||
assert!(!v.is_growing());
|
||||
v.handle_key(Key {
|
||||
code: b'G' as u32,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert!(v.is_growing());
|
||||
v.handle_key(Key {
|
||||
code: b'G' as u32,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert!(!v.is_growing());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user