You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Spike: decide whether the logs tail can be a virtualized list #83
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.
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
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:
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.
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
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.
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.
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.
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.
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.
Activity
elementalvoid commentedon Oct 30, 2026
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.rsin worktree/data/home/projects/immich-sync-wt/83, branchspike/83-logs-virtualized— uncommitted(the branch is deleted at the end of the spike). 12 probes, two identical runs.
LogSinkwith the default fmtlayer), then read with
logging::newest_tail(dir, TAIL_LINES)→ the real cap, 200 linesin the app's real format.
v_virtual_listfromgpui-base 0.8.1(the kit's virtualized primitive), rowsbuilt exactly as today — one
logs-line-{i}div witharia_label+ oneSelectableTextwith
document_order(i)— plus the tail's own wrapper, focus,stop_propagation, pause rowand
Scrollbar.LogsViewon the same 200 lines in the same 1200×1400 window,so each row of the table is one probe run twice.
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
overflow_y_scroll, 200 rows painted)v_virtual_list(74 rows painted)…198…198has_selection=false), clipboard untouched3 new lines · Jump to latest; rows frozen; second idle poll still says 3Why selection breaks (source, not only behaviour)
gpui-base 0.8.1registers a selection participant in the paint of the element that holdsthe text (
selectable_text.rs:194-195), copies by walking the registered participants(
text_selection.rs:1804), and everycopy_itemneeds a snapshot the paint produced(
:854). Participants whose element GPUI has dropped are swept after the frame (:1507,:1560), andwhen a swept participant is an endpoint the whole gesture is cleared (
:3193-3198). Avirtualized list keeps only ~74 of 200 rows in the tree, so:
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— notList/ListDelegate. What it needs is a rendererclosure plus
Rc<Vec<Size<Pixels>>>(one size per line, 200 entries, rebuilt on each tailreplace) and, because the closure is
'static, the view must holdRc<Vec<String>>instead ofVec<String>. The row element itself is unchanged.List/ListDelegatewould additionallydemand
items_count,render_item,set_selected_index,set_right_clicked_index,confirm,cancel,has_more,load_more, aSelectableitem type and its own single-itemselection model — all noise for a log tail.
Two things the list forces beyond the delegate:
v_virtual_list's element id is not findable (spike-listnever appearsin the registered paths), so
logs-linesmust move to a wrapper of the same bounds to keepthe test id, the focus target and the
on_scrollstop working. In the prototype the wrapperalso had to be
flex().flex_col(): as a plainrelativebox the overlayScrollbarpaintedoutside it and drew no thumb.
scroll_to_bottom()must be issued after the box has painted once. A flag set before thefirst 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/resumeandcopy_allall ran unchanged against thevirtual handle, and the pause/count/jump/follow rows above are their measurements. The only
additions are
sizesupkeep on a replace and thescroll_to_bottomordering above.Existing tail tests.
tests/logs_ui.rsholds 27 tests; 12 name the tail boxlogs-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_rowsand a virtual list only hasthe 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 testscover the two behaviours that break — the suite cannot catch this regression today.
What a future virtualized attempt must solve first
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:
TextSelectioncopies from painted participants only, and sweeps the rest. Either
gpui-basegrowsselectable 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 awaySelectableTextand its drag, double-click and touch behaviour.same requirement, seen from the gesture side.
the window; drag held at the bottom edge) before the virtual list lands, so the regression
is red first.
stop_propagation, plusflex().flex_col()for the scrollbar),
Rc<Vec<String>>+Rc<Vec<Size<Pixels>>>, andscroll_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_xsmono). 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.jpgis ~176 chars, and adrag 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 lossyon exactly the lines users want.
Measured on gpui-kit 0.8.1 (
gpui-base/gpui-component0.8.1,gpui-pre0.3.10), Linux,cargo test --test spike83_virtual_tail -- --nocapture, 12/12 probes, two identical runs.Prototype not committed; the branch is deleted.
elementalvoid commentedon Oct 30, 2026
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.