diff options
| author | blasty <blasty@local> | 2026-07-25 15:55:36 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 15:55:36 +0200 |
| commit | 26272d060d061bbc782959b4b764121b1ea9c779 (patch) | |
| tree | 1384dd518dd0ae7388043e5cc343cf9fb3da6d58 | |
| parent | projects: open the Ctrl+O switcher on the binary you're already in (diff) | |
| download | ida-tui-26272d060d061bbc782959b4b764121b1ea9c779.tar.gz ida-tui-26272d060d061bbc782959b4b764121b1ea9c779.tar.xz ida-tui-26272d060d061bbc782959b4b764121b1ea9c779.zip | |
strings: F2 widens the strings browser to the whole project
Completes phase 2 — the index already carried strings (KIND_STRING), the palette
just didn't offer the toggle. StringsPalette now mirrors SymbolPalette: F2 flips
between this binary and the project, project rows are prefixed with their binary,
and choosing a literal in another binary switches to it and jumps.
Ranking matches the symbol side: the trigram index guarantees the match, so
ordering is earliest-match then shortest, with a (binary, addr) tiebreak — the
same tie that crashed symbol search when two binaries shared a name, avoided here
by construction. Project scope caps at 60 like symbols.
_results is now (binary, addr, text) tuples in both palettes, so _on_string_chosen
takes the same (binary, addr) choice and routes through _switch_then_goto.
test_project_ui.py: local scope stays single-binary, F2 spans >=2 binaries
(23 checks). Suite 191/0.
| -rw-r--r-- | idatui/app.py | 94 | ||||
| -rw-r--r-- | tests/test_project_ui.py | 18 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 4 |
3 files changed, 90 insertions, 26 deletions
diff --git a/idatui/app.py b/idatui/app.py index 9ef0c47..3ef4ac8 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2210,16 +2210,24 @@ class StringsPalette(ModalScreen): Binding("escape", "close", "Close"), Binding("down,ctrl+n", "cursor_down", show=False), Binding("up,ctrl+p", "cursor_up", show=False), + Binding("f2", "scope", "This binary / whole project", show=False), ] LIMIT = 500 + #: Project scope saturates its cap (every binary contributes); keep the list + #: short so arrowing stays snappy. + PROJECT_LIMIT = 60 - def __init__(self, strings: list) -> None: + def __init__(self, strings: list, index=None, binary=None) -> None: super().__init__() # Pre-render + pre-lower once: filtering runs on every keystroke and a # big binary has tens of thousands of strings. self._rows = [(s, d, d.lower()) for s in strings for d in (_str_display(s.text),)] - self._results: list = [] + self._index = index # ProjectIndex, when this is a project + self._binary = binary + self._project_scope = False + #: (binary|None, addr, display text) — binary is None for a local hit + self._results: list[tuple] = [] def compose(self) -> ComposeResult: with Vertical(id="pal-box"): @@ -2240,24 +2248,49 @@ class StringsPalette(ModalScreen): event.stop() self.action_choose() + def action_scope(self) -> None: + if self._index is None: + return # not a project: nothing else to search + self._project_scope = not self._project_scope + self._apply(self.query_one("#pal-input", Input).value.strip()) + def _apply(self, query: str) -> None: q = query.lower() - rows = [] - for s, disp, low in self._rows: - hit = low.find(q) if q else -1 - if q and hit < 0: - continue - rows.append((s, disp, hit)) - if len(rows) >= self.LIMIT: - break - self._results = [s for s, _, _ in rows] + # rows: (binary|None, addr, length, display text, match offset) + if self._project_scope and self._index is not None: + from .index import KIND_STRING + hits = self._index.search(query, kind=KIND_STRING, + limit=self.PROJECT_LIMIT * 3) if query else [] + rows = [] + for h in hits: + disp = _str_display(h.text) + rows.append((h.binary, h.addr, len(h.text), disp, + disp.lower().find(q))) + # the index already guarantees a match, so ranking only orders them: + # earliest match, then shortest, with a stable (binary, addr) tiebreak + # (a literal shared by two binaries would otherwise be unordered). + rows.sort(key=lambda r: (r[4] if r[4] >= 0 else 1 << 30, + r[2], r[0], r[1])) + rows = rows[:self.PROJECT_LIMIT] + else: + rows = [] + for s, disp, low in self._rows: + hit = low.find(q) if q else -1 + if q and hit < 0: + continue + rows.append((None, s.addr, s.length, disp, hit)) + if len(rows) >= self.LIMIT: + break + self._results = [(b, a, d) for b, a, _, d, _ in rows] ol = self.query_one(OptionList) ol.clear_options() opts = [] - for s, disp, hit in rows: + for binary, addr, length, disp, hit in rows: label = Text() - label.append(f"{s.addr:08X} ", _S_ADDR) - label.append(f"{s.length:>5} ", _S_DIM) + if binary: + label.append(f"{binary:<14.14} ", _S_LABEL) + label.append(f"{addr:08X} ", _S_ADDR) + label.append(f"{length:>5} ", _S_DIM) body = Text(disp) if hit >= 0: body.stylize(_S_NAME_MATCH, hit, hit + len(q)) @@ -2266,9 +2299,13 @@ class StringsPalette(ModalScreen): ol.add_options(opts) if self._results: ol.highlighted = 0 - more = "+" if len(rows) == self.LIMIT else "" + scope = "project" if self._project_scope else "this binary" + cap = self.PROJECT_LIMIT if self._project_scope else self.LIMIT + more = "+" if len(rows) == cap else "" + hint = " (F2: this binary)" if self._project_scope else ( + " (F2: whole project)" if self._index is not None else "") self.query_one("#pal-title", Static).update( - f" strings: {len(self._results)}{more} of {len(self._rows)}") + f" strings [{scope}]: {len(self._results)}{more} of {len(self._rows)}{hint}") def action_cursor_down(self) -> None: ol = self.query_one(OptionList) @@ -2284,11 +2321,13 @@ class StringsPalette(ModalScreen): ol = self.query_one(OptionList) i = ol.highlighted if i is not None and 0 <= i < len(self._results): - self.dismiss(self._results[i].addr) + b, a, _ = self._results[i] + self.dismiss((b, a)) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: if 0 <= event.option_index < len(self._results): - self.dismiss(self._results[event.option_index].addr) + b, a, _ = self._results[event.option_index] + self.dismiss((b, a)) def action_close(self) -> None: self.dismiss(None) @@ -3715,8 +3754,8 @@ class IdaTui(App): self._status(f"could not open {label}: {why}") def action_strings(self) -> None: - """'\"' / Shift+F12: browse every string in the binary (filterable); - Enter jumps to it in the unified listing.""" + """'\"' / Shift+F12: browse every string in the binary (filterable, F2 + widens to the whole project); Enter jumps to it in the unified listing.""" if self.program is None: self._status("not connected yet") return @@ -3742,11 +3781,18 @@ class IdaTui(App): self._status("no strings found (needs the list_strings tool)") return self._status(f"strings: {len(items)}") - self.push_screen(StringsPalette(items), self._on_string_chosen) + self.push_screen(StringsPalette(items, index=self._index, + binary=self._binary), + self._on_string_chosen) - def _on_string_chosen(self, addr: int | None) -> None: - if addr is not None: # land on the literal in the unified listing - self._goto_ea(addr, push=True) + def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def] + if choice is None: + return + binary, addr = choice + if binary and binary != self._binary: + self._switch_then_goto(binary, addr) + return + self._goto_ea(addr, push=True) # land on the literal in the listing def action_toggle_view(self) -> None: """Tab: switch the code pane between disassembly and pseudocode (or leave diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py index e3bfb62..731811d 100644 --- a/tests/test_project_ui.py +++ b/tests/test_project_ui.py @@ -168,6 +168,24 @@ async def run(bins): await pilot.press("escape") await pilot.pause(0.2) + # -- and the same toggle for strings --------------------------- # + from idatui.app import StringsPalette + await pilot.press("quotation_mark") + if await settle(lambda: isinstance(app.screen, StringsPalette), 30): + pal = app.screen + pal.query_one(Input).value = "usage" + await pilot.pause(0.3) + local = {b for b, _, _ in pal._results} + await pilot.press("f2") + await pilot.pause(0.4) + wide = {b for b, _, _ in pal._results} + check("strings: local scope is this binary only", local == {None}, + f"{local}") + check("strings: F2 widens across the project", + len(wide) >= 2 and None not in wide, f"{wide}") + await pilot.press("escape") + await pilot.pause(0.2) + # -- the promise: nothing was written next to the sources ---------- # left = sorted(os.listdir(src)) check("the source tree stays pristine (no .i64/scratch beside it)", diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 7c41f1f..49bb6b9 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -424,10 +424,10 @@ async def s_strings(c: Ctx): pal.query_one(Input).value = frag await c.pause(0.2) ok = (pal._results - and all(frag.lower() in s.text.lower() for s in pal._results)) + and all(frag.lower() in t.lower() for _, _, t in pal._results)) c.check("filtering narrows to matching strings", bool(ok), f"frag={frag!r} n={len(pal._results)}") - want = pal._results[0].addr + want = pal._results[0][1] await c.press("enter") await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10) landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20) |
