ClaudeCodePanel / session_browser.py
smlflg's picture
Initial public upload from Projekte/ClaudeCodePanel
bbc65cd verified
Raw History Blame Contribute Delete
38.7 kB
#!/usr/bin/env python3
"""Session Browser — GTK3 widget module for Claude Code Control Panel.
Provides build_sessions_tab() returning a Gtk.ScrolledWindow with:
- Full session list from ~/.claude/projects/ (last 20 by mtime)
- Search/filter by project name or preview text
- Per-session resume button (kitty -e claude -r SESSION_ID)
- Stats bar: total sessions, sessions in last hour, unique projects
- Refresh button to re-scan
Theme: Catppuccin Mocha (dark) / Latte (light) via theme.py
"""
import html as html_mod
import json
import logging
import re
import shlex
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
log = logging.getLogger("claude_panel.session_browser")
# Module-level metadata cache: path → (mtime, meta_dict)
_meta_cache: dict[str, tuple[float, dict]] = {}
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk, Pango
from utils import idle_once, short_name_from_path, HOME
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
PROJECTS_DIR = Path(HOME) / ".claude" / "projects"
# Colors are handled by CSS classes defined in theme.py
# ---------------------------------------------------------------------------
# Module-level state (kept between refreshes)
# Grouped into a single namespace dict to avoid scattered globals.
# ---------------------------------------------------------------------------
_state: dict = {
"list_box": None, # Gtk.ListBox | None
"stats_label": None, # Gtk.Label | None
"search_entry": None, # Gtk.SearchEntry | None
"all_sessions": [], # list[dict]
"search_mode": False, # True when in grep search mode
"debounce_id": None, # GLib timeout source ID for debounce
"batch_id": None, # GLib idle source ID for batch insertion
}
# Cancellation token for the background grep thread.
# Replaced with a fresh Event each time a new search starts.
_search_cancel: threading.Event = threading.Event()
# ---------------------------------------------------------------------------
# Session scanning helpers
# ---------------------------------------------------------------------------
def _format_size(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
_PREVIEW_SKIP_PREFIXES = (
"[Request interrupted",
"Implement the following plan",
"<local-command-caveat>",
"<command-name>",
)
def _get_session_meta(path: Path, max_len: int = 120) -> dict:
"""Read metadata from JSONL file: preview text, cwd, slug.
Uses a module-level mtime cache — unchanged files are never re-read.
For files under 512KB: reads all lines ONCE, extracts first 30 + last 10.
For large files (>=512KB): reads first 30 lines + subprocess tail for last 10.
"""
path_str = str(path)
try:
stat = path.stat()
mtime = stat.st_mtime
file_size = stat.st_size
except OSError:
return {"preview": "(keine Vorschau)", "cwd": "", "slug": ""}
# Cache hit — file unchanged since last read
cached = _meta_cache.get(path_str)
if cached is not None and cached[0] == mtime:
return cached[1]
slug: str = ""
cwd: str = ""
preview: str = ""
try:
if file_size < 512 * 1024:
# Single read — extract head and tail from the same list
with path.open(encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
head_lines = all_lines[:30]
tail_lines = all_lines[-10:] if len(all_lines) > 30 else []
else:
# Large file: read first 30 lines + subprocess tail
head_lines = []
with path.open(encoding="utf-8", errors="replace") as f:
for _ in range(30):
line = f.readline()
if not line:
break
head_lines.append(line)
try:
result = subprocess.run(
["tail", "-n", "10", str(path)],
capture_output=True, text=True, timeout=2
)
tail_lines = result.stdout.splitlines(keepends=True)
except (subprocess.TimeoutExpired, OSError):
tail_lines = []
all_sample = head_lines + tail_lines
for line in all_sample:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not slug and entry.get("slug"):
slug = entry["slug"]
if not cwd and entry.get("cwd"):
cwd = entry["cwd"]
if preview:
if cwd and slug:
break
continue
if entry.get("type") != "user":
continue
msg = entry.get("message", {})
content = msg.get("content", "")
if isinstance(content, list):
text_blocks = [
block.get("text", "")
for block in content
if isinstance(block, dict)
and block.get("type") == "text"
and "<local-command-caveat>" not in block.get("text", "")
]
if text_blocks:
content = text_blocks[0]
else:
continue
if not isinstance(content, str):
continue
content = content.strip()
if any(content.startswith(p) for p in _PREVIEW_SKIP_PREFIXES):
if content.startswith("Implement the following plan"):
for plan_line in content.split("\n"):
plan_line = plan_line.strip()
if plan_line.startswith("# "):
title = plan_line[2:].strip()
if title.lower().startswith("plan:"):
title = title[5:].strip()
if len(title) > max_len:
title = title[:max_len] + "..."
preview = title
break
continue
first_line = content.split("\n")[0]
if len(first_line) > max_len:
first_line = first_line[:max_len] + "..."
preview = first_line
except (OSError, PermissionError):
pass
if not preview:
preview = slug if slug else "(keine Vorschau)"
result = {"preview": preview, "cwd": cwd, "slug": slug}
_meta_cache[path_str] = (mtime, result)
return result
def _get_session_status(path: Path, mtime: float, now: float) -> str:
"""Determine session status from last JSONL entry.
Returns:
"working" — Claude is actively processing (last entry is tool_use or user msg)
"ready" — Claude finished, waiting for user input
"idle" — session not recently active
"""
age = now - mtime
if age > 120:
return "idle"
# Read last non-empty line of JSONL (seek from end for speed)
try:
size = path.stat().st_size
if size == 0:
return "idle"
# Read last 4KB — enough for the final JSONL line
read_size = min(size, 4096)
with path.open("rb") as f:
f.seek(-read_size, 2)
tail = f.read().decode("utf-8", errors="replace")
lines = [l for l in tail.strip().splitlines() if l.strip()]
if not lines:
return "idle"
last_line = lines[-1]
try:
entry = json.loads(last_line)
except json.JSONDecodeError:
# If last line is corrupt, check second-to-last
if len(lines) >= 2:
try:
entry = json.loads(lines[-2])
except json.JSONDecodeError:
return "ready" if age < 120 else "idle"
else:
return "ready" if age < 120 else "idle"
entry_type = entry.get("type", "")
msg = entry.get("message", {})
# User message → Claude is about to process
if entry_type == "user":
return "working"
# Assistant message → check for tool_use (Claude still working)
if entry_type == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
return "working"
# Check stop_reason — tool_use means Claude wants to call a tool
stop = msg.get("stop_reason", "")
if stop == "tool_use":
return "working"
# end_turn or max_tokens → Claude is done
return "ready"
# Tool result → Claude is processing
if entry_type == "tool_result":
return "working"
# Fallback: if very recent mtime, likely working
return "working" if age < 5 else "ready"
except (OSError, PermissionError):
return "ready" if age < 120 else "idle"
def _build_project_info(path: Path, session_cwd: str) -> tuple[str, str]:
"""Derive (project_name, short_name) from a session's CWD or parent dir name.
Shared by _scan_all_sessions() and _grep_sessions() to avoid duplicated logic.
Returns (project_name, short_name).
"""
short_name = short_name_from_path(session_cwd)
if session_cwd and session_cwd != HOME:
project_name = session_cwd.replace(HOME, "~", 1)
else:
# Fallback: derive project from JSONL parent dir name
# e.g. "-home-smlflg-Projekte-ClaudeCodePanel" → "ClaudeCodePanel"
dir_name = path.parent.name # encoded path
parts = dir_name.split("-")
# Strip leading home-user prefix
# "-home-smlflg-Projekte-Foo" → ["", "home", "smlflg", "Projekte", "Foo"]
meaningful = [p for p in parts if p and p not in ("home", "smlflg")]
if meaningful:
short_name = meaningful[-1]
project_name = "~/" + "/".join(meaningful)
else:
project_name = "Home"
return project_name, short_name
def _scan_all_sessions() -> list[dict]:
"""Scan ~/.claude/projects/ and return session dicts sorted by mtime desc.
Caps at 200 most-recently-modified JSONL files to bound scan time on
large installations (2800+ files). Older sessions remain accessible
via fulltext search (_grep_sessions).
"""
sessions: list[dict] = []
if not PROJECTS_DIR.exists():
return sessions
# Collect all JSONL paths first, then sort by mtime and cap at 200
all_files: list[Path] = []
for project_dir in PROJECTS_DIR.iterdir():
if not project_dir.is_dir():
continue
for f in project_dir.glob("*.jsonl"):
all_files.append(f)
# Sort newest-first and limit to 200 — avoids scanning all 2800+ files
all_files = sorted(all_files, key=lambda p: p.stat().st_mtime, reverse=True)[:200]
now = time.time()
for f in all_files:
try:
stat = f.stat()
meta = _get_session_meta(f)
project_name, short_name = _build_project_info(f, meta["cwd"])
status = _get_session_status(f, stat.st_mtime, now)
sessions.append(
{
"path": str(f),
"project": project_name,
"short_name": short_name,
"session_id": f.stem,
"mtime": stat.st_mtime,
"size": stat.st_size,
"time_str": datetime.fromtimestamp(stat.st_mtime).strftime(
"%d.%m %H:%M"
),
"preview": meta["preview"],
"slug": meta["slug"],
"status": status,
}
)
except (OSError, PermissionError):
continue
sessions.sort(key=lambda s: s["mtime"], reverse=True)
return sessions
def _grep_sessions(query: str) -> list[dict]:
"""Fulltext search across all session files using grep -l -i.
Returns matching session dicts with snippet context.
Runs synchronously (~0.3s for 649 files).
"""
if not PROJECTS_DIR.exists():
return []
# Collect all JSONL files
all_files = []
for project_dir in PROJECTS_DIR.iterdir():
if not project_dir.is_dir():
continue
for f in project_dir.glob("*.jsonl"):
all_files.append(str(f))
if not all_files:
return []
# grep -l -i for matching files
try:
result = subprocess.run(
["grep", "-l", "-i", "--", query] + all_files,
capture_output=True, text=True, timeout=10
)
matching_files = [f for f in result.stdout.splitlines() if f.strip()]
except (subprocess.TimeoutExpired, OSError):
return []
if not matching_files:
return []
# Batch grep -c for match counts (one subprocess for all files)
counts: dict[str, int] = {}
try:
count_result = subprocess.run(
["grep", "-i", "-c", "--", query] + matching_files,
capture_output=True, text=True, timeout=10
)
for line in count_result.stdout.splitlines():
# Format: filepath:count
if ":" in line:
fpath, _, cnt = line.rpartition(":")
try:
counts[fpath] = int(cnt)
except ValueError:
pass
except (subprocess.TimeoutExpired, OSError):
pass
# Build session dicts for matching files
now = time.time()
query_lower = query.lower()
sessions = []
for filepath in matching_files:
path = Path(filepath)
try:
stat = path.stat()
meta = _get_session_meta(path)
project_name, short_name = _build_project_info(path, meta["cwd"])
status = _get_session_status(path, stat.st_mtime, now)
sessions.append({
"path": filepath,
"project": project_name,
"short_name": short_name,
"session_id": path.stem,
"mtime": stat.st_mtime,
"size": stat.st_size,
"time_str": datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m %H:%M"),
"preview": meta["preview"],
"status": status,
"snippet": "",
"match_count": counts.get(filepath, 0),
})
except (OSError, PermissionError):
continue
sessions.sort(key=lambda s: s["mtime"], reverse=True)
# Extract snippets: one batched grep -m1 -H for top 50 results
top_files = [s["path"] for s in sessions[:50]]
if top_files:
snippet_map: dict[str, str] = {}
try:
snip_result = subprocess.run(
["grep", "-i", "-m", "1", "-H", "--", query] + top_files,
capture_output=True, text=True, timeout=10
)
for line in snip_result.stdout.splitlines():
# Format: filepath:json_line (split on first : after filepath)
# JSONL paths contain no colons, so first : is the separator
sep = line.find(".jsonl:")
if sep < 0:
continue
fpath = line[:sep + 6] # include ".jsonl"
raw = line[sep + 7:] # skip ":"
if fpath not in snippet_map:
snippet_map[fpath] = raw.strip()
except (subprocess.TimeoutExpired, OSError):
snippet_map = {}
for session in sessions[:50]:
raw = snippet_map.get(session["path"], "")
if not raw:
continue
try:
obj = json.loads(raw)
msg = obj.get("message", {})
content = msg.get("content", "") if isinstance(msg, dict) else ""
if isinstance(content, list):
texts = [b.get("text", "") for b in content
if isinstance(b, dict) and b.get("type") == "text"]
content = " ".join(texts)
if not isinstance(content, str):
content = ""
if query_lower not in content.lower():
content = json.dumps(obj, ensure_ascii=False)
except (json.JSONDecodeError, AttributeError):
content = raw
if query_lower in content.lower():
idx = content.lower().find(query_lower)
start = max(0, idx - 60)
end = min(len(content), idx + len(query) + 60)
snippet = content[start:end].replace("\n", " ").strip()
for char in ('"', '{', '}', '\\'):
snippet = snippet.replace(char, ' ')
snippet = " ".join(snippet.split())
if start > 0:
snippet = "…" + snippet
if end < len(content):
snippet = snippet + "…"
session["snippet"] = snippet
return sessions
# ---------------------------------------------------------------------------
# Stats helper
# ---------------------------------------------------------------------------
def _compute_stats(sessions: list[dict], now: float | None = None) -> str:
total = len(sessions)
if now is None:
now = time.time()
working = sum(1 for s in sessions if s.get("status") == "working")
ready = sum(1 for s in sessions if s.get("status") == "ready")
recent = sum(1 for s in sessions if now - s["mtime"] < 3600)
projects = len({s["project"] for s in sessions})
parts = [f"{total} Sessions"]
if working:
parts.append(f"{working} arbeitet")
if ready:
parts.append(f"{ready} bereit")
parts.append(f"{recent} in letzter Stunde")
parts.append(f"{projects} Projekte")
return " | ".join(parts)
# ---------------------------------------------------------------------------
# Row builder
# ---------------------------------------------------------------------------
def _build_session_row(session: dict, now: float | None = None) -> Gtk.ListBoxRow:
"""Build a single styled ListBoxRow for one session."""
if now is None:
now = time.time()
row = Gtk.ListBoxRow()
row.set_name("session-row")
row.get_style_context().add_class("session-row")
status = session.get("status", "idle")
is_active = status in ("working", "ready")
if status == "working":
row.get_style_context().add_class("session-row-working")
elif status == "ready":
row.get_style_context().add_class("session-row-ready")
# Outer box
outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
outer.set_margin_top(4)
outer.set_margin_bottom(4)
outer.set_margin_start(8)
outer.set_margin_end(10)
row.add(outer)
# Left: short name + preview + full path stacked vertically
info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
info_box.set_hexpand(True)
outer.pack_start(info_box, True, True, 0)
# Short project name — bold, dedicated class
name_label = Gtk.Label(label=session["short_name"])
name_label.set_halign(Gtk.Align.START)
name_label.set_ellipsize(Pango.EllipsizeMode.END)
name_label.set_max_width_chars(45)
name_label.get_style_context().add_class("session-project")
info_box.pack_start(name_label, False, False, 0)
# Subtitle: slug (e.g. "Wild Crafting Sutton") or first 80 chars of preview
raw_slug = session.get("slug", "")
if raw_slug:
subtitle_text = raw_slug.replace("-", " ").title()
else:
raw_preview = session.get("preview", "")
subtitle_text = raw_preview[:80] + ("…" if len(raw_preview) > 80 else "")
if subtitle_text:
subtitle_label = Gtk.Label(label=subtitle_text)
subtitle_label.set_halign(Gtk.Align.START)
subtitle_label.set_ellipsize(Pango.EllipsizeMode.END)
subtitle_label.set_max_width_chars(70)
subtitle_label.get_style_context().add_class("session-meta")
subtitle_attrs = Pango.AttrList()
subtitle_attrs.insert(Pango.attr_scale_new(0.88))
subtitle_label.set_attributes(subtitle_attrs)
info_box.pack_start(subtitle_label, False, False, 0)
# Preview — Subtext1 for better contrast
preview_label = Gtk.Label(label=session["preview"])
preview_label.set_halign(Gtk.Align.START)
preview_label.set_ellipsize(Pango.EllipsizeMode.END)
preview_label.set_max_width_chars(90)
preview_label.get_style_context().add_class("session-preview")
info_box.pack_start(preview_label, False, False, 0)
# Project path — always shown for context
path_label = Gtk.Label(label=session["project"])
path_label.set_halign(Gtk.Align.START)
path_label.set_ellipsize(Pango.EllipsizeMode.END)
path_label.set_max_width_chars(70)
path_label.get_style_context().add_class("session-meta")
path_attrs = Pango.AttrList()
path_attrs.insert(Pango.attr_scale_new(0.82))
path_label.set_attributes(path_attrs)
info_box.pack_start(path_label, False, False, 0)
# Right: meta info + resume button
meta_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
meta_box.set_valign(Gtk.Align.CENTER)
outer.pack_start(meta_box, False, False, 0)
# Status badge for active sessions
if status == "working":
badge = Gtk.Label(label="ARBEITET")
badge.get_style_context().add_class("session-working-badge")
badge.set_halign(Gtk.Align.END)
meta_box.pack_start(badge, False, False, 0)
elif status == "ready":
badge = Gtk.Label(label="BEREIT")
badge.get_style_context().add_class("session-ready-badge")
badge.set_halign(Gtk.Align.END)
meta_box.pack_start(badge, False, False, 0)
# Date/time + size — dimmed metadata
meta_line = Gtk.Label(label=f"{session['time_str']} {_format_size(session['size'])}")
meta_line.set_halign(Gtk.Align.END)
meta_line.get_style_context().add_class("session-meta")
meta_box.pack_start(meta_line, False, False, 0)
# Resume button — custom styled
resume_btn = Gtk.Button(label="Resume")
resume_btn.set_tooltip_text(f"claude -r {session['session_id']}")
resume_btn.get_style_context().add_class("session-resume")
session_id = session["session_id"]
resume_btn.connect("clicked", _on_resume_clicked, session_id)
meta_box.pack_start(resume_btn, False, False, 0)
# Store session data on row for filtering
row._session_data = session # type: ignore[attr-defined]
row.show_all()
return row
def _build_search_row(session: dict, query: str, now: float | None = None) -> Gtk.ListBoxRow:
"""Build a ListBoxRow for a search result with snippet + highlighting."""
if now is None:
now = time.time()
row = Gtk.ListBoxRow()
row.set_name("session-row")
row.get_style_context().add_class("session-row")
row.get_style_context().add_class("session-row-search")
# Outer box
outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
outer.set_margin_top(4)
outer.set_margin_bottom(4)
outer.set_margin_start(8)
outer.set_margin_end(10)
row.add(outer)
# Left: short name + preview + snippet + path
info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
info_box.set_hexpand(True)
outer.pack_start(info_box, True, True, 0)
# Short project name
name_label = Gtk.Label(label=session["short_name"])
name_label.set_halign(Gtk.Align.START)
name_label.set_ellipsize(Pango.EllipsizeMode.END)
name_label.set_max_width_chars(45)
name_label.get_style_context().add_class("session-project")
info_box.pack_start(name_label, False, False, 0)
# Preview
preview_label = Gtk.Label(label=session["preview"])
preview_label.set_halign(Gtk.Align.START)
preview_label.set_ellipsize(Pango.EllipsizeMode.END)
preview_label.set_max_width_chars(90)
preview_label.get_style_context().add_class("session-preview")
info_box.pack_start(preview_label, False, False, 0)
# Snippet with Pango markup highlighting
snippet = session.get("snippet", "")
if snippet and query:
# Escape markup characters first, then highlight
safe_snippet = html_mod.escape(snippet)
safe_query = html_mod.escape(query)
# Case-insensitive highlight
highlighted = re.sub(
re.escape(safe_query),
lambda m: f"<b>{m.group()}</b>",
safe_snippet,
flags=re.IGNORECASE
)
snippet_label = Gtk.Label()
snippet_label.set_markup(highlighted)
snippet_label.set_halign(Gtk.Align.START)
snippet_label.set_ellipsize(Pango.EllipsizeMode.END)
snippet_label.set_max_width_chars(100)
snippet_label.get_style_context().add_class("session-snippet")
info_box.pack_start(snippet_label, False, False, 0)
# Project path
path_label = Gtk.Label(label=session["project"])
path_label.set_halign(Gtk.Align.START)
path_label.set_ellipsize(Pango.EllipsizeMode.END)
path_label.set_max_width_chars(70)
path_label.get_style_context().add_class("session-meta")
path_attrs = Pango.AttrList()
path_attrs.insert(Pango.attr_scale_new(0.82))
path_label.set_attributes(path_attrs)
info_box.pack_start(path_label, False, False, 0)
# Right: match badge + meta + resume button
meta_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
meta_box.set_valign(Gtk.Align.CENTER)
outer.pack_start(meta_box, False, False, 0)
# Match count badge
match_count = session.get("match_count", 0)
if match_count > 0:
badge = Gtk.Label(label=f"{match_count} Treffer")
badge.get_style_context().add_class("session-match-badge")
badge.set_halign(Gtk.Align.END)
meta_box.pack_start(badge, False, False, 0)
# Date/time + size
meta_line = Gtk.Label(label=f"{session['time_str']} {_format_size(session['size'])}")
meta_line.set_halign(Gtk.Align.END)
meta_line.get_style_context().add_class("session-meta")
meta_box.pack_start(meta_line, False, False, 0)
# Resume button
resume_btn = Gtk.Button(label="Resume")
resume_btn.set_tooltip_text(f"claude -r {session['session_id']}")
resume_btn.get_style_context().add_class("session-resume")
session_id = session["session_id"]
resume_btn.connect("clicked", _on_resume_clicked, session_id)
meta_box.pack_start(resume_btn, False, False, 0)
row._session_data = session
row.show_all()
return row
def _on_resume_clicked(_btn: Gtk.Button, session_id: str) -> None:
"""Launch kitty terminal with claude -r SESSION_ID."""
try:
# List-form Popen (not shell=True) is safe against injection regardless of
# special characters in session_id — each element is passed as a literal argument.
subprocess.Popen(
["kitty", "-e", "claude", "-r", session_id],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
# kitty not found — fallback terminal uses shell=False list form too.
# shlex.quote is applied to session_id only for the shell=True bash -c string.
try:
subprocess.Popen(
["x-terminal-emulator", "-e", "bash", "-c", f"claude -r {shlex.quote(session_id)}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
pass
# ---------------------------------------------------------------------------
# Filter function for Gtk.ListBox
# ---------------------------------------------------------------------------
def _filter_func(row: Gtk.ListBoxRow) -> bool:
"""Return True if row matches current search query."""
if _state["search_mode"]:
return True # grep already filtered — show all rows
if _state["search_entry"] is None:
return True
query = _state["search_entry"].get_text().strip().lower()
if not query:
return True
session = getattr(row, "_session_data", None)
if session is None:
return True
return (
query in session.get("short_name", "").lower()
or query in session["project"].lower()
or query in session["preview"].lower()
or query in session["session_id"].lower()
)
# ---------------------------------------------------------------------------
# Populate / refresh the list
# ---------------------------------------------------------------------------
def _populate_list_box(sessions: list[dict]) -> None:
"""Clear and re-populate the ListBox with new session rows."""
# Cancel any running batch insertion from search mode
if _state["batch_id"] is not None:
GLib.source_remove(_state["batch_id"])
_state["batch_id"] = None
_state["all_sessions"] = sessions
if _state["list_box"] is None:
return
# Remove all existing children
for child in _state["list_box"].get_children():
_state["list_box"].remove(child)
# Add all rows
now = time.time()
for session in sessions:
row = _build_session_row(session, now)
_state["list_box"].add(row)
_state["list_box"].show_all()
# Update stats bar
if _state["stats_label"] is not None:
_state["stats_label"].set_text(_compute_stats(sessions, now))
# ---------------------------------------------------------------------------
# Search logic (debounced, two-mode)
# ---------------------------------------------------------------------------
def _on_search_changed(_entry: Gtk.SearchEntry) -> None:
"""Handle search text changes with 300ms debounce."""
# Cancel previous debounce timer
if _state["debounce_id"] is not None:
GLib.source_remove(_state["debounce_id"])
_state["debounce_id"] = None
# Cancel any running batch insertion
if _state["batch_id"] is not None:
GLib.source_remove(_state["batch_id"])
_state["batch_id"] = None
query = _state["search_entry"].get_text().strip()
if len(query) < 3:
# Short query or empty: switch back to browse mode
if _state["search_mode"]:
_state["search_mode"] = False
_populate_list_box(_state["all_sessions"])
else:
# Just filter existing rows
if _state["list_box"] is not None:
_state["list_box"].invalidate_filter()
return
# Debounce: schedule grep search after 300ms
_state["debounce_id"] = GLib.timeout_add(300, _execute_search, query)
def _show_search_status(message: str) -> None:
"""Clear the ListBox and show a status message placeholder. GTK-thread only."""
if _state["list_box"] is None:
return
# Cancel any running batch insertion first
if _state["batch_id"] is not None:
GLib.source_remove(_state["batch_id"])
_state["batch_id"] = None
for child in _state["list_box"].get_children():
_state["list_box"].remove(child)
placeholder = Gtk.Label(label=message)
placeholder.get_style_context().add_class("session-meta")
placeholder.set_margin_top(16)
placeholder.set_margin_bottom(16)
row = Gtk.ListBoxRow()
row.set_activatable(False)
row.add(placeholder)
row.show_all()
_state["list_box"].add(row)
_state["list_box"].show_all()
def _display_search_results(results: list, query: str) -> bool:
"""Populate the ListBox with search results in batches. GTK-thread only.
Called via GLib.idle_add from the background worker thread.
Returns False (one-shot).
"""
if _state["list_box"] is None:
return False
# Clear the "Suche..." placeholder (and any stale rows)
for child in _state["list_box"].get_children():
_state["list_box"].remove(child)
# Update stats bar
if _state["stats_label"] is not None:
total_matches = sum(s.get("match_count", 0) for s in results)
_state["stats_label"].set_text(
f"{len(results)} Sessions gefunden | {total_matches} Treffer gesamt | Suche: \"{query}\""
)
if not results:
_state["list_box"].show_all()
return False
# Cancel any previous batch insertion
if _state["batch_id"] is not None:
GLib.source_remove(_state["batch_id"])
_state["batch_id"] = None
# Batch-insert via idle_add
now = time.time()
it = iter(results)
def _insert_batch() -> bool:
BATCH = 30
try:
for _ in range(BATCH):
session = next(it)
row = _build_search_row(session, query, now)
_state["list_box"].add(row)
_state["list_box"].show_all()
return True # more rows pending
except StopIteration:
_state["list_box"].show_all()
_state["batch_id"] = None
return False # done
_state["batch_id"] = GLib.idle_add(_insert_batch)
return False # one-shot
def _execute_search(query: str) -> bool:
"""Kick off a non-blocking grep search. Returns False (one-shot GLib timer)."""
global _search_cancel
_state["debounce_id"] = None
_state["search_mode"] = True
# Signal any previous background search to abort early
_search_cancel.set()
cancel = threading.Event()
_search_cancel = cancel
# Immediate visual feedback — clears the list and shows "Suche..."
_show_search_status("Suche…")
def _worker() -> None:
if cancel.is_set():
return
results = _grep_sessions(query)
if cancel.is_set():
return
GLib.idle_add(_display_search_results, results, query)
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
return False # one-shot timer
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def refresh_sessions() -> bool:
"""Re-scan sessions and update the UI. Returns True to keep GLib timer alive."""
try:
sessions = _scan_all_sessions()
GLib.idle_add(_populate_list_box, sessions)
except Exception:
log.exception("refresh sessions")
return True # keep timer running
def build_sessions_tab() -> Gtk.ScrolledWindow:
"""Build and return the Sessions tab widget (Gtk.ScrolledWindow)."""
# -----------------------------------------------------------------------
# Root: ScrolledWindow → main VBox
# -----------------------------------------------------------------------
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled.set_hexpand(True)
scrolled.set_vexpand(True)
main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
main_vbox.set_margin_top(8)
main_vbox.set_margin_bottom(8)
main_vbox.set_margin_start(8)
main_vbox.set_margin_end(8)
scrolled.add(main_vbox)
# -----------------------------------------------------------------------
# Toolbar: title + search + refresh button
# -----------------------------------------------------------------------
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
toolbar.set_margin_bottom(6)
main_vbox.pack_start(toolbar, False, False, 0)
title_label = Gtk.Label(label="Sessions")
title_label.get_style_context().add_class("section-title")
title_attrs = Pango.AttrList()
title_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
title_attrs.insert(Pango.attr_scale_new(1.1))
title_label.set_attributes(title_attrs)
title_label.set_halign(Gtk.Align.START)
toolbar.pack_start(title_label, False, False, 0)
# Spacer
spacer = Gtk.Box()
spacer.set_hexpand(True)
toolbar.pack_start(spacer, True, True, 0)
# Search entry
_state["search_entry"] = Gtk.SearchEntry()
_state["search_entry"].set_placeholder_text("Projekt oder Inhalt suchen…")
_state["search_entry"].set_size_request(220, -1)
toolbar.pack_start(_state["search_entry"], False, False, 0)
# Refresh button
refresh_btn = Gtk.Button()
refresh_icon = Gtk.Image.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON)
refresh_btn.add(refresh_icon)
refresh_btn.set_tooltip_text("Sessions neu einlesen")
refresh_btn.connect("clicked", lambda _b: refresh_sessions())
toolbar.pack_start(refresh_btn, False, False, 0)
# -----------------------------------------------------------------------
# Stats pill (rounded bar instead of frame)
# -----------------------------------------------------------------------
_state["stats_label"] = Gtk.Label(label="Lade Sessions…")
_state["stats_label"].get_style_context().add_class("session-stats")
_state["stats_label"].set_margin_top(2)
_state["stats_label"].set_margin_bottom(8)
_state["stats_label"].set_halign(Gtk.Align.START)
main_vbox.pack_start(_state["stats_label"], False, False, 0)
# -----------------------------------------------------------------------
# ListBox for sessions
# -----------------------------------------------------------------------
_state["list_box"] = Gtk.ListBox()
_state["list_box"].set_selection_mode(Gtk.SelectionMode.NONE)
_state["list_box"].set_filter_func(_filter_func)
_state["list_box"].get_style_context().add_class("view")
main_vbox.pack_start(_state["list_box"], True, True, 0)
# Connect search to filter
_state["search_entry"].connect("search-changed", _on_search_changed)
# -----------------------------------------------------------------------
# Initial load (non-blocking via idle_add)
# -----------------------------------------------------------------------
idle_once(lambda: _populate_list_box(_scan_all_sessions()))
scrolled.show_all()
return scrolled