diff options
| author | Duncan Ogilvie <mr.exodia.tpodt@gmail.com> | 2026-08-17 15:22:09 +0200 |
|---|---|---|
| committer | Duncan Ogilvie <mr.exodia.tpodt@gmail.com> | 2026-08-17 15:22:09 +0200 |
| commit | 10142605e0137fc27b542646c884d2587c6dcf12 (patch) | |
| tree | 20fb76771b9687b2bc977a1dac9e6be191790ec4 | |
| parent | Add kitty graphics fallback for Windows support (diff) | |
| download | ida-tui-10142605e0137fc27b542646c884d2587c6dcf12.tar.gz ida-tui-10142605e0137fc27b542646c884d2587c6dcf12.tar.xz ida-tui-10142605e0137fc27b542646c884d2587c6dcf12.zip | |
Add Ctrl+R to refresh all views
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | idatui/app.py | 123 | ||||
| -rw-r--r-- | idatui/domain.py | 22 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 61 |
4 files changed, 206 insertions, 2 deletions
@@ -76,7 +76,7 @@ Thumb entry points. | `o` `O` `B` | cycle this literal's format · reverse · opcode bytes | | `\` `"` `ctrl+t` | hex · strings · structs | | `ctrl+n` `ctrl+p` | symbol palette · command palette | -| `ctrl+s` `ctrl+l` `q` | save · reload as… · quit | +| `ctrl+r` `ctrl+s` `ctrl+l` `q` | refresh view · save · reload as… · quit | | `F1` | all of them | ## What's in it diff --git a/idatui/app.py b/idatui/app.py index 3f67756..d7212b1 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -3695,6 +3695,7 @@ _HELP = ( ("Ctrl+B", "show/hide the names pane"), ("Ctrl+T", "structs / types editor"), ("Ctrl+F", "search the database: text or bytes"), + ("Ctrl+R", "refresh the current view in place"), ("Ctrl+E", "export findings as markdown"), ("Ctrl+P", "command palette"), )), @@ -5164,6 +5165,7 @@ class IdaTui(App): Binding("ctrl+n", "symbols", "Symbols"), Binding("ctrl+t", "structs", "Structs"), Binding("ctrl+f", "find", "Find"), + Binding("ctrl+r", "refresh_view", "Refresh", show=False), Binding("ctrl+e", "export", "Export", show=False), Binding("backslash", "hex", "Hex"), Binding("s", "toggle_split", "Split", show=False), @@ -6317,6 +6319,127 @@ class IdaTui(App): return self._goto_ea(addr, push=True) # land on the literal in the listing + def action_refresh_view(self) -> None: + """Ctrl+R: discard cached data and reload the visible view in place.""" + if self.program is None or self._cur is None: + self._status("nothing to refresh") + return + if self._prompt_active() or self.screen is not self.screen_stack[0]: + return + + if self.is_hex: + hx = self.query_one(HexView) + if hx.model is None: + self._status("hex: nothing to refresh") + return + self._status("hex — refreshing…") + hx.model.invalidate() + # Re-read the visible blocks off the UI thread. ``center=False`` + # preserves both the byte cursor and the viewport. + hx._prime(center=False) + return + + cur = self._cur + mode = self._active + split = self._split + listing_anchor = None + if self.is_listing or split: + # _anchor() follows the active pane. In split mode pseudocode may be + # active, but the listing must still round-trip through addresses: + # row indices do not survive an external structure change. + lst = self.query_one(ListingView) + listing_anchor = ViewAnchor(view=ViewMode.LISTING, + cursor_x=lst.cursor_x) + model = lst.model + if model is not None: + listing_anchor.ea = lst._cursor_ea() + top = round(lst.scroll_offset.y) + h = model.cached_line(top) or model.get(top) + listing_anchor.top_ea = getattr(h, "ea", None) + + refresh_decomp = self.is_decomp or split + if refresh_decomp: + # Keep the live pseudocode position; NavEntry is only updated when + # navigating away and may lag behind the widget. + dec = self.query_one(DecompView) + if dec.loaded_ea == cur.ea: + cur.dec_cursor = dec.cursor + cur.dec_cursor_x = dec.cursor_x + cur.dec_scroll_y = round(dec.scroll_offset.y) + cur.dec_scroll_x = round(dec.scroll_offset.x) + dec.loading = True + + want_ea = (self.query_one(GraphView)._cursor_ea() + if self.is_graph else None) + if self.is_graph: + self._graph_sticky = True + self._status(f"{cur.name} — refreshing graph…") + else: + self._status(f"{cur.name} — refreshing…") + self._refresh_view(cur, mode, split, listing_anchor, + refresh_decomp, want_ea, self.program) + + @work(thread=True, exclusive=True, group="refresh-view") + def _refresh_view(self, cur: NavEntry, mode: ViewMode, split: bool, + anchor: ViewAnchor | None, refresh_decomp: bool, + want_ea: int | None, program) -> None: # type: ignore[no-untyped-def] + """Invalidate and rebuild without blocking Textual's event loop.""" + try: + program.bump_items() + if refresh_decomp: + program.force_recompile(cur.ea) + + model = None + cursor = top = -1 + if anchor is not None: + target = anchor.ea if anchor.ea is not None else cur.ea + model = program.listing(target) + if model is not None: + model.ensure_ea(target) + cursor, top = self._anchor_rows(anchor, model, target) + except Exception as exc: # noqa: BLE001 -- a refresh is recoverable + diag.note("refresh_view", exc) + self.app.call_from_thread( + self._view_refresh_failed, cur, program, str(exc)) + return + self.app.call_from_thread( + self._apply_view_refresh, cur, mode, split, anchor, + refresh_decomp, want_ea, program, model, cursor, top) + + def _view_refresh_failed(self, cur: NavEntry, program, error: str) -> None: # type: ignore[no-untyped-def] + if self.program is program and self._cur is cur: + self.query_one(DecompView).loading = False + self._status(f"refresh failed: {error}", priority=True) + + def _apply_view_refresh(self, cur: NavEntry, mode: ViewMode, split: bool, + anchor: ViewAnchor | None, refresh_decomp: bool, + want_ea: int | None, program, model, cursor: int, + top: int) -> None: # type: ignore[no-untyped-def] + # A binary switch or navigation completed while the refresh was in + # flight. Its newer view wins; never drag the user back. + if self.program is not program or self._cur is not cur: + return + self._active = mode + self._split = split + + if self.is_graph: + self._load_graph(cur.ea, want_ea) + return + + if anchor is not None and model is not None: + lst = self.query_one(ListingView) + cur.cursor = max(cursor, 0) + cur.cursor_x = anchor.cursor_x + cur.scroll_y = top + lst.load(model, cur.name, cursor=cur.cursor, + cursor_x=cur.cursor_x, + scroll_y=top if top >= 0 else None) + if refresh_decomp: + self.query_one(DecompView).loaded_ea = None + self._show_active() + if not refresh_decomp: + self._status(f"{cur.name} — refreshed", priority=True) + def action_toggle_view(self) -> None: """Tab: switch the code pane between disassembly and pseudocode (or leave the hex view back to the preferred code view).""" diff --git a/idatui/domain.py b/idatui/domain.py index 9690a65..699f33d 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1389,6 +1389,11 @@ class HexModel: for b in range(b0 - 1, b1 + 2): self._prefetch(b) + def invalidate(self) -> None: + """Drop cached bytes so the next viewport read reaches the database.""" + with self._lock: + self._blocks.clear() + # --------------------------------------------------------------------------- # # Program: top-level handle, model registry, prefetch pool @@ -1714,6 +1719,23 @@ class Program: return m # -- decompilation ----------------------------------------------------- # + def force_recompile(self, ea: int) -> None: + """Drop local and Hex-Rays caches before an explicit view refresh. + + Normal edit paths use generation-based invalidation. Ctrl+R is also for + changes made by another Code Mode/IDA client, for which this Program has + seen no generation bump, so it must explicitly ask Hex-Rays to discard + its cached cfunc. + """ + with self._lock: + self._decomp.pop(ea, None) + self._pc_nums.pop(ea, None) + self._decomp_maps.pop(ea, None) + try: + self.client.invoke("force_recompile", items=[{"addr": hex(ea)}]) + except Exception: # noqa: BLE001 -- refresh still refetches best-effort + pass + def decompile(self, ea: int, refresh: bool = False) -> Decompilation: """Full pseudocode for a function, returned directly by Code Mode.""" if not refresh: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index c08dc96..2acb5ba 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -935,7 +935,8 @@ async def s_help(c: Ctx): c.check("each key group gets its own card", titles == {t for t, _ in _HELP}, f"{titles}") c.check("it documents real bindings", - "set type" in txt and "split view" in txt and "cross-references" in txt) + "set type" in txt and "split view" in txt and "cross-references" in txt + and "refresh the current view" in txt) c.check("the graph keys are documented", "control-flow graph" in txt and "minimap" in txt) body = app.screen.query_one("#help-body") @@ -1048,6 +1049,45 @@ async def s_view_modes_all_handled(c: Ctx): await c.open(fn.addr, "listing") +@scenario("refresh_view") +async def s_refresh_view(c: Ctx): + """Ctrl+R replaces stale backing data without moving the listing.""" + fn = await c.open_biggest("listing") + await c.press("down", "down", "down") + lst = c.lst + old_model = lst.model + old_ea = lst._cursor_ea() + old_top = round(lst.scroll_offset.y) + old_head = old_model.get(old_top) if old_model is not None else None + old_top_ea = getattr(old_head, "ea", None) + + await c.press("ctrl+r") + landed = await c.wait( + lambda: lst.model is not old_model and lst._cursor_ea() == old_ea, 25) + c.check("Ctrl+R rebuilds the listing model", lst.model is not old_model) + c.check("Ctrl+R preserves the cursor address", landed, + f"got={lst._cursor_ea()} want={old_ea}") + new_top = round(lst.scroll_offset.y) + new_head = lst.model.get(new_top) if lst.model is not None else None + c.check("Ctrl+R preserves the viewport by address", + getattr(new_head, "ea", None) == old_top_ea, + f"got={getattr(new_head, 'ea', None)} want={old_top_ea}") + + await c.open(fn.addr, "decomp") + dec = c.dec + dec.cursor = min(3, max(len(dec._texts) - 1, 0)) + old_dec_cursor = dec.cursor + await c.press("ctrl+r") + refreshed = await c.wait( + lambda: c.app.is_decomp and dec.loaded_ea == fn.addr and not dec.loading, + 25) + c.check("Ctrl+R reloads pseudocode without changing views", refreshed, + f"active={c.app._active} loaded={dec.loaded_ea} want={fn.addr:#x}") + c.check("Ctrl+R preserves the pseudocode cursor", + dec.cursor == old_dec_cursor, + f"got={dec.cursor} want={old_dec_cursor}") + + @scenario("split_view") async def s_split_view(c: Ctx): app, lst, dec = c.app, c.lst, c.dec @@ -1792,6 +1832,16 @@ async def s_hex(c: Ctx): c.check("hex shows the actual byte at that address", rb is not None and rb[hx.cursor % 16] == want[0], f"got={rb[hx.cursor % 16] if rb else None} want={want[0]}") + old_va = hx.cursor_va() + block = (old_va - hx.model.start) // hx.model.BLOCK + old_bytes = hx.model._blocks.get(block) + await c.press("ctrl+r") + reloaded = await c.wait( + lambda: hx.model._blocks.get(block) is not None + and hx.model._blocks.get(block) is not old_bytes, 20) + c.check("Ctrl+R refetches the visible hex block", reloaded) + c.check("Ctrl+R preserves the hex cursor", hx.cursor_va() == old_va, + f"got={hx.cursor_va():#x} want={old_va:#x}") await c.press("l") await c.pause(0.1) c.check("hex cursor steps one byte", hx.cursor_va() == code_ea + 1, @@ -3691,6 +3741,15 @@ async def s_graph_open(c: Ctx): for i, a in enumerate(boxes) for b in boxes[i + 1:]) c.check("no two blocks overlap", not overlap) c.check("the status names the graph", "graph" in c.status(), c.status()) + old_fc = gv.fc + old_ea = gv._cursor_ea() + await c.press("ctrl+r") + rebuilt = await c.wait( + lambda: app.is_graph and gv.fc is not None and gv.fc is not old_fc + and gv.lay is not None, 30) + c.check("Ctrl+R rebuilds the graph", rebuilt) + c.check("Ctrl+R preserves the graph cursor", gv._cursor_ea() == old_ea, + f"got={gv._cursor_ea()} want={old_ea}") await c.press("space") await c.wait(lambda: app._active != "graph", 15) c.check("space returns to the listing", app._active == "listing", |
