Skip to content

Spike: decide whether the logs tail can be a virtualized list #83

Closed

Description

@elementalvoid

What to build

A decision, not a refactor: can the logs tail render from a virtualized list and keep everything it does today?

The tail is built by hand today — one selectable text run per line, each with its own handle, plus a private diff to count lines that arrived while the tail was paused. The per-line handles exist because a shared handle registers once and the last line painted wins, which is a workaround, not a design. A virtualized list would render only what is on screen, but it is only worth doing if cross-line selection survives.

The deliverable is a written verdict in a comment on this issue. No production change lands here.

Acceptance criteria

  • A throwaway prototype renders the tail through the virtualized list with real log content, at the longest tail the app shows.
  • It measures, and reports, each of: drag selection across many lines and the copied result, Copy all, following at the bottom, pausing on scroll up with a correct count of lines that arrived while paused, jumping back to the latest, and the scrollbar reflecting the offset.
  • It reports the cost: the delegate it needs, how much of the current pause/follow logic survives, and how many existing tail tests would need rewriting.
  • The verdict is one of: adopt (file a follow-up ticket with the constraints found), or keep the current tail (state what a future virtualized attempt would have to solve first).
  • The prototype is not committed, or is committed to a throwaway branch that is deleted.

Notes

If the prototype shows selection breaking across lines, that is a valid and useful outcome — close the spike with that finding rather than fighting it.

