diff options
| -rw-r--r-- | idatui/app.py | 68 | ||||
| -rw-r--r-- | idatui/launch.py | 16 | ||||
| -rw-r--r-- | idatui/project.py | 26 | ||||
| -rw-r--r-- | tests/test_project.py | 35 |
4 files changed, 133 insertions, 12 deletions
diff --git a/idatui/app.py b/idatui/app.py index e96483b..4bba7df 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1574,6 +1574,13 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True else: scroll_x = sx self._apply_scroll(min(max(scroll_y, 0), max(total - 1, 0)), max(scroll_x, 0)) + # `cursor` is reactive(repaint=False) and _apply_scroll only repaints via + # call_after_refresh, so a jump that lands in the SAME viewport (Esc back + # to another spot in the function already on screen) moved the cursor + # with nothing to redraw it — the pane kept showing the old highlight + # until the next keypress. Repaint here; _move does the same via + # _refresh_lines. + self.refresh() self._after_cursor_move() def get_loading_widget(self): # type: ignore[override] @@ -3158,6 +3165,7 @@ class IdaTui(App): self._states: dict[str, BinaryState] = {} self._pending_restore = None # entry to reopen after a switch self._goto_after_switch = None # cross-binary search hit to land on + self._nav_seq = 0 # bumped per navigation; drops stale ones # None = teardown wasn't an explicit quit (crash/kill): save defensively. # False = the user chose discard, or we already saved on the way out. self._save_on_exit: bool | None = None @@ -3871,6 +3879,28 @@ class IdaTui(App): return self._goto_ea(addr, push=True) # land on the literal in the listing + def on_descendant_focus(self, event) -> None: # type: ignore[no-untyped-def] + """Keep ``_active`` in step with focus while split. + + Tab moves both together, but focus also moves on its own — a click, or a + pane focusing itself after a load — and then ``_active`` still names the + pane you're NOT in. Everything downstream trusts ``_active``: follow + resolves the word under that pane's cursor and pushes history for it, so + Enter in the pseudocode would follow something from the listing and the + next Esc got spent undoing it. + """ + if not self._split: + return + w = self.focused + mode = ("decomp" if isinstance(w, DecompView) + else "listing" if isinstance(w, ListingView) else None) + if mode is None or mode == self._active: + return + self._active = mode + self._sync_split(mode) # re-link the band from the new driver + if not self.query_one(DecompView).loading: + self._status_for_cur("split") # never clobber "decompiling…" + 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).""" @@ -4067,8 +4097,14 @@ class IdaTui(App): self.clear_filter() # Esc on the list clears an active filter return if len(self._nav) > 1: + self._nav_seq += 1 # invalidate any navigation still in flight self._nav.pop() - self._open_entry(self._nav[-1], push=False) + # Say something immediately: going back can need a decompile, and + # until it lands the panes still show where you were — with no + # feedback Esc reads as a no-op. + dest = self._nav[-1] + self._status(f"\u25c2 back to {dest.name}\u2026") + self._open_entry(dest, push=False) elif self.query_one("#left", FunctionsPanel).display: table.focus() else: @@ -4867,6 +4903,10 @@ class IdaTui(App): def _do_navigate(self, ea: int, push: bool, focus_name: str | None = None, prefer_decomp: bool = False) -> None: # worker context assert self.program is not None + # Which navigation this is. Decompiling below can take a while, and if + # you press Esc (or jump again) in the meantime this result is stale — + # applying it anyway silently undoes what you just did. + seq = self._nav_seq fn = self.program.function_of(ea) # Jumping FROM the decompiler: stay in pseudocode when the target is a # decompilable function, landing on the line that matches ``ea``. @@ -4886,7 +4926,8 @@ class IdaTui(App): dec_idx, col = self._decomp_locate(fn.addr, ea, focus_name or fn.name) self.app.call_from_thread( - self._open_decomp_entry, fn.addr, fn.name, dec_idx, col, push) + self._open_decomp_entry, fn.addr, fn.name, dec_idx, col, + push, seq) return # Otherwise: everything opens the one continuous listing at ``ea``. A # function name is used for the status label; a region gets a segment @@ -4898,9 +4939,17 @@ class IdaTui(App): self._open_at, ea, name, idx, push, -1, 0, fn is None, focus_name) def _open_decomp_entry(self, fn_addr: int, fn_name: str, dec_idx: int, - dec_cursor_x: int, push: bool) -> None: + dec_cursor_x: int, push: bool, + seq: int | None = None) -> None: """Open ``fn_addr`` in the decompiler as a real navigation (nav history - aware), landing on pseudocode line ``dec_idx`` column ``dec_cursor_x``.""" + aware), landing on pseudocode line ``dec_idx`` column ``dec_cursor_x``. + + ``seq`` is the navigation this result belongs to; if the user has + navigated since (Esc, another jump), the result is stale and dropped — + otherwise a slow decompile lands afterwards and undoes their Esc. + """ + if seq is not None and seq != self._nav_seq: + return if push: # Snapshot where we jumped from so 'back' returns there. If that was # the pseudocode (F5 makes a transient _cur not yet on the stack), @@ -5305,14 +5354,21 @@ class IdaTui(App): hx.display = False lst.display = dec.display = True self.query_one("#panes").set_class(True, "split") - if self._cur is not None and dec.loaded_ea != self._cur.ea: + busy = self._cur is not None and dec.loaded_ea != self._cur.ea + if busy: dec.loading = True self._load_decomp(self._cur.ea, self._cur.name) else: dec.loading = False if grab: (dec if self._active == "decomp" else lst).focus() - self._status_for_cur("split") + if busy and self._cur is not None: + # Keep the in-flight message: the idle status used to overwrite + # it, so travelling history in split showed nothing at all while + # a decompile ran and Esc looked like a no-op. + self._status(f"{self._cur.name} \u2014 decompiling\u2026") + else: + self._status_for_cur("split") return self._split = False # a single-view target (hex, etc.) leaves split self.query_one("#panes").set_class(False, "split") diff --git a/idatui/launch.py b/idatui/launch.py index 0ae63a1..9618668 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -67,10 +67,18 @@ def main(argv: list[str] | None = None) -> int: try: if os.path.isfile(ppath): project = Project.load(ppath) - for b in args.binary: # extend an existing project - project.add(b) - if args.binary: - project.save() + if args.binary: # extend an existing project, skipping repeats + before = len(project.refs) + for b in args.binary: + project.add(b) + added = len(project.refs) - before + dupes = len(args.binary) - added + if added: + project.save() + _log(f"added {added} binary(ies) to {ppath}") + if dupes: + _log(f"{dupes} already in the project (matched by path) " + f"— left alone") elif args.binary: project = Project.create(ppath, args.binary) _log(f"created project {ppath} with {len(project.refs)} binaries") diff --git a/idatui/project.py b/idatui/project.py index 2d417df..ac4f033 100644 --- a/idatui/project.py +++ b/idatui/project.py @@ -123,8 +123,14 @@ class Project: """Write a new project file listing ``binaries`` (an ad-hoc project).""" if not binaries: raise ProjectError("a project needs at least one binary") - entries = [{"path": os.path.abspath(os.path.expanduser(b))} - for b in binaries] + entries, seen = [], set() + for b in binaries: # the same file twice on one command line is a typo + p = os.path.abspath(os.path.expanduser(b)) + key = os.path.realpath(p) + if key in seen: + continue + seen.add(key) + entries.append({"path": p}) path = os.path.abspath(os.path.expanduser(path)) proj = cls(path, name or os.path.splitext(os.path.basename(path))[0], entries, memory_pct) @@ -188,7 +194,23 @@ class Project: def by_label(self, label: str) -> BinaryRef | None: return next((r for r in self._refs if r.label == label), None) + def by_source(self, binary: str) -> BinaryRef | None: + """The entry for ``binary``, matched by resolved path. + + Identity is the real path, not the file name: a project can legitimately + hold two different ``foo.elf`` from different directories (the labels + disambiguate them), but the same file must not be listed twice — and + ``./a.elf``, ``/abs/a.elf`` and a symlink to it are all the same file. + """ + key = os.path.realpath(os.path.abspath(os.path.expanduser(binary))) + return next((r for r in self._refs + if os.path.realpath(r.source) == key), None) + def add(self, binary: str, label: str | None = None) -> BinaryRef: + """Add a binary, or return the existing entry if it's already here.""" + existing = self.by_source(binary) + if existing is not None: + return existing entry = {"path": os.path.abspath(os.path.expanduser(binary))} if label: entry["label"] = label diff --git a/tests/test_project.py b/tests/test_project.py index 1f93d4a..be46875 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -124,6 +124,41 @@ def main() -> int: extra = _bin(os.path.join(src, "extra")) proj2.add(extra, label="extra") check("add() appends a binary", proj2.by_label("extra") is not None) + + # -- re-adding must not duplicate (matched by resolved path) --------- # + n = len(proj2.refs) + proj2.add(extra) + check("re-adding the same path is a no-op", len(proj2.refs) == n, + f"{[r.label for r in proj2.refs]}") + os.chdir(src) + proj2.add("./extra") # same file, relative + proj2.add(os.path.join(src, "..", "src", "extra")) # same file, messy + check("a different spelling of the same path is a no-op", + len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + link = os.path.join(src, "extra_link") + os.symlink(extra, link) + proj2.add(link) + check("a symlink to an existing binary is a no-op", + len(proj2.refs) == n, f"{[r.label for r in proj2.refs]}") + + # ...but a DIFFERENT file with the same basename must still be added + other_dir = os.path.join(tmp, "other2") + os.makedirs(other_dir) + twin = _bin(os.path.join(other_dir, "extra"), b"\x7fELF a different extra") + proj2.add(twin) + check("a same-named file from another directory IS added", + len(proj2.refs) == n + 1 + and proj2.by_source(twin) is not None + and proj2.by_source(extra) is not proj2.by_source(twin), + f"{[(r.label, r.source) for r in proj2.refs[-2:]]}") + check("the twins get distinct labels", + len({r.label for r in proj2.refs}) == len(proj2.refs), + f"{[r.label for r in proj2.refs]}") + check("create() also drops repeats on the command line", + len(Project.create(os.path.join(tmp, "dup.json"), + [extra, "./extra", extra]).refs) == 1) + os.chdir(tmp) + proj2.remove(proj2.by_source(twin).label) check("remove() drops one", proj2.remove("extra") and proj2.by_label("extra") is None) |
