diff --git a/bterminal.py b/bterminal.py index 2502bc9..6f4caba 100755 --- a/bterminal.py +++ b/bterminal.py @@ -2303,6 +2303,482 @@ class SessionSidebar(Gtk.Box): dlg.destroy() +# ─── Ctx Import / Export ────────────────────────────────────────────────────── + + +class _CtxExportDialog(Gtk.Dialog): + """Dialog for selectively exporting ctx data to a JSON file.""" + + def __init__(self, parent): + super().__init__( + title="Export Context", + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + "Export", Gtk.ResponseType.OK, + ) + self.set_default_size(500, 450) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + # Select all / Deselect all + sel_box = Gtk.Box(spacing=8) + btn_all = Gtk.Button(label="Select All") + btn_all.connect("clicked", lambda _: self._set_all(True)) + btn_none = Gtk.Button(label="Deselect All") + btn_none.connect("clicked", lambda _: self._set_all(False)) + sel_box.pack_start(btn_all, False, False, 0) + sel_box.pack_start(btn_none, False, False, 0) + box.pack_start(sel_box, False, False, 0) + + # Tree with checkboxes: toggle, icon, name, data_type, data_key + self.store = Gtk.TreeStore(bool, str, str, str, str) + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect("toggled", self._on_toggled) + col.pack_start(cell_toggle, False) + col.add_attribute(cell_toggle, "active", 0) + + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", 1) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", 2) + + self.tree.append_column(col) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(self.tree) + box.pack_start(scroll, True, True, 0) + + self._load_data() + self.show_all() + + def _load_data(self): + import sqlite3 + if not os.path.exists(CTX_DB): + return + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + + projects = db.execute( + "SELECT name FROM sessions ORDER BY name" + ).fetchall() + for proj in projects: + pname = proj["name"] + proj_iter = self.store.append(None, [ + True, "\U0001f4c1", pname, "project", pname, + ]) + entries = db.execute( + "SELECT key FROM contexts WHERE project = ? ORDER BY key", + (pname,), + ).fetchall() + for entry in entries: + self.store.append(proj_iter, [ + True, " ", entry["key"], "entry", entry["key"], + ]) + scount = db.execute( + "SELECT COUNT(*) as c FROM summaries WHERE project = ?", + (pname,), + ).fetchone()["c"] + if scount: + self.store.append(proj_iter, [ + True, "\U0001f4cb", f"Summaries ({scount})", "summaries", pname, + ]) + + shared = db.execute("SELECT key FROM shared ORDER BY key").fetchall() + if shared: + shared_iter = self.store.append(None, [ + True, "\U0001f517", "Shared", "shared", "", + ]) + for entry in shared: + self.store.append(shared_iter, [ + True, " ", entry["key"], "shared_entry", entry["key"], + ]) + + db.close() + self.tree.expand_all() + + def _on_toggled(self, renderer, path): + it = self.store.get_iter(path) + new_val = not self.store.get_value(it, 0) + self.store.set_value(it, 0, new_val) + # Propagate to children + child = self.store.iter_children(it) + while child: + self.store.set_value(child, 0, new_val) + child = self.store.iter_next(child) + # Update parent based on children + parent = self.store.iter_parent(it) + if parent: + any_checked = False + child = self.store.iter_children(parent) + while child: + if self.store.get_value(child, 0): + any_checked = True + break + child = self.store.iter_next(child) + self.store.set_value(parent, 0, any_checked) + + def _set_all(self, val): + def _walk(it): + while it: + self.store.set_value(it, 0, val) + child = self.store.iter_children(it) + if child: + _walk(child) + it = self.store.iter_next(it) + root = self.store.get_iter_first() + if root: + _walk(root) + + def get_export_data(self): + """Collect checked items and return export dict.""" + import sqlite3 + if not os.path.exists(CTX_DB): + return None + db = sqlite3.connect(CTX_DB) + db.row_factory = sqlite3.Row + data = {"sessions": [], "contexts": [], "shared": [], "summaries": []} + + root = self.store.get_iter_first() + while root: + dtype = self.store.get_value(root, 3) + dkey = self.store.get_value(root, 4) + + if dtype == "project": + proj_name = dkey + child = self.store.iter_children(root) + checked_entries = [] + include_summaries = False + while child: + if self.store.get_value(child, 0): + ctype = self.store.get_value(child, 3) + ckey = self.store.get_value(child, 4) + if ctype == "entry": + checked_entries.append(ckey) + elif ctype == "summaries": + include_summaries = True + child = self.store.iter_next(child) + + if checked_entries or include_summaries or self.store.get_value(root, 0): + row = db.execute( + "SELECT * FROM sessions WHERE name = ?", (proj_name,) + ).fetchone() + if row: + data["sessions"].append(dict(row)) + + for ekey in checked_entries: + row = db.execute( + "SELECT project, key, value, updated_at FROM contexts " + "WHERE project = ? AND key = ?", + (proj_name, ekey), + ).fetchone() + if row: + data["contexts"].append(dict(row)) + + if include_summaries: + rows = db.execute( + "SELECT project, summary, created_at FROM summaries " + "WHERE project = ?", + (proj_name,), + ).fetchall() + data["summaries"].extend(dict(r) for r in rows) + + elif dtype == "shared": + child = self.store.iter_children(root) + while child: + if self.store.get_value(child, 0): + skey = self.store.get_value(child, 4) + row = db.execute( + "SELECT * FROM shared WHERE key = ?", (skey,) + ).fetchone() + if row: + data["shared"].append(dict(row)) + child = self.store.iter_next(child) + + root = self.store.iter_next(root) + db.close() + + data = {k: v for k, v in data.items() if v} + if not data: + return None + data["_export_version"] = 1 + return data + + +class _CtxImportDialog(Gtk.Dialog): + """Dialog for importing ctx data from a JSON file.""" + + def __init__(self, parent): + super().__init__( + title="Import Context", + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + "Import", Gtk.ResponseType.OK, + ) + self.set_default_size(500, 450) + self.set_default_response(Gtk.ResponseType.OK) + self.set_response_sensitive(Gtk.ResponseType.OK, False) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + # File chooser + file_box = Gtk.Box(spacing=8) + file_box.pack_start(Gtk.Label(label="File:"), False, False, 0) + self.file_entry = Gtk.Entry(hexpand=True) + self.file_entry.set_placeholder_text("Select JSON file\u2026") + self.file_entry.set_editable(False) + file_box.pack_start(self.file_entry, True, True, 0) + btn_browse = Gtk.Button(label="Browse\u2026") + btn_browse.connect("clicked", self._on_browse) + file_box.pack_start(btn_browse, False, False, 0) + box.pack_start(file_box, False, False, 0) + + # Select all / Deselect all + sel_box = Gtk.Box(spacing=8) + btn_all = Gtk.Button(label="Select All") + btn_all.connect("clicked", lambda _: self._set_all(True)) + btn_none = Gtk.Button(label="Deselect All") + btn_none.connect("clicked", lambda _: self._set_all(False)) + sel_box.pack_start(btn_all, False, False, 0) + sel_box.pack_start(btn_none, False, False, 0) + box.pack_start(sel_box, False, False, 0) + + # Preview tree: toggle, icon, name, data_type, data_key + self.store = Gtk.TreeStore(bool, str, str, str, str) + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + + col = Gtk.TreeViewColumn() + cell_toggle = Gtk.CellRendererToggle() + cell_toggle.connect("toggled", self._on_toggled) + col.pack_start(cell_toggle, False) + col.add_attribute(cell_toggle, "active", 0) + + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", 1) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", 2) + + self.tree.append_column(col) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.add(self.tree) + box.pack_start(scroll, True, True, 0) + + # Overwrite option + self.chk_overwrite = Gtk.CheckButton(label="Overwrite existing entries") + box.pack_start(self.chk_overwrite, False, False, 0) + + self.import_data = None + self.show_all() + + def _on_browse(self, button): + dlg = Gtk.FileChooserDialog( + title="Select context file", + parent=self, + action=Gtk.FileChooserAction.OPEN, + ) + dlg.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK, + ) + filt = Gtk.FileFilter() + filt.set_name("JSON files") + filt.add_pattern("*.json") + dlg.add_filter(filt) + filt_all = Gtk.FileFilter() + filt_all.set_name("All files") + filt_all.add_pattern("*") + dlg.add_filter(filt_all) + if dlg.run() == Gtk.ResponseType.OK: + path = dlg.get_filename() + self.file_entry.set_text(path) + self._load_preview(path) + dlg.destroy() + + def _load_preview(self, path): + self.store.clear() + self.import_data = None + self.set_response_sensitive(Gtk.ResponseType.OK, False) + try: + with open(path, "r") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + dlg = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=f"Failed to load file: {e}", + ) + dlg.run() + dlg.destroy() + return + + self.import_data = data + + # Group by project + sessions = {s["name"]: s for s in data.get("sessions", [])} + contexts_by_proj = {} + for ctx in data.get("contexts", []): + contexts_by_proj.setdefault(ctx["project"], []).append(ctx) + summaries_by_proj = {} + for s in data.get("summaries", []): + summaries_by_proj.setdefault(s["project"], []).append(s) + + all_projects = sorted( + set(sessions) | set(contexts_by_proj) | set(summaries_by_proj) + ) + for proj_name in all_projects: + proj_iter = self.store.append(None, [ + True, "\U0001f4c1", proj_name, "project", proj_name, + ]) + for ctx in contexts_by_proj.get(proj_name, []): + self.store.append(proj_iter, [ + True, " ", ctx["key"], "entry", ctx["key"], + ]) + scount = len(summaries_by_proj.get(proj_name, [])) + if scount: + self.store.append(proj_iter, [ + True, "\U0001f4cb", f"Summaries ({scount})", "summaries", proj_name, + ]) + + shared = data.get("shared", []) + if shared: + shared_iter = self.store.append(None, [ + True, "\U0001f517", "Shared", "shared", "", + ]) + for entry in shared: + self.store.append(shared_iter, [ + True, " ", entry["key"], "shared_entry", entry["key"], + ]) + + self.tree.expand_all() + self.set_response_sensitive(Gtk.ResponseType.OK, True) + + def _on_toggled(self, renderer, path): + it = self.store.get_iter(path) + new_val = not self.store.get_value(it, 0) + self.store.set_value(it, 0, new_val) + child = self.store.iter_children(it) + while child: + self.store.set_value(child, 0, new_val) + child = self.store.iter_next(child) + parent = self.store.iter_parent(it) + if parent: + any_checked = False + child = self.store.iter_children(parent) + while child: + if self.store.get_value(child, 0): + any_checked = True + break + child = self.store.iter_next(child) + self.store.set_value(parent, 0, any_checked) + + def _set_all(self, val): + def _walk(it): + while it: + self.store.set_value(it, 0, val) + child = self.store.iter_children(it) + if child: + _walk(child) + it = self.store.iter_next(it) + root = self.store.get_iter_first() + if root: + _walk(root) + + def get_selected_data(self): + """Return (filtered_data_dict, overwrite_bool) or (None, False).""" + if not self.import_data: + return None, False + + data = self.import_data + overwrite = self.chk_overwrite.get_active() + sessions_map = {s["name"]: s for s in data.get("sessions", [])} + contexts_by_proj = {} + for ctx in data.get("contexts", []): + contexts_by_proj.setdefault(ctx["project"], []).append(ctx) + summaries_by_proj = {} + for s in data.get("summaries", []): + summaries_by_proj.setdefault(s["project"], []).append(s) + shared_map = {s["key"]: s for s in data.get("shared", [])} + + result = {"sessions": [], "contexts": [], "shared": [], "summaries": []} + + root = self.store.get_iter_first() + while root: + dtype = self.store.get_value(root, 3) + dkey = self.store.get_value(root, 4) + + if dtype == "project": + proj_name = dkey + child = self.store.iter_children(root) + checked_entries = [] + include_summaries = False + while child: + if self.store.get_value(child, 0): + ctype = self.store.get_value(child, 3) + ckey = self.store.get_value(child, 4) + if ctype == "entry": + checked_entries.append(ckey) + elif ctype == "summaries": + include_summaries = True + child = self.store.iter_next(child) + + if checked_entries or include_summaries: + if proj_name in sessions_map: + result["sessions"].append(sessions_map[proj_name]) + for ekey in checked_entries: + for ctx in contexts_by_proj.get(proj_name, []): + if ctx["key"] == ekey: + result["contexts"].append(ctx) + break + if include_summaries: + result["summaries"].extend( + summaries_by_proj.get(proj_name, []) + ) + + elif dtype == "shared": + child = self.store.iter_children(root) + while child: + if self.store.get_value(child, 0): + skey = self.store.get_value(child, 4) + if skey in shared_map: + result["shared"].append(shared_map[skey]) + child = self.store.iter_next(child) + + root = self.store.iter_next(root) + + result = {k: v for k, v in result.items() if v} + return (result if result else None), overwrite + + # ─── CtxManagerPanel ────────────────────────────────────────────────────────── @@ -2398,10 +2874,24 @@ class CtxManagerPanel(Gtk.Box): btn_refresh.set_tooltip_text("Refresh") btn_refresh.connect("clicked", lambda _: self.refresh()) + btn_more = Gtk.MenuButton(label="\u22ee") + btn_more.get_style_context().add_class("sidebar-btn") + btn_more.set_tooltip_text("More actions") + more_menu = Gtk.Menu() + item_export = Gtk.MenuItem(label="Export\u2026") + item_export.connect("activate", lambda _: self._on_export()) + more_menu.append(item_export) + item_import = Gtk.MenuItem(label="Import\u2026") + item_import.connect("activate", lambda _: self._on_import()) + more_menu.append(item_import) + more_menu.show_all() + btn_more.set_popup(more_menu) + btn_box.pack_start(btn_add, True, True, 0) btn_box.pack_start(btn_edit, True, True, 0) btn_box.pack_start(btn_del, True, True, 0) btn_box.pack_start(btn_refresh, False, False, 0) + btn_box.pack_start(btn_more, False, False, 0) self.pack_start(btn_box, False, False, 0) # Signals @@ -2793,6 +3283,112 @@ class CtxManagerPanel(Gtk.Box): self.refresh() dlg.destroy() + def _on_export(self): + dlg = _CtxExportDialog(self.app) + if dlg.run() == Gtk.ResponseType.OK: + data = dlg.get_export_data() + if data: + save_dlg = Gtk.FileChooserDialog( + title="Save export file", + parent=self.app, + action=Gtk.FileChooserAction.SAVE, + ) + save_dlg.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK, + ) + save_dlg.set_do_overwrite_confirmation(True) + save_dlg.set_current_name("ctx_export.json") + filt = Gtk.FileFilter() + filt.set_name("JSON files") + filt.add_pattern("*.json") + save_dlg.add_filter(filt) + if save_dlg.run() == Gtk.ResponseType.OK: + path = save_dlg.get_filename() + try: + with open(path, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except OSError as e: + err = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=f"Failed to save: {e}", + ) + err.run() + err.destroy() + save_dlg.destroy() + dlg.destroy() + + def _on_import(self): + dlg = _CtxImportDialog(self.app) + if dlg.run() == Gtk.ResponseType.OK: + data, overwrite = dlg.get_selected_data() + if data: + self._do_import(data, overwrite) + self.refresh() + dlg.destroy() + + def _do_import(self, data, overwrite): + import sqlite3 + # Ensure database and tables exist + subprocess.run(["ctx", "list"], capture_output=True, text=True) + if not os.path.exists(CTX_DB): + return + + db = sqlite3.connect(CTX_DB) + mode = "REPLACE" if overwrite else "IGNORE" + + for session in data.get("sessions", []): + db.execute( + f"INSERT OR {mode} INTO sessions (name, description, work_dir, created_at) " + "VALUES (?, ?, ?, ?)", + ( + session["name"], + session.get("description", ""), + session.get("work_dir", ""), + session.get("created_at", ""), + ), + ) + + for ctx in data.get("contexts", []): + db.execute( + f"INSERT OR {mode} INTO contexts (project, key, value, updated_at) " + "VALUES (?, ?, ?, ?)", + ( + ctx["project"], + ctx["key"], + ctx["value"], + ctx.get("updated_at", ""), + ), + ) + + for shared in data.get("shared", []): + db.execute( + f"INSERT OR {mode} INTO shared (key, value, updated_at) " + "VALUES (?, ?, ?)", + ( + shared["key"], + shared["value"], + shared.get("updated_at", ""), + ), + ) + + for summary in data.get("summaries", []): + db.execute( + "INSERT INTO summaries (project, summary, created_at) " + "VALUES (?, ?, ?)", + ( + summary["project"], + summary["summary"], + summary.get("created_at", ""), + ), + ) + + db.commit() + db.close() + # ─── BTerminalApp ─────────────────────────────────────────────────────────────