diff options
| author | blasty <blasty@local> | 2026-07-09 15:36:12 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 15:36:12 +0200 |
| commit | 6a8e022441c973bbdfb1c4ee0f55373ebf1d1456 (patch) | |
| tree | 6075f908ee4c7d0d02aa0a86ca09332a9057cbda | |
| parent | paging: preserve the cursor's viewport-relative row (IDA-style) (diff) | |
| download | ida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.tar.gz ida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.tar.xz ida-tui-6a8e022441c973bbdfb1c4ee0f55373ebf1d1456.zip | |
fix input visibility (search/rename) + refresh stale names across history
- Visibility: #search/#rename/#status were dock:bottom like the Footer and got
placed on the SAME row (y=39) as the Footer, which drew over them, so the
typed text was invisible. Drop the docking; they now sit in normal flow just
above the Footer (y=38). Verified: input region is above the footer.
- Stale names on 'back': a rename only invalidated the current function, so
popping back to a caller (cached earlier) showed the old name. Add
Program.bump_names(): a rename bumps a name-generation, clears all disasm block
caches (disasm names are live), and decompilation is generation-checked and
force-recompiled lazily on next view. _after_rename now invalidates globally.
- verified: rename a callee, 'back' to the caller shows the new name.
- pilot suite 51/51.
| -rw-r--r-- | TODO | 5 | ||||
| -rw-r--r-- | idatui/app.py | 24 | ||||
| -rw-r--r-- | idatui/domain.py | 28 | ||||
| -rw-r--r-- | tests/test_tui.py | 70 |
4 files changed, 106 insertions, 21 deletions
@@ -1,4 +1,5 @@ [ ] RPC endpoint for robot-spectator-ida -[ ] page up/down cursor x/y preserve -[ ] hlsearch should jump cursor +[ ] rename -> history stack pop -> old name +[x] page up/down cursor x/y preserve +[x] hlsearch should jump cursor [x] makes names pane sortable (addr/name columns) diff --git a/idatui/app.py b/idatui/app.py index 87420c2..3dfc553 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -951,14 +951,14 @@ class IdaTui(App): DisasmView { width: 1fr; padding: 0 1; } DecompView { width: 1fr; } #search { - dock: bottom; height: 1; border: none; padding: 0 1; + height: 1; border: none; padding: 0 1; background: $primary-darken-2; color: $text; } #rename { - dock: bottom; height: 1; border: none; padding: 0 1; + height: 1; border: none; padding: 0 1; background: $warning-darken-2; color: $text; } - #status { dock: bottom; height: 1; background: $panel; color: $text; padding: 0 1; } + #status { height: 1; background: $panel; color: $text; padding: 0 1; } XrefsScreen { align: center middle; } #xref-box { width: 84; max-height: 70%; height: auto; border: thick $accent; background: $panel; } #xref-title { dock: top; height: 1; background: $accent; color: $text; padding: 0 1; } @@ -1449,8 +1449,13 @@ class IdaTui(App): def _after_rename(self, kind: str, addr: int | None, old: str, new: str) -> None: cur = self._cur + # A renamed symbol can appear in many functions, so invalidate globally; + # each function refreshes its names the next time it's viewed. + self.program.bump_names() if cur is not None: - self._invalidate_and_reload(cur) + self._save_current_pos() + self.query_one(DecompView).loaded_ea = None # force pseudocode reload + self._open_entry(cur, push=False) if kind == "func" and addr is not None and self._func_index is not None: self._func_index.update_name(addr, new) for e in self._nav: @@ -1467,17 +1472,6 @@ class IdaTui(App): self._dirty = True self._status(f"renamed {old} → {new} (Ctrl+S to save)") - def _invalidate_and_reload(self, cur: NavEntry) -> None: - assert self.program is not None - try: - self.program.client.call("force_recompile", addr=hex(cur.ea)) - except Exception: # noqa: BLE001 - pass - self.program.invalidate(cur.ea) - self._save_current_pos() # capture cursor + scroll before reloading - self.query_one(DecompView).loaded_ea = None # force pseudocode reload - self._open_entry(cur, push=False) - @work(thread=True, exclusive=True, group="save") def _save(self) -> None: assert self.program is not None diff --git a/idatui/domain.py b/idatui/domain.py index b219bbe..1f52be6 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -374,7 +374,8 @@ class Program: ) self._indices: dict[str | None, FunctionIndex] = {} self._disasm: dict[int, DisasmModel] = {} - self._decomp: dict[int, Decompilation] = {} + self._decomp: dict[int, tuple[Decompilation, int]] = {} + self._name_gen = 0 # bumped on rename; invalidates stale name caches self._lock = threading.Lock() # -- prefetch plumbing ------------------------------------------------- # @@ -417,8 +418,17 @@ class Program: if not refresh: with self._lock: hit = self._decomp.get(ea) - if hit is not None: - return hit + gen = self._name_gen + if hit is not None: + dec, hit_gen = hit + if hit_gen == gen: + return dec + # Cached before a rename: names may be stale. Drop the server's + # Hex-Rays cache so the refetch reflects the new names. + try: + self.client.call("force_recompile", addr=hex(ea)) + except Exception: # noqa: BLE001 + pass envelope = self.client.call_envelope("decompile", addr=hex(ea)) result = envelope.get("result", {}) payload = result.get("structuredContent") @@ -431,9 +441,19 @@ class Program: payload = full dec = _parse_decompilation(ea, payload) with self._lock: - self._decomp[ea] = dec + self._decomp[ea] = (dec, self._name_gen) return dec + def bump_names(self) -> None: + """Signal that symbol names changed (a rename). Disasm names are live in + the IDB, so clearing the block caches is enough for those; decompilation + is generation-checked and force-recompiled lazily on next access.""" + with self._lock: + self._name_gen += 1 + models = list(self._disasm.values()) + for m in models: + m.invalidate() + @staticmethod def _fetch_output(url: str, timeout: float = 15.0): """GET the server's cached full-output blob (plain HTTP, not MCP).""" diff --git a/tests/test_tui.py b/tests/test_tui.py index 47c5a67..3b45ef9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -195,6 +195,11 @@ async def run(db): await pilot.pause(0.2) check("search bar visible, status hidden (no overlap)", si.display and not status.display, f"si={si.display} status={status.display}") + from textual.widgets import Footer as _Footer + footer = app.query_one(_Footer) + check("search input is rendered above the footer (not overlapping)", + si.region.height >= 1 and si.region.y < footer.region.y, + f"search={si.region} footer={footer.region}") for ch in term: await pilot.press(ch) await pilot.pause(0.1) @@ -522,6 +527,71 @@ async def run(db): dis.cursor - round(dis.scroll_offset.y) == rel, f"rel={dis.cursor - round(dis.scroll_offset.y)} want={rel}") + # Rename refresh across history: rename a callee while viewing it, then + # 'back' to the caller (cached earlier) shows the new name. + table.focus() + table.move_cursor(row=biggest_i) + await pilot.press("enter") + await wait_until(pilot, lambda: dis.total > 0, timeout=20) + dis.focus() + await pilot.pause(0.2) + hrow = hsym = None + for _ in range(40): + top = round(dis.scroll_offset.y) + dis.model.lines(top, dis.size.height + 2, prefetch=False) + for r in range(min(dis.size.height, dis.total - top)): + p = dis._line_plain(top + r) + if p and "call sub_" in p: + mm = re.search(r"\bcall (sub_[0-9A-Fa-f]+)", p) + if mm: + hrow, hsym = top + r, mm.group(1) + break + if hrow is not None: + break + await pilot.press("pagedown") + await pilot.pause(0.05) + if hrow is not None: + htarget = app.program.resolve(hsym) + await pilot.press("g") + await pilot.pause(0.2) + for ch in hsym: + await pilot.press(ch) + await pilot.press("enter") + await wait_until(pilot, lambda: app._cur.ea == htarget, timeout=20) + if app._active != "decomp": + await pilot.press("tab") + await wait_until(pilot, lambda: dec.loaded_ea == htarget, timeout=20) + nx = dec._texts[0].find(hsym) if dec._texts else -1 + if nx >= 0: + dec.focus() + dec.cursor, dec.cursor_x = 0, nx + 1 + dec.refresh() + await pilot.pause(0.1) + hnew = f"h_{os.getpid()}" + await pilot.press("n") + await pilot.pause(0.2) + app.query_one("#rename", Input).value = hnew + await pilot.press("enter") + await wait_until( + pilot, + lambda: app._func_index.by_addr(htarget) + and app._func_index.by_addr(htarget).name == hnew, + timeout=25, + ) + if app._active != "disasm": + await pilot.press("tab") + await pilot.press("escape") + await wait_until( + pilot, lambda: dis.total > 0 and app._cur.ea != htarget, timeout=25) + await pilot.pause(0.4) + dis.model.lines(hrow, 4, prefetch=False) + await pilot.pause(0.2) + hline = dis._line_plain(hrow) + check("caller shows renamed callee after 'back' (cache refresh)", + hline is not None and hnew in hline, f"line={hline!r}") + app.program.client.call( + "rename", batch={"func": {"addr": hex(htarget), "name": hsym}}) + def main(argv): db = None |
