W1: Fix 3 dead dialogs (DisplayBits/VfsSettings/LearnKeys) that could never close
- Capture handle_key() return value, close on Cancel/Confirm
W2: Implement Cmd::Suspend with actual SIGTSTP via kill -TSTP 4035172
- Add want_suspend field, ExternalAction::Suspend variant
- Drop TUI, send SIGTSTP, recreate TUI on resume
W3: Wire Connection dialog to Panel::navigate_to_vfs()
- Parse VFS URL, look up backend, redirect active panel
- Store Encoding dialog selection on FileManager.display_encoding
W4: Wire CK_EditUserMenu (EditorCmd::EditUserMenu)
- Opens user menu storage_path in editor via Editor::open()
- Fix unreachable!() in SaveBeforeClose prompt rendering
W5: Add ErrorOutcome::SkipAll variant + Shift-S keybinding
- Fix misleading doc comment about non-existent 'All' variants
- Add SkipAll button in error dialog render
Also: Fix all 41 compiler warnings (unused imports/vars, missing docs on
public API, remove dead SPECIAL_LABELS constant, remove unused viewer_bold)
1381 tests pass, zero warnings.
The getty binary opens a PTY via /scheme/pty during startup. Adding an
explicit requires_weak on 00_ptyd.service avoids races where getty runs
before the pseudo-terminal daemon is fully ready, which could delay or
prevent the login prompt from appearing.
G1: Error dialog Retry now re-invokes the stored file operation (copy/move/
delete) using the PendingErrorOp parameters. Previously Retry just logged
'Retry not yet wired'. Copy/move/delete error paths now create a
PendingErrorOp + ErrorDialog instead of silently setting a status message.
Skip/Ignore/Abort outcomes produce appropriate status messages.
G2: SftpVfs::open_write now returns a working SftpWriter that buffers writes
and flushes to the remote SFTP server via session.write(). Previously
returned VfsError::Unsupported. The SftpWriter buffers in memory and
writes on flush/drop, matching the existing open_read buffer pattern.
1369 tests pass (default and --features sftp).
- relibc: variadic sem_open and local fork path deps
- base: already at latest RedBear-OS submodule/base
- bootloader/kernel/libredox/userutils: pushed local path-dep fixes
- installer/redoxfs: diverged from remote submodule/*; local commits saved,
divergence to be resolved after build
- driver-manager: add syscall path dependency
- AGENTS.md: document +rb build metadata and no-patches-for-local-forks rule
- remove dead patch symlinks from recipes/core/relibc (path-source local fork)
F1: Remove stale 'Phase N' / 'not yet wired' comments from vfs/local.rs,
vfs/traits.rs, editor/usermenu.rs — the functionality they described as
future work is already implemented.
F2: Replace placeholder stubs in viewer/hex.rs and viewer/text.rs with actual
rendering for Chunked sources (files >= 1 MiB). hex.rs reads viewport-sized
chunks via read_at(); text.rs reads up to 64 MiB cap for line offset mapping.
check_growing() in viewer/mod.rs also reads Chunked content instead of
returning empty Vec.
F3: Editor Settings dialog now shows actual toggle state (auto-indent,
word-wrap, show-whitespace, save-on-quit) instead of '(TBD)' placeholder.
F4: Add xz2 crate dependency and TarKind::Xz decompression support.
Feature-gated as 'xz2' (optional, follows bzip2 pattern). Uses
XzDecoder::new_multi_decoder for multi-stream .tar.xz files.
1369 tests pass. Default build (without optional features) verified.
- Update AGENTS.md single-repo rule to explicitly forbid creating any new
Gitea repositories and to require deleting per-component repos.
- Change version suffix policy from pre-release -rb to build-metadata +rb
in AGENTS.md, sync-versions.sh, apply-rb-suffix.sh, verify-fork-versions.sh.
- Update migration instructions to push to existing submodule/<component>
branches inside RedBear-OS, not create new repos.
- Update BUILD-SYSTEM-IMPROVEMENTS.md and SLEEP-IMPLEMENTATION-PLAN.md to
reference submodule/* branches instead of defunct per-component repos.
- Fix delete-per-component-repos.sh example to use a real historical repo.
SpellChecker with ~300 common English words + programming terms.
Word tokenizer handles apostrophes, digits, underscores.
Editor integration: toggle_spell_check(), find_next_misspelled().
MC uses libaspell; TLC embeds dictionary for zero external deps.
17 new tests: check_word, check_line, case insensitive, editor
integration (toggle, find_next, disabled, correct text).
Mirrors MC's panel.c:4080-4197 mouse handling. Wraps the
Tui screen in termion's MouseTerminal (enables GPM on Linux /
xterm mouse on others — without it mouse events never reach
ratatui). The translate_mouse function maps termion MouseEvent
to a MouseAction enum that the FileManager dispatches as
cursor moves / entries / scroll (deferring HistoryPrev/Next/
List and ToggleHidden which need dedicated Cmds not yet in
the keymap).
New APIs:
- terminal::event::translate_mouse(event, panel_top,
panel_height, panel_width) -> MouseAction
- terminal::event::MouseAction (Click, DoubleClick,
ScrollUp/Down, HistoryPrev/Next/List, ToggleHidden,
Unhandled)
- FileManager::handle_mouse_event(m, size) routes
MouseAction to existing cursor_up_n / cursor_down_n /
Panel::enter / Panel::set_cursor helpers.
- app.rs routes TermEvent::Mouse through handle_mouse_event
before the keyboard-dispatch path.
Tests (9 new in terminal::event::mouse_tests):
- click_on_file_row_emits_click_action
- click_below_panel_is_unhandled
- click_on_header_history_prev_button (col 1)
- click_on_header_history_next_button (col width-2)
- click_on_header_toggle_hidden_button (col width-6)
- scroll_up_emits_scroll_up
- scroll_down_emits_scroll_down
- click_on_column_name_row_is_unhandled (sort-by-column
not yet wired)
- click_respects_panel_top_offset (panel_top parameter
works for menu-bar offset)
1345 passing (was 1336; +9 new).
Mirrors MC's src/learn.c::learn_keys (mc/source/src/learn.c:393-422).
MC shows a grid of buttons; TLC is simpler: scrollable list of
captured keys with their resolved Cmd.
New file: src/filemanager/learn_keys_dialog.rs (LearnKeysDialog,
CapturedKey, 6 unit tests).
New DialogState::LearnKeys variant wired into dispatch, render,
is_finished, title. Keymap is stored at construction so the
dispatcher doesn't thread it through every key event.
Tests: 1336 passing (was 1330; +6 new).
Mirrors MC's boxes.c::configure_vfs_box
(mc/source/src/filemanager/boxes.c:1120-1198). MC's dialog has
VFS timeout plus FTP options (anonymous password, directory
cache timeout, proxy host, passive mode); TLC has no FTP/SFTP in
the active build, so we expose the relevant 1-field subset:
- Timeout for freeing VFSs (sec): 0..=10000, default 10
(out-of-range falls back to 10 — matches MC's
boxes.c:1193-1194 validation)
New file: src/filemanager/vfs_settings_dialog.rs
- VfsSettings struct (free_timeout: u32)
- parse_timeout() (handles negative as 0, > 10000 as None)
- VfsSettingsDialog with Edit-keyed text input
- 12 unit tests
New DialogState variant:
DialogState::VfsSettings(Box<VfsSettingsDialog>)
Wired into dispatch, render, is_finished, title.
Tests: 1330 passing (was 1318; +12 new).
Mirrors MC's boxes.c::display_bits_box
(mc/source/src/filemanager/boxes.c:955-1004). MC exposes 4 modes
(UTF-8, Full 8 bits, ISO 8859-1, 7 bits); TLC is UTF-8 throughout
so we expose the relevant 3-state subset:
- Full 8 bits: bytes pass through verbatim
- 7 bits: high bit stripped (parity-stripped serial consoles)
- UTF-8 (validated): pass through UTF-8, ? for invalid
New file: src/filemanager/display_bits_dialog.rs
- DisplayBits enum with 3 variants
- DisplayBitsDialog (radio-list, mirrors SortDialog)
- from_config() / map_byte() / is_valid_utf8() helpers
- 12 unit tests
New DialogState variant:
DialogState::DisplayBits(Box<DisplayBitsDialog>)
Wired into dispatch (handle_key), render, is_finished (false;
the dispatcher captures Confirm/Cancel from handle_key), title.
Tests: 1318 passing (was 1306; +12 new).
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors MC's filegui.c::file_progress_real_query_replace and the
FileProgressStatus enum (filegui.h:45-54): FILE_CONT, FILE_RETRY,
FILE_SKIP, FILE_ABORT, FILE_IGNORE.
New file: src/filemanager/error_dialog.rs
- ErrorDialog with path + error message
- R / S / I / A shortcuts (MC letter bindings)
- Esc maps to Abort
- 7 unit tests (one per shortcut + an 'other keys don't finish' guard)
New FileManager state:
- pending_error_op: Option<PendingErrorOp>
stores kind + sources + dst + flags for the in-flight op so
the user can Retry / Skip / Ignore / Abort
- PendingErrorOp struct (parallel to existing PendingFileOp)
- pending_error_op initialized to None
New DialogState variant:
DialogState::Error(Box<error_dialog::ErrorDialog>)
Wired into dispatch (handle_key), render, is_finished, title.
Retry is currently a stub (logs a message) because the copy
engine's batch step resumption would require threading the
operation index into copy_many/move_many/delete_many. The dialog
scaffolding is complete and tested; the retry can be wired in a
follow-up sprint when the batch step API is added.
Tests: 1306 passing (was 1299; +7 new).
Adds regression tests for Sprint 3 items that were verified
already-done during recon but had no dedicated test coverage:
- viewer::tests::hex_edit_apply_nibble_at_eof_is_safe_noop
Locks in the C8 contract: apply_nibble at cursor past EOF
is a safe no-op (no panic, no spurious modified flag).
- viewer::tests::move_cursor_clamps_to_size
Locks in the C8 cursor bounds invariant at move_cursor
level: positive deltas clamp to file size, negative deltas
clamp to 0 with no underflow.
- filemanager::panel::tests::history_dedups_consecutive_entries
Locks in the C11 contract: refreshing the same directory
N times grows the history by 1 entry (consecutive dedup),
not N.
- filemanager::panel::tests::sort_field_mtime_round_trips_through_config
Locks in the C12 contract: 'mtime' and 'time' config
strings both resolve to the Mtime sort field, and the
Panel reports the human-readable name 'Mtime'.
Tests (4 new, total 1299 passing):
+hex_edit_apply_nibble_at_eof_is_safe_noop
+move_cursor_clamps_to_size
+history_dedups_consecutive_entries
+sort_field_mtime_round_trips_through_config
Adds regression tests for already-implemented Sprint 3 items so
future refactors can't silently regress these behaviors:
- terminal::color::tests::default_theme_preserves_rgb_precision
Locks in the C1 contract: when Theme::background is Rgb, the
exact 8-bit values (julia256's core.bg = 58,58,58) survive.
The parser may also emit Indexed(237) for the same source
value depending on detected color depth, so the test accepts
either variant but verifies 24-bit precision when present.
- terminal::color::tests::light_theme_preserves_rgb_precision
Same invariant for LIGHT_THEME (sand256 bright off-white
foreground > 200 RGB).
- editor::tests::bookmark_jump_restores_both_line_and_column
Locks in the C5 contract: jumping to a named bookmark must
restore both the line AND the column where the bookmark was
set. The existing test only verified the byte offset, not
the column, so a future regression to line-only restoration
would not have been caught.
Tests (3 new, total 1295 passing):
+default_theme_preserves_rgb_precision
+light_theme_preserves_rgb_precision
+bookmark_jump_restores_both_line_and_column
When set_message() is called with text containing '\n' characters,
the status line now renders the message across multiple rows
(previously only the first line was shown, with the rest lost).
StatusLine (terminal/status.rs):
- Message::line_count() returns the newline-segment count
- StatusLine::has_multiline_message() detects newline-containing
unexpired messages
- StatusLine::current_message_line_count() returns the row
count for allocation (0 for no message)
- StatusLine::render_multiline() renders each newline-separated
line as its own row
FileManager (filemanager/mod.rs):
- status_message_line_count() helper that clamps to 1..=4 rows
to avoid eating the whole screen, returns 1 when no message
render.rs (filemanager/render.rs):
- When has_message() AND has_multiline_message(), allocate the
multi-line area above the single-line status bar (which still
shows clock/spinner/hint). Single-line messages unchanged.
Tests (3 new in terminal::status::tests):
- has_multiline_message_detects_newlines
- current_message_line_count_counts_newlines
- message_line_count_handles_empty_and_multiline
Total: 1292 passing (was 1289; +3 new).
Previously reformat_paragraph_at() would join indented lines
together, destroying intentional indentation (Python code,
shell heredocs, ASCII art, markdown sub-blocks).
Now reformat_paragraph_at() detects code blocks (paragraphs
where every non-blank line begins with whitespace) and
returns the text unchanged. The detection is intentionally
conservative — false positives leave the paragraph alone
rather than destroying user-written indentation.
format.rs:
- New is_code_block(paragraph) helper
- reformat_paragraph_at() calls is_code_block(); if true,
returns text unchanged (preserving indentation)
Tests (6 new in editor::format::tests):
- is_code_block_detects_indented_paragraph
- is_code_block_rejects_unindented_paragraph
- is_code_block_ignores_blank_lines_in_paragraph
- reformat_paragraph_skips_code_block
- reformat_paragraph_skips_shell_heredoc
- reformat_paragraph_handles_mixed_code_and_prose
Total: 1289 passing (was 1283; +6 new).
Previously cmdline commands were always synchronous — even
typing 'firefox &' would block TLC until the foreground shell
process exited (which for firefox would never happen because
the shell exits immediately and firefox keeps running, but
the spawn process remained a child of the shell until the shell
exited, blocking the parent).
Now run_command detects a trailing '&' (with optional
trailing whitespace) and spawns the command in a new process
group via process_group(0) (Unix). The command is detached,
TLC continues immediately, and the parent shell exits without
waiting.
Falls back to a synchronous run on non-Unix platforms.
Tests (2 new in terminal::subshell::tests):
- run_command_backgrounded_with_ampersand_succeeds
'sleep 5 &' returns in < 2s (not 5s)
- run_command_backgrounded_with_trailing_whitespace
'sleep 3 & ' (space before &) also backgrounded
Total: 1283 passing (was 1281; +2 new).
The tar crate returns an empty iterator (without erroring) for
both zero-byte files and files that aren't valid tar archives.
TarVfs::open() previously succeeded in both cases, producing an
'unbrowsable' archive the user couldn't navigate.
Fix:
TarVfs::open() now checks entries.is_empty() after list() and
returns VfsError::Other('empty tar archive') when empty. This
covers both truly empty files (corrupt/truncated) and garbage
bytes (e.g., text mistakenly saved with .tar extension).
Tests (2 new in vfs::tar::tests):
- tar_vfs_open_empty_file_errors
- tar_vfs_open_garbage_bytes_errors
Total: 1281 passing (was 1279; +2 new).
Previously parse() only recognised the local:// scheme. A
file:///path URL (the standard scheme used by browsers, text
editors, and many CLI tools) would be treated as an unknown
scheme and fall through to the 'unknown scheme → local'
fallback in for_path(), which works but is silent.
Now parse() explicitly handles file:// as an alias for local://,
making the VFS path normal form 'local://' after parsing.
Tests (1 new in vfs::path::tests):
- parse_file_scheme_is_alias_for_local
Total: 1279 passing (was 1278; +1 new).
Previously Find dialog always opened with the default placeholder
('*.rs'). Now open_find_dialog() inspects the active panel's
cursor entry and uses its name as the initial pattern — so
pressing M-? with cursor on 'main.rs' opens the dialog with
'main.rs' already in the input, ready for refinement (e.g.
'main.rs~' for backups, 'main*' for variants).
Implementation:
- find::FindDialog::new_with_pattern(start_dir, initial_pattern)
- panel::Panel::cursor_entry() -> Option<&Entry>
- widget::input::Input::set_text(s) mutable setter (the
existing .text() builder consumes self)
- dialog_ops::open_find_dialog() uses cursor entry name as
the initial pattern; skips '..' and falls back to default
when the panel is empty
Tests (2 new in find::tests):
- new_with_pattern_prefills_pattern_input
- new_with_pattern_empty_falls_back_to_default
Total: 1278 passing (was 1276; +2 new).
Previously mark_pattern("") and unmark_pattern("") were silent
no-ops (glob_match returned false for empty pattern). Now they
match MC's behavior:
- mark_pattern(""): calls reverse_marks() (toggles every
entry; from 0 marks → all entries marked, vice versa)
- unmark_pattern(""): clears all marks
Tests (2 new in panel::tests):
- mark_pattern_empty_inverts_marks (0→2→0 cycle)
- unmark_pattern_empty_clears_all_marks
Total: 1276 passing (was 1274; +2 new).
The hex viewer now reserves the top row for a status header
showing the cursor byte offset and the total file size:
'Offset: 00000040 / 00000100 bytes'
When the area has height < 2 (degenerate case), the header is
skipped and the full row is used for hex bytes.
Layout split: total_height >= 2 → 1-row header + body_area
(remaining rows). total_height == 1 → no header, body uses
full area. The body Paragraph is rendered to body_area (not
the full area) so the header is not overwritten.
Tests (2 new in viewer::hex::tests):
- render_shows_offset_header_when_area_has_height
cursor=64 → header shows 'Offset: 00000040' at row 0
- render_skips_header_when_area_height_is_one
80x1 area → first row starts with hex offset '00000000'
Total: 1274 passing (was 1272; +2 new).
Previously, Panel::set_path() would pass the input path straight to
read_directory(). A relative path like 'sub' would fail because
read_directory tries to stat it relative to wherever, not relative
to the user's current working directory.
Implementation:
Panel::set_path() now checks if p.is_relative() and, if so,
joins it onto std::env::current_dir() before reading. Absolute
paths pass through unchanged. Falls back to the raw path if
current_dir() fails (process has no cwd).
Tests (1 new in panel::tests):
- set_path_resolves_relative_against_cwd
Total: 1272 passing (was 1271; +1 new).
DeleteDialog header now includes the total recursive size of
all paths to be deleted (formatted via format_size). For example:
'Delete 3 (1.2 MB) ?' instead of just 'Delete ?'.
Computed eagerly at construction via crate::ops::count_bytes so
render() does not block on filesystem traversal.
DeleteDialog (delete_dialog.rs):
- New field total_bytes: u64 (cached at construction)
- new() calls crate::ops::count_bytes(&paths) to populate it
- render() prepends '(N items)' to header and appends size when > 0
Tests (3 new in delete_dialog::tests):
- new_computes_total_bytes_for_directory (recursive dir sum)
- new_total_bytes_zero_for_missing_paths
- new_total_bytes_sums_multiple_paths
Total: 1271 passing (was 1268; +3 new).
Input widget colors no longer hardcoded to Color::White / Blue /
Yellow. New instances use Color::Reset as a sentinel meaning
'use the theme token', and render() falls back to:
- fg: theme.foreground
- bg: theme.background
- cursor_color: theme.warning
Callers that explicitly call .fg() / .bg() / .cursor_color() still
override the sentinel — existing API surface preserved.
Changes:
- input.rs::Input::new() defaults fg/bg/cursor_color to Color::Reset
- input.rs::Input::render() resolves Reset → theme token
- New pub fn cursor_color(c) builder (was missing; .fg/.bg existed)
Tests (2 new in widget::input::tests):
- default_colors_are_reset_sentinel
- explicit_color_overrides_reset_sentinel
Total: 1268 passing (was 1266; +2 new).
Theme::by_name() now lowercases the input before matching aliases,
so 'MC-Classic', 'Mc-Dark', and 'DARK' all resolve identically
to their lowercase canonical names. Previously a user setting
config.toml [skin] name = 'MC-Classic' would silently fall back
to default (no match found for the capitalized name).
Implementation:
- In by_name(), lowercase the input via to_ascii_lowercase()
- Match against the existing alias map (which already covers
mc-classic, mc-dark, mc-dark-gray, default-dark, etc.)
- The lowercase form is passed to mc_skin::theme_by_name,
which handles the embedded MC .ini files; the lookup there
also becomes case-insensitive as a side effect.
Tests (2 new in terminal::color::tests):
- by_name_resolves_aliases_case_insensitively
MC-CLASSIC → default, Mc-Dark → dark, etc.
- by_name_real_skin_name_case_insensitive
DARK → dark, NICEDARK → nicedark (verifies the alias->real
skin chain works regardless of input case)
Total: 1266 passing (was 1264; +2 new).
Previously Viewer::search for Chunked sources read the ENTIRE
file into RAM via read_at(0, size) — defeating the memory-efficient
design of Chunked mode (a 150 MB Chunked file would load all 150 MB
just to search it).
Search (viewer/search.rs):
New method find_all_streaming<E>(pattern, case_insensitive, next_chunk)
- Generic error type E so callers can use their own error type
- next_chunk closure yields (bytes, is_last_chunk) until exhausted
- Sliding-window algorithm: keeps last (pattern.len() - 1) bytes
from each chunk as 'tail', combines with next chunk for regex
- Matches that span chunk boundaries are detected correctly
- Matches across overlapping combined buffers are deduped by
start offset after collection
Viewer (viewer/mod.rs):
search() now dispatches by source variant:
- Inline / Compressed: in-memory find_all (unchanged)
- Chunked: find_all_streaming with closure yielding CHUNK_SIZE
reads until EOF reached
Tests (5 new in viewer::search::tests):
- streaming_single_chunk_finds_match
- streaming_match_spans_two_chunks (xxfoobarxx at chunk 5)
- streaming_no_matches
- streaming_multiple_matches_in_one_chunk
- streaming_chunk_size_smaller_than_pattern (chunk_size=1, pattern=hello)
Total: 1264 passing (was 1259; +5 new).
Bookmarks now track the correct line number across insert/delete
edits (MC parity). Previously a bookmark at line 5 would still
report line 5 even after lines 1-3 were deleted, pointing at
wrong content.
BookmarkSet (editor/bookmark.rs):
New method adjust_lines(at_line: u32, delta: i32)
- For each mark with line > at_line: shift by delta
- For positive delta: mark.line += delta
- For negative delta with mark.line in deletion range:
clamp to at_line + 1 (first surviving line after range)
- Column is preserved across all shifts
- Zero delta is a no-op (no allocation)
Editor (editor/mod.rs):
New private helper adjust_bookmarks_after_edit(edit_line, lines_before)
Wraps insert_char, insert_str, delete_back, delete_forward:
- Captures (line_before, lines_before) before buffer op
- Calls buffer op
- Computes delta = lines_after - lines_before
- Calls bookmarks.adjust_lines(line_before, delta) if non-zero
Tests (6 new in editor::bookmark::tests):
- adjust_lines_zero_delta_is_noop
- adjust_lines_shifts_marks_after_insertion
- adjust_lines_shifts_marks_after_deletion
- adjust_lines_multi_line_insertion
- adjust_lines_deletion_clamps_to_anchor
- adjust_lines_does_not_touch_col
Total: 1259 passing (was 1253; +6 new).
Adds user-facing control over the editor tab width.
New methods on Editor (editor/mod.rs):
tab_width() -> usize
- Returns current visual tab width in spaces.
set_tab_width(width: usize)
- Sets tab width, clamped to min 1.
cycle_tab_width() -> usize
- Cycles 4 → 8 → 2 → 4 (default 4 → wider → narrower → back).
- Returns the new width.
Alt-T binding (editor/handlers.rs):
try_global_shortcut now handles Alt-T (0x74) and calls
cycle_tab_width(). Status bar shows 'Tab width: N'.
Tab-to-indent (B3) and word-wrap (B1) both consume tab_width
via the field, so changing it via Alt-T takes effect immediately
on next render / insert.
Tests (5 new in editor::tests):
- tab_width_default_is_four
- set_tab_width_updates_field (verifies 0 → 1 clamp)
- cycle_tab_width_advances_2_4_8_2
- cycle_tab_width_from_unusual_value_resets_to_2
- tab_to_indent_uses_current_tab_width
Config wiring note: cfg.editor.tab_width (config.rs) already has
the field with serde defaults and persistence. Wiring it into
Editor::open() requires the constructor to accept &Config and
is deferred to a future sprint.
Total: 1253 passing (was 1248; +5 new).
Tab key now has indent-aware behavior (MC parity):
- When cursor is in the indent zone (only whitespace before it on
the current line), Tab inserts enough spaces to advance to the
next tab_width boundary.
- When cursor is past any non-whitespace, Tab inserts a single
literal '\t' character for column alignment.
New methods on Editor (editor/mod.rs):
insert_tab_with_indent()
- Computes next_boundary = ((col / tab_width) + 1) * tab_width
- In indent zone: inserts (next_boundary - col) spaces
- Otherwise: inserts literal '\t'
is_in_indent_zone() -> bool
- True if all bytes between line start and cursor are space/tab
Tab handler in editor/handlers.rs routes through
insert_tab_with_indent() instead of insert_char('\t').
Tests (6 new in editor::tests):
- tab_at_col_0_indents_to_next_boundary
- tab_at_col_4_indents_to_next_boundary
- tab_mid_line_inserts_literal_tab
- tab_after_non_whitespace_inserts_literal_tab
- tab_after_whitespace_runs_continues_indent
- tab_inside_word_inserts_literal_tab
Total: 1248 passing (was 1242; +6 new).
Replaces the character-counting wrap in build_wrap_map with a
word-boundary algorithm that matches MC's intent: lines break at
the last whitespace before the wrap limit, mid-word break as
fallback for overlong words, tabs counted as their visual width
(next tab_width boundary), control chars as 2 cols.
New helpers (editor/render.rs):
visual_width(ch, col, tab_width) -> usize
- Tabs: tab_width - (col % tab_width), min 1
- Control chars (0x00..=0x1F, 0x7F): 2 cols (renders as ^X)
- Other chars: 1 col
count_wrapped_rows(line_bytes, body_width, tab_width) -> usize
- Walks line left-to-right, tracks last whitespace position
- On overflow: wrap at last_ws_col (preserves word boundary),
else mid-word break
- Stops at \n (line_bytes excludes \n in caller)
Editor (mod.rs):
- New tab_width: usize field (default 4)
- Matches the renderer's ' ' tab expansion in push_rendered_text
- Future B4 will make this user-configurable
Tests:
+12 wrap_tests in editor::render::wrap_tests:
- visual_width_tab_expands_to_next_boundary
- visual_width_ascii_and_control
- count_wrapped_rows_empty_line
- count_wrapped_rows_short_line_fits_in_one_row
- count_wrapped_rows_exact_fit
- count_wrapped_rows_one_char_overflow_no_whitespace
- count_wrapped_rows_word_boundary_break
- count_wrapped_rows_three_words_at_width_six
- count_wrapped_rows_long_word_falls_back_to_mid_word
- count_wrapped_rows_tab_visual_width
- count_wrapped_rows_zero_width_returns_one
- count_wrapped_rows_stops_at_newline
Total: 1242 passing (was 1230; +12 new).
Implements all 5 critical parity items from the comprehensive MC
assessment. Reference: MC source at local/recipes/tui/mc/source/.
A1 — Editor line number gutter:
Split editor inner area into gutter_area + body_area; gutter
renders right-aligned line numbers (or relative offsets when
relative_lines mode is on); bookmark rows show current-line
style; ~ shown for lines past end-of-file.
A2 — Viewer cursor line highlight:
cursor_line_bg derived from body_bg + RGB(12,12,12); applied
before search-match overlay so matches win on the cursor line.
A3 — Hide terminal cursor in file manager mode:
App::run() hides the cursor after Tui::new(); render() shows
the cursor only when editor/viewer/cmdline/dialog/menubar
is active.
A6 — Shift-F5/F6 same-directory rename:
New Cmd::CopySameDir and Cmd::MoveSameDir variants, bound to
Shift-F5 / Shift-F6. Reuse CopyDialog/MoveDialog with a new
same_dir flag and new_rename() constructor; result() resolves
the typed name against the source's parent directory.
A7 — SUID/SGID/sticky bits in chmod dialog:
4-row PermCell grid (user, group, other, special); class_shift
= 0o4000, bit = 1. Display as 0oXXXX. Overwrite dialog shows
all 12 cells. 6 new unit tests cover all special-bit combinations.
Other fixes in the same commit:
- 4 editor render tests shifted x-coordinates by gutter_chars
to account for the new gutter column.
- 1 viewer text test moves cursor to line 1 so cursor-line
highlight doesn't overlap the search-match assertion.
Tests: 1230 passed (was 1219 before A7; +11 new tests across A7
and A6). All Sprint 1 items compile clean.
MenuBarOutcome::Dispatch now carries Option<usize> indicating which
menu (0=Left, 4=Right) originated the command. app.rs switches focus
to the corresponding panel before dispatching. This matches MC
behavior where the Left menu always operates on the left panel and
the Right menu on the right panel, regardless of which panel had
keyboard focus.