From 5a3232432bb0ba64b44234793d1bd15a4adf49f0 Mon Sep 17 00:00:00 2001 From: Torstein Tauno Svendsen Date: Mon, 31 Aug 2026 09:32:26 +0200 Subject: [PATCH] The pager opens one entry back out of the join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `z` renders every multi-line entry as one row, which is what makes ten stack traces comparable at all — and the next thing after picking one out of the ten is reading it. `Z` opens the entry under the cursor back into its own lines while the other nine stay rows to come back to; pressing it again joins it, from any of its lines, since what the reader is pointing at is the entry and not the row. Several can be open at once, because comparing two of them is what picking them out was for. It is an exception to a rendering rather than a second mode: an open entry is rendered from the source's OWN lines, so its framing is the entry framing again — the entry motion steps over the whole trace and a bare `c` still copies all of it — and `z`, which sets the whole screen, builds the join afresh and so clears the exceptions. An open entry is remembered by its ORDINAL, the nth entry of the source, against the pager's habit of holding every position as an offset: an entry still at a live edge has none, and every one of them reports 0, so a set of offsets would open all of the live-edge entries together. An answer is a closed set, so the nth entry stays the nth — and the tape, where the run does renumber, has no multi-line entry to open in the first place. The place-keeping `z` already did — the cursor and both ends of a region travelling as offsets, the upper one widened to the end of the entry it lands in — is now shared with `Z` rather than written twice: the rows renumber the same way under both, and a region that quietly became a different set of lines is the copy that takes the wrong thing. --- docs/plans/view.md | 61 ++++++++++++--- packaging/timberview.1 | 21 ++++- tests/timberview/test-timberview | 104 +++++++++++++++++++++++++ tools/README.md | 9 +++ tools/timberview.py | 130 ++++++++++++++++++++++++++----- 5 files changed, 292 insertions(+), 33 deletions(-) diff --git a/docs/plans/view.md b/docs/plans/view.md index 2a420e0..e7f8c9f 100644 --- a/docs/plans/view.md +++ b/docs/plans/view.md @@ -114,17 +114,17 @@ made for it. picked term, so nothing is lost. No colour: reverse video is the terminal's own, and a background chosen here is a bet on somebody's theme. -- ⚠ **Toggling `z` keeps what was SELECTED, not the row numbers.** Both - ends travel as offsets, and the upper one is taken to the END of the - entry it lands in: a joined row is an entry, so unjoining it is several - lines and an offset resolves to the first of them. Keeping the numbers - collapsed a selection of two records to their two first lines — a - highlight that read as lost, and a copy that then took two lines of the - fourteen selected. Joined, every row is a first, so the rule widens - nothing in that direction and needs no test of which way the toggle - went. The cursor is one of those ends, so where it was the upper one - the view follows it to the entry's end; a selection longer than the - screen cannot show both ends anyway. +- ⚠ **Toggling `z`, or opening one entry with `Z`, keeps what was + SELECTED, not the row numbers.** Both ends travel as offsets, and the + upper one is taken to the END of the entry it lands in: a joined row is + an entry, so unjoining it is several lines and an offset resolves to + the first of them. Keeping the numbers collapsed a selection of two + records to their two first lines — a highlight that read as lost, and a + copy that then took two lines of the fourteen selected. Joined, every + row is a first, so the rule widens nothing in that direction and needs + no test of which way the toggle went. The cursor is one of those ends, + so where it was the upper one the view follows it to the entry's end; a + selection longer than the screen cannot show both ends anyway. - **A joined row copies as the log's own lines.** `z` renders an entry as one row with `↵` between its lines; that is a thing to read, and the clipboard wants what the log holds. So `z` then a mark is how entry-wide @@ -175,6 +175,45 @@ multiplexer. there too rather than being handed to something that would truncate it without saying so. +## Built: ten traces on one screen, and one of them opened + +`z` renders every multi-line entry as one row and `Z` opens the entry +under the cursor back out into its own lines. + +- **The question `z` answers is "are these ten the same failure".** Four + hundred lines of trace answer it with no screen to answer it on; ten + rows put the messages in a column where the eye compares them, with + the frames trailing off to the right where `h`/`l` can go and read + them. The continuation lines are LSTRIPPED as they join — their indent + is what puts frames under a message that is no longer above them, and + it is the difference between ten rows that line up and ten that do not. +- **A rendering, not a fold.** Every line of the entry is on the row, so + the search, `Tab`, the hit list and the entry motion all keep working: + each row is a real line with a real address. A fold would have had to + teach every one of those about lines that are not on the screen. +- **And `Z` is the next question**: which is exactly one of the ten, read + in full, while the other nine stay rows to come back to. Several can be + open at once, because comparing two of them is what picking them out of + the ten was for. It is an EXCEPTION to a rendering rather than a second + mode — an open entry is the source's own lines, so its framing is the + entry framing again and a bare `c` still takes the whole trace — and + `z`, which sets the whole screen, builds the join afresh and so clears + the exceptions. +- **Any of its lines closes it**, not just its first: what the reader is + pointing at is the entry, and having to walk back to its head to put it + away is a coordinate they would have to keep. +- ⚠ **An open entry is remembered by its ORDINAL**, the nth entry of the + answer — not by its offset, which is how every other position in this + pager is held. An entry still at a live edge has no offset yet and they + all report 0, so a set of offsets would open every live-edge entry at + once. The answer is a closed set, so the nth entry stays the nth. +- **A decorator over the source, not a mode inside it.** `Tape` and + `Records` both have entries in this sense, the view swaps its source at + runtime when a hit is in another store, and neither should learn about + a display option. On the tape, which parses nothing, every line is its + own entry — so joining is the identity there and `Z` says there is + nothing folded in, exactly as the entry motion is a line there. + ## /etc/hosts, and the DNS that would replace it **The hosts file half is BUILT.** `TIMBERFS_CMD` plus `TIMBERFS_HOSTS` was diff --git a/packaging/timberview.1 b/packaging/timberview.1 index 0a3be44..246594e 100644 --- a/packaging/timberview.1 +++ b/packaging/timberview.1 @@ -150,6 +150,23 @@ all keep working, because every line on the screen is a real line with a real address. .RE .TP +.B Z +Open THIS entry back out of the join, as its own lines \(em the one of +the ten you picked out to read, while the other nine stay rows to come +back to. Pressing it again joins it, from ANY of its lines: what you are +pointing at is the entry, not the row. +.RS +.PP +Several can be open at once, because comparing two traces is the next +thing after picking them out of the ten. It is an exception to a +rendering and not a second mode \(em the entry is still one entry to the +entry motion and to a bare +.BR c , +and +.BR z , +which sets the whole screen, builds the join afresh and so clears them. +.RE +.TP .BR space " " b A page. .BR h " " l " " \(<- " " \(-> @@ -240,7 +257,9 @@ in \(em that rendering is for reading. So .B z and then a mark is how a run of whole entries is selected. Toggling .B z -with a region live keeps what was selected rather than the row numbers: +\(em or opening one entry with +.B Z +\(em with a region live keeps what was selected rather than the row numbers: both ends travel as offsets, and the upper one is taken to the END of the entry it lands in, since a row unjoins into several lines and an offset resolves to the first of them. diff --git a/tests/timberview/test-timberview b/tests/timberview/test-timberview index e0d141a..a8e7429 100755 --- a/tests/timberview/test-timberview +++ b/tests/timberview/test-timberview @@ -927,6 +927,110 @@ def joining_keeps_the_entry_you_are_on(): (v.cur, v.source.lines[v.cur].text) +@test +def one_entry_can_be_opened_back_out_of_the_join(): + """`z` is the whole answer at once, which is what makes ten traces + comparable — and then one of them is the one you read. Opening it is + an exception to the join and not a second mode: everything else stays + a row, and the entry is still one entry to the motions and the copy.""" + frames = "\n".join(f" at frame.{i}(F.java:1)" for i in range(5)) + r = tv.Records(answer(("one", {"offset": 0}), + (f"boom\n{frames}", {"offset": 100}), + ("three", {"offset": 900}))) + v = tv.View(Fake(["x\n"]), source=r) + # Through the KEYS: a binding nothing exercises is one a refactor can + # lose, and `Z` is only reachable through one. + screen = Screen_for_test() + screen.view = v + screen.step(Screenish(), ord("z")) + assert len(v.source.lines) == 3, [ln.text for ln in v.source.lines] + + v.cur = 1 + screen.step(Screenish(), ord("Z")) + assert len(v.source.lines) == 8, [ln.text for ln in v.source.lines] + # The other two are untouched, and the cursor is on what it was on. + assert [v.source.lines[i].text for i in (0, 7)] == ["one", "three"] + assert v.cur == 1 and v.source.lines[1].text == "boom", v.cur + assert "↵" not in "".join(ln.text for ln in v.source.lines) + # Still ONE entry: the whole trace is what a bare `c` takes, and the + # entry motion steps over it. + assert v.entry_span() == (1, 6), v.entry_span() + assert v.selection()[1] == "this entry, 6 lines", v.selection()[1] + v.move_entry(1) + assert v.source.lines[v.cur].text == "three", v.source.lines[v.cur].text + + # From ANY of its lines, because what you point at is the entry. + v.cur = 4 + v.open_entry() + assert len(v.source.lines) == 3, [ln.text for ln in v.source.lines] + assert v.source.lines[v.cur].text.startswith("boom ↵ at frame.0") + + # A one-line entry has nothing folded into it, and says so. + v.cur = 0 + v.open_entry() + assert len(v.source.lines) == 3 and "one line" in v.message, v.message + + +@test +def opening_one_entry_is_a_property_of_the_join(): + """It is an exception to a rendering, so it lives and dies with one: + `z` sets the whole screen, and a screen that is not joined has + nothing to open out of.""" + r = tv.Records(answer(("boom\n at A\n at B", {"offset": 0}), + ("next", {"offset": 100}))) + v = tv.View(Fake(["x\n"]), source=r) + v.open_entry() + assert len(v.source.lines) == 4 and "nothing is joined" in v.message, \ + v.message + + v.join_entries() + v.cur = 0 + v.open_entry() + assert len(v.source.lines) == 4, [ln.text for ln in v.source.lines] + v.join_entries() # everything as written + v.join_entries() # ...and joined again + assert len(v.source.lines) == 2, [ln.text for ln in v.source.lines] + + +@test +def entries_at_a_live_edge_open_one_at_a_time(): + """An entry that has not been placed yet has no offset, and every one + of them reports 0 — so an open entry is remembered by its ORDINAL. By + offset, opening one of them would open all the others with it.""" + r = tv.Records(answer(("first\n at A", {}), ("second\n at B", {}))) + v = tv.View(Fake(["x\n"]), source=r) + assert r.unplaced == 2, r.unplaced + v.join_entries() + v.cur = 1 + v.open_entry() + assert [ln.text for ln in v.source.lines] == \ + ["first ↵ at A", "second", " at B"], \ + [ln.text for ln in v.source.lines] + + +@test +def opening_an_entry_keeps_what_was_selected(): + """The rows renumber under it exactly as they do under `z`, so both + ends of a region travel as offsets — a region that quietly became a + different set of lines is the copy that takes the wrong thing.""" + r = tv.Records(answer(("one", {"offset": 0}), + ("boom\n at A\n at B", {"offset": 100}), + ("three", {"offset": 900}))) + v = tv.View(Fake(["x\n"]), source=r) + v.join_entries() + v.cur = 0 + v.set_mark() + v.move(1) # "one", then the trace + whole = "one\nboom\n at A\n at B\n" + assert v.selection()[0] == whole, v.selection() + + v.open_entry() + assert v.region() == (0, 3), v.region() + assert v.selection()[0] == whole, v.selection() + v.open_entry() + assert v.region() == (0, 1) and v.selection()[0] == whole, v.selection() + + @test def the_join_survives_the_view_changing_store(): """A hit in another store replaces the source outright. A display diff --git a/tools/README.md b/tools/README.md index 56c74ea..058c856 100644 --- a/tools/README.md +++ b/tools/README.md @@ -416,6 +416,15 @@ match. - **A multi-line entry is one entry.** The lines of a stack trace belong to the entry that raised it; splitting them would be the same lie as splitting a line across a chunk boundary. +- **Ten of them is four hundred lines**, and every one pushes the next + entry off the screen — which leaves no screen for the thing you are + doing, deciding whether these ten are the same failure. `z` renders + each entry as ONE row, the trace trailing off to the right of the + message that raised it, and `Z` opens the one you have picked out back + into its own lines while the other nine stay rows. Nothing is hidden + either way: a joined row carries every line of the entry, `h`/`l` read + along it, and the search, the terms and the entry motion keep working + because each row is a real line with a real address. - **An answer is a closed set.** Both ends are ends, nothing extends, and the boundary says "end of the answer" rather than naming a chunk you are not in. diff --git a/tools/timberview.py b/tools/timberview.py index f7344ec..0dd2897 100644 --- a/tools/timberview.py +++ b/tools/timberview.py @@ -1219,7 +1219,11 @@ class Joined: A DECORATOR rather than a mode inside each source: `Tape` and `Records` both have entries in this sense, the view swaps its source at runtime (a hit in another store), and neither of them should learn - about a display option.""" + about a display option. + + An entry can be OPENED out of the join and rendered as its own lines + again — the one of the ten you have decided to read, while the other + nine stay rows to come back to.""" SEP = " ↵ " @@ -1228,6 +1232,15 @@ def __init__(self, source): self._n = None self._lines = [] self._runs = [] + # Which entry each row belongs to, and which entries are open — + # both by ORDINAL, the nth entry of the source, rather than by + # offset: an entry still at a live edge has no offset yet and + # every one of them reports 0, so a set of offsets would open all + # of them together. An answer is a closed set, so the nth entry + # stays the nth — and the tape, where the run does renumber, has + # no multi-line entry to open. + self._of = [] + self._open = set() def __getattr__(self, name): # Everything not about lines is the source's, including the @@ -1258,18 +1271,32 @@ def rows_as_lines(self, lo, hi): return out def _rebuild(self): - out, runs, run = [], [], [] + out, runs, of, run = [], [], [], [] + n = 0 def flush(): + nonlocal n if not run: return - head = run[0] - ln = Line(head.offset, b"") - ln.text = head.text + "".join( - self.SEP + x.text.lstrip() for x in run[1:]) - ln.at, ln.store, ln.wf, ln.first = head.at, head.store, head.wf, True - out.append(ln) - runs.append(list(run)) + if n in self._open: + # The source's OWN Line objects, so the framing on them is + # the entry framing again: the entry motion steps over the + # trace and a bare `c` takes the whole of it, exactly as + # they do with no join at all. + out.extend(run) + runs.extend([ln] for ln in run) + of.extend([n] * len(run)) + else: + head = run[0] + ln = Line(head.offset, b"") + ln.text = head.text + "".join( + self.SEP + x.text.lstrip() for x in run[1:]) + ln.at, ln.store, ln.wf, ln.first = \ + head.at, head.store, head.wf, True + out.append(ln) + runs.append(list(run)) + of.append(n) + n += 1 for line in self.source.lines: if getattr(line, "first", True) and run: @@ -1277,9 +1304,30 @@ def flush(): run = [] run.append(line) flush() - self._lines, self._runs = out, runs + self._lines, self._runs, self._of = out, runs, of self._n = len(self.source.lines) + def open_row(self, row): + """Open the entry rendered at `row` out into its own lines, or + join it again. Whether it is now open, or None where there is + nothing folded into it. + + Any row of an open entry closes it, not just its first: what you + are pointing at is the entry, and having to walk back to its head + to put it away would be a coordinate the reader has to keep.""" + self._fresh() + if not 0 <= row < len(self._of): + return None + n = self._of[row] + if n in self._open: + self._open.discard(n) + elif len(self._runs[row]) < 2: + return None + else: + self._open.add(n) + self._rebuild() + return n in self._open + # An offset lands on the ENTRY holding it, which is the row it is now # part of. The base implementations would scan the source's lines and # answer with an index into the wrong list. @@ -1377,21 +1425,19 @@ def set_window(self, text): self.source.window = (lo, hi) self.message = f"window {when_ms(lo)} .. {when_ms(hi)}" - def join_entries(self, on=None): - """Show each multi-line entry as one row, or stop. + def _rerender(self, change): + """A change to how the lines are RENDERED, keeping the place and + the selection. What `change` returned comes back. - The line under the cursor is kept: its offset addresses the ENTRY - either way, so the same entry is under you before and after, and - a toggle is not also a jump.""" + Rows renumber when entries join or open; offsets do not, so the + cursor and both ends of a region travel as offsets: a display + toggle must not also be a jump, nor change what a copy takes.""" where = self.line() lines = self.source.lines - # Both ends of the region travel as OFFSETS, for the reason the - # cursor does: a display toggle must not change what is selected. span = self.region() ends = (lines[span[0]].offset, lines[span[1]].offset) if span else None mark_low = span is not None and self.mark <= self.cur - self.join = (not self.join) if on is None else on - self.source = self._source # re-wrap, or unwrap + got = change() if where is not None: self.cur = self.source.index_of(where.offset) self.top = min(self.top, self.cur) @@ -1408,8 +1454,44 @@ def join_entries(self, on=None): hi = self.entry_span(at=self.source.index_of(ends[1]))[1] self.mark, self.cur = (lo, hi) if mark_low else (hi, lo) self.tok, self.col = 0, 0 - self.message = ("multi-line entries as one row" if self.join - else "entries as they are written") + return got + + def join_entries(self, on=None): + """Show each multi-line entry as one row, or stop. + + The line under the cursor is kept: its offset addresses the ENTRY + either way, so the same entry is under you before and after, and + a toggle is not also a jump.""" + def flip(): + self.join = (not self.join) if on is None else on + self.source = self._source # re-wrap, or unwrap + self._rerender(flip) + self.message = ("multi-line entries as one row · Z opens the one you " + "are on" + if self.join else "entries as they are written") + + def open_entry(self): + """THIS entry as its own lines, inside a screen that is otherwise + joined — or joined again. + + `z` is the whole answer at once, which is what makes ten traces + comparable; this is the one of them you have decided to read. + Several can be open, because comparing two is the next thing + after picking them out of the ten — and `z` builds the rendering + afresh, so setting the whole screen again clears them.""" + if self._joined is None: + self.message = ("nothing is joined — `z` shows each multi-line " + "entry as one row") + return + if not self.source.lines: + self.message = "nothing here to open" + return + state = self._rerender(lambda: self._joined.open_row(self.cur)) + if state is None: + self.message = "this entry is one line — nothing is folded into it" + else: + self.message = ("this entry as its own lines · Z joins it again" + if state else "this entry as one row again") # -- opening def leave_for_the_log(self): @@ -2037,6 +2119,10 @@ def status(self): z a multi-line entry as ONE row — a stack trace beside the message that raised it, so ten of them can be compared at all. Nothing is hidden: h/l read along it + Z THIS entry back out of the join, as its own lines — the + one of the ten you picked. Again joins it, from any of + its lines; several can be open at once, and z sets the + whole screen again t the investigation's window — the tape stops at it rather than scrolling out of the period you are looking at. `from T to T`, or `none` @@ -2236,6 +2322,8 @@ def step(self, stdscr, key): self.set_window(stdscr) elif key == ord("z"): v.join_entries() + elif key == ord("Z"): + v.open_entry() elif key == ord("w"): v.toggle_wrap() elif key == KEY_TAB: