Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 98 additions & 19 deletions codegluer_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import sys
import subprocess
import shutil
import datetime
import re
from pathlib import Path

# ----------------------------------------------------------------------
Expand Down Expand Up @@ -81,10 +83,18 @@ def _looks_heavy(path: str, scan_limit: int = 500) -> bool:
# 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 → ..."""
def default_name(target_dir: str, fmt: str, existing: set | None = None,
include_timestamp: bool = False) -> str:
"""Collision-safe default filename.
Glued_Code.md → Glued_Code_1.md → ...
With include_timestamp=True:
Glued_Code_YYYYMMDD_HHMMSS.md → Glued_Code_YYYYMMDD_HHMMSS_1.md → ...
"""
ext = "txt" if fmt == "plain" else "md"
base = "Glued_Code"
if include_timestamp:
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
base = f"Glued_Code_{ts}"
if existing is None:
existing = set(os.listdir(target_dir)) if os.path.isdir(target_dir) else set()
name = f"{base}.{ext}"
Expand All @@ -97,6 +107,39 @@ def default_name(target_dir: str, fmt: str, existing: set | None = None) -> str:
break
return name

# Matches every default-name variant the GUI can produce:
# Glued_Code.md
# Glued_Code_1.md (collision suffix)
# Glued_Code_20260710_143022.md (timestamp)
# Glued_Code_20260710_143022_1.md (timestamp + collision suffix)
# Glued_Code_<pid>.md (pid fallback after 99 collisions)
# …and the .txt variants.
DEFAULT_NAME_RE = re.compile(
r"^Glued_Code"
r"(?:_\d{8}_\d{6})?" # optional timestamp _YYYYMMDD_HHMMSS
r"(?:_\d+)?" # optional collision suffix or pid fallback _N
r"\.(md|txt)$"
)


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.

Bare defaults are checked by exact match (they're deterministic).
Timestamped / collision-suffixed / pid-fallback defaults are checked
by regex, because re-calling default_name() would produce a different
timestamp and never exactly match the string already in the entry.
"""
if current_text == "":
return True
default_md = default_name(target_dir, "markdown")
default_txt = default_name(target_dir, "plain")
if current_text in (default_md, default_txt):
return True
return bool(DEFAULT_NAME_RE.match(current_text))


def is_any_dir(files: list[str]) -> bool:
# Treat .zip files as directories (they are expanded and glued recursively)
Expand Down Expand Up @@ -268,16 +311,6 @@ def validate_exclude_pattern(pattern: str, scope_roots: list[str]) -> tuple[bool
return True, raw, ""



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.
Expand All @@ -289,14 +322,16 @@ def build_command(files: list[str], opts: dict) -> list[str]:
stats, estimate_tokens, tree, toc, respect_gitignore: bool
any_dir: bool (precomputed, drives -r and dir-only flags)
target_dir: str (where output lands)
include_timestamp: bool (add timestamp to default name)
"""
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()
include_timestamp = opts.get("include_timestamp", False)
if not output:
output = default_name(target_dir, fmt)
output = default_name(target_dir, fmt, include_timestamp=include_timestamp)

cmd = ["codegluer", *files]
cmd += ["--format", fmt]
Expand Down Expand Up @@ -457,6 +492,8 @@ def __init__(self, app):
self.target_dir = target_dir
self.dry_run = dry_run
self.current_theme = read_theme()
self.include_timestamp = False
self._suppress_changed = False

# FIX (2026-07-09): operation scope — the set of directories
# the exclude feature is allowed to operate on. Empty for
Expand Down Expand Up @@ -536,13 +573,27 @@ def _build_ui(self):

# 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)

output_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
output_box.set_hexpand(True)

default = default_name(self.target_dir, self.format, include_timestamp=self.include_timestamp)
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)
output_box.append(self.output_entry)

# Timestamp checkbox — when on, default name becomes Glued_Code_YYYYMMDD_HHMMSS.md
self.timestamp_cb = Gtk.CheckButton(label="Timestamp")
self.timestamp_cb.set_tooltip_text(
"Include date and time (YYYYMMDD_HHMMSS) in the default filename"
)
self.timestamp_cb.connect("toggled", self._on_timestamp_toggled)
output_box.append(self.timestamp_cb)

grid.attach(output_box, 1, row, 1, 1)
row += 1

# Exclude patterns — only show when directories or zips are selected
Expand Down Expand Up @@ -625,17 +676,35 @@ def _build_ui(self):
self.checkboxes["respect_gitignore"] = Gtk.CheckButton(label="Respect .gitignore")
checks_box.append(self.checkboxes["respect_gitignore"])

def _set_output_text(self, text: str):
"""Update entry text programmatically without tripping the
changed handler, then re-derive _user_modified_output from the
new text via should_update_default()."""
self._suppress_changed = True
self.output_entry.set_text(text)
self._suppress_changed = False
self._user_modified_output = not should_update_default(text, self.target_dir)

def _on_output_changed(self, entry):
if self._suppress_changed:
return
self._user_modified_output = not should_update_default(
entry.get_text(), self.target_dir
)

def _on_timestamp_toggled(self, cb):
self.include_timestamp = cb.get_active()
if not self._user_modified_output:
self._set_output_text(
default_name(self.target_dir, self.format, include_timestamp=self.include_timestamp)
)

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)
self._set_output_text(
default_name(self.target_dir, self.format, include_timestamp=self.include_timestamp)
)
if self.format == "plain" and "toc" in self.checkboxes:
self.checkboxes["toc"].set_active(False)
Expand Down Expand Up @@ -681,6 +750,7 @@ def _collect_opts(self):
"estimate_tokens": self.checkboxes["estimate_tokens"].get_active(),
"any_dir": self.any_dir,
"target_dir": self.target_dir,
"include_timestamp": self.include_timestamp,
}
if self.any_dir:
opts["tree"] = self.checkboxes["tree"].get_active()
Expand Down Expand Up @@ -1005,12 +1075,21 @@ def _execute(self, opts):
self.close()
return

# Pull the actual -o argument the CLI was given. This was computed
# by build_command BEFORE the file was written, so it's guaranteed
# to match what hit disk. Never recompute default_name() here —
# the file now exists in target_dir and would shift the result.
try:
idx = cmd.index("-o")
actual_output = os.path.basename(cmd[idx + 1])
except (ValueError, IndexError):
actual_output = opts["output"] or "Glued_Code.md"

save_theme(self.current_theme)
try:
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}")
self._notify("CodeGluer", f"Created: {actual_output}")
else:
self._notify("CodeGluer", f"Failed: {result.stderr.strip()}")
except subprocess.TimeoutExpired:
Expand Down
68 changes: 67 additions & 1 deletion tests/test_codegluer_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import os
import pytest
import datetime
import re
from pathlib import Path
import codegluer_gui as cg

Expand Down Expand Up @@ -366,4 +368,68 @@ def test_is_path_in_scope_prefix_trap(tmp_path):
foobar.mkdir()
# /tmp/foobar/x must NOT match scope root /tmp/foo
assert not cg.is_path_in_scope(str(foobar / "x"), [str(foo)])
assert cg.is_path_in_scope(str(foo / "x"), [str(foo)])
assert cg.is_path_in_scope(str(foo / "x"), [str(foo)])


# ── New timestamp tests ─────────────────────────────────────────────

def _frozen_now(frozen):
class _DT:
@staticmethod
def now():
return frozen
return _DT


def test_default_name_with_timestamp(tmp_path, monkeypatch):
"""Timestamp flag should inject YYYYMMDD_HHMMSS into the default name."""
frozen = datetime.datetime(2026, 7, 10, 14, 30, 22)
monkeypatch.setattr(datetime, "datetime", _frozen_now(frozen))

name = cg.default_name(str(tmp_path), "markdown", include_timestamp=True)
assert name == "Glued_Code_20260710_143022.md"


def test_default_name_with_timestamp_collision(tmp_path, monkeypatch):
"""If a timestamped file exists, it should append _1, _2, etc."""
frozen = datetime.datetime(2026, 7, 10, 14, 30, 22)
monkeypatch.setattr(datetime, "datetime", _frozen_now(frozen))

(tmp_path / "Glued_Code_20260710_143022.md").touch()
name = cg.default_name(str(tmp_path), "markdown", include_timestamp=True)
assert name == "Glued_Code_20260710_143022_1.md"


def test_should_update_default_recognizes_timestamped(tmp_path):
"""A timestamped default in the entry must still be recognized as a
default — even though re-calling default_name() would produce a
different timestamp. This is the regression friend #1's patch had."""
assert cg.should_update_default("Glued_Code_20260710_143022.md", str(tmp_path)) is True
assert cg.should_update_default("Glued_Code_20260710_143022_1.md", str(tmp_path)) is True
# Sanity: custom names still rejected
assert cg.should_update_default("my_project.md", str(tmp_path)) is False
assert cg.should_update_default("Glued_Code_custom.md", str(tmp_path)) is False


def test_build_command_with_timestamp_empty_output(tmp_path):
"""If output is empty but timestamp is checked, build_command should
generate a timestamped name in the -o argument."""
src = tmp_path / "src"
src.mkdir()
opts = {
"format": "markdown",
"output": "",
"excludes": "",
"stats": False,
"estimate_tokens": False,
"any_dir": True,
"target_dir": str(tmp_path),
"include_timestamp": True,
}
cmd = cg.build_command([str(src)], opts)
try:
idx = cmd.index("-o")
out_path = cmd[idx + 1]
except ValueError:
assert False, "-o not in command"
assert re.search(r"Glued_Code_\d{8}_\d{6}\.md$", out_path)