aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-10 12:38:51 +0200
committerblasty <blasty@local>2026-07-10 12:38:51 +0200
commit50cbb7e982e03be863ad7f1979638520ffc95f55 (patch)
tree4fb97251f1715e7a34dfa14535448bf01875e09f
parentclipboard: keyboard copy (OSC 52 + tmux) since mouse-select is captured (diff)
downloadida-tui-50cbb7e982e03be863ad7f1979638520ffc95f55.tar.gz
ida-tui-50cbb7e982e03be863ad7f1979638520ffc95f55.tar.xz
ida-tui-50cbb7e982e03be863ad7f1979638520ffc95f55.zip
struct editor: loud save errors + guard unsaved edits
A struct with a field IDA's C parser rejects (notably '__unused', a predefined macro) failed to re-declare, but the failure was silent: the edit looked applied, then reloading showed the old definition (lost work). Now: - a failed declare shows a loud, styled 'save failed' status and, when it can, names the offending reserved field ('__unused') instead of IDA's empty 'Failed to parse'; the type is left unchanged and the editor keeps your text. - switching structs / starting new / closing with unsaved edits prompts to discard first (ConfirmScreen), so edits aren't silently reloaded away. Pilot: rejected save is loud/named/non-destructive; switching structs guards unsaved edits. full suite 91/91.
-rw-r--r--idatui/app.py52
-rw-r--r--tests/test_tui.py22
2 files changed, 66 insertions, 8 deletions
diff --git a/idatui/app.py b/idatui/app.py
index ab28d72..271d8f7 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -1223,12 +1223,33 @@ class StructEditor(ModalScreen):
]
NEW_TEMPLATE = "struct NewStruct\n{\n int field;\n};\n"
+ # Field names IDA's C parser predefines as macros and refuses to re-parse.
+ RESERVED_FIELDS = ("__unused",)
def __init__(self, program: Program) -> None:
super().__init__()
self._program = program
self._structs: list[Struct] = []
self._loaded: str | None = None # name currently in the editor
+ self._loaded_src: str | None = None # its source, to detect unsaved edits
+
+ @classmethod
+ def _reserved_field(cls, text: str) -> str | None:
+ for nm in cls.RESERVED_FIELDS:
+ if re.search(rf"\b{re.escape(nm)}\b", text):
+ return nm
+ return None
+
+ def _is_dirty(self) -> bool:
+ cur = self.query_one("#se-edit", TextArea).text.strip()
+ return bool(cur) and cur != (self._loaded_src or "").strip()
+
+ def _confirm_discard(self, then, msg="Discard unsaved changes to the definition?"):
+ """Run ``then()`` directly, or after a discard confirmation if dirty."""
+ if self._is_dirty():
+ self.app.push_screen(ConfirmScreen(msg), lambda ok: then() if ok else None)
+ else:
+ then()
def compose(self) -> ComposeResult:
with Vertical(id="se-box"):
@@ -1276,7 +1297,7 @@ class StructEditor(ModalScreen):
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
i = event.option_index
if 0 <= i < len(self._structs):
- self._load(self._structs[i].name)
+ self._confirm_discard(lambda n=self._structs[i].name: self._load(n))
@work(thread=True, exclusive=True, group="se-load")
def _load(self, name: str) -> None:
@@ -1289,6 +1310,7 @@ class StructEditor(ModalScreen):
def _set_editor(self, name: str, src: str) -> None:
self._loaded = name
+ self._loaded_src = src
ta = self.query_one("#se-edit", TextArea)
ta.text = src
ta.focus()
@@ -1312,22 +1334,34 @@ class StructEditor(ModalScreen):
return
m = re.search(r"\b(?:struct|union)\s+([A-Za-z_]\w*)", text)
name = m.group(1) if m else None
- self.app.call_from_thread(self._after_save, name, err)
+ self.app.call_from_thread(self._after_save, name, err, text)
- def _after_save(self, name: str | None, err: str | None) -> None:
+ def _after_save(self, name: str | None, err: str | None, text: str) -> None:
if err:
- self._set_status(f"declare failed: {(err.strip() or 'parse error')[:80]}")
+ # IDA's parse error is usually empty/cryptic; name the likely cause.
+ msg = err.strip()
+ if not msg or "parse" in msg.lower() or "fail" in msg.lower():
+ bad = self._reserved_field(text)
+ msg = (f"'{bad}' is a reserved name in IDA's C parser — rename "
+ f"that field to save" if bad else
+ "IDA couldn't parse it (unknown type or reserved field name?)")
+ self._set_status(f"save failed — {msg}", error=True)
return
self._loaded = name
+ self._loaded_src = text.strip() # editor now matches the saved type
if getattr(self.app, "_dirty", None) is not None:
- self.app._dirty = True # struct change is unsaved until Ctrl+S in the app
+ self.app._dirty = True # unsaved-to-disk until Ctrl+S in the app
self._refresh(select=name)
self._set_status(f"saved {name}" if name else "saved")
def action_new(self) -> None:
+ self._confirm_discard(self._do_new, "Discard unsaved changes and start new?")
+
+ def _do_new(self) -> None:
ta = self.query_one("#se-edit", TextArea)
ta.text = self.NEW_TEMPLATE
self._loaded = None
+ self._loaded_src = self.NEW_TEMPLATE
ta.focus()
self._set_status("new struct — edit and Ctrl+S")
@@ -1378,10 +1412,12 @@ class StructEditor(ModalScreen):
if self.focused is self.query_one("#se-edit", TextArea):
self.query_one("#se-list", OptionList).focus()
return
- self.dismiss(None)
+ self._confirm_discard(lambda: self.dismiss(None),
+ "Discard unsaved changes and close?")
- def _set_status(self, text: str) -> None:
- self.query_one("#se-status", Static).update(text)
+ def _set_status(self, text, error: bool = False) -> None: # type: ignore[no-untyped-def]
+ st = self.query_one("#se-status", Static)
+ st.update(Text("⚠ " + text, style="bold #ff5f5f") if error else text)
# --------------------------------------------------------------------------- #
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 6384ec6..e990bef 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -193,6 +193,28 @@ async def run(db):
check("Ctrl+S updates an existing struct in place",
next((s.members for s in se._structs if s.name == sname), 0) == 3,
"member count not 3")
+ # A save IDA's C parser rejects (the reserved field name '__unused')
+ # fails loudly and non-destructively, naming the offending field.
+ ta.text = f"struct {sname} {{ int a; char b[8]; long c; int __unused; }};"
+ await pilot.press("ctrl+s")
+ await wait_until(
+ pilot, lambda: "save failed" in str(se.query_one("#se-status").render()), 15)
+ st = str(se.query_one("#se-status").render())
+ check("a rejected save fails loudly, naming the reserved field",
+ "save failed" in st and "__unused" in st, f"status={st!r}")
+ check("a rejected save leaves the struct unchanged",
+ next((s.members for s in se._structs if s.name == sname), 0) == 3,
+ "members changed")
+ check("a rejected save keeps your edited text", "__unused" in ta.text)
+ # Unsaved edits are guarded when switching structs.
+ other = next(i for i, s in enumerate(se._structs) if s.name != sname)
+ se.on_option_list_option_selected(type("E", (), {"option_index": other})())
+ guard = await wait_until(pilot, lambda: isinstance(app.screen, ConfirmScreen), 10)
+ check("unsaved edits prompt before switching structs", guard,
+ f"screen={type(app.screen).__name__}")
+ await pilot.press("enter") # confirm discard -> loads the other (clean)
+ await wait_until(
+ pilot, lambda: isinstance(app.screen, StructEditor) and not se._is_dirty(), 15)
# delete: 'd' asks to confirm, Enter deletes (server del_type). If the
# server lacks del_type, the status reports it instead.
idx = next(i for i, s in enumerate(se._structs) if s.name == sname)