Activity

  1. elementalvoid commented on Oct 30, 2026

    @elementalvoid
    OwnerAuthor

    Spike #83 — the logs tail through the kit's virtualized list

    Verdict: keep the current tail. Cross-line selection does not survive virtualization: a
    selection is cleared the moment its rows leave the rendered window, and a drag held at the
    bottom edge ends with nothing copied at all. Everything else the tail does (Copy all, follow,
    pause + count, jump, scrollbar) came through unchanged, so the blocker is selection, not the
    list.

    Method

    Throwaway prototype at crates/app/tests/spike83_virtual_tail.rs in worktree
    /data/home/projects/immich-sync-wt/83, branch spike/83-logs-virtualized — uncommitted
    (the branch is deleted at the end of the spike). 12 probes, two identical runs.

    • Content: 220 events emitted through the app's own subscriber (LogSink with the default fmt
      layer), then read with logging::newest_tail(dir, TAIL_LINES) → the real cap, 200 lines
      in the app's real format.
    • Prototype: v_virtual_list from gpui-base 0.8.1 (the kit's virtualized primitive), rows
      built exactly as today — one logs-line-{i} div with aria_label + one SelectableText
      with document_order(i) — plus the tail's own wrapper, focus, stop_propagation, pause row
      and Scrollbar.
    • Today's column is the shipped LogsView on the same 200 lines in the same 1200×1400 window,
      so each row of the table is one probe run twice.
    • The prototype has no header/location/level cards, so its box is taller and fits 74 rows
      against today's 30; that is why two counts in the table differ. Row height is 18px in the
      prototype against today's natural one-line row.

    Measurements

    Criterion Today (overflow_y_scroll, 200 rows painted) v_virtual_list (74 rows painted)
    rows in the element tree at 200 lines 200 74
    Copy all 200 lines, 16,143 chars identical 200 lines, 16,143 chars
    drag across the visible rows, then Ctrl+C dragged 171–198 (28 rows) → 28 lines, ends …198 dragged 127–198 (72 rows) → 72 lines, ends …198
    drag held at the bottom edge until the tail autoscrolls 14 lines copied (anchor row through the row under the pointer) nothing copied — clipboard untouched
    select 3 rows, then scroll them out of the window, then Ctrl+C selection kept, 3 lines copied selection cleared (has_selection=false), clipboard untouched
    following: new tail lands while at the bottom newest line on screen after 1 frame newest line on screen after 1 frame
    scrolling up pauses, count of lines that arrived paused row shows 3 new lines · Jump to latest; rows frozen; second idle poll still says 3 same, exactly
    jump back to the latest newest line visible, paused row gone, follows again same
    scrollbar reflects the offset thumb 82.5px tall, y 1284.5 → 1241.0 after 300px up thumb 482.0px tall, y 898.0 → 787.5 after 300px up

    Why selection breaks (source, not only behaviour)

    gpui-base 0.8.1 registers a selection participant in the paint of the element that holds
    the text
    (selectable_text.rs:194-195), copies by walking the registered participants
    (text_selection.rs:1804), and every copy_item needs a snapshot the paint produced
    (:854). Participants whose element GPUI has dropped are swept after the frame (:1507, :1560), and
    when a swept participant is an endpoint the whole gesture is cleared (:3193-3198). A
    virtualized list keeps only ~74 of 200 rows in the tree, so:

    • scrolling a selected range out of view removes its participants and ends the selection;
    • a drag held at the bottom edge scrolls the anchor row out of the window, and the drag's
      selection is gone before the mouse comes up.

    The kit exposes no way to register a run that is laid out but not painted, and no virtualized
    variant of SelectableText. This is the finding; it is not fought here.

    Cost

    The delegate. v_virtual_list — not List/ListDelegate. What it needs is a renderer
    closure plus Rc<Vec<Size<Pixels>>> (one size per line, 200 entries, rebuilt on each tail
    replace) and, because the closure is 'static, the view must hold Rc<Vec<String>> instead of
    Vec<String>. The row element itself is unchanged. List/ListDelegate would additionally
    demand items_count, render_item, set_selected_index, set_right_clicked_index,
    confirm, cancel, has_more, load_more, a Selectable item type and its own single-item
    selection model — all noise for a log tail.

    Two things the list forces beyond the delegate:

    1. A wrapper div. v_virtual_list's element id is not findable (spike-list never appears
      in the registered paths), so logs-lines must move to a wrapper of the same bounds to keep
      the test id, the focus target and the on_scroll stop working. In the prototype the wrapper
      also had to be flex().flex_col(): as a plain relative box the overlay Scrollbar painted
      outside it and drew no thumb.
    2. scroll_to_bottom() must be issued after the box has painted once. A flag set before the
      first frame is dropped and the tail silently stays at line 0 (measured over 3 frames). The
      app's own sequence is safe — a read lands after the first paint — but the ordering is a live
      trap.

    Pause/follow logic: all of it survives. at_bottom() (max_offset + offset < 4), paused,
    new_lines, counted, gained_lines, read_generation, Follow::FromViewport/Always,
    poll_live, has_selection, refresh/resume and copy_all all ran unchanged against the
    virtual handle, and the pause/count/jump/follow rows above are their measurements. The only
    additions are sizes upkeep on a replace and the scroll_to_bottom ordering above.

    Existing tail tests. tests/logs_ui.rs holds 27 tests; 12 name the tail box
    logs-lines (scroll target, geometry or focus): refresh_reads_the_lines_written_since_the_view_opened,
    ten_refreshes_keep_every_row_at_full_height,
    a_drag_across_lines_and_ctrl_c_copies_the_selection,
    scrolling_up_pauses_the_tail_and_counts_the_lines_it_misses,
    a_scroll_up_during_the_read_is_not_undone_when_it_lands,
    jump_to_latest_loads_the_current_tail_and_follows_again,
    the_tail_pane_fills_the_height_left_below_the_level_control,
    a_short_window_keeps_the_tail_on_screen_and_scrolls_the_cards_above_it,
    a_paused_tail_keeps_the_jump_control_inside_a_short_window,
    a_failed_tail_read_keeps_its_notice_and_paused_row_inside_the_card,
    a_wheel_over_the_tail_moves_the_tail_and_a_wheel_over_the_cards_moves_the_cards,
    the_tail_scrollbar_follows_the_scroll_handle_it_was_given.
    With the id kept on the wrapper, none of them needs an edit (the prototype's wrapper carries
    focus, wheel targeting and geometry). 3 would keep passing while proving less, because they
    iterate every row of a long tail through tail_rows/shown_rows and a virtual list only has
    the rendered ones: ten_refreshes_keep_every_row_at_full_height,
    scrolling_up_pauses_the_tail_and_counts_the_lines_it_misses,
    a_wheel_over_the_tail_moves_the_tail_and_a_wheel_over_the_cards_moves_the_cards. 0 tests
    cover the two behaviours that break — the suite cannot catch this regression today.

    What a future virtualized attempt must solve first

    1. Selection in model coordinates. The anchor and cursor must live in (line, character) space
      with the run text retained for unrendered rows, so a range can be copied while its
      middle — or its endpoints — are out of the window. The kit does not offer this: TextSelection
      copies from painted participants only, and sweeps the rest. Either gpui-base grows
      selectable runs that survive unpainted (the natural upstream ask), or the tail owns its
      selection (anchor/cursor + highlight quads + copy by slicing self.lines), which throws away
      SelectableText and its drag, double-click and touch behaviour.
    2. Drag auto-scroll that does not drop the anchor when its row scrolls out of the window — the
      same requirement, seen from the gesture side.
    3. Rewrite the 3 weaker tests above and add the 2 missing ones (cross-line copy whose rows leave
      the window; drag held at the bottom edge) before the virtual list lands, so the regression
      is red first.
    4. Then the small ones: the wrapper div (id, focus, stop_propagation, plus flex().flex_col()
      for the scrollbar), Rc<Vec<String>> + Rc<Vec<Size<Pixels>>>, and scroll_to_bottom()
      after the first paint.

    Aside, not caused by this spike: long rows are dropped from a drag copy

    Found while building the harness, on today's tail: a row whose text is wider than the box
    never reaches the clipboard. Measured at an 1110px text box, a 140-char row copies and a
    160-char row does not; at the prototype's 1168px box, 160 copies and 200 does not (≈7.2px per
    char at text_xs mono). The app's own real lines are in that class — … INFO immich_sync::engine: upload attempt finished … item=120 path=/home/user/Pictures/2026/holiday-120.jpg is ~176 chars, and a
    drag over it copies every shorter row and skips the long ones. Copy all is unaffected (it joins
    the view's own lines). Worth its own ticket: it makes "drag and copy the tail" quietly lossy
    on exactly the lines users want.


    Measured on gpui-kit 0.8.1 (gpui-base/gpui-component 0.8.1, gpui-pre 0.3.10), Linux,
    cargo test --test spike83_virtual_tail -- --nocapture, 12/12 probes, two identical runs.
    Prototype not committed; the branch is deleted.

  2. elementalvoid commented on Oct 30, 2026

    @elementalvoid
    OwnerAuthor

    Verdict above: keep the current tail. Virtualization loses cross-line selection (cleared when the selected rows leave the window; a drag at the bottom edge copies nothing), while Copy all, follow, pause+count, jump and the scrollbar all came through unchanged. No production change landed; the prototype was never committed.

    The over-wide-row drag-copy defect the prototype turned up is filed separately as #85 so the finding is not lost with the branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions