aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 14:04:53 +0200
committerblasty <blasty@local>2026-07-25 14:04:53 +0200
commit1f1440285d8caf995abfc49c0849f3ad3000c64e (patch)
tree43710761e57eed5a64742039669086b8d954c20f
parenttests: fix the "filter flake" and the view_toggle cascade — suite is 187/0 (diff)
downloadida-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.py106
-rw-r--r--tests/test_scenarios.py42
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