aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--idatui/app.py68
-rw-r--r--idatui/launch.py16
-rw-r--r--idatui/project.py26
-rw-r--r--tests/test_project.py35
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)