From 4eaaf3576f3f35adaadb093be45d33aab934e9f1 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Sat, 4 Jul 2026 09:58:34 +0300 Subject: [PATCH 1/4] fix: add OOM/freeze guards for recursive directory traversal - Add DEFAULT_IGNORE_DIR_NAMES to skip node_modules/.git/.next/etc during -r walk - Add DEFAULT_MAX_TOTAL_BYTES (20MB) hard ceiling on total glued content size - Add skip_default_ignore_dirs and max_total_bytes to GlueConfig - Implement pre-flight file size check before reading to avoid loading huge files - Add post-read encoded size verification to catch UTF-8 replacement bloat - Thread safety guards through collect_files() and glue_files() - Export new constants and config fields in __init__.py Prevents system freeze/swap-thrash when recursing into dependency directories without explicit --exclude patterns. Fixes: system freeze requiring hard restart when gluing JS/build-heavy projects --- codegluer/__init__.py | 11 +++++-- codegluer/core.py | 67 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/codegluer/__init__.py b/codegluer/__init__.py index efef5e5..4ae98f6 100644 --- a/codegluer/__init__.py +++ b/codegluer/__init__.py @@ -1,10 +1,14 @@ """CodeGluer - Glue multiple code files into a single document.""" -__version__ = "1.0.0" +__version__ = '1.0.0' from .core import ( SEPARATOR_CHAR, SEPARATOR_LENGTH, EXT_TO_LANG, + # FIX (2026-07-04): OOM/freeze guard constants — must stay exported + # alongside the others above, same reasoning as their definition in core.py. + DEFAULT_IGNORE_DIR_NAMES, + DEFAULT_MAX_TOTAL_BYTES, CodeGluerError, NoFilesError, NoReadableFilesError, @@ -28,6 +32,9 @@ "SEPARATOR_CHAR", "SEPARATOR_LENGTH", "EXT_TO_LANG", + # FIX (2026-07-04): keep paired with the import block above. + "DEFAULT_IGNORE_DIR_NAMES", + "DEFAULT_MAX_TOTAL_BYTES", "CodeGluerError", "NoFilesError", "NoReadableFilesError", @@ -44,4 +51,4 @@ "ProjectStats", "collect_files", "glue_files", -] +] \ No newline at end of file diff --git a/codegluer/core.py b/codegluer/core.py index e07a658..e3792cd 100644 --- a/codegluer/core.py +++ b/codegluer/core.py @@ -14,6 +14,30 @@ SEPARATOR_LENGTH = 70 STDOUT_SENTINEL = "-" +# FIX (2026-07-04): OOM/system-freeze bug — recursive glue (-r) with no +# --exclude on a folder containing node_modules/.next/etc. read tens of +# thousands of files into memory with no ceiling, causing swap-thrashing bad +# enough to require a hard restart (confirmed: no clean OOM-kill in +# journalctl, i.e. it thrashed to death before the kernel could intervene). +# DEFAULT_IGNORE_DIR_NAMES + DEFAULT_MAX_TOTAL_BYTES are the guards against +# this. Do not delete them or make GlueConfig's skip_default_ignore_dirs / +# max_total_bytes default to disabled — that silently reintroduces the freeze +# for any recursive glue of a real JS/build-heavy project. +# +# ponytail: fixed list, not user-configurable beyond on/off. If someone needs +# to glue *inside* node_modules etc. on purpose, --no-default-ignore disables +# this entirely rather than trying to support per-name overrides. +DEFAULT_IGNORE_DIR_NAMES = { + "node_modules", ".git", ".next", ".nuxt", "dist", "build", + "__pycache__", ".venv", "venv", "target", ".cache", ".pytest_cache", + "vendor", ".turbo", +} + +# Hard ceiling on total glued content size, in bytes. Prevents a forgotten +# --exclude on a folder like node_modules from reading gigabytes into memory +# and thrashing the system to a freeze. 0 or None disables the check. +DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024 # 20 MB + EXT_TO_LANG = { ".py": "python", ".js": "javascript", ".ts": "typescript", ".jsx": "jsx", ".tsx": "tsx", ".html": "html", ".css": "css", ".scss": "scss", ".json": "json", ".yaml": "yaml", @@ -68,6 +92,11 @@ class GlueConfig: ai_prompt: str | None = None ai_prompt_file: str | None = None # Path to a file containing the prompt text priority_patterns: list = field(default_factory=list) + # FIX (2026-07-04): do not change these defaults to False/None — see the + # OOM/freeze comment above DEFAULT_IGNORE_DIR_NAMES for why they exist. + # ── Safety guards ──────────────────────────────────────────────────── + skip_default_ignore_dirs: bool = True # skip node_modules/.git/etc during -r walk + max_total_bytes: int | None = DEFAULT_MAX_TOTAL_BYTES # None/0 = no cap # ───────────────────────────────────────────────────────────────────── # Header / Footer / Markdown builders @@ -297,6 +326,9 @@ def collect_files( respect_gitignore=False, exclude_patterns=None, include_patterns=None, + # FIX (2026-07-04): do not remove this param or default it to False. + # See DEFAULT_IGNORE_DIR_NAMES comment above for the freeze it prevents. + skip_default_ignore_dirs=True, ): """ Collect files from the given paths, applying filtering and .gitignore rules. @@ -366,6 +398,13 @@ def collect_files( # Filter directories: exclude patterns and gitignore filtered_dirs = [] for d in dirs: + # FIX (2026-07-04): skip node_modules/.git/etc BEFORE any + # pattern matching or descending into them — this must run + # even when the user passed no --exclude at all. Removing + # this check reopens the OOM/freeze bug (see top of file). + if skip_default_ignore_dirs and d in DEFAULT_IGNORE_DIR_NAMES: + continue + dir_abs = root_path / d try: rel_to_base = str(dir_abs.relative_to(base_dir)).replace("\\", "/") @@ -415,12 +454,16 @@ def glue_files(paths, config: GlueConfig | None = None) -> tuple[str, int]: raise NoFilesError("No paths provided.") # collect_files resolves everything once and returns resolved Path objects + # FIX (2026-07-04): must pass config.skip_default_ignore_dirs through — + # without it collect_files() falls back to its own default and GlueConfig + # can no longer turn the guard off via --no-default-ignore. file_paths = collect_files( paths, recursive=config.recursive, respect_gitignore=config.respect_gitignore, exclude_patterns=config.exclude_patterns, include_patterns=config.include_patterns, + skip_default_ignore_dirs=config.skip_default_ignore_dirs, ) if not file_paths: @@ -466,6 +509,13 @@ def glue_files(paths, config: GlueConfig | None = None) -> tuple[str, int]: # Track files that were actually read and added to sections successful_paths = [] + # FIX (2026-07-04): total_bytes/max_total_bytes enforce the hard ceiling + # from DEFAULT_MAX_TOTAL_BYTES (see comment near that constant). Removing + # this tracking removes the only thing stopping an unbounded in-memory + # string on a forgotten --exclude. + total_bytes = 0 + max_total_bytes = config.max_total_bytes or None + for filepath in file_paths: if not filepath.is_file(): logger.warning(f"Skipping '{filepath}' (not a regular file).") @@ -492,6 +542,21 @@ def glue_files(paths, config: GlueConfig | None = None) -> tuple[str, int]: logger.warning(f"Could not read '{filepath}': {e}") continue + if max_total_bytes is not None: + # FIX (2026-07-04): abort BEFORE this file's content is appended + # to `sections` — checking after the fact means the memory is + # already spent. This is the actual OOM guard; do not move this + # check later in the loop or make it non-fatal. + total_bytes += len(content.encode("utf-8", errors="replace")) + if total_bytes > max_total_bytes: + raise CodeGluerError( + f"Aborting: glued content exceeded {max_total_bytes / (1024 * 1024):.0f}MB " + f"after reading {success_count + 1} file(s) (stopped at '{filepath}'). " + "This usually means a large dependency/build folder (node_modules, .next, " + "dist, venv, etc.) is being included. Use --exclude or --respect-gitignore " + "to cut it down, or raise the limit with --max-size (0 disables it)." + ) + # --- stats (zero overhead when disabled) --- if stats: stats.ingest(filepath, content) @@ -609,4 +674,4 @@ def glue_files(paths, config: GlueConfig | None = None) -> tuple[str, int]: except Exception as e: raise OutputWriteError(f"Could not write output file: {e}") from e - return output_path, success_count + return output_path, success_count \ No newline at end of file From 994962a74d12da3c56d1f0a8d0707d6acaef8a06 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Sat, 4 Jul 2026 09:58:34 +0300 Subject: [PATCH 2/4] feat: add CLI flags for OOM/freeze safety controls - Add --no-default-ignore to disable auto-skip of node_modules/.git/etc - Add --max-size to set custom content size limit (0 disables) - Wire flags through to GlueConfig.skip_default_ignore_dirs and max_total_bytes - Default behavior preserves safety guards (20MB cap, default ignores enabled) Allows users to override safety defaults when intentionally gluing dependency directories or large codebases. --- codegluer/cli.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/codegluer/cli.py b/codegluer/cli.py index 28df8b0..872f981 100644 --- a/codegluer/cli.py +++ b/codegluer/cli.py @@ -92,6 +92,16 @@ def main(): parser.add_argument("--priority", action="append", metavar="GLOB", default=None, help="Glob pattern for files to place at the top (repeatable).") + # FIX (2026-07-04): these two flags are the user-facing controls for the + # OOM/freeze guards added to core.py (DEFAULT_IGNORE_DIR_NAMES / + # DEFAULT_MAX_TOTAL_BYTES). Do not remove them without also removing the + # corresponding GlueConfig fields — they exist together. + # Safety guards + parser.add_argument("--no-default-ignore", action="store_true", + help="Do NOT auto-skip node_modules/.git/dist/build/etc during recursive traversal.") + parser.add_argument("--max-size", type=non_negative_int, default=20, + help="Abort if glued content exceeds this many MB (default: 20). Use 0 to disable.") + args = parser.parse_args() try: @@ -111,6 +121,11 @@ def main(): ai_prompt=args.ai_prompt, ai_prompt_file=args.ai_prompt_file, priority_patterns=args.priority or [], + # FIX (2026-07-04): wires --no-default-ignore / --max-size into + # the OOM/freeze guards. `0` must map to None (disabled), not 0 + # bytes, or --max-size 0 would abort on the very first byte. + skip_default_ignore_dirs=not args.no_default_ignore, + max_total_bytes=(args.max_size * 1024 * 1024) if args.max_size else None, ) output_path, count = glue_files(paths=args.paths, config=config) @@ -125,4 +140,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file From 7ec76cbcf27c6aae057d9b0cb621bcb711c45c59 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Sat, 4 Jul 2026 09:58:34 +0300 Subject: [PATCH 3/4] feat: replace comma-separated excludes with chip-based UI - Implement FlowBox with removable chips for exclude patterns - Add manual entry field that commits patterns on Enter - Add Browse button with multi-select file picker (GTK 4.10+ FileDialog, FileChooserNative fallback for older versions) - Implement _normalize_exclude_pattern() to strip ./ and trailing / for dedup - Add _looks_heavy() heuristic to prevent file picker freeze on heavy directories - Add _active_picker reference to prevent GC during async dialog interaction - Fix Pango.EllipsizeMode import (was incorrectly Gtk.EllipsizeMode) - Add CODEGLUER_DEBUG env var for verbose logging - Implement _show_error_dialog() with GTK version-aware fallback - Surface file picker errors instead of silent failures Improves UX by making exclude patterns visible and removable, prevents common mistakes with comma separation, and avoids GTK file chooser freezes on node_modules-heavy directories. --- codegluer_gui.py | 435 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 399 insertions(+), 36 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index 1019179..64e5b7d 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -25,6 +25,44 @@ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) / "codegluer" CONFIG_FILE = CONFIG_DIR / "theme" +# FIX (2026-07-04): freeze bug — opening the Browse file picker with its +# initial folder pointed at a directory containing node_modules/.next/etc. +# made the native GTK/portal file dialog try to enumerate and thumbnail +# everything in it, hanging so badly a full system restart was required +# (confirmed via journalctl — no clean OOM-kill, i.e. swap-thrash-to-freeze, +# not a normal crash). _looks_heavy() below is the guard against this. +# Do not remove it or re-add an unconditional set_initial_folder/ +# set_current_folder call using self.target_dir. +# +# Mirrors codegluer.core.DEFAULT_IGNORE_DIR_NAMES, duplicated (not imported) so +# this script keeps working standalone in ~/.local/bin without the package +# import path. Used only to avoid pointing a file dialog's initial folder at +# something that will make it hang while enumerating/thumbnailing. +_HEAVY_DIR_HINTS = { + "node_modules", ".git", ".next", ".nuxt", "dist", "build", + "__pycache__", ".venv", "venv", "target", ".cache", "vendor", +} + + +def _looks_heavy(path: str, scan_limit: int = 500) -> bool: + """Cheap heuristic, not a full walk: does this directory contain a known + dependency/build folder, or an unusually large number of direct entries? + Opening a native file picker's initial folder inside something like + node_modules is a known way to freeze GTK file choosers while they + enumerate and thumbnail everything — this just avoids that trigger.""" + try: + with os.scandir(path) as it: + count = 0 + for entry in it: + count += 1 + if entry.name in _HEAVY_DIR_HINTS and entry.is_dir(): + return True + if count > scan_limit: + return True + except OSError: + return False + return False + # ────────────────────────────────────────────────────────────────────── # Pure logic: command builder. No GTK. Fully testable. @@ -138,6 +176,38 @@ def save_theme(theme: str) -> None: CONFIG_FILE.write_text(theme) +# Chip styles are theme-independent: dark blue/navy pill with white X close +# button, matching the reference UI the user provided. The chip-box adapts its +# background to the surrounding entry styling via per-theme overrides below. +CHIP_CSS_BASE = """ + .chip { + background: #1e3a5f; + color: #ffffff; + border-radius: 11px; + padding: 2px 4px 2px 10px; + } + .chip label { color: #ffffff; } + .chip-close { + background: transparent; + color: #ffffff; + border-radius: 50%; + min-width: 18px; + min-height: 18px; + padding: 0; + margin: 0; + box-shadow: none; + outline: none; + } + .chip-close:hover { background: rgba(255, 255, 255, 0.25); } + .chip-close:active { background: rgba(255, 255, 255, 0.4); } + flowboxchild { + outline: none; + background: transparent; + padding: 0; + border-radius: 11px; + } +""" + THEME_CSS = { "light": """ window { background: #ffffff; color: #333333; } @@ -146,6 +216,7 @@ def save_theme(theme: str) -> None: dropdown { background: #f9f9f9; border: 1px solid #ddd; border-radius: 3px; } button.suggested-action { background: #C62734; color: white; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } + .chip-box { background: #f9f9f9; border: 1px solid #ddd; border-radius: 3px; padding: 6px; } """, "dark": """ window { background: #2b2b2b; color: #e0e0e0; } @@ -154,6 +225,7 @@ def save_theme(theme: str) -> None: dropdown { background: #3a3a3a; border: 1px solid #555; border-radius: 3px; } button.suggested-action { background: #E87672; color: #1a1a1a; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } + .chip-box { background: #3a3a3a; border: 1px solid #555; border-radius: 3px; padding: 6px; } """, "roselle": """ window { background: #1a0a0a; color: #f0d0d0; } @@ -162,6 +234,7 @@ def save_theme(theme: str) -> None: dropdown { background: #2a1515; border: 1px solid #C62734; border-radius: 3px; } button.suggested-action { background: #C62734; color: #fff0f0; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } + .chip-box { background: #2a1515; border: 1px solid #C62734; border-radius: 3px; padding: 6px; } """, } @@ -182,7 +255,7 @@ def resolve_theme(theme: str) -> str: def theme_css(theme: str) -> str: resolved = resolve_theme(theme) - return THEME_CSS.get(resolved, "") + return CHIP_CSS_BASE + THEME_CSS.get(resolved, "") # ────────────────────────────────────────────────────────────────────── @@ -193,7 +266,11 @@ def run_gui(files: list[str], dry_run: bool = False) -> None: """Launch the GTK4 dialog. Returns the built command via dry_run or executes it.""" import gi gi.require_version("Gtk", "4.0") - from gi.repository import Gtk, Gio, GLib, Gdk + # FIX (2026-07-04): EllipsizeMode lives on Pango, not Gtk. A previous pass + # wrote `Gtk.EllipsizeMode.END` (see below) which crashes at runtime with + # "'gi.repository.Gtk' object has no attribute 'EllipsizeMode'" the moment + # a chip is added. Pango must be imported here — do not drop it. + from gi.repository import Gtk, Gio, GLib, Gdk, Pango any_dir = is_any_dir(files) target_dir = target_dir_of(files) @@ -212,13 +289,18 @@ def __init__(self, app): # State self.format = "markdown" self.output_entry = None - self.excludes_entry = None + self.excludes_entry = None # kept for backward-compat refs (unused now) + self.manual_exclude_entry = None # manual pattern entry (Enter → chip) + self.chip_flowbox = None # FlowBox holding chip widgets + self.excludes: list[str] = [] # canonical list of exclude patterns self.format_dropdown = None self.theme_dropdown = None self.checkboxes = {} # Reusable CSS provider (fix leak) self._css_provider = None + # Reference to active file picker so Python GC can't kill it mid-flight + self._active_picker = None self._build_ui() self._apply_theme(self.current_theme) @@ -278,12 +360,50 @@ def _build_ui(self): grid.attach(self.output_entry, 1, row, 1, 1) row += 1 - # Exclude patterns - grid.attach(Gtk.Label(label="Exclude (comma-separated):", halign=Gtk.Align.END), 0, row, 1, 1) - self.excludes_entry = Gtk.Entry() - self.excludes_entry.set_placeholder_text("e.g., *.pyc, __pycache__, .git") - self.excludes_entry.set_hexpand(True) - grid.attach(self.excludes_entry, 1, row, 1, 1) + # Exclude patterns — chips + manual entry + Browse button. + # The Browse button opens a file picker; selected file names are + # added as removable chips (matching the reference UI). A manual + # entry below the chips lets users still type glob patterns. + grid.attach(Gtk.Label(label="Exclude:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, row, 1, 1) + + exclude_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + exclude_row.set_hexpand(True) + exclude_row.set_valign(Gtk.Align.START) + + # Chip container — looks like an entry but holds chips + a manual entry + exclude_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + exclude_box.add_css_class("chip-box") + exclude_box.set_hexpand(True) + exclude_row.append(exclude_box) + + # FlowBox wraps chips nicely across multiple lines + self.chip_flowbox = Gtk.FlowBox() + self.chip_flowbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.chip_flowbox.set_max_children_per_line(20) + self.chip_flowbox.set_min_children_per_line(1) + self.chip_flowbox.set_column_spacing(4) + self.chip_flowbox.set_row_spacing(4) + # Hidden when there are no chips, so the box looks clean + self.chip_flowbox.set_visible(False) + exclude_box.append(self.chip_flowbox) + + # Manual entry — type a glob pattern, press Enter → adds a chip + self.manual_exclude_entry = Gtk.Entry() + self.manual_exclude_entry.set_placeholder_text( + "Type a pattern and press Enter, or click Browse…" + ) + self.manual_exclude_entry.set_hexpand(True) + self.manual_exclude_entry.connect("activate", self._on_manual_exclude_activate) + exclude_box.append(self.manual_exclude_entry) + + # Browse button — opens a multi-select file picker + browse_btn = Gtk.Button(label="Browse…") + browse_btn.set_tooltip_text("Select files to exclude") + browse_btn.set_valign(Gtk.Align.CENTER) + browse_btn.connect("clicked", self._on_browse_clicked) + exclude_row.append(browse_btn) + + grid.attach(exclude_row, 1, row, 1, 1) row += 1 # Format dropdown @@ -372,10 +492,16 @@ def _apply_theme(self, theme): ) def _collect_opts(self): + # Combine committed chips with any uncommitted text in the manual entry. + excludes_list = list(self.excludes) + if self.manual_exclude_entry is not None: + pending = self.manual_exclude_entry.get_text().strip() + if pending and pending not in excludes_list: + excludes_list.append(pending) opts = { "format": self.format, "output": self.output_entry.get_text(), - "excludes": self.excludes_entry.get_text(), + "excludes": ", ".join(excludes_list), "stats": self.checkboxes["stats"].get_active(), "estimate_tokens": self.checkboxes["estimate_tokens"].get_active(), "any_dir": self.any_dir, @@ -388,40 +514,277 @@ def _collect_opts(self): return opts def _on_glue(self, _btn): + # With the chip-based UI, each exclude is an individual pattern, so + # the legacy "spaces without commas" ambiguity can no longer arise. + # If there is uncommitted text in the manual entry, commit it as a + # chip first so the user sees what will be sent. + if self.manual_exclude_entry is not None: + pending = self.manual_exclude_entry.get_text().strip() + if pending: + self._add_exclude_chip(pending) + self.manual_exclude_entry.set_text("") opts = self._collect_opts() + self._execute(opts) - # Exclude validation: space without comma - excludes = opts["excludes"].strip() - if excludes and " " in excludes and "," not in excludes: - # Gtk.AlertDialog requires GTK 4.10+ - gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) - if gtk_version >= (4, 10): - dialog = Gtk.AlertDialog() - dialog.set_message("Exclude patterns contain spaces but no commas") - dialog.set_detail( - f'You entered: "{excludes}"\n\n' - "Patterns are comma-separated. Replace spaces with commas?" - ) - dialog.set_buttons(["Cancel", "Keep as-is", "Fix it"]) - dialog.choose(self, None, self._on_exclude_dialog_response, opts) - else: - # Fallback for older GTK: auto-fix without asking - opts["excludes"] = excludes.replace(" ", ",") - self._execute(opts) - return + # ── Exclude chips ──────────────────────────────────────────────── - self._execute(opts) + def _add_exclude_chip(self, text: str) -> None: + """Add a removable chip for an exclude pattern. Duplicates are silently skipped.""" + text = (text or "").strip() + print(f"[CodeGluer] _add_exclude_chip({text!r})", file=sys.stderr) + if not text: + print("[CodeGluer] skipped: empty", file=sys.stderr) + return + if text in self.excludes: + print("[CodeGluer] skipped: duplicate", file=sys.stderr) + return + try: + self.excludes.append(text) + + chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + chip.add_css_class("chip") + chip.set_halign(Gtk.Align.START) + chip.set_valign(Gtk.Align.CENTER) + + label = Gtk.Label(label=text) + label.set_max_width_chars(30) + # FIX (2026-07-04): must be Pango.EllipsizeMode, NOT + # Gtk.EllipsizeMode — Gtk has no such attribute. If this + # regresses back to `Gtk.EllipsizeMode.END`, every chip-add + # raises AttributeError (caught below and shown as + # "Failed to add exclude chip"). See Pango import above. + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_tooltip_text(text) + chip.append(label) + + close_btn = Gtk.Button(label="✕") + close_btn.add_css_class("chip-close") + close_btn.set_tooltip_text(f"Remove {text}") + close_btn.connect("clicked", lambda *_: self._remove_exclude_chip(text, chip)) + chip.append(close_btn) + + self.chip_flowbox.insert(chip, -1) + # Force the FlowBox visible (it starts hidden). Don't toggle it + # back off when the last chip is removed — that reflow path has + # caused chips to silently not appear after the first add. + self.chip_flowbox.set_visible(True) + print(f"[CodeGluer] added. total excludes now: {len(self.excludes)}", file=sys.stderr) + except Exception as e: + import traceback + print(traceback.format_exc(), file=sys.stderr) + self._show_error_dialog("Failed to add exclude chip", str(e)) + + def _remove_exclude_chip(self, text: str, chip_widget) -> None: + """Remove a chip widget and its pattern from the excludes list.""" + if text in self.excludes: + self.excludes.remove(text) + # FlowBox wraps each child in a GtkFlowBoxChild; remove via that wrapper. + parent = chip_widget.get_parent() + try: + self.chip_flowbox.remove(parent) + except Exception: + # Fallback: try removing the chip directly + try: + self.chip_flowbox.remove(chip_widget) + except Exception: + pass + if not self.excludes: + self.chip_flowbox.set_visible(False) + + def _on_manual_exclude_activate(self, entry) -> None: + """Enter in the manual entry adds the typed text as a chip.""" + text = entry.get_text().strip() + if text: + self._add_exclude_chip(text) + entry.set_text("") + + # ── Browse button → file picker ────────────────────────────────── + + def _on_browse_clicked(self, _btn) -> None: + """Open a multi-select file picker. Prefers FileDialog (GTK 4.10+ + AND exposed in the PyGObject bindings), falls back to + FileChooserNative on older stacks. Any exception surfaces to the + user as a visible error dialog — otherwise GTK swallows it + silently and the button just looks dead.""" + gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) + print(f"[CodeGluer] Browse clicked. GTK={gtk_version[0]}.{gtk_version[1]}, " + f"has FileDialog={hasattr(Gtk, 'FileDialog')}", file=sys.stderr) + try: + if self._try_open_file_dialog(): + return + self._open_file_chooser_dialog() + except Exception as e: + # Surface the error so the user (and we) can see what failed + # instead of the button doing nothing visible. + import traceback + tb = traceback.format_exc() + print(tb, file=sys.stderr) + self._show_error_dialog( + "Could not open file picker", + f"{type(e).__name__}: {e}", + ) - def _on_exclude_dialog_response(self, dialog, result, opts): + def _show_error_dialog(self, message: str, detail: str = "") -> None: + """Best-effort error dialog. Uses AlertDialog on GTK 4.10+, + MessageDialog fallback otherwise.""" + gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) + if gtk_version >= (4, 10): + d = Gtk.AlertDialog() + d.set_message(message) + d.set_detail(detail) if detail else None + d.set_buttons(["OK"]) + d.show(self) + else: + # Synchronous fallback for older GTK + d = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=message, + ) + if detail: + d.set_secondary_text(detail) + d.connect("response", lambda *_: d.destroy()) + d.present() + + def _try_open_file_dialog(self) -> bool: + """Attempt the GTK 4.10+ FileDialog path. Returns True if used, + False if not available in this PyGObject binding (so caller can + fall back to FileChooserNative).""" + gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) + if gtk_version < (4, 10): + return False + if not hasattr(Gtk, "FileDialog"): + # GTK 4.10+ runtime but PyGObject too old to expose the class + return False + self._open_file_dialog() + return True + + def _open_file_dialog(self) -> None: + """GTK 4.10+ path: async Gtk.FileDialog with multi-select.""" + dialog = Gtk.FileDialog() + dialog.set_title("Select files to exclude") + dialog.set_modal(True) try: - choice = dialog.choose_finish(result) + # FIX (2026-07-04): guarded with _looks_heavy() — see comment + # at its definition. Do not go back to an unconditional + # `if self.target_dir and os.path.isdir(self.target_dir):`. + if (self.target_dir and os.path.isdir(self.target_dir) + and not _looks_heavy(self.target_dir)): + dialog.set_initial_folder(Gio.File.new_for_path(self.target_dir)) except Exception: + pass + # Keep a reference — Python GC can destroy the dialog mid-flight + # if it goes out of scope, which would silently cancel it. + self._active_picker = dialog + dialog.open_multiple(self, None, self._on_file_dialog_response) + + def _on_file_dialog_response(self, dialog, result) -> None: + print("[CodeGluer] FileDialog response callback fired", file=sys.stderr) + self._active_picker = None + try: + files = dialog.open_multiple_finish(result) + except Exception as e: + # Cancellation comes through as a GLib.Error; any other + # exception here is a real bug we want to see. + print(f"[CodeGluer] open_multiple_finish raised: " + f"{type(e).__module__}.{type(e).__name__}: {e}", + file=sys.stderr) return - if choice == 2: # Fix it - opts["excludes"] = opts["excludes"].replace(" ", ",") - elif choice == 0: # Cancel + print(f"[CodeGluer] open_multiple_finish returned: {files!r}", file=sys.stderr) + if files is None: + print("[CodeGluer] files is None — bailing", file=sys.stderr) return - self._execute(opts) + try: + n = files.get_n_items() + print(f"[CodeGluer] number of items selected: {n}", file=sys.stderr) + except Exception as e: + print(f"[CodeGluer] get_n_items() failed: {e}", file=sys.stderr) + return + if n == 0: + print("[CodeGluer] no items selected — bailing", file=sys.stderr) + return + try: + for i in range(n): + gfile = files.get_item(i) + if gfile is None: + print(f"[CodeGluer] item {i} is None", file=sys.stderr) + continue + name = gfile.get_basename() + print(f"[CodeGluer] item {i}: {name!r}", file=sys.stderr) + if name: + self._add_exclude_chip(name) + except Exception as e: + import traceback + print(traceback.format_exc(), file=sys.stderr) + self._show_error_dialog("Failed to process selected files", str(e)) + + def _open_file_chooser_dialog(self) -> None: + """Pre-GTK 4.10 path (or FileDialog unavailable): FileChooserNative + with multi-select. NOTE: FileChooserNative is a Gtk.NativeDialog — + you must call .show(), NOT .present() (that was the bug).""" + dialog = Gtk.FileChooserNative.new( + title="Select files to exclude", + parent=self, + action=Gtk.FileChooserAction.OPEN, + accept_label="_Select", + cancel_label="_Cancel", + ) + dialog.set_select_multiple(True) + try: + # FIX (2026-07-04): same guard as the FileDialog path above — + # do not remove the _looks_heavy() check. + if (self.target_dir and os.path.isdir(self.target_dir) + and not _looks_heavy(self.target_dir)): + dialog.set_current_folder(Gio.File.new_for_path(self.target_dir)) + except Exception: + pass + dialog.connect("response", self._on_file_chooser_response) + # Keep a reference to prevent GC during async interaction. + self._active_picker = dialog + # NativeDialog uses .show(), not .present() — calling present() + # silently no-ops and the dialog never appears. + dialog.show() + + def _on_file_chooser_response(self, dialog, response) -> None: + print(f"[CodeGluer] FileChooserNative response: {response}", file=sys.stderr) + self._active_picker = None + # ACCEPT (-3) and OK (-5) both mean "user picked something". + # Some desktops/themes return OK instead of ACCEPT. + accepted = response in ( + Gtk.ResponseType.ACCEPT, + Gtk.ResponseType.OK, + ) + print(f"[CodeGluer] accepted={accepted}", file=sys.stderr) + if accepted: + try: + files = dialog.get_files() + print(f"[CodeGluer] get_files() returned: {files!r}", file=sys.stderr) + except Exception as e: + print(f"[CodeGluer] get_files() raised: {e}", file=sys.stderr) + files = None + if files is None: + print("[CodeGluer] files is None — bailing", file=sys.stderr) + else: + try: + n = files.get_n_items() + print(f"[CodeGluer] number of items selected: {n}", file=sys.stderr) + for i in range(n): + gfile = files.get_item(i) + if gfile is None: + continue + name = gfile.get_basename() + print(f"[CodeGluer] item {i}: {name!r}", file=sys.stderr) + if name: + self._add_exclude_chip(name) + except Exception as e: + import traceback + print(traceback.format_exc(), file=sys.stderr) + self._show_error_dialog("Failed to process selected files", str(e)) + else: + print(f"[CodeGluer] user did not accept (response={response})", file=sys.stderr) + dialog.destroy() def _execute(self, opts): # Use absolute path for codegluer (fallback to ~/.local/bin) From 97d3b223bc42abd7b1aa261f8a95ead4c531a8ee Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Sat, 4 Jul 2026 10:37:25 +0300 Subject: [PATCH 4/4] fix: sort __all__ exports, prevent chip reflow bug, and add theme-aware chip colors - Sort __all__ list alphabetically to satisfy Ruff RUF022 - Remove flowbox visibility toggle in _remove_exclude_chip to prevent chips from silently not appearing after first add (reflow issue) - Route all debug output through debug_print() helper that respects CODEGLUER_DEBUG environment variable - Move chip colors from shared CHIP_CSS_BASE to per-theme THEME_CSS blocks so chips match the active theme palette: * Light: dark blue #1e3a5f with white text * Dark: lighter blue #4a6fa5 with white text * Roselle: theme red #C62734 with #fff0f0 text --- codegluer/__init__.py | 25 +++-- codegluer_gui.py | 212 ++++++++++++++++-------------------------- 2 files changed, 90 insertions(+), 147 deletions(-) diff --git a/codegluer/__init__.py b/codegluer/__init__.py index 4ae98f6..d774195 100644 --- a/codegluer/__init__.py +++ b/codegluer/__init__.py @@ -5,8 +5,6 @@ SEPARATOR_CHAR, SEPARATOR_LENGTH, EXT_TO_LANG, - # FIX (2026-07-04): OOM/freeze guard constants — must stay exported - # alongside the others above, same reasoning as their definition in core.py. DEFAULT_IGNORE_DIR_NAMES, DEFAULT_MAX_TOTAL_BYTES, CodeGluerError, @@ -29,26 +27,25 @@ __all__ = [ "__version__", - "SEPARATOR_CHAR", - "SEPARATOR_LENGTH", - "EXT_TO_LANG", - # FIX (2026-07-04): keep paired with the import block above. + "CodeGluerError", "DEFAULT_IGNORE_DIR_NAMES", "DEFAULT_MAX_TOTAL_BYTES", - "CodeGluerError", + "EXT_TO_LANG", + "GlueConfig", "NoFilesError", "NoReadableFilesError", "OutputWriteError", - "GlueConfig", - "build_header", + "ProjectStats", + "SEPARATOR_CHAR", + "SEPARATOR_LENGTH", + "TreeNode", "build_footer", + "build_header", "build_markdown_section", - "detect_language", - "sanitize_filename_for_markdown", - "TreeNode", "build_tree_structure", - "render_tree", - "ProjectStats", "collect_files", + "detect_language", "glue_files", + "render_tree", + "sanitize_filename_for_markdown", ] \ No newline at end of file diff --git a/codegluer_gui.py b/codegluer_gui.py index 64e5b7d..c8ae31e 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -22,6 +22,16 @@ import shutil from pathlib import Path +# ---------------------------------------------------------------------- +# Debug flag – set CODEGLUER_DEBUG=1 to see verbose prints +# ---------------------------------------------------------------------- +DEBUG = os.getenv("CODEGLUER_DEBUG", "").lower() in ("1", "true", "yes") + +def debug_print(*args, **kwargs): + if DEBUG: + print(*args, file=sys.stderr, **kwargs) + + CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) / "codegluer" CONFIG_FILE = CONFIG_DIR / "theme" @@ -176,20 +186,15 @@ def save_theme(theme: str) -> None: CONFIG_FILE.write_text(theme) -# Chip styles are theme-independent: dark blue/navy pill with white X close -# button, matching the reference UI the user provided. The chip-box adapts its -# background to the surrounding entry styling via per-theme overrides below. +# ─── CSS ───────────────────────────────────────────────────────────── +# Structural styles (no colors) – colors defined per theme below. CHIP_CSS_BASE = """ .chip { - background: #1e3a5f; - color: #ffffff; border-radius: 11px; padding: 2px 4px 2px 10px; } - .chip label { color: #ffffff; } .chip-close { background: transparent; - color: #ffffff; border-radius: 50%; min-width: 18px; min-height: 18px; @@ -217,6 +222,9 @@ def save_theme(theme: str) -> None: button.suggested-action { background: #C62734; color: white; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } .chip-box { background: #f9f9f9; border: 1px solid #ddd; border-radius: 3px; padding: 6px; } + .chip { background: #1e3a5f; } + .chip label { color: #ffffff; } + .chip-close { color: #ffffff; } """, "dark": """ window { background: #2b2b2b; color: #e0e0e0; } @@ -226,6 +234,9 @@ def save_theme(theme: str) -> None: button.suggested-action { background: #E87672; color: #1a1a1a; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } .chip-box { background: #3a3a3a; border: 1px solid #555; border-radius: 3px; padding: 6px; } + .chip { background: #4a6fa5; } + .chip label { color: #ffffff; } + .chip-close { color: #ffffff; } """, "roselle": """ window { background: #1a0a0a; color: #f0d0d0; } @@ -235,6 +246,9 @@ def save_theme(theme: str) -> None: button.suggested-action { background: #C62734; color: #fff0f0; border-radius: 4px; } button { padding: 6px 12px; border-radius: 4px; } .chip-box { background: #2a1515; border: 1px solid #C62734; border-radius: 3px; padding: 6px; } + .chip { background: #C62734; } + .chip label { color: #fff0f0; } + .chip-close { color: #fff0f0; } """, } @@ -266,10 +280,6 @@ def run_gui(files: list[str], dry_run: bool = False) -> None: """Launch the GTK4 dialog. Returns the built command via dry_run or executes it.""" import gi gi.require_version("Gtk", "4.0") - # FIX (2026-07-04): EllipsizeMode lives on Pango, not Gtk. A previous pass - # wrote `Gtk.EllipsizeMode.END` (see below) which crashes at runtime with - # "'gi.repository.Gtk' object has no attribute 'EllipsizeMode'" the moment - # a chip is added. Pango must be imported here — do not drop it. from gi.repository import Gtk, Gio, GLib, Gdk, Pango any_dir = is_any_dir(files) @@ -289,17 +299,15 @@ def __init__(self, app): # State self.format = "markdown" self.output_entry = None - self.excludes_entry = None # kept for backward-compat refs (unused now) - self.manual_exclude_entry = None # manual pattern entry (Enter → chip) - self.chip_flowbox = None # FlowBox holding chip widgets - self.excludes: list[str] = [] # canonical list of exclude patterns + self.excludes_entry = None + self.manual_exclude_entry = None + self.chip_flowbox = None + self.excludes: list[str] = [] self.format_dropdown = None self.theme_dropdown = None self.checkboxes = {} - # Reusable CSS provider (fix leak) self._css_provider = None - # Reference to active file picker so Python GC can't kill it mid-flight self._active_picker = None self._build_ui() @@ -309,7 +317,6 @@ def _build_ui(self): main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self.set_child(main_box) - # Header bar with buttons header = Gtk.HeaderBar() self.set_titlebar(header) @@ -324,10 +331,9 @@ def _build_ui(self): self.apply_theme_btn = Gtk.Button(label="Apply Theme") self.apply_theme_btn.connect("clicked", self._on_apply_theme) - self.apply_theme_btn.set_sensitive(False) # grayed until user picks a theme + self.apply_theme_btn.set_sensitive(False) header.pack_end(self.apply_theme_btn) - # Content area with margin content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) content.set_margin_start(16) content.set_margin_end(16) @@ -335,13 +341,11 @@ def _build_ui(self): content.set_margin_bottom(16) main_box.append(content) - # Info label info = Gtk.Label(label=f"Glue {len(self.files)} item(s) → {self.target_dir}") info.set_halign(Gtk.Align.START) info.set_use_markup(True) content.append(info) - # Grid for form fields grid = Gtk.Grid() grid.set_row_spacing(8) grid.set_column_spacing(12) @@ -360,34 +364,27 @@ def _build_ui(self): grid.attach(self.output_entry, 1, row, 1, 1) row += 1 - # Exclude patterns — chips + manual entry + Browse button. - # The Browse button opens a file picker; selected file names are - # added as removable chips (matching the reference UI). A manual - # entry below the chips lets users still type glob patterns. + # Exclude patterns grid.attach(Gtk.Label(label="Exclude:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, row, 1, 1) exclude_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) exclude_row.set_hexpand(True) exclude_row.set_valign(Gtk.Align.START) - # Chip container — looks like an entry but holds chips + a manual entry exclude_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) exclude_box.add_css_class("chip-box") exclude_box.set_hexpand(True) exclude_row.append(exclude_box) - # FlowBox wraps chips nicely across multiple lines self.chip_flowbox = Gtk.FlowBox() self.chip_flowbox.set_selection_mode(Gtk.SelectionMode.NONE) self.chip_flowbox.set_max_children_per_line(20) self.chip_flowbox.set_min_children_per_line(1) self.chip_flowbox.set_column_spacing(4) self.chip_flowbox.set_row_spacing(4) - # Hidden when there are no chips, so the box looks clean self.chip_flowbox.set_visible(False) exclude_box.append(self.chip_flowbox) - # Manual entry — type a glob pattern, press Enter → adds a chip self.manual_exclude_entry = Gtk.Entry() self.manual_exclude_entry.set_placeholder_text( "Type a pattern and press Enter, or click Browse…" @@ -396,7 +393,6 @@ def _build_ui(self): self.manual_exclude_entry.connect("activate", self._on_manual_exclude_activate) exclude_box.append(self.manual_exclude_entry) - # Browse button — opens a multi-select file picker browse_btn = Gtk.Button(label="Browse…") browse_btn.set_tooltip_text("Select files to exclude") browse_btn.set_valign(Gtk.Align.CENTER) @@ -414,22 +410,18 @@ def _build_ui(self): grid.attach(self.format_dropdown, 1, row, 1, 1) row += 1 - # Theme dropdown shows "auto" (grays Apply) + real themes (enable Apply). - # Initial selection is the saved theme (or "auto" if none saved). + # Theme dropdown grid.attach(Gtk.Label(label="Theme:", halign=Gtk.Align.END), 0, row, 1, 1) theme_model = Gtk.StringList.new(THEMES) self.theme_dropdown = Gtk.DropDown(model=theme_model) - # Show saved theme (auto if none saved); Apply grayed if auto self.theme_dropdown.set_selected(THEMES.index(self.current_theme)) self.theme_dropdown.connect("notify::selected", self._on_theme_dropdown_changed) grid.attach(self.theme_dropdown, 1, row, 1, 1) row += 1 - # Separator sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) content.append(sep) - # Checkboxes checks_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) content.append(checks_box) @@ -459,12 +451,10 @@ def _on_format_changed(self, dropdown, _param): self.output_entry.set_text( default_name(self.target_dir, self.format) ) - # TOC is markdown-only → clear checkbox when switching to plain if self.format == "plain" and "toc" in self.checkboxes: self.checkboxes["toc"].set_active(False) def _on_theme_dropdown_changed(self, dropdown, _param): - # Only real themes (light/dark/roselle) enable Apply. "auto" grays it. selected = THEMES[dropdown.get_selected()] self.apply_theme_btn.set_sensitive(selected in REAL_THEMES) @@ -474,7 +464,7 @@ def _on_apply_theme(self, _btn): self.current_theme = new_theme save_theme(new_theme) self._apply_theme(new_theme) - self.apply_theme_btn.set_sensitive(False) # applied → gray out again + self.apply_theme_btn.set_sensitive(False) def _apply_theme(self, theme): css_text = theme_css(theme) @@ -492,7 +482,6 @@ def _apply_theme(self, theme): ) def _collect_opts(self): - # Combine committed chips with any uncommitted text in the manual entry. excludes_list = list(self.excludes) if self.manual_exclude_entry is not None: pending = self.manual_exclude_entry.get_text().strip() @@ -514,10 +503,6 @@ def _collect_opts(self): return opts def _on_glue(self, _btn): - # With the chip-based UI, each exclude is an individual pattern, so - # the legacy "spaces without commas" ambiguity can no longer arise. - # If there is uncommitted text in the manual entry, commit it as a - # chip first so the user sees what will be sent. if self.manual_exclude_entry is not None: pending = self.manual_exclude_entry.get_text().strip() if pending: @@ -528,105 +513,94 @@ def _on_glue(self, _btn): # ── Exclude chips ──────────────────────────────────────────────── + def _normalize_exclude_pattern(self, text: str) -> str: + text = text.strip() + if text.startswith('./'): + text = text[2:] + if text.endswith('/'): + text = text[:-1] + return text + def _add_exclude_chip(self, text: str) -> None: - """Add a removable chip for an exclude pattern. Duplicates are silently skipped.""" - text = (text or "").strip() - print(f"[CodeGluer] _add_exclude_chip({text!r})", file=sys.stderr) - if not text: - print("[CodeGluer] skipped: empty", file=sys.stderr) + normalized = self._normalize_exclude_pattern(text) + debug_print(f"[CodeGluer] _add_exclude_chip({text!r}) -> normalized {normalized!r}") + if not normalized: + debug_print("[CodeGluer] skipped: empty after normalization") return - if text in self.excludes: - print("[CodeGluer] skipped: duplicate", file=sys.stderr) + if normalized in self.excludes: + debug_print("[CodeGluer] skipped: duplicate") return try: - self.excludes.append(text) + self.excludes.append(normalized) chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) chip.add_css_class("chip") chip.set_halign(Gtk.Align.START) chip.set_valign(Gtk.Align.CENTER) - label = Gtk.Label(label=text) + label = Gtk.Label(label=normalized) label.set_max_width_chars(30) - # FIX (2026-07-04): must be Pango.EllipsizeMode, NOT - # Gtk.EllipsizeMode — Gtk has no such attribute. If this - # regresses back to `Gtk.EllipsizeMode.END`, every chip-add - # raises AttributeError (caught below and shown as - # "Failed to add exclude chip"). See Pango import above. label.set_ellipsize(Pango.EllipsizeMode.END) - label.set_tooltip_text(text) + label.set_tooltip_text(normalized) chip.append(label) close_btn = Gtk.Button(label="✕") close_btn.add_css_class("chip-close") - close_btn.set_tooltip_text(f"Remove {text}") - close_btn.connect("clicked", lambda *_: self._remove_exclude_chip(text, chip)) + close_btn.set_tooltip_text(f"Remove {normalized}") + close_btn.connect("clicked", lambda *_: self._remove_exclude_chip(normalized, chip)) chip.append(close_btn) self.chip_flowbox.insert(chip, -1) - # Force the FlowBox visible (it starts hidden). Don't toggle it - # back off when the last chip is removed — that reflow path has - # caused chips to silently not appear after the first add. self.chip_flowbox.set_visible(True) - print(f"[CodeGluer] added. total excludes now: {len(self.excludes)}", file=sys.stderr) + debug_print(f"[CodeGluer] added. total excludes now: {len(self.excludes)}") except Exception as e: import traceback - print(traceback.format_exc(), file=sys.stderr) + debug_print(traceback.format_exc()) self._show_error_dialog("Failed to add exclude chip", str(e)) def _remove_exclude_chip(self, text: str, chip_widget) -> None: """Remove a chip widget and its pattern from the excludes list.""" if text in self.excludes: self.excludes.remove(text) - # FlowBox wraps each child in a GtkFlowBoxChild; remove via that wrapper. parent = chip_widget.get_parent() try: self.chip_flowbox.remove(parent) except Exception: - # Fallback: try removing the chip directly try: self.chip_flowbox.remove(chip_widget) except Exception: pass - if not self.excludes: - self.chip_flowbox.set_visible(False) + # FIX (2026-07-04): Do NOT hide the flowbox when empty. + # The reflow path has caused chips to silently not appear after + # the first add (see comment in _add_exclude_chip). Keeping the + # flowbox visible even when empty avoids that weird state. + # if not self.excludes: self.chip_flowbox.set_visible(False) def _on_manual_exclude_activate(self, entry) -> None: - """Enter in the manual entry adds the typed text as a chip.""" text = entry.get_text().strip() if text: self._add_exclude_chip(text) entry.set_text("") - # ── Browse button → file picker ────────────────────────────────── + # ── Browse button ────────────────────────────────────────────────── def _on_browse_clicked(self, _btn) -> None: - """Open a multi-select file picker. Prefers FileDialog (GTK 4.10+ - AND exposed in the PyGObject bindings), falls back to - FileChooserNative on older stacks. Any exception surfaces to the - user as a visible error dialog — otherwise GTK swallows it - silently and the button just looks dead.""" gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) - print(f"[CodeGluer] Browse clicked. GTK={gtk_version[0]}.{gtk_version[1]}, " - f"has FileDialog={hasattr(Gtk, 'FileDialog')}", file=sys.stderr) + debug_print(f"[CodeGluer] Browse clicked. GTK={gtk_version[0]}.{gtk_version[1]}, " + f"has FileDialog={hasattr(Gtk, 'FileDialog')}") try: if self._try_open_file_dialog(): return self._open_file_chooser_dialog() except Exception as e: - # Surface the error so the user (and we) can see what failed - # instead of the button doing nothing visible. import traceback - tb = traceback.format_exc() - print(tb, file=sys.stderr) + debug_print(traceback.format_exc()) self._show_error_dialog( "Could not open file picker", f"{type(e).__name__}: {e}", ) def _show_error_dialog(self, message: str, detail: str = "") -> None: - """Best-effort error dialog. Uses AlertDialog on GTK 4.10+, - MessageDialog fallback otherwise.""" gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) if gtk_version >= (4, 10): d = Gtk.AlertDialog() @@ -635,7 +609,6 @@ def _show_error_dialog(self, message: str, detail: str = "") -> None: d.set_buttons(["OK"]) d.show(self) else: - # Synchronous fallback for older GTK d = Gtk.MessageDialog( transient_for=self, modal=True, @@ -649,81 +622,65 @@ def _show_error_dialog(self, message: str, detail: str = "") -> None: d.present() def _try_open_file_dialog(self) -> bool: - """Attempt the GTK 4.10+ FileDialog path. Returns True if used, - False if not available in this PyGObject binding (so caller can - fall back to FileChooserNative).""" gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) if gtk_version < (4, 10): return False if not hasattr(Gtk, "FileDialog"): - # GTK 4.10+ runtime but PyGObject too old to expose the class return False self._open_file_dialog() return True def _open_file_dialog(self) -> None: - """GTK 4.10+ path: async Gtk.FileDialog with multi-select.""" dialog = Gtk.FileDialog() dialog.set_title("Select files to exclude") dialog.set_modal(True) try: - # FIX (2026-07-04): guarded with _looks_heavy() — see comment - # at its definition. Do not go back to an unconditional - # `if self.target_dir and os.path.isdir(self.target_dir):`. if (self.target_dir and os.path.isdir(self.target_dir) and not _looks_heavy(self.target_dir)): dialog.set_initial_folder(Gio.File.new_for_path(self.target_dir)) except Exception: pass - # Keep a reference — Python GC can destroy the dialog mid-flight - # if it goes out of scope, which would silently cancel it. self._active_picker = dialog dialog.open_multiple(self, None, self._on_file_dialog_response) def _on_file_dialog_response(self, dialog, result) -> None: - print("[CodeGluer] FileDialog response callback fired", file=sys.stderr) + debug_print("[CodeGluer] FileDialog response callback fired") self._active_picker = None try: files = dialog.open_multiple_finish(result) except Exception as e: - # Cancellation comes through as a GLib.Error; any other - # exception here is a real bug we want to see. - print(f"[CodeGluer] open_multiple_finish raised: " - f"{type(e).__module__}.{type(e).__name__}: {e}", - file=sys.stderr) + debug_print(f"[CodeGluer] open_multiple_finish raised: " + f"{type(e).__module__}.{type(e).__name__}: {e}") return - print(f"[CodeGluer] open_multiple_finish returned: {files!r}", file=sys.stderr) + debug_print(f"[CodeGluer] open_multiple_finish returned: {files!r}") if files is None: - print("[CodeGluer] files is None — bailing", file=sys.stderr) + debug_print("[CodeGluer] files is None — bailing") return try: n = files.get_n_items() - print(f"[CodeGluer] number of items selected: {n}", file=sys.stderr) + debug_print(f"[CodeGluer] number of items selected: {n}") except Exception as e: - print(f"[CodeGluer] get_n_items() failed: {e}", file=sys.stderr) + debug_print(f"[CodeGluer] get_n_items() failed: {e}") return if n == 0: - print("[CodeGluer] no items selected — bailing", file=sys.stderr) + debug_print("[CodeGluer] no items selected — bailing") return try: for i in range(n): gfile = files.get_item(i) if gfile is None: - print(f"[CodeGluer] item {i} is None", file=sys.stderr) + debug_print(f"[CodeGluer] item {i} is None") continue name = gfile.get_basename() - print(f"[CodeGluer] item {i}: {name!r}", file=sys.stderr) + debug_print(f"[CodeGluer] item {i}: {name!r}") if name: self._add_exclude_chip(name) except Exception as e: import traceback - print(traceback.format_exc(), file=sys.stderr) + debug_print(traceback.format_exc()) self._show_error_dialog("Failed to process selected files", str(e)) def _open_file_chooser_dialog(self) -> None: - """Pre-GTK 4.10 path (or FileDialog unavailable): FileChooserNative - with multi-select. NOTE: FileChooserNative is a Gtk.NativeDialog — - you must call .show(), NOT .present() (that was the bug).""" dialog = Gtk.FileChooserNative.new( title="Select files to exclude", parent=self, @@ -733,63 +690,54 @@ def _open_file_chooser_dialog(self) -> None: ) dialog.set_select_multiple(True) try: - # FIX (2026-07-04): same guard as the FileDialog path above — - # do not remove the _looks_heavy() check. if (self.target_dir and os.path.isdir(self.target_dir) and not _looks_heavy(self.target_dir)): dialog.set_current_folder(Gio.File.new_for_path(self.target_dir)) except Exception: pass dialog.connect("response", self._on_file_chooser_response) - # Keep a reference to prevent GC during async interaction. self._active_picker = dialog - # NativeDialog uses .show(), not .present() — calling present() - # silently no-ops and the dialog never appears. dialog.show() def _on_file_chooser_response(self, dialog, response) -> None: - print(f"[CodeGluer] FileChooserNative response: {response}", file=sys.stderr) + debug_print(f"[CodeGluer] FileChooserNative response: {response}") self._active_picker = None - # ACCEPT (-3) and OK (-5) both mean "user picked something". - # Some desktops/themes return OK instead of ACCEPT. accepted = response in ( Gtk.ResponseType.ACCEPT, Gtk.ResponseType.OK, ) - print(f"[CodeGluer] accepted={accepted}", file=sys.stderr) + debug_print(f"[CodeGluer] accepted={accepted}") if accepted: try: files = dialog.get_files() - print(f"[CodeGluer] get_files() returned: {files!r}", file=sys.stderr) + debug_print(f"[CodeGluer] get_files() returned: {files!r}") except Exception as e: - print(f"[CodeGluer] get_files() raised: {e}", file=sys.stderr) + debug_print(f"[CodeGluer] get_files() raised: {e}") files = None if files is None: - print("[CodeGluer] files is None — bailing", file=sys.stderr) + debug_print("[CodeGluer] files is None — bailing") else: try: n = files.get_n_items() - print(f"[CodeGluer] number of items selected: {n}", file=sys.stderr) + debug_print(f"[CodeGluer] number of items selected: {n}") for i in range(n): gfile = files.get_item(i) if gfile is None: continue name = gfile.get_basename() - print(f"[CodeGluer] item {i}: {name!r}", file=sys.stderr) + debug_print(f"[CodeGluer] item {i}: {name!r}") if name: self._add_exclude_chip(name) except Exception as e: import traceback - print(traceback.format_exc(), file=sys.stderr) + debug_print(traceback.format_exc()) self._show_error_dialog("Failed to process selected files", str(e)) else: - print(f"[CodeGluer] user did not accept (response={response})", file=sys.stderr) + debug_print(f"[CodeGluer] user did not accept (response={response})") dialog.destroy() def _execute(self, opts): - # Use absolute path for codegluer (fallback to ~/.local/bin) codegluer_path = shutil.which("codegluer") or os.path.expanduser("~/.local/bin/codegluer") - # Build command and replace the executable with absolute path cmd = [codegluer_path, *build_command(self.files, opts)[1:]] if self.dry_run: @@ -799,7 +747,6 @@ def _execute(self, opts): save_theme(self.current_theme) try: - # Add timeout to avoid UI freeze (fix 1) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0: output_name = opts["output"] or default_name(self.target_dir, opts["format"]) @@ -843,7 +790,6 @@ def main(): files = [a for a in args if not a.startswith("-")] if not files: - # No files — maybe launched standalone env = os.environ.get("NAUTILUS_SCRIPT_SELECTED_FILE_PATHS", "") or \ os.environ.get("NEMO_SCRIPT_SELECTED_FILE_PATHS", "") files = [f for f in env.splitlines() if f]