diff options
| author | blasty <blasty@local> | 2026-07-25 14:04:53 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 14:04:53 +0200 |
| commit | 1f1440285d8caf995abfc49c0849f3ad3000c64e (patch) | |
| tree | 43710761e57eed5a64742039669086b8d954c20f | |
| parent | tests: fix the "filter flake" and the view_toggle cascade — suite is 187/0 (diff) | |
| download | ida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.tar.gz ida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.tar.xz ida-tui-1f1440285d8caf995abfc49c0849f3ad3000c64e.zip | |
exit: ask before quitting with unsaved database changes
Quitting used to be silent AND inconsistent. Single-binary mode closed the worker
with save=False, so renames/types/comments were DROPPED without a word; project
mode did the opposite and saved everything silently via the pool. Neither told
you anything.
Now 'q'/ctrl+q routes through action_quit: clean databases exit immediately, and
anything unsaved raises a QuitScreen naming the affected databases with
s save & quit d discard & quit Esc cancel
Saving happens with an overlay up, because writing a large .i64 takes seconds and
doing it during teardown would look like a hang with no UI left to explain it.
_dirty_labels() covers project mode too: the active binary plus any still-resident
one that was edited. Evicted binaries were already saved on the way out, so they
can't be silently lost.
on_unmount now distinguishes an explicit choice from an unexpected teardown:
_save_on_exit is None (crash/kill -> save defensively, including single-binary
mode which previously discarded), False (user chose discard, or we already saved).
Verified end-to-end across two sessions on a temp copy: rename + 's' -> the rename
is still there on reopen; rename + 'd' -> it is not. Plus a quit_guard scenario
(clean exits immediately, dirty asks, Esc cancels).
Also hardens Ctx.open(view="decomp"): F5/Tab only decompiles from a focused code
pane and the listing may still be settling, so a swallowed Tab surfaced much later
as "pseudocode view shows: active=listing". It now retries instead of assuming the
first Tab takes. Full suite 191/0, green twice in a row.
| -rw-r--r-- | idatui/app.py | 106 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 42 |
2 files changed, 141 insertions, 7 deletions
diff --git a/idatui/app.py b/idatui/app.py index 037e244..9e415ce 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2301,6 +2301,42 @@ _HELP = ( ) +class QuitScreen(ModalScreen): + """Asked before exiting with unsaved database changes. Dismisses with + "save", "discard" or None (stay).""" + + BINDINGS = [ + Binding("s", "save", "Save & quit"), + Binding("d", "discard", "Discard & quit"), + Binding("escape,c", "cancel", "Cancel"), + ] + + def __init__(self, labels: list[str]) -> None: + super().__init__() + self._labels = labels + + def compose(self) -> ComposeResult: + what = (f"{len(self._labels)} databases have unsaved changes" + if len(self._labels) > 1 else "unsaved changes") + with Vertical(id="quit-box"): + yield Static(f"\u26a0 {what}", id="quit-title") + body = Text() + for label in self._labels: + body.append(f" \u2022 {label}\n", _S_LABEL) + yield Static(body, id="quit-list") + yield Static("s save & quit d discard & quit Esc cancel", + id="quit-help") + + def action_save(self) -> None: + self.dismiss("save") + + def action_discard(self) -> None: + self.dismiss("discard") + + def action_cancel(self) -> None: + self.dismiss(None) + + class HelpScreen(ModalScreen): """F1: the keyboard cheatsheet, replacing the permanent footer.""" @@ -2874,6 +2910,11 @@ class IdaTui(App): width: 100%; height: 100%; content-align: center middle; background: $panel-darken-1; } + QuitScreen { align: center middle; } + #quit-box { width: 64; height: auto; border: thick $warning; background: $panel; } + #quit-title { dock: top; height: 1; background: $warning; color: $text; padding: 0 1; } + #quit-list { height: auto; padding: 1 2 0 2; } + #quit-help { height: 1; color: $text-muted; padding: 0 2; margin-top: 1; } HelpScreen { align: center middle; } #help-box { width: 76; max-width: 92%; height: auto; max-height: 85%; border: thick $accent; background: $panel; } @@ -2951,6 +2992,9 @@ class IdaTui(App): self._binary: str | None = None # active project binary (label) self._states: dict[str, BinaryState] = {} self._pending_restore = None # entry to reopen after a switch + # 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 if project is not None: from .pool import WorkerPool self._pool = WorkerPool(project, ttl=ttl) @@ -3418,6 +3462,59 @@ class IdaTui(App): self._open_function(addr, f.name if f else hex(addr)) # -- projects: switching between the binaries of one target ------------ # + # -- exit ---------------------------------------------------------------- # + def _dirty_labels(self) -> list[str]: + """Databases with edits that aren't on disk yet. + + A binary the pool has evicted was saved on the way out, so only the + active one and other still-resident binaries can be dirty. + """ + if self._pool is None: + return [os.path.basename(self._open_path or "database")] if self._dirty else [] + out = [self._binary] if (self._dirty and self._binary) else [] + out += [label for label, st in self._states.items() + if st.dirty and label != self._binary + and self._pool.is_resident(label)] + return out + + async def action_quit(self) -> None: + """Never drop edits on the floor: ask before exiting when a database has + unsaved changes (IDA/Ghidra behaviour).""" + dirty = self._dirty_labels() + if not dirty: + self._save_on_exit = False # nothing to write + self.exit() + return + self.push_screen(QuitScreen(dirty), self._on_quit_choice) + + def _on_quit_choice(self, choice: str | None) -> None: + if choice == "discard": + self._save_on_exit = False + self.exit() + elif choice == "save": + # Save with the overlay up: writing a big .i64 takes seconds, and + # doing it during teardown would look like a hang with no UI left. + self._loading_screen = LoadingScreen("saving", note="writing databases\u2026") + self.push_screen(self._loading_screen) + self._save_then_exit() + # None: cancel, stay put + + @work(thread=True, exclusive=True, group="save-exit") + def _save_then_exit(self) -> None: + try: + if self._pool is not None: + self._pool.close_all(save=True) # saves each resident worker + elif self.program is not None: + self.program.client.call("idb_save", timeout=600.0) + except Exception as e: # noqa: BLE001 -- still exit, but say so + self.app.call_from_thread(self._status, f"save failed: {e}") + self.app.call_from_thread(self._finish_exit) + + def _finish_exit(self) -> None: + self._save_on_exit = False # already written above + self._dirty = False + self.exit() + def action_help(self) -> None: """F1: the keyboard cheatsheet (there's no permanent footer any more).""" if self._prompt_active(): @@ -5313,6 +5410,13 @@ class IdaTui(App): if self.program is not None: self.program.close() if self._pool is not None: - self._pool.close_all() # saves each DB, then closes cleanly + # _save_on_exit is False only when the user chose discard or we + # already saved; an unexpected teardown still writes defensively. + self._pool.close_all(save=self._save_on_exit is not False) elif self.client is not None: + if self._save_on_exit is None and self._dirty: + try: # unexpected teardown with edits: don't drop them + self.client.call("idb_save", timeout=600.0) + except Exception: # noqa: BLE001 + pass self.client.close() diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index e88547b..c1d315f 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -26,8 +26,8 @@ import traceback sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui, - HelpScreen, ListingView, StringsPalette, StructEditor, SymbolPalette, - XrefsScreen, _str_display, _word_occurrences, + HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor, + SymbolPalette, XrefsScreen, _str_display, _word_occurrences, ) from textual.widgets import ( # noqa: E402 DataTable, Input, OptionList, Static, TextArea, @@ -173,10 +173,17 @@ class Ctx: await self.wait(lambda: self.lst.total > 0 and self.lst._cursor_ea() == fn.addr, t) if view == "decomp": - self.lst.focus() - await self.press("tab") # F5/Tab -> decompile the function at cursor - await self.wait(lambda: self.app._active == "decomp" - and self.dec.loaded_ea == fn.addr, t) + # F5/Tab only decompiles from a focused code pane, and the listing + # may still be settling from the open above — a swallowed Tab used to + # surface much later as "pseudocode view shows: active=listing". + # Retry rather than assume the first one takes. + for _ in range(3): + self.lst.focus() + await self.pause(0.05) + await self.press("tab") + if await self.wait(lambda: self.app._active == "decomp" + and self.dec.loaded_ea == fn.addr, max(t / 3, 5)): + break return fn async def open_biggest(self, view="listing"): @@ -341,6 +348,29 @@ async def s_command_palette(c: Ctx): await c.wait(lambda: app._active != "hex", 5) +@scenario("quit_guard") +async def s_quit_guard(c: Ctx): + app = c.app + c.check("a clean database reports nothing unsaved", app._dirty_labels() == [], + f"{app._dirty_labels()}") + app._dirty = True # as an edit would + c.check("an edited database is reported unsaved", + len(app._dirty_labels()) == 1, f"{app._dirty_labels()}") + await c.press("q") + asked = await c.wait(lambda: isinstance(app.screen, QuitScreen), 10) + c.check("quitting with unsaved changes asks first", asked and app.is_running, + f"screen={type(app.screen).__name__} running={app.is_running}") + if not asked: + return + await c.press("escape") + await c.wait(lambda: not isinstance(app.screen, QuitScreen), 10) + c.check("Esc cancels the quit and stays put", + app.is_running and not isinstance(app.screen, QuitScreen)) + # leave it clean so the rest of the suite (and teardown) isn't affected + app._dirty = False + app._save_on_exit = False + + @scenario("help") async def s_help(c: Ctx): app = c.app |
