Skip to content

fix(tui): yank cell/row no longer reports "No row selected" - #41

Merged
TimelordUK merged 1 commit into
mainfrom
fix/yank-cell-single-row-selection
Aug 1, 2026
Merged

fix(tui): yank cell/row no longer reports "No row selected"#41
TimelordUK merged 1 commit into
mainfrom
fix/yank-cell-single-row-selection

Conversation

@TimelordUK

Copy link
Copy Markdown
Owner

The bug

Filter down to a single row (f + a term), navigate to a cell, press yv — the status bar says "No row selected" and nothing is copied. Reported from real use: pulling version numbers out of a TeamCity CSV, cell by cell.

Root cause

Yank resolves its row from Buffer::get_selected_row() (src/handlers/yank.rs:43), which was backed by ratatui's TableState — a second source of truth for the crosshair, separate from the ViewportManager / view_state.crosshair_row position the table actually renders.

Only two things ever wrote it:

  • row navigation — sync_row_state() (src/ui/traits/navigation.rs:37), i.e. j/k/G
  • query execution — reset_table_state()reset_navigation_state()

Loading a file does neither: new_with_dataview and add_dataview_with_refs install the DataView and the ViewportManager but never initialise the selection. So it stayed None until you moved the cursor — and on a view narrowed to one row, j/k can never fire. Hence "consistent with a single row in the view".

The two filter halves had also drifted apart:

path key resets selection?
apply_fuzzy_filter_with_refs Shift+F yes — set_selected_row(Some(0)) + vm.set_crosshair_row(0)
apply_text_filter_with_refs f no — applied the filter, set a status message, nothing else

Second bug, same root

No clamping either. Navigate to row 57, then filter down to one row: the selection stayed at Some(57), dataview.get_cell_value(57, col) returned None, and yank silently copied the string NULL.

The fix

get_selected_row() derives from the crosshair. It now reads view_state.crosshair_row, returns None only when there are genuinely no visible rows, and clamps to the view's row count. Kills both failure modes. TableState is untouched and still drives rendering; the only functional caller of get_selected_row() was yank (the rest are debug dumps and one unwrap_or(0)).

The f text filter resets to the first match, mirroring the fuzzy filter — viewport ref threaded in from enhanced_tui::apply_filter. Better behaviour in its own right, independent of the yank bug.

Tests

New tests/test_yank_single_row_repro.rs — fresh load has a selection, empty results don't, both filters keep a selection at a single row, and the selection is clamped so yank reads the visible cell.

Two existing tests asserted the old contract using buffers with no data at all (test_buffer::test_buffer_navigation, test_buffer_state_refactor::test_direct_buffer_viewstate_access); they now install data, since an empty buffer having no selection is correct under the new semantics.

  • cargo test — 688 + 412 + 1 passed, 0 failed
  • cargo fmt / cargo clippy — clean, no new warnings
  • Python suite — 497 passed; the 18 failures are the known Windows-local .exe-suffix noise, green in CI
  • Examples — all FORMAL passed, only pre-existing smoke failures

Verified by hand against the original CSV that triggered the report.

Follow-up, not in this PR

f is the text filter and Shift+F the fuzzy filter per KeyMapper (src/ui/key_handling/mapper.rs:222), but src/config/key_bindings.rs:365 and dispatcher.rs carry the opposite mapping. They're dead duplicate tables that KeyMapper overrides — worth pruning so the code and the help agree.

🤖 Generated with Claude Code

Yank resolved its row from Buffer::get_selected_row(), which was backed by
ratatui's TableState - a second source of truth for the crosshair that only
row navigation (sync_row_state) and query execution (reset_navigation_state)
ever wrote. Loading a file never initialised it, so on a view narrowed to a
single row - where j/k can never fire - `yv` and `yy` refused to copy a
perfectly visible cell.

The `f` text filter made it worse: apply_text_filter_with_refs applied the
filter and set a status message, nothing else, unlike the fuzzy filter which
resets selection, scroll and the viewport crosshair to the first match.

- get_selected_row() now derives from view_state.crosshair_row, the position
  the table actually renders. None means "no visible rows", and the row is
  clamped to the view - so a filter that shrinks the results can no longer
  leave yank reading past the end and copying "NULL". TableState is untouched
  and still drives rendering.
- apply_text_filter_with_refs now resets to the first match like the fuzzy
  filter does, which is also the better behaviour on its own.

Two existing tests asserted the old contract against buffers holding no data
at all; they now install data, since an empty buffer having no selection is
correct under the new semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TimelordUK
TimelordUK merged commit d795037 into main Aug 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant