aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--idatui/app.py123
-rw-r--r--idatui/domain.py22
-rw-r--r--tests/test_scenarios.py61
4 files changed, 206 insertions, 2 deletions
diff --git a/README.md b/README.md
index 3eb6c60..ee42e52 100644
--- a/README.md
+++ b/README.md
@@ -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",