389 lines
17 KiB
Python
389 lines
17 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
import csv
|
|
from pathlib import Path
|
|
import threading
|
|
import queue
|
|
|
|
from ..core.countings_impl import analyze_paths, analyze_file_counts
|
|
from ..core.scanner import find_source_files
|
|
from ..config.languages import LANGUAGE_EXTENSIONS
|
|
|
|
|
|
class CountingsTab(ttk.Frame):
|
|
"""Tab that executes counting (SLOC) and displays results.
|
|
|
|
The CountingsTab relies on a shared TopBar for folder selection and
|
|
uses `analyze_paths` from the core implementation.
|
|
"""
|
|
|
|
def __init__(self, parent, topbar, app=None, *args, **kwargs):
|
|
"""Initialize the CountingsTab.
|
|
|
|
Args:
|
|
parent: The notebook widget containing this tab.
|
|
topbar: Shared TopBar instance exposing `path_var`.
|
|
"""
|
|
super().__init__(parent, *args, **kwargs)
|
|
|
|
self.topbar = topbar
|
|
self.app = app
|
|
self.worker = None
|
|
|
|
controls = ttk.Frame(self)
|
|
controls.grid(row=0, column=0, sticky="ew", padx=8, pady=6)
|
|
self.run_btn = ttk.Button(controls, text="Run Countings", command=self.start_countings)
|
|
self.run_btn.grid(row=0, column=0, sticky="w")
|
|
self.cancel_btn = ttk.Button(controls, text="Cancel", command=self.cancel, state="disabled")
|
|
self.cancel_btn.grid(row=0, column=1, padx=(8, 0))
|
|
self.export_btn = ttk.Button(controls, text="Export CSV", command=self.export_csv, state="disabled")
|
|
self.export_btn.grid(row=0, column=2, padx=(8, 0))
|
|
# help icon with tooltip (explain columns)
|
|
try:
|
|
from .tooltip import ToolTip
|
|
help_txt = (
|
|
"Columns:\n"
|
|
"File: filename\n"
|
|
"Path: full file path\n"
|
|
"Code: logical/source lines\n"
|
|
"Comment: comment lines count\n"
|
|
"Blank: blank lines count\n"
|
|
"Total: physical lines in file\n"
|
|
"Language: detected language"
|
|
)
|
|
help_lbl = tk.Label(controls, text="❓", foreground='#2b6fb2', cursor='question_arrow')
|
|
help_lbl.grid(row=0, column=10, sticky='e', padx=(8,0))
|
|
ToolTip(help_lbl, help_txt, delay=1000)
|
|
except Exception:
|
|
# non-fatal: tooltip helper may not be available in some contexts
|
|
pass
|
|
|
|
# Progress bar: will be set to determinate and updated per-file
|
|
self.progress = ttk.Progressbar(self, mode="determinate")
|
|
self.progress.grid(row=1, column=0, sticky="ew", padx=8, pady=(6, 0))
|
|
|
|
# Counters placed under the progress bar
|
|
counters = ttk.Frame(self)
|
|
counters.grid(row=2, column=0, sticky="ew", padx=8, pady=(6, 0))
|
|
self._lbl_physical = ttk.Label(counters, text="Physical: 0")
|
|
self._lbl_code = ttk.Label(counters, text="Code: 0")
|
|
self._lbl_comment = ttk.Label(counters, text="Comments: 0")
|
|
self._lbl_blank = ttk.Label(counters, text="Blank: 0")
|
|
self._lbl_files = ttk.Label(counters, text="Files: 0/0")
|
|
self._lbl_physical.pack(side="left", padx=(0,10))
|
|
self._lbl_code.pack(side="left", padx=(0,10))
|
|
self._lbl_comment.pack(side="left", padx=(0,10))
|
|
self._lbl_blank.pack(side="left", padx=(0,10))
|
|
self._lbl_files.pack(side="right", padx=(0,10))
|
|
|
|
# Adjust tree positioning (moved down one row)
|
|
|
|
# Treeview for tabular display (name, path, numeric columns)
|
|
columns = ("name", "path", "code", "comment", "blank", "total", "lang")
|
|
self.tree = ttk.Treeview(self, columns=columns, show="headings")
|
|
self.tree.heading("name", text="File", command=lambda: self._sort_by("name", False))
|
|
self.tree.heading("path", text="Path", command=lambda: self._sort_by("path", False))
|
|
self.tree.heading("code", text="Code", command=lambda: self._sort_by("code", False))
|
|
self.tree.heading("comment", text="Comment", command=lambda: self._sort_by("comment", False))
|
|
self.tree.heading("blank", text="Blank", command=lambda: self._sort_by("blank", False))
|
|
self.tree.heading("total", text="Total", command=lambda: self._sort_by("total", False))
|
|
self.tree.heading("lang", text="Language", command=lambda: self._sort_by("lang", False))
|
|
|
|
# column sizing: numeric columns smaller and right-aligned
|
|
self.tree.column("name", width=300, anchor="w", stretch=True)
|
|
self.tree.column("path", width=400, anchor="w", stretch=True)
|
|
self.tree.column("code", width=80, anchor="e", stretch=False)
|
|
self.tree.column("comment", width=80, anchor="e", stretch=False)
|
|
self.tree.column("blank", width=60, anchor="e", stretch=False)
|
|
self.tree.column("total", width=90, anchor="e", stretch=False)
|
|
self.tree.column("lang", width=120, anchor="w", stretch=False)
|
|
|
|
# Layout tree and scrollbars (both vertical and horizontal)
|
|
self.tree.grid(row=3, column=0, sticky="nsew", padx=8, pady=8)
|
|
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
|
|
hsb = ttk.Scrollbar(self, orient="horizontal", command=self.tree.xview)
|
|
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
# Place vertical scrollbar alongside the tree (same row)
|
|
vsb.grid(row=3, column=1, sticky="ns")
|
|
# Place horizontal scrollbar below the tree and vertical scrollbar
|
|
hsb.grid(row=4, column=0, columnspan=2, sticky="ew", padx=8)
|
|
|
|
# make tree expand to fill available space
|
|
self.rowconfigure(3, weight=1)
|
|
self.columnconfigure(0, weight=1)
|
|
# bind double-click to open file viewer
|
|
self.tree.bind("<Double-1>", self._on_double_click)
|
|
|
|
# internal state for counts/progress
|
|
self._cumulative_counts = {"physical": 0, "code": 0, "comment": 0, "blank": 0}
|
|
self._total_files = 0
|
|
self._processed_files = 0
|
|
|
|
def start_countings(self):
|
|
"""Start counting on the folder(s)/file(s) configured in the shared TopBar or current profile.
|
|
|
|
This now mirrors `ScannerTab` behavior and will operate on all `paths` defined
|
|
in the selected profile (or the fallback single `path_var`).
|
|
"""
|
|
# determine list of paths to analyze: prefer profile.paths if available
|
|
paths = []
|
|
pr = getattr(self.topbar, "current_profile", None)
|
|
if not pr:
|
|
pname = getattr(self.topbar, "profile_var", None)
|
|
if pname:
|
|
from ..config import profiles as profiles_cfg
|
|
|
|
pr = profiles_cfg.find_profile(pname.get()) if hasattr(pname, "get") else profiles_cfg.find_profile(str(pname))
|
|
|
|
if pr:
|
|
paths = pr.get("paths") or []
|
|
else:
|
|
p = self.topbar.path_var.get().strip()
|
|
if p:
|
|
paths = [p]
|
|
|
|
if not paths:
|
|
messagebox.showwarning("Missing path", "Please select a folder or profile to analyze first.")
|
|
return
|
|
|
|
# log start
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Countings started on: {', '.join(paths)}", "INFO")
|
|
except Exception:
|
|
pass
|
|
|
|
# Build allowed extensions and ignore patterns from profile languages
|
|
allowed_exts = None
|
|
if pr:
|
|
langs = pr.get("languages", []) or []
|
|
exts = []
|
|
for ln in langs:
|
|
if ln in LANGUAGE_EXTENSIONS:
|
|
exts.extend(LANGUAGE_EXTENSIONS[ln])
|
|
else:
|
|
val = ln.strip()
|
|
if val.startswith('.'):
|
|
exts.append(val.lower())
|
|
elif len(val) <= 5 and not val.isalpha():
|
|
exts.append(f".{val.lower()}")
|
|
else:
|
|
pass
|
|
if exts:
|
|
allowed_exts = set(exts)
|
|
ignore_patterns = pr.get("ignore", []) if pr else None
|
|
|
|
# Aggregate targets from all configured paths
|
|
targets = []
|
|
for pstr in paths:
|
|
pth = Path(pstr)
|
|
try:
|
|
if pth.is_dir():
|
|
targets.extend(find_source_files(pth, allowed_extensions=allowed_exts, ignore_patterns=ignore_patterns))
|
|
elif pth.is_file():
|
|
targets.append(pth)
|
|
except Exception:
|
|
# skip problematic entries but continue
|
|
continue
|
|
|
|
self.run_btn.config(state="disabled")
|
|
self.cancel_btn.config(state="normal")
|
|
self.export_btn.config(state="disabled")
|
|
# clear tree
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
|
|
# prepare progress and counters using scanner results
|
|
self._total_files = len(targets)
|
|
self._processed_files = 0
|
|
self._cumulative_counts = {"physical": 0, "code": 0, "comment": 0, "blank": 0}
|
|
try:
|
|
self.progress['maximum'] = max(1, self._total_files)
|
|
self.progress['value'] = 0
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}")
|
|
self._lbl_physical.config(text="Physical: 0")
|
|
self._lbl_code.config(text="Code: 0")
|
|
self._lbl_comment.config(text="Comments: 0")
|
|
self._lbl_blank.config(text="Blank: 0")
|
|
except Exception:
|
|
pass
|
|
|
|
# Submit per-file work via the central WorkerManager so the GUI stays responsive.
|
|
try:
|
|
if not getattr(self, 'app', None) or not getattr(self.app, 'worker', None):
|
|
# fallback to previous behavior
|
|
self.worker = threading.Thread(target=self._worker_countings, args=(targets,), daemon=True)
|
|
self.worker.start()
|
|
self.after(200, self._poll_queue)
|
|
return
|
|
|
|
# use the worker manager to map analyze_file_counts over targets
|
|
# on_progress will be called for each file result; on_done when all finished
|
|
# Map per-file counting so we can show progress incrementally.
|
|
self._task_id = self.app.worker.map_iterable(
|
|
func=analyze_file_counts,
|
|
items=targets,
|
|
kind='thread',
|
|
on_progress=self._on_file_result,
|
|
on_done=self._on_all_done,
|
|
)
|
|
except Exception:
|
|
# if worker API not available or mapping failed, fallback
|
|
self.worker = threading.Thread(target=self._worker_countings, args=(targets,), daemon=True)
|
|
self.worker.start()
|
|
self.after(200, self._poll_queue)
|
|
|
|
def cancel(self):
|
|
"""Request cancellation (informational only)."""
|
|
# Cancellation not yet supported for task submitted via WorkerManager
|
|
messagebox.showinfo("Cancel", "Cancellation requested but not supported yet.")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log("Countings cancelled by user", "WARNING")
|
|
except Exception:
|
|
pass
|
|
self._finish()
|
|
|
|
def _worker_countings(self, targets):
|
|
"""Legacy fallback worker that runs analyze_paths on the whole list."""
|
|
try:
|
|
results = analyze_paths(targets)
|
|
# Directly populate via main thread scheduling
|
|
self.after(0, lambda: self._on_all_done(results))
|
|
except Exception as e:
|
|
self.after(0, lambda: messagebox.showerror("Error", f"Error during countings: {e}"))
|
|
|
|
# New callbacks used by WorkerManager
|
|
def _on_file_result(self, res):
|
|
"""Called in main thread for each file result emitted by WorkerManager."""
|
|
try:
|
|
if isinstance(res, dict):
|
|
r = res
|
|
else:
|
|
r = res
|
|
if "error" in r:
|
|
file = r.get("file")
|
|
name = Path(file).name if file else ""
|
|
self.tree.insert("", "end", values=(name, file, 0, 0, 0, 0, r.get("error")))
|
|
else:
|
|
file = r.get("file")
|
|
name = Path(file).name if file else ""
|
|
code = r.get("code_lines") or 0
|
|
comment = r.get("comment_lines") or 0
|
|
blank = r.get("blank_lines") or 0
|
|
total = r.get("physical_lines") or 0
|
|
lang = r.get("language") or "unknown"
|
|
self.tree.insert("", "end", values=(name, file, int(code), int(comment), int(blank), int(total), lang))
|
|
# update cumulative counters and progress
|
|
try:
|
|
self._cumulative_counts['physical'] += int(total)
|
|
self._cumulative_counts['code'] += int(code)
|
|
self._cumulative_counts['comment'] += int(comment)
|
|
self._cumulative_counts['blank'] += int(blank)
|
|
self._processed_files += 1
|
|
# update labels
|
|
self._lbl_physical.config(text=f"Physical: {self._cumulative_counts['physical']}")
|
|
self._lbl_code.config(text=f"Code: {self._cumulative_counts['code']}")
|
|
self._lbl_comment.config(text=f"Comments: {self._cumulative_counts['comment']}")
|
|
self._lbl_blank.config(text=f"Blank: {self._cumulative_counts['blank']}")
|
|
self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}")
|
|
try:
|
|
self.progress['value'] = self._processed_files
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_all_done(self, results):
|
|
"""Called in main thread when all results are available."""
|
|
try:
|
|
written = len(self.tree.get_children())
|
|
self.export_btn.config(state="normal")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Countings finished: {written} items", "INFO")
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._finish()
|
|
|
|
# ensure progress reflects completion
|
|
try:
|
|
self._processed_files = self._total_files
|
|
self._lbl_files.config(text=f"Files: {self._processed_files}/{self._total_files}")
|
|
self.progress['value'] = self._processed_files
|
|
except Exception:
|
|
pass
|
|
|
|
def _finish(self):
|
|
"""Finalize UI state after countings finished or cancelled."""
|
|
self.progress.stop()
|
|
self.run_btn.config(state="normal")
|
|
self.cancel_btn.config(state="disabled")
|
|
|
|
def _sort_by(self, col, descending):
|
|
"""Sort tree contents by given column. Toggle ascending/descending.
|
|
|
|
Args:
|
|
col: column key (file, code, comment, blank, total, lang)
|
|
descending: bool whether to sort descending
|
|
"""
|
|
col_map = {"name": 0, "path": 1, "code": 2, "comment": 3, "blank": 4, "total": 5, "lang": 6}
|
|
idx = col_map.get(col, 0)
|
|
children = list(self.tree.get_children(""))
|
|
def _key(item):
|
|
val = self.tree.item(item, "values")[idx]
|
|
try:
|
|
return int(val)
|
|
except Exception:
|
|
return str(val).lower()
|
|
|
|
children.sort(key=_key, reverse=descending)
|
|
for index, item in enumerate(children):
|
|
self.tree.move(item, "", index)
|
|
# update heading to reverse sort next time
|
|
self.tree.heading(col, command=lambda c=col: self._sort_by(c, not descending))
|
|
|
|
def export_csv(self):
|
|
"""Export current table contents to CSV file chosen by the user."""
|
|
path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV files", "*.csv"), ("All files", "*")])
|
|
if not path:
|
|
return
|
|
headers = ["name", "path", "code", "comment", "blank", "total", "language"]
|
|
from ..utils.csv_exporter import export_rows_to_csv
|
|
|
|
try:
|
|
rows = (self.tree.item(child, "values") for child in self.tree.get_children())
|
|
written = export_rows_to_csv(path, headers, rows)
|
|
messagebox.showinfo("Export", f"Exported {written} rows to {path}")
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Exported {written} rows to {path}", "INFO")
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
messagebox.showerror("Export Error", str(e))
|
|
try:
|
|
if getattr(self, 'app', None):
|
|
self.app.log(f"Export error: {e}", "ERROR")
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_double_click(self, _evt=None):
|
|
sel = self.tree.selection()
|
|
if not sel:
|
|
return
|
|
item = sel[0]
|
|
vals = self.tree.item(item, "values")
|
|
# values: (name, path, ...)
|
|
if len(vals) >= 2:
|
|
path = vals[1]
|
|
from .file_viewer import FileViewer
|
|
|
|
FileViewer(self, path)
|