From 68a4285bb9d8d516c13d83655d80cfd88187affc Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Wed, 1 Jul 2026 07:45:50 +0300 Subject: [PATCH 1/3] feat(gui): add GTK4 Python GUI for file-manager integration Add a native GTK4 Python GUI for right-click file/folder gluing that provides: - Default filename with collision-safe auto-increment (Glued_Code.md, Glued_Code_1.md, ... with pid fallback after 99 collisions) - Format-aware file extension (.md/.txt) with live switching when user hasn't customized the name - Theme support (auto/light/dark/roselle) with CSS styling and persistence in ~/.config/codegluer/theme - Smart flag visibility: dir-only flags (tree/toc/respect-gitignore) hidden when selection is files-only - Exclude pattern validation with auto-fix dialog for space-separated input - Apply Theme button that grays out when no change is pending - Nautilus (env var) and Nemo (positional args) integration via single codegluer-gui binary in ~/.local/bin Architecture separates pure logic (build_command, default_name, should_update_default, theme management) from GTK UI for testability. 22-test suite covers all logic paths without requiring a display. Installer updated to check for GTK4 Python bindings (python3-gi, gir1.2-gtk-4.0) and fail fast with install instructions if missing. --- codegluer_gui.py | 475 ++++++++++++++++++++++++++++++++++++++++++ install.sh | 182 +++++----------- test_codegluer_gui.py | 308 +++++++++++++++++++++++++++ 3 files changed, 836 insertions(+), 129 deletions(-) create mode 100755 codegluer_gui.py create mode 100755 test_codegluer_gui.py diff --git a/codegluer_gui.py b/codegluer_gui.py new file mode 100755 index 0000000..4dcf3e3 --- /dev/null +++ b/codegluer_gui.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +""" +CodeGluer GUI — GTK4 file-manager integration. + +Right-click selected files/folders in Nautilus or Nemo, choose "CodeGluer" +from the scripts/actions menu, configure options in a native GTK4 dialog, +and glue them into a single file via the `codegluer` CLI. + +Architecture: + - build_command() is pure logic, no GTK. Tested by test_codegluer_gui.py. + - CodeGluerWindow is the GTK4 UI. Hard to test (needs display), so kept thin. + - Theme persistence in ~/.config/codegluer/theme (one line: auto|light|dark|roselle). + +Usage: + codegluer_gui.py [file2] ... # from file manager + codegluer_gui.py --dry-run ... # print cmd, don't run +""" + +import os +import sys +import json +import subprocess +from pathlib import Path + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) / "codegluer" +CONFIG_FILE = CONFIG_DIR / "theme" + + +# ────────────────────────────────────────────────────────────────────── +# Pure logic: command builder. No GTK. Fully testable. +# ────────────────────────────────────────────────────────────────────── + +def default_name(target_dir: str, fmt: str, existing: set | None = None) -> str: + """Collision-safe default filename. Glued_Code.md → Glued_Code_1.md → ...""" + ext = "txt" if fmt == "plain" else "md" + base = "Glued_Code" + if existing is None: + existing = set(os.listdir(target_dir)) if os.path.isdir(target_dir) else set() + name = f"{base}.{ext}" + i = 1 + while name in existing: + name = f"{base}_{i}.{ext}" + i += 1 + if i > 99: + name = f"{base}_{os.getpid()}.{ext}" + break + return name + + +def is_any_dir(files: list[str]) -> bool: + return any(os.path.isdir(f) for f in files) + + +def target_dir_of(files: list[str]) -> str: + """Directory where output should land. '.' for bare filenames.""" + if not files: + return "." + parent = os.path.dirname(files[0]) + return parent if parent else "." + + +def should_update_default(current_text: str, target_dir: str) -> bool: + """True if the output field still holds a default value (or is empty), + meaning a format switch may safely update the extension. False if the + user typed a custom name that should be preserved.""" + default_md = default_name(target_dir, "markdown") + default_txt = default_name(target_dir, "plain") + return current_text in (default_md, default_txt, "") + + +def build_command(files: list[str], opts: dict) -> list[str]: + """ + Build the codegluer CLI command from user options. + + opts keys: + format: 'plain' | 'markdown' + output: str (filename, empty = default) + excludes: str (comma-separated patterns) + stats, estimate_tokens, tree, toc, respect_gitignore: bool + any_dir: bool (precomputed, drives -r and dir-only flags) + target_dir: str (where output lands) + """ + any_dir = opts.get("any_dir", is_any_dir(files)) + target_dir = opts.get("target_dir") or target_dir_of(files) + + fmt = opts.get("format", "plain") + output = opts.get("output", "").strip() + if not output: + output = default_name(target_dir, fmt) + + cmd = ["codegluer"] + files + cmd += ["--format", fmt] + if any_dir: + cmd += ["-r"] + if opts.get("tree") and any_dir: + cmd += ["--tree"] + if opts.get("stats"): + cmd += ["--stats"] + if opts.get("toc") and any_dir: + cmd += ["--toc"] + if opts.get("estimate_tokens"): + cmd += ["--estimate-tokens"] + if opts.get("respect_gitignore") and any_dir: + cmd += ["--respect-gitignore"] + + excludes = opts.get("excludes", "").strip() + if excludes: + for pat in excludes.split(","): + pat = pat.strip() + if pat: + cmd += ["--exclude", pat] + + cmd += ["-o", os.path.join(target_dir, output)] + return cmd + + +# ────────────────────────────────────────────────────────────────────── +# Theme management +# ────────────────────────────────────────────────────────────────────── + +# Dropdown shows "auto" (placeholder, grays Apply) + real themes (enable Apply). +# "auto" means "follow GTK" — it's in the dropdown as text but acts as no-selection. +THEMES = ["auto", "light", "dark", "roselle"] +REAL_THEMES = ["light", "dark", "roselle"] # only these enable the Apply button + +def read_theme() -> str: + try: + t = CONFIG_FILE.read_text().strip() + if t in THEMES: + return t + except (OSError, FileNotFoundError): + pass + return "auto" + + +def save_theme(theme: str) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(theme) + + +THEME_CSS = { + "light": """ + window { background: #ffffff; color: #333333; } + entry { background: #f9f9f9; border: 1px solid #ddd; border-radius: 3px; padding: 4px; color: #333333; } + checkbutton { color: #333333; } + 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; } + """, + "dark": """ + window { background: #2b2b2b; color: #e0e0e0; } + entry { background: #3a3a3a; border: 1px solid #555; border-radius: 3px; padding: 4px; color: #e0e0e0; } + checkbutton { color: #e0e0e0; } + 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; } + """, + "roselle": """ + window { background: #1a0a0a; color: #f0d0d0; } + entry { background: #2a1515; border: 1px solid #C62734; border-radius: 3px; padding: 4px; color: #f0d0d0; } + checkbutton { color: #f0d0d0; } + 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; } + """, +} + +def resolve_theme(theme: str) -> str: + """auto → light/dark via GTK; explicit themes pass through.""" + if theme != "auto": + return theme + try: + result = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", "gtk-theme"], + capture_output=True, text=True, timeout=3 + ) + gtk_theme = result.stdout.strip().strip("'") + return "dark" if "dark" in gtk_theme.lower() else "light" + except Exception: + return "light" + + +def theme_css(theme: str) -> str: + resolved = resolve_theme(theme) + return THEME_CSS.get(resolved, "") + + +# ────────────────────────────────────────────────────────────────────── +# GTK4 GUI +# ────────────────────────────────────────────────────────────────────── + +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 + + any_dir = is_any_dir(files) + target_dir = target_dir_of(files) + + class CodeGluerWindow(Gtk.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title="CodeGluer") + self.set_default_size(480, -1) + + self.files = files + self.any_dir = any_dir + self.target_dir = target_dir + self.dry_run = dry_run + self.current_theme = read_theme() + + # State + self.format = "markdown" + self.output_entry = None + self.excludes_entry = None + self.format_dropdown = None + self.theme_dropdown = None + self.checkboxes = {} + + self._build_ui() + self._apply_theme(self.current_theme) + + 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) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.connect("clicked", lambda *_: self.close()) + header.pack_start(cancel_btn) + + glue_btn = Gtk.Button(label="Glue!") + glue_btn.add_css_class("suggested-action") + glue_btn.connect("clicked", self._on_glue) + header.pack_end(glue_btn) + + 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 + 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) + content.set_margin_top(12) + 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) + content.append(grid) + + row = 0 + + # Output filename + grid.attach(Gtk.Label(label="Output filename:", halign=Gtk.Align.END), 0, row, 1, 1) + default = default_name(self.target_dir, self.format) + self.output_entry = Gtk.Entry() + self.output_entry.set_text(default) + self.output_entry.set_hexpand(True) + self.output_entry.connect("changed", self._on_output_changed) + self._user_modified_output = False + 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) + row += 1 + + # Format dropdown + grid.attach(Gtk.Label(label="Format:", halign=Gtk.Align.END), 0, row, 1, 1) + fmt_model = Gtk.StringList.new(["markdown", "plain"]) + self.format_dropdown = Gtk.DropDown(model=fmt_model) + self.format_dropdown.connect("notify::selected", self._on_format_changed) + 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). + 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) + + self.checkboxes["stats"] = Gtk.CheckButton(label="Stats") + checks_box.append(self.checkboxes["stats"]) + + self.checkboxes["estimate_tokens"] = Gtk.CheckButton(label="Estimate tokens") + checks_box.append(self.checkboxes["estimate_tokens"]) + + if self.any_dir: + self.checkboxes["tree"] = Gtk.CheckButton(label="Tree") + checks_box.append(self.checkboxes["tree"]) + self.checkboxes["toc"] = Gtk.CheckButton(label="TOC (markdown only)") + checks_box.append(self.checkboxes["toc"]) + self.checkboxes["respect_gitignore"] = Gtk.CheckButton(label="Respect .gitignore") + checks_box.append(self.checkboxes["respect_gitignore"]) + + def _on_output_changed(self, entry): + self._user_modified_output = not should_update_default( + entry.get_text(), self.target_dir + ) + + def _on_format_changed(self, dropdown, _param): + selected = dropdown.get_selected() + self.format = ["markdown", "plain"][selected] + if not self._user_modified_output: + self.output_entry.set_text( + default_name(self.target_dir, self.format) + ) + + 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) + + def _on_apply_theme(self, _btn): + selected = self.theme_dropdown.get_selected() + new_theme = THEMES[selected] + 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 + + def _apply_theme(self, theme): + css_text = theme_css(theme) + if not css_text: + return + provider = Gtk.CssProvider() + provider.load_from_data(css_text.encode()) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _collect_opts(self): + opts = { + "format": self.format, + "output": self.output_entry.get_text(), + "excludes": self.excludes_entry.get_text(), + "stats": self.checkboxes["stats"].get_active(), + "estimate_tokens": self.checkboxes["estimate_tokens"].get_active(), + "any_dir": self.any_dir, + "target_dir": self.target_dir, + } + if self.any_dir: + opts["tree"] = self.checkboxes["tree"].get_active() + opts["toc"] = self.checkboxes["toc"].get_active() + opts["respect_gitignore"] = self.checkboxes["respect_gitignore"].get_active() + return opts + + def _on_glue(self, _btn): + opts = self._collect_opts() + + # Exclude validation: space without comma + excludes = opts["excludes"].strip() + if excludes and " " in excludes and "," not in excludes: + 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) + return + + self._execute(opts) + + def _on_exclude_dialog_response(self, dialog, result, opts): + try: + choice = dialog.choose_finish(result) + except Exception: + return + if choice == 2: # Fix it + opts["excludes"] = opts["excludes"].replace(" ", ",") + elif choice == 0: # Cancel + return + self._execute(opts) + + def _execute(self, opts): + cmd = build_command(self.files, opts) + + if self.dry_run: + print("\n".join(cmd)) + self.close() + return + + save_theme(self.current_theme) + try: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + output_name = opts["output"] or default_name(self.target_dir, opts["format"]) + self._notify("CodeGluer", f"Created: {output_name}") + else: + self._notify("CodeGluer", f"Failed: {result.stderr.strip()}") + except Exception as e: + self._notify("CodeGluer", f"Error: {e}") + self.close() + + def _notify(self, title, body): + try: + subprocess.run(["notify-send", title, body], timeout=5) + except Exception: + pass + + app = Gtk.Application(application_id="com.codegluer.gui", flags=Gio.ApplicationFlags.FLAGS_NONE) + win = None + + def on_activate(a): + nonlocal win + win = CodeGluerWindow(a) + win.present() + + app.connect("activate", on_activate) + app.run(None) + + +# ────────────────────────────────────────────────────────────────────── +# Entry point +# ────────────────────────────────────────────────────────────────────── + +def main(): + args = sys.argv[1:] + dry_run = False + if "--dry-run" in args: + dry_run = True + args.remove("--dry-run") + + 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] + + if not files: + print("Usage: codegluer_gui.py [file2] ...", file=sys.stderr) + print(" (or run via file manager right-click)", file=sys.stderr) + sys.exit(1) + + run_gui(files, dry_run=dry_run) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index cb5fea0..1d17a36 100755 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ============================================================ -# CodeGluer – Installer -# Sets up the Python package and right-click context menus. +# CodeGluer GUI – Installer +# Sets up the Python package and GTK4 right-click context menus. # ============================================================ set -euo pipefail @@ -16,12 +16,12 @@ NC='\033[0m' echo -e "${CYAN}" echo "╔══════════════════════════════════════════╗" -echo "║ CodeGluer – Installer ║" +echo "║ CodeGluer GUI – Installer ║" echo "╚══════════════════════════════════════════╝" echo -e "${NC}" # ---------------------------------------------------------- -# 1. Install the Python package +# 1. Install the Python package (codegluer CLI) # ---------------------------------------------------------- echo -e "${YELLOW}➜ Installing CodeGluer Python package...${NC}" if command -v pipx &>/dev/null; then @@ -33,7 +33,7 @@ else echo -e " ${GREEN}✔ Installed via pip --user.${NC}" fi -# Ensure ~/.local/bin is on PATH (warn if not) +# Ensure ~/.local/bin is on PATH if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then echo -e " ${YELLOW}⚠ ~/.local/bin is not on your PATH." echo -e " Add this line to your ~/.bashrc or ~/.zshrc:" @@ -41,147 +41,69 @@ if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then fi # ---------------------------------------------------------- -# 2. Nautilus integration (GNOME) +# 2. Check for GTK4 Python bindings # ---------------------------------------------------------- -NAUTILUS_SCRIPT_DIR="$HOME/.local/share/nautilus/scripts" -echo -e "${YELLOW}➜ Setting up Nautilus right-click integration...${NC}" -mkdir -p "$NAUTILUS_SCRIPT_DIR" - -create_nautilus_script() { - local name="$1" - local format="$2" - local script_path="$NAUTILUS_SCRIPT_DIR/$name" - - cat > "$script_path" << 'NAUTILUS_EOF' -#!/usr/bin/env bash -# Nautilus script – "Glue Code Files" - -# 🧠 Robust PATH resolution (GUI shells don't source .bashrc) -GLUER="$HOME/.local/bin/codegluer" -if [ ! -x "$GLUER" ]; then - GLUER="codegluer" - if ! command -v "$GLUER" &>/dev/null; then - notify-send "CodeGluer" "Error: CodeGluer not found in PATH" --icon=dialog-error - exit 1 - fi +echo -e "${YELLOW}➜ Checking GTK4 Python bindings...${NC}" +if python3 -c "import gi; gi.require_version('Gtk', '4.0')" 2>/dev/null; then + echo -e " ${GREEN}✔ GTK4 Python bindings available.${NC}" +else + echo -e " ${RED}✘ GTK4 Python bindings (PyGObject) not found.${NC}" + echo -e " ${YELLOW} Install with: sudo apt install python3-gi gir1.2-gtk-4.0${NC}" + echo -e " ${YELLOW} (or your distro's equivalent)${NC}" + exit 1 fi -# Collect selected files -FILES=() -while IFS= read -r file; do - [ -n "$file" ] && FILES+=("$file") -done <<< "$NAUTILUS_SCRIPT_SELECTED_FILE_PATHS" +# ---------------------------------------------------------- +# 3. Install the GUI script +# ---------------------------------------------------------- +echo -e "${YELLOW}➜ Installing CodeGluer GUI script...${NC}" +GUI_SRC="$SCRIPT_DIR/codegluer_gui.py" +GUI_DEST="$HOME/.local/bin/codegluer-gui" -if [ ${#FILES[@]} -eq 0 ]; then - notify-send "CodeGluer" "No files selected." --icon=dialog-warning +if [[ ! -f "$GUI_SRC" ]]; then + echo -e "${RED}✘ codegluer_gui.py not found next to install.sh.${NC}" >&2 exit 1 fi -# Smart GUI: automatically add -r if a folder is selected -RECURSIVE_FLAG="" -for file in "${FILES[@]}"; do - if [ -d "$file" ]; then - RECURSIVE_FLAG="-r" - break - fi -done +cp -f "$GUI_SRC" "$GUI_DEST" +chmod +x "$GUI_DEST" +echo -e " ${GREEN}✔ Installed: $GUI_DEST${NC}" -OUTPUT=$("$GLUER" "${FILES[@]}" $RECURSIVE_FLAG --format FORMAT_PLACEHOLDER 2>&1) -EXIT_CODE=$? +# ---------------------------------------------------------- +# 4. Nautilus integration (GNOME) — script in ~/.local/share/nautilus/scripts/ +# ---------------------------------------------------------- +NAUTILUS_SCRIPT_DIR="$HOME/.local/share/nautilus/scripts" +echo -e "${YELLOW}➜ Setting up Nautilus right-click integration...${NC}" +mkdir -p "$NAUTILUS_SCRIPT_DIR" -if [ $EXIT_CODE -eq 0 ]; then - notify-send "CodeGluer" "$OUTPUT" --icon=dialog-information -else - notify-send "CodeGluer" "Error: $OUTPUT" --icon=dialog-error -fi +cat > "$NAUTILUS_SCRIPT_DIR/CodeGluer" << 'NAUTILUS_EOF' +#!/usr/bin/env bash +# Nautilus script — launches CodeGluer GUI with selected files. +# Nautilus passes paths via env var (newline-separated), not positional args. +IFS=$'\n' read -r -d '' -a files <<< "$NAUTILUS_SCRIPT_SELECTED_FILE_PATHS" +exec codegluer-gui "${files[@]}" NAUTILUS_EOF - - # Safe replacement that works on Linux & macOS (with error handling) - tmp_file=$(mktemp) - if sed "s/FORMAT_PLACEHOLDER/$format/g" "$script_path" > "$tmp_file"; then - mv "$tmp_file" "$script_path" - chmod +x "$script_path" - echo -e " ${GREEN}✔ Nautilus script: $name${NC}" - else - rm -f "$tmp_file" - echo -e " ${RED}✘ Failed to process $script_path${NC}" >&2 - exit 1 - fi -} - -create_nautilus_script "Glue Code Files (Plain)" "plain" -create_nautilus_script "Glue Code Files (Markdown)" "markdown" +chmod +x "$NAUTILUS_SCRIPT_DIR/CodeGluer" +echo -e " ${GREEN}✔ Nautilus script: CodeGluer${NC}" # ---------------------------------------------------------- -# 3. Nemo integration (Cinnamon) +# 5. Nemo integration (Cinnamon) — .nemo_action file # ---------------------------------------------------------- if command -v nemo &>/dev/null; then - echo -e "${YELLOW}➜ Nemo detected – setting up Nemo actions...${NC}" + echo -e "${YELLOW}➜ Nemo detected – setting up Nemo action...${NC}" NEMO_ACTION_DIR="$HOME/.local/share/nemo/actions" mkdir -p "$NEMO_ACTION_DIR" - create_nemo_action() { - local label="$1" - local format="$2" - local action_file="$NEMO_ACTION_DIR/codegluer-${format}.nemo_action" - local wrapper_file="$NEMO_ACTION_DIR/codegluer-nemo-${format}.sh" - - cat > "$wrapper_file" << 'NEMO_WRAPPER_EOF' -#!/usr/bin/env bash -# 🧠 Robust PATH resolution (GUI shells don't source .bashrc) -GLUER="$HOME/.local/bin/codegluer" -if [ ! -x "$GLUER" ]; then - GLUER="codegluer" - if ! command -v "$GLUER" &>/dev/null; then - notify-send "CodeGluer" "Error: CodeGluer not found in PATH" --icon=dialog-error - exit 1 - fi -fi - -# Smart GUI: automatically add -r if a folder is selected -RECURSIVE_FLAG="" -for arg in "$@"; do - if [ -d "$arg" ]; then - RECURSIVE_FLAG="-r" - break - fi -done - -OUTPUT=$("$GLUER" "$@" $RECURSIVE_FLAG --format FORMAT_PLACEHOLDER 2>&1) -EXIT_CODE=$? - -if [ $EXIT_CODE -eq 0 ]; then - notify-send "CodeGluer" "$OUTPUT" --icon=dialog-information -else - notify-send "CodeGluer" "Error: $OUTPUT" --icon=dialog-error -fi -NEMO_WRAPPER_EOF - - # Safe replacement with error handling - tmp_file=$(mktemp) - if sed "s/FORMAT_PLACEHOLDER/$format/g" "$wrapper_file" > "$tmp_file"; then - mv "$tmp_file" "$wrapper_file" - chmod +x "$wrapper_file" - else - rm -f "$tmp_file" - echo -e " ${RED}✘ Failed to process $wrapper_file${NC}" >&2 - exit 1 - fi - - cat > "$action_file" << EOF + cat > "$NEMO_ACTION_DIR/codegluer.nemo_action" < 10, f"theme_css('{t}') returned empty/short: {css!r}" +passed += 1 +print("✓ Test 15: theme_css returns CSS for known themes") + +# ────────────────────────────────────────────────────────────────────── +# Test 16: build_command with no excludes → no --exclude flag +# ────────────────────────────────────────────────────────────────────── +opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} +cmd = cg.build_command([os.path.join(TMP, "src")], opts) +assert_absent("--exclude", cmd) +passed += 1 +print("✓ Test 16: no excludes → no --exclude flag") + +# ────────────────────────────────────────────────────────────────────── +# Test 17: --toc only for markdown (codegluer constraint) +# Note: codegluer's --toc is markdown-only. We still pass the flag if user +# checked it, codegluer will handle/no-op. Test that flag is passed for markdown. +# ────────────────────────────────────────────────────────────────────── +opts = {"format": "markdown", "output": "out.md", "excludes": "", + "stats": False, "estimate_tokens": False, + "tree": False, "toc": True, "respect_gitignore": False, + "any_dir": True, "target_dir": TMP} +cmd = cg.build_command([os.path.join(TMP, "src")], opts) +assert_present("--toc", cmd) +passed += 1 +print("✓ Test 17: --toc passed for markdown") + +# ────────────────────────────────────────────────────────────────────── +# Test 18: multiple files passed correctly +# ────────────────────────────────────────────────────────────────────── +f1 = os.path.join(TMP, "src", "file.py") +f2 = os.path.join(TMP, "standalone.txt") +opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} +cmd = cg.build_command([f1, f2], opts) +assert_present(f1, cmd) +assert_present(f2, cmd) +passed += 1 +print("✓ Test 18: multiple files passed correctly") + +# ────────────────────────────────────────────────────────────────────── +# Test 19: should_update_default — default markdown name → True (update ok) +# ────────────────────────────────────────────────────────────────────── +default_md = cg.default_name(TMP, "markdown") +assert_eq(True, cg.should_update_default(default_md, TMP), "default .md → update") +passed += 1 +print("✓ Test 19: default markdown name → should_update=True") + +# ────────────────────────────────────────────────────────────────────── +# Test 20: should_update_default — default plain name → True +# ────────────────────────────────────────────────────────────────────── +default_txt = cg.default_name(TMP, "plain") +assert_eq(True, cg.should_update_default(default_txt, TMP), "default .txt → update") +passed += 1 +print("✓ Test 20: default plain name → should_update=True") + +# ────────────────────────────────────────────────────────────────────── +# Test 21: should_update_default — empty string → True +# ────────────────────────────────────────────────────────────────────── +assert_eq(True, cg.should_update_default("", TMP), "empty → update") +passed += 1 +print("✓ Test 21: empty filename → should_update=True") + +# ────────────────────────────────────────────────────────────────────── +# Test 22: should_update_default — custom name → False (preserve!) +# Regression: "Glued_Code_custom.md" must NOT be treated as default. +# ────────────────────────────────────────────────────────────────────── +assert_eq(False, cg.should_update_default("Glued_Code_custom.md", TMP), "custom → preserve") +assert_eq(False, cg.should_update_default("my_output.md", TMP), "custom2 → preserve") +assert_eq(False, cg.should_update_default("report.txt", TMP), "custom3 → preserve") +passed += 1 +print("✓ Test 22: custom name → should_update=False (regression for startswith bug)") + +print(f"\nPASS: all {passed} tests pass") From 7b57094e7ef3ed7dcc7dcb2f76373127efa4af8f Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Wed, 1 Jul 2026 10:32:26 +0300 Subject: [PATCH 2/3] refactor(gui): address code review findings in GTK4 GUI Apply nine fixes identified during code review of the GTK4 file-manager integration, improving reliability, correctness, and compatibility. Correctness fixes: - build_command: --toc now gated on fmt == 'markdown' (codegluer's TOC is markdown-only; previously passed unconditionally) - _on_format_changed: clear TOC checkbox when switching to plain, keeping UI state consistent with the command builder - _apply_theme: reuse a single Gtk.CssProvider per window instead of allocating a new one on every apply (was leaking providers) - _on_glue: guard Gtk.AlertDialog with a GTK 4.10+ version check, falling back to silent auto-fix on older GTK 4.0-4.9 installs Reliability fixes: - install.sh: move GTK4 Python bindings check before the pipx/pip install step so missing deps fail fast instead of leaving a partial install behind - install.sh: mkdir -p ~/.local/bin before copying codegluer-gui so fresh systems without the directory don't fail - install.sh + launchers: use absolute path $HOME/.local/bin/codegluer-gui in Nautilus/Nemo integration; GUI resolves codegluer via shutil.which with ~/.local/bin fallback so right-click launches work even when the user's shell PATH is incomplete (common in file-manager contexts) Code quality: - test_codegluer_gui.py: wrap all test logic in if __name__ == '__main__' so pytest collection doesn't execute the self-checks or trigger sys.exit(1) at import time - test_codegluer_gui.py: replace == True / == False assertions with truthy/falsey checks (Ruff E712) - build_command: use list unpacking ['codegluer', *files] instead of concatenation - Remove unused 'import json' from codegluer_gui.py Add Test 23 as a regression guard for the TOC+plain fix, verifying --toc is absent from the command when format is plain even if the user had the TOC checkbox enabled. --- codegluer_gui.py | 53 ++-- install.sh | 37 +-- test_codegluer_gui.py | 559 +++++++++++++++++++++--------------------- 3 files changed, 341 insertions(+), 308 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index 4dcf3e3..4077a6e 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -18,8 +18,8 @@ import os import sys -import json import subprocess +import shutil from pathlib import Path CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))) / "codegluer" @@ -88,7 +88,7 @@ def build_command(files: list[str], opts: dict) -> list[str]: if not output: output = default_name(target_dir, fmt) - cmd = ["codegluer"] + files + cmd = ["codegluer", *files] cmd += ["--format", fmt] if any_dir: cmd += ["-r"] @@ -96,7 +96,7 @@ def build_command(files: list[str], opts: dict) -> list[str]: cmd += ["--tree"] if opts.get("stats"): cmd += ["--stats"] - if opts.get("toc") and any_dir: + if opts.get("toc") and any_dir and fmt == "markdown": cmd += ["--toc"] if opts.get("estimate_tokens"): cmd += ["--estimate-tokens"] @@ -217,6 +217,9 @@ def __init__(self, app): self.theme_dropdown = None self.checkboxes = {} + # Reusable CSS provider (fix leak) + self._css_provider = None + self._build_ui() self._apply_theme(self.current_theme) @@ -336,6 +339,9 @@ 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. @@ -354,11 +360,14 @@ def _apply_theme(self, theme): css_text = theme_css(theme) if not css_text: return - provider = Gtk.CssProvider() - provider.load_from_data(css_text.encode()) + display = Gdk.Display.get_default() + if self._css_provider: + Gtk.StyleContext.remove_provider_for_display(display, self._css_provider) + self._css_provider = Gtk.CssProvider() + self._css_provider.load_from_data(css_text.encode()) Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - provider, + display, + self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) @@ -384,14 +393,21 @@ def _on_glue(self, _btn): # Exclude validation: space without comma excludes = opts["excludes"].strip() if excludes and " " in excludes and "," not in excludes: - 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) + # 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 self._execute(opts) @@ -408,7 +424,10 @@ def _on_exclude_dialog_response(self, dialog, result, opts): self._execute(opts) def _execute(self, opts): - cmd = build_command(self.files, 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: print("\n".join(cmd)) @@ -472,4 +491,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/install.sh b/install.sh index 1d17a36..38ad358 100755 --- a/install.sh +++ b/install.sh @@ -21,7 +21,20 @@ echo "╚═══════════════════════ echo -e "${NC}" # ---------------------------------------------------------- -# 1. Install the Python package (codegluer CLI) +# 1. Check for GTK4 Python bindings (moved earlier) +# ---------------------------------------------------------- +echo -e "${YELLOW}➜ Checking GTK4 Python bindings...${NC}" +if python3 -c "import gi; gi.require_version('Gtk', '4.0')" 2>/dev/null; then + echo -e " ${GREEN}✔ GTK4 Python bindings available.${NC}" +else + echo -e " ${RED}✘ GTK4 Python bindings (PyGObject) not found.${NC}" + echo -e " ${YELLOW} Install with: sudo apt install python3-gi gir1.2-gtk-4.0${NC}" + echo -e " ${YELLOW} (or your distro's equivalent)${NC}" + exit 1 +fi + +# ---------------------------------------------------------- +# 2. Install the Python package (codegluer CLI) # ---------------------------------------------------------- echo -e "${YELLOW}➜ Installing CodeGluer Python package...${NC}" if command -v pipx &>/dev/null; then @@ -40,19 +53,6 @@ if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then echo -e " export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}" fi -# ---------------------------------------------------------- -# 2. Check for GTK4 Python bindings -# ---------------------------------------------------------- -echo -e "${YELLOW}➜ Checking GTK4 Python bindings...${NC}" -if python3 -c "import gi; gi.require_version('Gtk', '4.0')" 2>/dev/null; then - echo -e " ${GREEN}✔ GTK4 Python bindings available.${NC}" -else - echo -e " ${RED}✘ GTK4 Python bindings (PyGObject) not found.${NC}" - echo -e " ${YELLOW} Install with: sudo apt install python3-gi gir1.2-gtk-4.0${NC}" - echo -e " ${YELLOW} (or your distro's equivalent)${NC}" - exit 1 -fi - # ---------------------------------------------------------- # 3. Install the GUI script # ---------------------------------------------------------- @@ -65,6 +65,9 @@ if [[ ! -f "$GUI_SRC" ]]; then exit 1 fi +# Ensure target directory exists +mkdir -p "$(dirname "$GUI_DEST")" + cp -f "$GUI_SRC" "$GUI_DEST" chmod +x "$GUI_DEST" echo -e " ${GREEN}✔ Installed: $GUI_DEST${NC}" @@ -81,7 +84,7 @@ cat > "$NAUTILUS_SCRIPT_DIR/CodeGluer" << 'NAUTILUS_EOF' # Nautilus script — launches CodeGluer GUI with selected files. # Nautilus passes paths via env var (newline-separated), not positional args. IFS=$'\n' read -r -d '' -a files <<< "$NAUTILUS_SCRIPT_SELECTED_FILE_PATHS" -exec codegluer-gui "${files[@]}" +exec "$HOME/.local/bin/codegluer-gui" "${files[@]}" NAUTILUS_EOF chmod +x "$NAUTILUS_SCRIPT_DIR/CodeGluer" echo -e " ${GREEN}✔ Nautilus script: CodeGluer${NC}" @@ -98,7 +101,7 @@ if command -v nemo &>/dev/null; then [Nemo Action] Name=CodeGluer Comment=Glue selected files/folders into a single file (GTK4 dialog) -Exec=codegluer-gui %F +Exec=$HOME/.local/bin/codegluer-gui %F Icon-Name=text-x-generic Selection=notnone Extensions=any; @@ -123,4 +126,4 @@ echo "" echo " Terminal usage:" echo " codegluer-gui file1.py file2.js" echo " codegluer-gui --dry-run src/ (print command, don't run)" -echo "" +echo "" \ No newline at end of file diff --git a/test_codegluer_gui.py b/test_codegluer_gui.py index a7c3a00..df69216 100755 --- a/test_codegluer_gui.py +++ b/test_codegluer_gui.py @@ -32,277 +32,288 @@ def assert_eq(expected, actual, msg=""): sys.exit(1) -TMP = tempfile.mkdtemp() -def cleanup(): - shutil.rmtree(TMP, ignore_errors=True) -atexit = __import__("atexit") -atexit.register(cleanup) - -# Set up test dirs/files -os.makedirs(os.path.join(TMP, "src")) -open(os.path.join(TMP, "src", "file.py"), "w").close() -open(os.path.join(TMP, "standalone.txt"), "w").close() - -passed = 0 - -# ────────────────────────────────────────────────────────────────────── -# Test 1: markdown + dir → all flags available, correct command built -# ────────────────────────────────────────────────────────────────────── -opts = { - "format": "markdown", "output": "out.md", "excludes": "foo,bar", - "stats": True, "estimate_tokens": True, - "tree": True, "toc": True, "respect_gitignore": True, - "any_dir": True, "target_dir": TMP, -} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("codegluer", cmd) -assert_present(os.path.join(TMP, "src"), cmd) -assert_present("-r", cmd) -assert_present("--format", cmd) -assert_present("markdown", cmd) -assert_present("--tree", cmd) -assert_present("--stats", cmd) -assert_present("--toc", cmd) -assert_present("--estimate-tokens", cmd) -assert_present("--respect-gitignore", cmd) -assert_present("--exclude", cmd) -assert_present("foo", cmd) -assert_present("bar", cmd) -assert_present("-o", cmd) -assert_present(os.path.join(TMP, "out.md"), cmd) -# Ensure flags use hyphens, not underscores -assert_absent("--estimate_tokens", cmd) -assert_absent("--respect_gitignore", cmd) -passed += 1 -print("✓ Test 1: markdown + dir, all flags") - -# ────────────────────────────────────────────────────────────────────── -# Test 2: plain format, empty output → default Glued_Code.txt -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "plain", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("Glued_Code.txt", cmd) -assert_absent("Glued_Code.md", cmd) -assert_present("-r", cmd) -assert_present("--format", cmd) -assert_present("plain", cmd) -passed += 1 -print("✓ Test 2: plain format → .txt default") - -# ────────────────────────────────────────────────────────────────────── -# Test 3: markdown format, empty output → default Glued_Code.md -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "markdown", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("Glued_Code.md", cmd) -passed += 1 -print("✓ Test 3: markdown format → .md default") - -# ────────────────────────────────────────────────────────────────────── -# Test 4: collision → Glued_Code_1.md -# ────────────────────────────────────────────────────────────────────── -open(os.path.join(TMP, "Glued_Code.md"), "w").close() -opts = {"format": "markdown", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("Glued_Code_1.md", cmd) -assert_absent("Glued_Code.md", cmd) -os.remove(os.path.join(TMP, "Glued_Code.md")) -passed += 1 -print("✓ Test 4: collision → _1 suffix") - -# ────────────────────────────────────────────────────────────────────── -# Test 5: files-only → no -r, no dir-only flags even if True -# ────────────────────────────────────────────────────────────────────── -opts = { - "format": "markdown", "output": "out.md", "excludes": "", - "stats": True, "estimate_tokens": True, - "tree": True, "toc": True, "respect_gitignore": True, - "any_dir": False, "target_dir": TMP, -} -cmd = cg.build_command([os.path.join(TMP, "standalone.txt")], opts) -assert_absent("-r", cmd) -assert_absent("--tree", cmd) -assert_absent("--toc", cmd) -assert_absent("--respect-gitignore", cmd) -assert_present("--stats", cmd) -assert_present("--estimate-tokens", cmd) -passed += 1 -print("✓ Test 5: files-only hides dir-only flags") - -# ────────────────────────────────────────────────────────────────────── -# Test 6: bare filename (no directory) → target_dir is "." -# ────────────────────────────────────────────────────────────────────── -assert_eq(".", cg.target_dir_of(["file.txt"]), "bare filename target_dir") -passed += 1 -print("✓ Test 6: bare filename → target_dir='.'") - -# ────────────────────────────────────────────────────────────────────── -# Test 7: custom output name respected -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "markdown", "output": "Glued_Code_custom.md", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("Glued_Code_custom.md", cmd) -assert_absent("Glued_Code_1.md", cmd) -passed += 1 -print("✓ Test 7: custom name respected") - -# ────────────────────────────────────────────────────────────────────── -# Test 8: exclude with spaces (auto-fixed by GUI before build_command) -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "markdown", "output": "out.md", "excludes": "foo.py,bar.py", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("--exclude", cmd) -assert_present("foo.py", cmd) -assert_present("bar.py", cmd) -passed += 1 -print("✓ Test 8: exclude comma-separated") - -# ────────────────────────────────────────────────────────────────────── -# Test 9: default_name collision cap at 99 -# ────────────────────────────────────────────────────────────────────── -# Create Glued_Code.md + Glued_Code_1.md through Glued_Code_99.md (100 total) -open(os.path.join(TMP, "Glued_Code.md"), "w").close() -for i in range(1, 100): - open(os.path.join(TMP, f"Glued_Code_{i}.md"), "w").close() -name = cg.default_name(TMP, "markdown") -assert name.startswith("Glued_Code_"), f"Expected pid fallback, got {name}" -assert name.endswith(".md"), f"Expected .md extension, got {name}" -assert "_" in name and not name.endswith("_100.md"), f"Should use pid, not _100: {name}" -# Clean up -os.remove(os.path.join(TMP, "Glued_Code.md")) -for i in range(1, 100): - os.remove(os.path.join(TMP, f"Glued_Code_{i}.md")) -passed += 1 -print(f"✓ Test 9: collision cap → pid fallback ({name})") - -# ────────────────────────────────────────────────────────────────────── -# Test 10: is_any_dir detection -# ────────────────────────────────────────────────────────────────────── -assert cg.is_any_dir([os.path.join(TMP, "src")]) == True -assert cg.is_any_dir([os.path.join(TMP, "standalone.txt")]) == False -assert cg.is_any_dir([os.path.join(TMP, "src"), os.path.join(TMP, "standalone.txt")]) == True -passed += 1 -print("✓ Test 10: is_any_dir detection") - -# ────────────────────────────────────────────────────────────────────── -# Test 11: theme save/read roundtrip -# ────────────────────────────────────────────────────────────────────── -cg.CONFIG_DIR = Path = __import__("pathlib").Path -cg.CONFIG_DIR = Path(TMP) / "config" / "codegluer" -cg.CONFIG_FILE = cg.CONFIG_DIR / "theme" -cg.save_theme("roselle") -assert_eq("roselle", cg.read_theme(), "theme roundtrip") -passed += 1 -print("✓ Test 11: theme save/read roundtrip") - -# ────────────────────────────────────────────────────────────────────── -# Test 12: invalid theme falls back to auto -# ────────────────────────────────────────────────────────────────────── -cg.CONFIG_DIR.mkdir(parents=True, exist_ok=True) -cg.CONFIG_FILE.write_text("invalid_theme") -assert_eq("auto", cg.read_theme(), "invalid theme fallback") -passed += 1 -print("✓ Test 12: invalid theme → auto") - -# ────────────────────────────────────────────────────────────────────── -# Test 13: missing config file → auto -# ────────────────────────────────────────────────────────────────────── -cg.CONFIG_FILE.unlink(missing_ok=True) -assert_eq("auto", cg.read_theme(), "missing config fallback") -passed += 1 -print("✓ Test 13: missing config → auto") - -# ────────────────────────────────────────────────────────────────────── -# Test 14: resolve_theme passes through explicit themes -# ────────────────────────────────────────────────────────────────────── -assert_eq("light", cg.resolve_theme("light"), "resolve light") -assert_eq("dark", cg.resolve_theme("dark"), "resolve dark") -assert_eq("roselle", cg.resolve_theme("roselle"), "resolve roselle") -passed += 1 -print("✓ Test 14: resolve_theme explicit passthrough") - -# ────────────────────────────────────────────────────────────────────── -# Test 15: theme_css returns non-empty for known themes -# ────────────────────────────────────────────────────────────────────── -for t in ["light", "dark", "roselle"]: - css = cg.theme_css(t) - assert css and len(css) > 10, f"theme_css('{t}') returned empty/short: {css!r}" -passed += 1 -print("✓ Test 15: theme_css returns CSS for known themes") - -# ────────────────────────────────────────────────────────────────────── -# Test 16: build_command with no excludes → no --exclude flag -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_absent("--exclude", cmd) -passed += 1 -print("✓ Test 16: no excludes → no --exclude flag") - -# ────────────────────────────────────────────────────────────────────── -# Test 17: --toc only for markdown (codegluer constraint) -# Note: codegluer's --toc is markdown-only. We still pass the flag if user -# checked it, codegluer will handle/no-op. Test that flag is passed for markdown. -# ────────────────────────────────────────────────────────────────────── -opts = {"format": "markdown", "output": "out.md", "excludes": "", - "stats": False, "estimate_tokens": False, - "tree": False, "toc": True, "respect_gitignore": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([os.path.join(TMP, "src")], opts) -assert_present("--toc", cmd) -passed += 1 -print("✓ Test 17: --toc passed for markdown") - -# ────────────────────────────────────────────────────────────────────── -# Test 18: multiple files passed correctly -# ────────────────────────────────────────────────────────────────────── -f1 = os.path.join(TMP, "src", "file.py") -f2 = os.path.join(TMP, "standalone.txt") -opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, - "any_dir": True, "target_dir": TMP} -cmd = cg.build_command([f1, f2], opts) -assert_present(f1, cmd) -assert_present(f2, cmd) -passed += 1 -print("✓ Test 18: multiple files passed correctly") - -# ────────────────────────────────────────────────────────────────────── -# Test 19: should_update_default — default markdown name → True (update ok) -# ────────────────────────────────────────────────────────────────────── -default_md = cg.default_name(TMP, "markdown") -assert_eq(True, cg.should_update_default(default_md, TMP), "default .md → update") -passed += 1 -print("✓ Test 19: default markdown name → should_update=True") - -# ────────────────────────────────────────────────────────────────────── -# Test 20: should_update_default — default plain name → True -# ────────────────────────────────────────────────────────────────────── -default_txt = cg.default_name(TMP, "plain") -assert_eq(True, cg.should_update_default(default_txt, TMP), "default .txt → update") -passed += 1 -print("✓ Test 20: default plain name → should_update=True") - -# ────────────────────────────────────────────────────────────────────── -# Test 21: should_update_default — empty string → True -# ────────────────────────────────────────────────────────────────────── -assert_eq(True, cg.should_update_default("", TMP), "empty → update") -passed += 1 -print("✓ Test 21: empty filename → should_update=True") - -# ────────────────────────────────────────────────────────────────────── -# Test 22: should_update_default — custom name → False (preserve!) -# Regression: "Glued_Code_custom.md" must NOT be treated as default. -# ────────────────────────────────────────────────────────────────────── -assert_eq(False, cg.should_update_default("Glued_Code_custom.md", TMP), "custom → preserve") -assert_eq(False, cg.should_update_default("my_output.md", TMP), "custom2 → preserve") -assert_eq(False, cg.should_update_default("report.txt", TMP), "custom3 → preserve") -passed += 1 -print("✓ Test 22: custom name → should_update=False (regression for startswith bug)") - -print(f"\nPASS: all {passed} tests pass") +if __name__ == "__main__": + TMP = tempfile.mkdtemp() + def cleanup(): + shutil.rmtree(TMP, ignore_errors=True) + import atexit + atexit.register(cleanup) + + # Set up test dirs/files + os.makedirs(os.path.join(TMP, "src")) + open(os.path.join(TMP, "src", "file.py"), "w").close() + open(os.path.join(TMP, "standalone.txt"), "w").close() + + passed = 0 + + # ────────────────────────────────────────────────────────────────────── + # Test 1: markdown + dir → all flags available, correct command built + # ────────────────────────────────────────────────────────────────────── + opts = { + "format": "markdown", "output": "out.md", "excludes": "foo,bar", + "stats": True, "estimate_tokens": True, + "tree": True, "toc": True, "respect_gitignore": True, + "any_dir": True, "target_dir": TMP, + } + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("codegluer", cmd) + assert_present(os.path.join(TMP, "src"), cmd) + assert_present("-r", cmd) + assert_present("--format", cmd) + assert_present("markdown", cmd) + assert_present("--tree", cmd) + assert_present("--stats", cmd) + assert_present("--toc", cmd) + assert_present("--estimate-tokens", cmd) + assert_present("--respect-gitignore", cmd) + assert_present("--exclude", cmd) + assert_present("foo", cmd) + assert_present("bar", cmd) + assert_present("-o", cmd) + assert_present(os.path.join(TMP, "out.md"), cmd) + # Ensure flags use hyphens, not underscores + assert_absent("--estimate_tokens", cmd) + assert_absent("--respect_gitignore", cmd) + passed += 1 + print("✓ Test 1: markdown + dir, all flags") + + # ────────────────────────────────────────────────────────────────────── + # Test 2: plain format, empty output → default Glued_Code.txt + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "plain", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("Glued_Code.txt", cmd) + assert_absent("Glued_Code.md", cmd) + assert_present("-r", cmd) + assert_present("--format", cmd) + assert_present("plain", cmd) + passed += 1 + print("✓ Test 2: plain format → .txt default") + + # ────────────────────────────────────────────────────────────────────── + # Test 3: markdown format, empty output → default Glued_Code.md + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "markdown", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("Glued_Code.md", cmd) + passed += 1 + print("✓ Test 3: markdown format → .md default") + + # ────────────────────────────────────────────────────────────────────── + # Test 4: collision → Glued_Code_1.md + # ────────────────────────────────────────────────────────────────────── + open(os.path.join(TMP, "Glued_Code.md"), "w").close() + opts = {"format": "markdown", "output": "", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("Glued_Code_1.md", cmd) + assert_absent("Glued_Code.md", cmd) + os.remove(os.path.join(TMP, "Glued_Code.md")) + passed += 1 + print("✓ Test 4: collision → _1 suffix") + + # ────────────────────────────────────────────────────────────────────── + # Test 5: files-only → no -r, no dir-only flags even if True + # ────────────────────────────────────────────────────────────────────── + opts = { + "format": "markdown", "output": "out.md", "excludes": "", + "stats": True, "estimate_tokens": True, + "tree": True, "toc": True, "respect_gitignore": True, + "any_dir": False, "target_dir": TMP, + } + cmd = cg.build_command([os.path.join(TMP, "standalone.txt")], opts) + assert_absent("-r", cmd) + assert_absent("--tree", cmd) + assert_absent("--toc", cmd) + assert_absent("--respect-gitignore", cmd) + assert_present("--stats", cmd) + assert_present("--estimate-tokens", cmd) + passed += 1 + print("✓ Test 5: files-only hides dir-only flags") + + # ────────────────────────────────────────────────────────────────────── + # Test 6: bare filename (no directory) → target_dir is "." + # ────────────────────────────────────────────────────────────────────── + assert_eq(".", cg.target_dir_of(["file.txt"]), "bare filename target_dir") + passed += 1 + print("✓ Test 6: bare filename → target_dir='.'") + + # ────────────────────────────────────────────────────────────────────── + # Test 7: custom output name respected + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "markdown", "output": "Glued_Code_custom.md", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("Glued_Code_custom.md", cmd) + assert_absent("Glued_Code_1.md", cmd) + passed += 1 + print("✓ Test 7: custom name respected") + + # ────────────────────────────────────────────────────────────────────── + # Test 8: exclude with spaces (auto-fixed by GUI before build_command) + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "markdown", "output": "out.md", "excludes": "foo.py,bar.py", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("--exclude", cmd) + assert_present("foo.py", cmd) + assert_present("bar.py", cmd) + passed += 1 + print("✓ Test 8: exclude comma-separated") + + # ────────────────────────────────────────────────────────────────────── + # Test 9: default_name collision cap at 99 + # ────────────────────────────────────────────────────────────────────── + # Create Glued_Code.md + Glued_Code_1.md through Glued_Code_99.md (100 total) + open(os.path.join(TMP, "Glued_Code.md"), "w").close() + for i in range(1, 100): + open(os.path.join(TMP, f"Glued_Code_{i}.md"), "w").close() + name = cg.default_name(TMP, "markdown") + assert name.startswith("Glued_Code_"), f"Expected pid fallback, got {name}" + assert name.endswith(".md"), f"Expected .md extension, got {name}" + assert "_" in name and not name.endswith("_100.md"), f"Should use pid, not _100: {name}" + # Clean up + os.remove(os.path.join(TMP, "Glued_Code.md")) + for i in range(1, 100): + os.remove(os.path.join(TMP, f"Glued_Code_{i}.md")) + passed += 1 + print(f"✓ Test 9: collision cap → pid fallback ({name})") + + # ────────────────────────────────────────────────────────────────────── + # Test 10: is_any_dir detection — fixed E712 (no explicit True/False) + # ────────────────────────────────────────────────────────────────────── + assert cg.is_any_dir([os.path.join(TMP, "src")]) + assert not cg.is_any_dir([os.path.join(TMP, "standalone.txt")]) + assert cg.is_any_dir([os.path.join(TMP, "src"), os.path.join(TMP, "standalone.txt")]) + passed += 1 + print("✓ Test 10: is_any_dir detection") + + # ────────────────────────────────────────────────────────────────────── + # Test 11: theme save/read roundtrip + # ────────────────────────────────────────────────────────────────────── + cg.CONFIG_DIR = Path = __import__("pathlib").Path + cg.CONFIG_DIR = Path(TMP) / "config" / "codegluer" + cg.CONFIG_FILE = cg.CONFIG_DIR / "theme" + cg.save_theme("roselle") + assert_eq("roselle", cg.read_theme(), "theme roundtrip") + passed += 1 + print("✓ Test 11: theme save/read roundtrip") + + # ────────────────────────────────────────────────────────────────────── + # Test 12: invalid theme falls back to auto + # ────────────────────────────────────────────────────────────────────── + cg.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + cg.CONFIG_FILE.write_text("invalid_theme") + assert_eq("auto", cg.read_theme(), "invalid theme fallback") + passed += 1 + print("✓ Test 12: invalid theme → auto") + + # ────────────────────────────────────────────────────────────────────── + # Test 13: missing config file → auto + # ────────────────────────────────────────────────────────────────────── + cg.CONFIG_FILE.unlink(missing_ok=True) + assert_eq("auto", cg.read_theme(), "missing config fallback") + passed += 1 + print("✓ Test 13: missing config → auto") + + # ────────────────────────────────────────────────────────────────────── + # Test 14: resolve_theme passes through explicit themes + # ────────────────────────────────────────────────────────────────────── + assert_eq("light", cg.resolve_theme("light"), "resolve light") + assert_eq("dark", cg.resolve_theme("dark"), "resolve dark") + assert_eq("roselle", cg.resolve_theme("roselle"), "resolve roselle") + passed += 1 + print("✓ Test 14: resolve_theme explicit passthrough") + + # ────────────────────────────────────────────────────────────────────── + # Test 15: theme_css returns non-empty for known themes + # ────────────────────────────────────────────────────────────────────── + for t in ["light", "dark", "roselle"]: + css = cg.theme_css(t) + assert css and len(css) > 10, f"theme_css('{t}') returned empty/short: {css!r}" + passed += 1 + print("✓ Test 15: theme_css returns CSS for known themes") + + # ────────────────────────────────────────────────────────────────────── + # Test 16: build_command with no excludes → no --exclude flag + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_absent("--exclude", cmd) + passed += 1 + print("✓ Test 16: no excludes → no --exclude flag") + + # ────────────────────────────────────────────────────────────────────── + # Test 17: --toc passed for markdown + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "markdown", "output": "out.md", "excludes": "", + "stats": False, "estimate_tokens": False, + "tree": False, "toc": True, "respect_gitignore": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_present("--toc", cmd) + passed += 1 + print("✓ Test 17: --toc passed for markdown") + + # ────────────────────────────────────────────────────────────────────── + # Test 18: multiple files passed correctly + # ────────────────────────────────────────────────────────────────────── + f1 = os.path.join(TMP, "src", "file.py") + f2 = os.path.join(TMP, "standalone.txt") + opts = {"format": "markdown", "output": "out.md", "excludes": "", "stats": False, "estimate_tokens": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([f1, f2], opts) + assert_present(f1, cmd) + assert_present(f2, cmd) + passed += 1 + print("✓ Test 18: multiple files passed correctly") + + # ────────────────────────────────────────────────────────────────────── + # Test 19: should_update_default — default markdown name → True (update ok) + # ────────────────────────────────────────────────────────────────────── + default_md = cg.default_name(TMP, "markdown") + assert_eq(True, cg.should_update_default(default_md, TMP), "default .md → update") + passed += 1 + print("✓ Test 19: default markdown name → should_update=True") + + # ────────────────────────────────────────────────────────────────────── + # Test 20: should_update_default — default plain name → True + # ────────────────────────────────────────────────────────────────────── + default_txt = cg.default_name(TMP, "plain") + assert_eq(True, cg.should_update_default(default_txt, TMP), "default .txt → update") + passed += 1 + print("✓ Test 20: default plain name → should_update=True") + + # ────────────────────────────────────────────────────────────────────── + # Test 21: should_update_default — empty string → True + # ────────────────────────────────────────────────────────────────────── + assert_eq(True, cg.should_update_default("", TMP), "empty → update") + passed += 1 + print("✓ Test 21: empty filename → should_update=True") + + # ────────────────────────────────────────────────────────────────────── + # Test 22: should_update_default — custom name → False (preserve!) + # Regression: "Glued_Code_custom.md" must NOT be treated as default. + # ────────────────────────────────────────────────────────────────────── + assert_eq(False, cg.should_update_default("Glued_Code_custom.md", TMP), "custom → preserve") + assert_eq(False, cg.should_update_default("my_output.md", TMP), "custom2 → preserve") + assert_eq(False, cg.should_update_default("report.txt", TMP), "custom3 → preserve") + passed += 1 + print("✓ Test 22: custom name → should_update=False (regression for startswith bug)") + + # ────────────────────────────────────────────────────────────────────── + # Test 23: --toc NOT added for plain format (regression guard) + # ────────────────────────────────────────────────────────────────────── + opts = {"format": "plain", "output": "out.txt", "excludes": "", + "stats": False, "estimate_tokens": False, + "tree": False, "toc": True, "respect_gitignore": False, + "any_dir": True, "target_dir": TMP} + cmd = cg.build_command([os.path.join(TMP, "src")], opts) + assert_absent("--toc", cmd) + passed += 1 + print("✓ Test 23: --toc absent for plain format") + + print(f"\nPASS: all {passed} tests pass") \ No newline at end of file From 51a86f53f1cc74484d09dc00dea81136e1adf497 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Wed, 1 Jul 2026 10:47:43 +0300 Subject: [PATCH 3/3] fix(gui): prevent UI freeze on hung codegluer subprocess The _execute method was running subprocess.run synchronously on the GTK main thread with no timeout. If codegluer hung (e.g. waiting on a slow filesystem, a large directory tree, or an unresponsive pipe), the entire GUI would freeze indefinitely with no way to recover. Add a 60-second timeout to the subprocess.run call and handle subprocess.TimeoutExpired with a user-visible notification so the user knows what happened and the window closes cleanly. The timeout is generous enough that legitimate large glues complete normally, but short enough that a genuinely stuck process doesn't strand the user with a frozen window. Also clean up two style issues flagged by static analysis: - _execute: replace list concatenation with unpacking [codegluer_path, *build_command(...)[1:]] to match the pattern already used in build_command itself - test_codegluer_gui.py: import Path at module level instead of the awkward inline __import__('pathlib').Path assignment that briefly set cg.CONFIG_DIR to the Path class before overwriting it --- codegluer_gui.py | 7 +++++-- test_codegluer_gui.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index 4077a6e..1019179 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -427,7 +427,7 @@ 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:] + cmd = [codegluer_path, *build_command(self.files, opts)[1:]] if self.dry_run: print("\n".join(cmd)) @@ -436,12 +436,15 @@ def _execute(self, opts): save_theme(self.current_theme) try: - result = subprocess.run(cmd, capture_output=True, text=True) + # 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"]) self._notify("CodeGluer", f"Created: {output_name}") else: self._notify("CodeGluer", f"Failed: {result.stderr.strip()}") + except subprocess.TimeoutExpired: + self._notify("CodeGluer", "Failed: command timed out after 60 seconds") except Exception as e: self._notify("CodeGluer", f"Error: {e}") self.close() diff --git a/test_codegluer_gui.py b/test_codegluer_gui.py index df69216..aae98b1 100755 --- a/test_codegluer_gui.py +++ b/test_codegluer_gui.py @@ -10,6 +10,7 @@ import sys import tempfile import shutil +from pathlib import Path # <-- added for clean Path usage # Import the module under test sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -191,9 +192,8 @@ def cleanup(): print("✓ Test 10: is_any_dir detection") # ────────────────────────────────────────────────────────────────────── - # Test 11: theme save/read roundtrip + # Test 11: theme save/read roundtrip — clean Path usage # ────────────────────────────────────────────────────────────────────── - cg.CONFIG_DIR = Path = __import__("pathlib").Path cg.CONFIG_DIR = Path(TMP) / "config" / "codegluer" cg.CONFIG_FILE = cg.CONFIG_DIR / "theme" cg.save_theme("roselle")