SXXXXXXX_GitUtility/gui.py
2025-04-14 09:02:34 +02:00

1185 lines
46 KiB
Python

# gui.py
import tkinter as tk
from tkinter import ttk # Import themed widgets, including Notebook
from tkinter import scrolledtext, filedialog, messagebox, simpledialog
import logging
import os
import re # Needed for validation in dialogs
# Import constant from the central location
from config_manager import DEFAULT_BACKUP_DIR
# --- Tooltip Class Definition ---
class Tooltip:
"""Simple tooltip implementation for Tkinter widgets."""
def __init__(self, widget, text):
"""Initialize tooltip."""
self.widget = widget
self.text = text
self.tooltip_window = None
self.id = None
self.x = 0.0 # Use float for position calculation if needed
self.y = 0.0
def showtip(self):
"""Display text in a tooltip window."""
# Hide any existing tooltip first
self.hidetip()
# Avoid error if the parent widget has been destroyed
if not self.widget.winfo_exists():
return
try:
# Get widget position relative to screen
# bbox("insert") gets coordinates relative to widget's top-left
x_rel, y_rel, _, _ = self.widget.bbox("insert")
x_root = self.widget.winfo_rootx() # Widget's top-left corner X on screen
y_root = self.widget.winfo_rooty() # Widget's top-left corner Y on screen
# Calculate tooltip position (below and right of cursor/widget)
x_pos = x_root + x_rel + 25
y_pos = y_root + y_rel + 25
except tk.TclError:
# Fallback if bbox fails (e.g., widget not visible)
# Position below the center of the widget
x_root = self.widget.winfo_rootx()
y_root = self.widget.winfo_rooty()
widget_width = self.widget.winfo_width()
widget_height = self.widget.winfo_height()
x_pos = x_root + widget_width // 2
y_pos = y_root + widget_height + 5
# Create the tooltip as a Toplevel window
self.tooltip_window = tw = tk.Toplevel(self.widget)
# Remove window decorations (border, title bar)
tw.wm_overrideredirect(True)
# Position the window on screen (ensure integer coordinates)
tw.wm_geometry(f"+{int(x_pos)}+{int(y_pos)}")
# Create the label inside the tooltip window
label = tk.Label(
tw,
text=self.text,
justify=tk.LEFT,
background="#ffffe0", # Light yellow background
relief=tk.SOLID, # Give it a border
borderwidth=1,
font=("tahoma", "8", "normal"), # Small standard font
)
label.pack(ipadx=1) # Small internal padding
def hidetip(self):
"""Hide the tooltip window."""
tw = self.tooltip_window
# Reset the reference
self.tooltip_window = None
# Destroy the window if it exists
if tw:
try:
# Check if window still exists before trying to destroy
if tw.winfo_exists():
tw.destroy()
except tk.TclError:
# Handle race condition where window might be destroyed already
pass
# --- End Tooltip Class ---
# --- Gitignore Editor Window Class ---
class GitignoreEditorWindow(tk.Toplevel):
"""Toplevel window for editing the .gitignore file."""
def __init__(self, master, gitignore_path, logger):
"""Initialize the editor window."""
super().__init__(master)
self.gitignore_path = gitignore_path
self.logger = logger
self.original_content = "" # Store initial content
# --- Window Config ---
self.title(f"Edit {os.path.basename(gitignore_path)}")
self.geometry("600x450")
self.minsize(400, 300)
self.grab_set() # Make modal
self.transient(master) # Stay on top of parent
self.protocol("WM_DELETE_WINDOW", self._on_close) # Handle X button
# --- Widgets ---
main_frame = ttk.Frame(self, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
main_frame.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
self.text_editor = scrolledtext.ScrolledText(
main_frame, wrap=tk.WORD, font=("Consolas", 10), undo=True
)
self.text_editor.grid(row=0, column=0, sticky="nsew", pady=(0, 10))
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=1, column=0, sticky="ew")
button_frame.columnconfigure(0, weight=1) # Spacer left
button_frame.columnconfigure(3, weight=1) # Spacer right
self.save_button = ttk.Button(
button_frame, text="Save and Close", command=self._save_and_close
)
self.save_button.grid(row=0, column=2, padx=5)
self.cancel_button = ttk.Button(
button_frame, text="Cancel", command=self._on_close
)
self.cancel_button.grid(row=0, column=1, padx=5)
# --- Load Content & Position ---
self._load_file()
self._center_window(master)
self.text_editor.focus_set()
def _center_window(self, parent):
"""Centers the editor window relative to its parent."""
self.update_idletasks() # Ensure window size is calculated
parent_x = parent.winfo_rootx()
parent_y = parent.winfo_rooty()
parent_w = parent.winfo_width()
parent_h = parent.winfo_height()
win_w = self.winfo_width()
win_h = self.winfo_height()
x = parent_x + (parent_w // 2) - (win_w // 2)
y = parent_y + (parent_h // 2) - (win_h // 2)
# Basic screen boundary check
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = max(0, min(x, sw - win_w))
y = max(0, min(y, sh - win_h))
self.geometry(f"+{int(x)}+{int(y)}")
def _load_file(self):
"""Loads the .gitignore content into the editor."""
self.logger.info(f"Loading gitignore: {self.gitignore_path}")
content = ""
try:
if os.path.exists(self.gitignore_path):
with open(
self.gitignore_path, "r", encoding="utf-8", errors="replace"
) as f:
content = f.read()
self.logger.debug(".gitignore loaded.")
else:
self.logger.info(".gitignore does not exist.")
# Store original content and populate editor
self.original_content = content
self.text_editor.delete("1.0", tk.END)
self.text_editor.insert(tk.END, self.original_content)
self.text_editor.edit_reset() # Reset undo stack
except IOError as e:
self.logger.error(f"Read error: {e}", exc_info=True)
messagebox.showerror("Error", f"Read error:\n{e}", parent=self)
except Exception as e:
self.logger.exception(f"Load error: {e}")
messagebox.showerror("Error", f"Load error:\n{e}", parent=self)
def _save_file(self):
"""Saves the editor content back to the .gitignore file."""
# Get content, normalize trailing newline
current = self.text_editor.get("1.0", tk.END).rstrip()
current += "\n" if current else ""
# Normalize original for comparison
original = self.original_content.rstrip()
original += "\n" if original else ""
if current == original:
self.logger.info("No changes to save in .gitignore.")
return True # Indicate success (no action needed)
self.logger.info(f"Saving changes to: {self.gitignore_path}")
try:
with open(self.gitignore_path, "w", encoding="utf-8", newline="\n") as f:
f.write(current)
self.logger.info(".gitignore saved successfully.")
self.original_content = current # Update baseline for change tracking
self.text_editor.edit_reset() # Reset undo stack after save
return True # Indicate success
except IOError as e:
self.logger.error(f"Write error: {e}", exc_info=True)
messagebox.showerror("Error", f"Save error:\n{e}", parent=self)
return False # Indicate failure
except Exception as e:
self.logger.exception(f"Save error: {e}")
messagebox.showerror("Error", f"Save error:\n{e}", parent=self)
return False
def _save_and_close(self):
"""Saves the file then closes the window if save succeeds."""
if self._save_file():
self.destroy()
def _on_close(self):
"""Handles window close: checks for unsaved changes."""
current = self.text_editor.get("1.0", tk.END).rstrip()
current += "\n" if current else ""
original = self.original_content.rstrip()
original += "\n" if original else ""
if current != original:
# Ask user: Yes (Save), No (Discard), Cancel
response = messagebox.askyesnocancel(
"Unsaved Changes", "Save changes?", parent=self
)
if response is True: # Yes -> Save and Close
self._save_and_close()
elif response is False: # No -> Discard and Close
self.logger.warning("Discarding .gitignore changes.")
self.destroy()
# Else (Cancel): Do nothing, window stays open
else:
# No changes, simply close
self.destroy()
# --- End Gitignore Editor Window ---
# --- Create Tag Dialog ---
class CreateTagDialog(simpledialog.Dialog):
"""Dialog to get new tag name and message."""
def __init__(self, parent, title="Create New Tag"):
"""Initialize the dialog."""
self.tag_name_var = tk.StringVar()
self.tag_message_var = tk.StringVar()
self.result = None
super().__init__(parent, title=title)
def body(self, master):
"""Create dialog body with input fields."""
name_label = ttk.Label(master, text="Tag Name:")
name_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry = ttk.Entry(master, textvariable=self.tag_name_var, width=40)
self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
msg_label = ttk.Label(master, text="Tag Message:")
msg_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.message_entry = ttk.Entry(
master, textvariable=self.tag_message_var, width=40
)
self.message_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
master.columnconfigure(1, weight=1) # Allow entries to expand
return self.name_entry # Initial focus
def validate(self):
"""Validate the input fields."""
name = self.tag_name_var.get().strip()
message = self.tag_message_var.get().strip()
if not name:
messagebox.showwarning(
"Input Error", "Tag name cannot be empty.", parent=self
)
return 0 # Validation failed
if not message:
messagebox.showwarning(
"Input Error", "Tag message cannot be empty.", parent=self
)
return 0 # Validation failed
# Validate tag name format using regex
# Ensure 're' module is imported at the top of gui.py
pattern = r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?<!\.)$"
if not re.match(pattern, name):
messagebox.showwarning(
"Input Error", "Invalid tag name format.", parent=self
)
return 0 # Validation failed
return 1 # Validation successful
def apply(self):
"""Process the data if validation succeeded."""
name = self.tag_name_var.get().strip()
message = self.tag_message_var.get().strip()
self.result = (name, message)
# --- End Create Tag Dialog ---
# --- Create Branch Dialog ---
class CreateBranchDialog(simpledialog.Dialog):
"""Dialog to get new branch name."""
def __init__(self, parent, title="Create New Branch"):
"""Initialize the dialog."""
self.branch_name_var = tk.StringVar()
self.result = None
super().__init__(parent, title=title)
def body(self, master):
"""Create dialog body."""
lbl = ttk.Label(master, text="New Branch Name:")
lbl.grid(row=0, column=0, padx=5, pady=10, sticky="w")
self.name_entry = ttk.Entry(master, textvariable=self.branch_name_var, width=40)
self.name_entry.grid(row=0, column=1, padx=5, pady=10, sticky="ew")
master.columnconfigure(1, weight=1) # Allow entry to expand
return self.name_entry # Initial focus
def validate(self):
"""Validate the branch name input."""
name = self.branch_name_var.get().strip()
if not name:
messagebox.showwarning(
"Input Error", "Branch name cannot be empty.", parent=self
)
return 0 # Validation failed
# Git branch name validation (simplified but catches common issues)
pattern = (
r"^(?!\.| |.*[/.]\.|\.|.*\\|.*@\{|.*[/]$|.*\.\.)[^ \t\n\r\f\v~^:?*[\\]+$"
)
if not re.match(pattern, name) or name.lower() == "head":
messagebox.showwarning(
"Input Error", "Invalid branch name format.", parent=self
)
return 0 # Validation failed
return 1 # Validation successful
def apply(self):
"""Process the data if validation succeeded."""
self.result = self.branch_name_var.get().strip()
# --- End Create Branch Dialog ---
class MainFrame(ttk.Frame):
"""The main frame using a ttk.Notebook for tabbed interface."""
GREEN = "#90EE90" # Light green color constant
RED = "#F08080" # Light coral color constant
def __init__(
self,
master,
load_profile_settings_cb,
browse_folder_cb,
update_svn_status_cb,
prepare_svn_for_git_cb,
create_git_bundle_cb,
fetch_from_git_bundle_cb,
config_manager_instance,
profile_sections_list,
add_profile_cb,
remove_profile_cb,
manual_backup_cb,
open_gitignore_editor_cb,
save_profile_cb,
commit_changes_cb, # Added commit
refresh_tags_cb,
create_tag_cb,
checkout_tag_cb,
refresh_history_cb,
refresh_branches_cb,
checkout_branch_cb,
create_branch_cb,
):
"""Initializes the MainFrame with tabs."""
super().__init__(master)
self.master = master
# Store callbacks from the controller
self.load_profile_settings_callback = load_profile_settings_cb
self.browse_folder_callback = browse_folder_cb
self.update_svn_status_callback = update_svn_status_cb
self.prepare_svn_for_git_callback = prepare_svn_for_git_cb
self.create_git_bundle_callback = create_git_bundle_cb
self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb
self.manual_backup_callback = manual_backup_cb
self.add_profile_callback = add_profile_cb
self.remove_profile_callback = remove_profile_cb
self.save_profile_callback = save_profile_cb
self.open_gitignore_editor_callback = open_gitignore_editor_cb
self.commit_changes_callback = commit_changes_cb # Store commit callback
self.refresh_tags_callback = refresh_tags_cb
self.create_tag_callback = create_tag_cb
self.checkout_tag_callback = checkout_tag_cb
self.refresh_history_callback = refresh_history_cb
self.refresh_branches_callback = refresh_branches_cb
self.checkout_branch_callback = checkout_branch_cb
self.create_branch_callback = create_branch_cb
# Store instances and initial data
self.config_manager = config_manager_instance
self.initial_profile_sections = profile_sections_list
# Configure style
self.style = ttk.Style()
self.style.theme_use("clam") # Or 'alt', 'default', 'classic'
# Configure main frame packing
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
# --- Tkinter Variables ---
self.profile_var = tk.StringVar()
self.autobackup_var = tk.BooleanVar()
self.backup_dir_var = tk.StringVar()
self.backup_exclude_extensions_var = tk.StringVar()
self.autocommit_var = tk.BooleanVar() # For autocommit before bundle
# Commit message uses ScrolledText widget directly now
# --- Create UI Elements ---
# Profile selection frame (always visible)
self._create_profile_frame()
# Notebook for tabs
self.notebook = ttk.Notebook(self, padding=(0, 5, 0, 0))
self.notebook.pack(pady=(5, 0), padx=0, fill="both", expand=True)
# Create frames for each tab (methods return the frame)
self.repo_tab_frame = self._create_repo_tab()
self.backup_tab_frame = self._create_backup_tab()
self.commit_tab_frame = self._create_commit_tab()
self.tags_tab_frame = self._create_tags_tab()
self.branch_tab_frame = self._create_branch_tab()
self.history_tab_frame = self._create_history_tab()
# Add frames as tabs to the notebook
self.notebook.add(self.repo_tab_frame, text=" Repository / Bundle ")
self.notebook.add(self.backup_tab_frame, text=" Backup ")
self.notebook.add(self.commit_tab_frame, text=" Commit ")
self.notebook.add(self.tags_tab_frame, text=" Tags ")
self.notebook.add(self.branch_tab_frame, text=" Branches ")
self.notebook.add(self.history_tab_frame, text=" History ")
# Log area (always visible below tabs)
self._create_log_area()
# --- Initial State Configuration ---
self._initialize_profile_selection()
self.toggle_backup_dir() # Set initial state of backup dir widgets
# Status updates are triggered by initial profile load
def _create_profile_frame(self):
"""Creates the frame for profile configuration (above tabs)."""
profile_outer_frame = ttk.Frame(self, padding=(0, 0, 0, 5))
profile_outer_frame.pack(fill="x")
frame = ttk.LabelFrame(profile_outer_frame, text="Profile", padding=(10, 5))
frame.pack(fill="x")
frame.columnconfigure(1, weight=1) # Dropdown expands
# Label
lbl = ttk.Label(frame, text="Profile:")
lbl.grid(row=0, column=0, sticky=tk.W, padx=5, pady=5)
# Combobox
self.profile_dropdown = ttk.Combobox(
frame,
textvariable=self.profile_var,
state="readonly",
width=35,
values=self.initial_profile_sections,
)
self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5)
self.profile_dropdown.bind(
"<<ComboboxSelected>>",
lambda e: self.load_profile_settings_callback(self.profile_var.get()),
)
self.profile_var.trace_add(
"write",
lambda *a: self.load_profile_settings_callback(self.profile_var.get()),
)
# Buttons
self.save_settings_button = ttk.Button(
frame, text="Save Settings", command=self.save_profile_callback
)
self.save_settings_button.grid(
row=0, column=2, sticky=tk.W, padx=(5, 2), pady=5
)
self.create_tooltip(self.save_settings_button, "Save settings.")
self.add_profile_button = ttk.Button(
frame, text="Add", width=5, command=self.add_profile_callback
)
self.add_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 0), pady=5)
self.remove_profile_button = ttk.Button(
frame, text="Remove", width=8, command=self.remove_profile_callback
)
self.remove_profile_button.grid(
row=0, column=4, sticky=tk.W, padx=(2, 5), pady=5
)
def _create_repo_tab(self):
"""Creates the frame for the 'Repository / Bundle' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.columnconfigure(1, weight=1) # Allow entries to expand
# --- Paths and Bundle Names ---
paths_frame = ttk.LabelFrame(
frame, text="Paths & Bundle Names", padding=(10, 5)
)
paths_frame.pack(pady=5, fill="x")
paths_frame.columnconfigure(1, weight=1) # Entry column expands
col_label = 0
col_entry = 1
col_button = 2
col_indicator = 3
# SVN Path
lbl_svn = ttk.Label(paths_frame, text="SVN Working Copy:")
lbl_svn.grid(row=0, column=col_label, sticky=tk.W, padx=5, pady=3)
self.svn_path_entry = ttk.Entry(paths_frame, width=60)
self.svn_path_entry.grid(row=0, column=col_entry, sticky=tk.EW, padx=5, pady=3)
self.svn_path_entry.bind(
"<FocusOut>",
lambda e: self.update_svn_status_callback(self.svn_path_entry.get()),
)
self.svn_path_entry.bind(
"<Return>",
lambda e: self.update_svn_status_callback(self.svn_path_entry.get()),
)
self.svn_path_browse_button = ttk.Button(
paths_frame,
text="Browse...",
width=9,
command=lambda: self.browse_folder_callback(self.svn_path_entry),
)
self.svn_path_browse_button.grid(
row=0, column=col_button, sticky=tk.W, padx=(0, 5), pady=3
)
self.svn_status_indicator = tk.Label(
paths_frame,
text="",
width=2,
height=1,
relief=tk.SUNKEN,
background=self.RED,
anchor=tk.CENTER,
)
self.svn_status_indicator.grid(
row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3
)
self.create_tooltip(self.svn_status_indicator, "Git repo status (Green=Ready)")
# Bundle Target Dir
lbl_usb = ttk.Label(paths_frame, text="Bundle Target Dir:")
lbl_usb.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=3)
self.usb_path_entry = ttk.Entry(paths_frame, width=60)
self.usb_path_entry.grid(row=1, column=col_entry, sticky=tk.EW, padx=5, pady=3)
self.usb_path_browse_button = ttk.Button(
paths_frame,
text="Browse...",
width=9,
command=lambda: self.browse_folder_callback(self.usb_path_entry),
)
self.usb_path_browse_button.grid(
row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=3
)
# Create Bundle Name
lbl_create_b = ttk.Label(paths_frame, text="Create Bundle Name:")
lbl_create_b.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=3)
self.bundle_name_entry = ttk.Entry(paths_frame, width=60)
self.bundle_name_entry.grid(
row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3
)
# Fetch Bundle Name
lbl_fetch_b = ttk.Label(paths_frame, text="Fetch Bundle Name:")
lbl_fetch_b.grid(row=3, column=col_label, sticky=tk.W, padx=5, pady=3)
self.bundle_updated_name_entry = ttk.Entry(paths_frame, width=60)
self.bundle_updated_name_entry.grid(
row=3, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3
)
# --- Actions Frame ---
actions_frame = ttk.LabelFrame(frame, text="Actions", padding=(10, 5))
actions_frame.pack(pady=10, fill="x")
# Prepare Button
self.prepare_svn_button = ttk.Button(
actions_frame,
text="Prepare SVN Repo",
command=self.prepare_svn_for_git_callback,
state=tk.DISABLED,
)
self.prepare_svn_button.pack(side=tk.LEFT, padx=(0, 5), pady=5)
self.create_tooltip(self.prepare_svn_button, "Initialize Git & .gitignore")
# Create Bundle Button
self.create_bundle_button = ttk.Button(
actions_frame,
text="Create Bundle",
command=self.create_git_bundle_callback,
state=tk.DISABLED,
)
self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
self.create_tooltip(self.create_bundle_button, "Create Git bundle file")
# Fetch Bundle Button
self.fetch_bundle_button = ttk.Button(
actions_frame,
text="Fetch from Bundle",
command=self.fetch_from_git_bundle_callback,
state=tk.DISABLED,
)
self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
self.create_tooltip(self.fetch_bundle_button, "Fetch & merge from bundle")
# Edit .gitignore Button (Moved here)
self.edit_gitignore_button = ttk.Button(
actions_frame,
text="Edit .gitignore",
width=12,
command=self.open_gitignore_editor_callback,
state=tk.DISABLED,
)
self.edit_gitignore_button.pack(side=tk.LEFT, padx=5, pady=5)
self.create_tooltip(self.edit_gitignore_button, "Edit .gitignore file")
return frame
def _create_backup_tab(self):
"""Creates the frame for the 'Backup' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.columnconfigure(1, weight=1) # Entry expands
# --- Configuration ---
config_frame = ttk.LabelFrame(frame, text="Configuration", padding=(10, 5))
config_frame.pack(pady=5, fill="x")
config_frame.columnconfigure(1, weight=1)
col_label = 0
col_entry = 1
col_button = 2
self.autobackup_checkbox = ttk.Checkbutton(
config_frame,
text="Auto Backup before Create/Fetch",
variable=self.autobackup_var,
command=self.toggle_backup_dir,
)
self.autobackup_checkbox.grid(
row=0, column=0, columnspan=3, sticky=tk.W, padx=5, pady=(5, 0)
)
backup_dir_label = ttk.Label(config_frame, text="Backup Directory:")
backup_dir_label.grid(row=1, column=col_label, sticky=tk.W, padx=5, pady=5)
self.backup_dir_entry = ttk.Entry(
config_frame, textvariable=self.backup_dir_var, width=60, state=tk.DISABLED
)
self.backup_dir_entry.grid(
row=1, column=col_entry, sticky=tk.EW, padx=5, pady=5
)
self.backup_dir_button = ttk.Button(
config_frame,
text="Browse...",
width=9,
command=self.browse_backup_dir,
state=tk.DISABLED,
)
self.backup_dir_button.grid(
row=1, column=col_button, sticky=tk.W, padx=(0, 5), pady=5
)
exclude_label = ttk.Label(config_frame, text="Exclude Extensions:")
exclude_label.grid(row=2, column=col_label, sticky=tk.W, padx=5, pady=5)
self.backup_exclude_entry = ttk.Entry(
config_frame, textvariable=self.backup_exclude_extensions_var, width=60
)
self.backup_exclude_entry.grid(
row=2, column=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=5
)
self.create_tooltip(
self.backup_exclude_entry, "Comma-separated (e.g., .log,.tmp)"
)
# --- Manual Backup Action ---
action_frame = ttk.LabelFrame(frame, text="Manual Backup", padding=(10, 5))
action_frame.pack(pady=10, fill="x")
self.manual_backup_button = ttk.Button(
action_frame,
text="Backup Now (ZIP)",
command=self.manual_backup_callback,
state=tk.DISABLED,
)
self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5)
self.create_tooltip(self.manual_backup_button, "Create a ZIP backup now.")
return frame
def _create_commit_tab(self):
"""Creates the frame for the 'Commit' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.rowconfigure(2, weight=1) # Text area expands
frame.columnconfigure(0, weight=1) # Text area expands
# Row 0: Autocommit Checkbox (for Create Bundle action)
self.autocommit_checkbox = ttk.Checkbutton(
frame,
text="Autocommit changes before 'Create Bundle' action",
variable=self.autocommit_var,
state=tk.DISABLED,
)
self.autocommit_checkbox.grid(
row=0, column=0, columnspan=2, sticky="w", padx=5, pady=(0, 5)
)
self.create_tooltip(
self.autocommit_checkbox,
"If checked, uses message below to commit before Create Bundle.",
)
# Row 1: Commit Message Label
commit_msg_label = ttk.Label(frame, text="Commit Message:")
commit_msg_label.grid(row=1, column=0, columnspan=2, sticky="w", padx=5)
# Row 2: Commit Message Text Area
self.commit_message_text = scrolledtext.ScrolledText(
frame,
height=7,
width=60,
wrap=tk.WORD, # Increased height
font=("Segoe UI", 9),
state=tk.DISABLED, # Start disabled
)
self.commit_message_text.grid(
row=2, column=0, columnspan=2, sticky="nsew", padx=5, pady=(0, 5)
)
self.create_tooltip(
self.commit_message_text,
"Enter commit message here for manual commit or autocommit.",
)
# Row 3: Commit Button
self.commit_button = ttk.Button(
frame,
text="Commit Staged Changes", # Clarified label
command=self.commit_changes_callback, # Link to controller method
state=tk.DISABLED,
)
self.commit_button.grid(
row=3, column=0, columnspan=2, sticky="e", padx=5, pady=5
)
self.create_tooltip(
self.commit_button, "Stage ALL changes and commit with the message above."
)
return frame
def _create_tags_tab(self):
"""Creates the frame for the 'Tags' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.columnconfigure(0, weight=1) # Listbox column expands
frame.rowconfigure(1, weight=1) # Listbox row expands
# Row 0: Label
lbl = ttk.Label(frame, text="Existing Tags (Newest First):")
lbl.grid(row=0, column=0, sticky="w", padx=5, pady=(0, 2))
# Row 1: Listbox + Scrollbar Frame
list_frame = ttk.Frame(frame)
list_frame.grid(row=1, column=0, sticky="nsew", padx=(5, 0), pady=(0, 5))
list_frame.rowconfigure(0, weight=1)
list_frame.columnconfigure(0, weight=1)
self.tag_listbox = tk.Listbox(
list_frame,
height=10,
exportselection=False,
selectmode=tk.SINGLE,
font=("Consolas", 9),
)
self.tag_listbox.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(
list_frame, orient=tk.VERTICAL, command=self.tag_listbox.yview
)
scrollbar.grid(row=0, column=1, sticky="ns")
self.tag_listbox.config(yscrollcommand=scrollbar.set)
self.create_tooltip(self.tag_listbox, "Select tag to checkout.")
# Row 1, Column 1: Vertical Button Frame
button_frame = ttk.Frame(frame)
button_frame.grid(row=1, column=1, sticky="ns", padx=(10, 5), pady=(0, 5))
button_width = 18 # Uniform width
self.refresh_tags_button = ttk.Button(
button_frame,
text="Refresh Tags",
width=button_width,
command=self.refresh_tags_callback,
state=tk.DISABLED,
)
self.refresh_tags_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 5))
self.create_tooltip(self.refresh_tags_button, "Reload tag list.")
self.create_tag_button = ttk.Button(
button_frame,
text="Create Tag...",
width=button_width,
command=self.create_tag_callback,
state=tk.DISABLED,
)
self.create_tag_button.pack(side=tk.TOP, fill=tk.X, pady=5)
self.create_tooltip(
self.create_tag_button, "Commit (if needed with msg) & create tag."
)
self.checkout_tag_button = ttk.Button(
button_frame,
text="Checkout Selected Tag",
width=button_width,
command=self.checkout_tag_callback,
state=tk.DISABLED,
)
self.checkout_tag_button.pack(side=tk.TOP, fill=tk.X, pady=(5, 0))
self.create_tooltip(self.checkout_tag_button, "Switch to selected tag.")
return frame
def _create_branch_tab(self):
"""Creates the frame for the 'Branches' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.columnconfigure(0, weight=1) # Listbox expands
frame.rowconfigure(1, weight=1) # Listbox expands
# Row 0: Label
lbl = ttk.Label(frame, text="Local Branches (* Current):")
lbl.grid(row=0, column=0, sticky="w", padx=5, pady=(0, 2))
# Row 1: Listbox + Scrollbar Frame
list_frame = ttk.Frame(frame)
list_frame.grid(row=1, column=0, sticky="nsew", padx=(5, 0), pady=(0, 5))
list_frame.rowconfigure(0, weight=1)
list_frame.columnconfigure(0, weight=1)
self.branch_listbox = tk.Listbox(
list_frame, height=10, exportselection=False, selectmode=tk.SINGLE
)
self.branch_listbox.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(
list_frame, orient=tk.VERTICAL, command=self.branch_listbox.yview
)
scrollbar.grid(row=0, column=1, sticky="ns")
self.branch_listbox.config(yscrollcommand=scrollbar.set)
self.create_tooltip(self.branch_listbox, "Select branch to checkout.")
# Row 1, Column 1: Vertical Button Frame
button_frame = ttk.Frame(frame)
button_frame.grid(row=1, column=1, sticky="ns", padx=(10, 5), pady=(0, 5))
button_width = 18 # Uniform width
self.refresh_branches_button = ttk.Button(
button_frame,
text="Refresh Branches",
width=button_width,
command=self.refresh_branches_callback,
state=tk.DISABLED,
)
self.refresh_branches_button.pack(side=tk.TOP, fill=tk.X, pady=(0, 5))
self.create_tooltip(self.refresh_branches_button, "Reload branch list.")
self.create_branch_button = ttk.Button(
button_frame,
text="Create Branch...",
width=button_width,
command=self.create_branch_callback,
state=tk.DISABLED,
)
self.create_branch_button.pack(side=tk.TOP, fill=tk.X, pady=5)
self.create_tooltip(self.create_branch_button, "Create a new local branch.")
self.checkout_branch_button = ttk.Button(
button_frame,
text="Checkout Selected",
width=button_width, # Shortened text
command=self.checkout_branch_callback,
state=tk.DISABLED,
)
self.checkout_branch_button.pack(side=tk.TOP, fill=tk.X, pady=5)
self.create_tooltip(self.checkout_branch_button, "Switch to selected branch.")
# Placeholder for Delete Branch if added later
# self.delete_branch_button = ttk.Button(...)
# self.delete_branch_button.pack(...)
return frame
def _create_history_tab(self):
"""Creates the frame for the 'History' tab."""
frame = ttk.Frame(self.notebook, padding=(10, 10))
frame.rowconfigure(2, weight=1) # Text area expands
frame.columnconfigure(0, weight=1) # Text area expands
# --- Filters --- (Row 0)
filter_frame = ttk.Frame(frame)
filter_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
filter_frame.columnconfigure(1, weight=1) # Allow combobox to expand
filter_label = ttk.Label(filter_frame, text="Filter by Branch:")
filter_label.pack(side=tk.LEFT, padx=(0, 5))
self.history_branch_filter_var = tk.StringVar()
self.history_branch_filter_combo = ttk.Combobox(
filter_frame,
textvariable=self.history_branch_filter_var,
state="readonly",
width=30,
)
self.history_branch_filter_combo.pack(
side=tk.LEFT, expand=True, fill=tk.X, padx=5
)
# Apply filter when selection changes
self.history_branch_filter_combo.bind(
"<<ComboboxSelected>>", lambda e: self.refresh_history_callback()
)
self.create_tooltip(
self.history_branch_filter_combo,
"Show history for selected branch (or all).",
)
self.refresh_history_button = ttk.Button(
filter_frame,
text="Refresh History",
command=self.refresh_history_callback,
state=tk.DISABLED,
)
self.refresh_history_button.pack(side=tk.LEFT, padx=5)
self.create_tooltip(self.refresh_history_button, "Load commit history.")
# --- History Display --- (Row 1, 2, 3)
history_label = ttk.Label(frame, text="Recent Commits:")
history_label.grid(row=1, column=0, sticky="w", padx=5, pady=(5, 0))
self.history_text = scrolledtext.ScrolledText(
frame,
height=15,
width=100,
font=("Consolas", 9),
wrap=tk.NONE,
state=tk.DISABLED,
)
self.history_text.grid(row=2, column=0, sticky="nsew", padx=5, pady=(0, 5))
history_xscroll = ttk.Scrollbar(
frame, orient=tk.HORIZONTAL, command=self.history_text.xview
)
history_xscroll.grid(row=3, column=0, sticky="ew", padx=5)
self.history_text.config(xscrollcommand=history_xscroll.set)
return frame
def _create_function_frame(self):
"""REMOVED - This frame is no longer used in the tabbed layout."""
pass # Return None or simply don't call this method
def _create_log_area(self):
"""Creates the application log area at the bottom."""
log_frame = ttk.Frame(self, padding=(0, 5, 0, 0)) # Parent is MainFrame
log_frame.pack(
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=(5, 0)
) # Pack below notebook
log_label = ttk.Label(log_frame, text="Application Log:")
log_label.pack(side=tk.TOP, anchor=tk.W, padx=5)
self.log_text = scrolledtext.ScrolledText(
log_frame,
height=8,
width=100,
font=("Consolas", 9),
wrap=tk.WORD,
state=tk.DISABLED,
)
self.log_text.pack(
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=5, pady=(0, 5)
)
def _initialize_profile_selection(self):
"""Sets the initial value of the profile dropdown."""
try:
from config_manager import DEFAULT_PROFILE
except ImportError:
DEFAULT_PROFILE = "default"
if DEFAULT_PROFILE in self.initial_profile_sections:
self.profile_var.set(DEFAULT_PROFILE)
elif self.initial_profile_sections:
self.profile_var.set(self.initial_profile_sections[0])
# --- GUI Update Methods ---
def toggle_backup_dir(self):
"""Toggles state of backup directory widgets."""
state = tk.NORMAL if self.autobackup_var.get() else tk.DISABLED
if hasattr(self, "backup_dir_entry"):
self.backup_dir_entry.config(state=state)
if hasattr(self, "backup_dir_button"):
self.backup_dir_button.config(state=state)
def browse_backup_dir(self):
"""Opens directory dialog for backup path."""
initial = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR
dirname = filedialog.askdirectory(
initialdir=initial, title="Select Backup Dir", parent=self.master
)
if dirname:
self.backup_dir_var.set(dirname)
def update_svn_indicator(self, is_prepared):
"""Updates repo indicator color and Prepare button state."""
color = self.GREEN if is_prepared else self.RED
state = tk.DISABLED if is_prepared else tk.NORMAL
tooltip = "Prepared" if is_prepared else "Not prepared"
if hasattr(self, "svn_status_indicator"):
self.svn_status_indicator.config(background=color)
self.update_tooltip(self.svn_status_indicator, tooltip)
if hasattr(self, "prepare_svn_button"): # Prepare button is in Repo tab
self.prepare_svn_button.config(state=state)
def update_profile_dropdown(self, sections):
"""Updates profile dropdown list and selection."""
if hasattr(self, "profile_dropdown"):
current = self.profile_var.get()
self.profile_dropdown["values"] = sections
if sections:
if current in sections:
self.profile_var.set(current)
elif "default" in sections:
self.profile_var.set("default")
else:
self.profile_var.set(sections[0])
else:
self.profile_var.set("")
def update_tag_list(self, tags_data):
"""Updates tag listbox with (name, subject) tuples."""
if not hasattr(self, "tag_listbox"):
return
try:
self.tag_listbox.delete(0, tk.END)
if tags_data:
try: # Reset color
if self.tag_listbox.cget("fg") == "grey":
self.tag_listbox.config(fg="SystemWindowText")
except tk.TclError:
pass
for name, subject in tags_data:
self.tag_listbox.insert(tk.END, f"{name}\t({subject})")
else:
self.tag_listbox.insert(tk.END, "(No tags found)")
try:
self.tag_listbox.config(fg="grey")
except tk.TclError:
pass
except Exception as e:
logging.error(f"Error tags: {e}", exc_info=True)
def get_selected_tag(self):
"""Returns the name only of the selected tag."""
if hasattr(self, "tag_listbox"):
indices = self.tag_listbox.curselection()
if indices:
item = self.tag_listbox.get(indices[0])
if item != "(No tags found)":
return item.split("\t", 1)[0].strip()
return None
def update_branch_list(self, branches, current_branch):
"""Updates branch listbox, highlighting current."""
if not hasattr(self, "branch_listbox"):
return
try:
self.branch_listbox.delete(0, tk.END)
sel_index = -1
if branches:
for i, branch in enumerate(branches):
prefix = "* " if branch == current_branch else " "
self.branch_listbox.insert(tk.END, f"{prefix}{branch}")
if branch == current_branch:
sel_index = i
else:
self.branch_listbox.insert(tk.END, "(No local branches)")
# Select current branch if found
if sel_index >= 0:
self.branch_listbox.selection_set(sel_index)
self.branch_listbox.see(sel_index)
except Exception as e:
logging.error(f"Error branches: {e}", exc_info=True)
def get_selected_branch(self):
"""Returns the name only of the selected branch."""
if hasattr(self, "branch_listbox"):
indices = self.branch_listbox.curselection()
if indices:
item = self.branch_listbox.get(indices[0])
if item != "(No local branches)":
return item.lstrip("* ").strip()
return None
def get_commit_message(self):
"""Gets commit message from ScrolledText widget."""
if hasattr(self, "commit_message_text"):
return self.commit_message_text.get("1.0", tk.END).strip()
return ""
def clear_commit_message(self):
"""Clears the commit message ScrolledText widget."""
if hasattr(self, "commit_message_text"):
# Check state before modifying - avoid error if disabled
if self.commit_message_text.cget("state") == tk.NORMAL:
self.commit_message_text.delete("1.0", tk.END)
else: # If disabled, enable, clear, disable
self.commit_message_text.config(state=tk.NORMAL)
self.commit_message_text.delete("1.0", tk.END)
self.commit_message_text.config(state=tk.DISABLED)
def update_history_display(self, log_lines):
"""Updates the commit history text area."""
if not hasattr(self, "history_text"):
return
try:
self.history_text.config(state=tk.NORMAL)
self.history_text.delete("1.0", tk.END)
if log_lines:
self.history_text.insert(tk.END, "\n".join(log_lines))
else:
self.history_text.insert(tk.END, "(No history found)")
self.history_text.config(state=tk.DISABLED)
self.history_text.yview_moveto(0.0) # Scroll top
except Exception as e:
logging.error(f"Error history: {e}", exc_info=True)
def update_history_branch_filter(self, branches, current_branch=None):
"""Populates branch filter combobox in History tab."""
if not hasattr(self, "history_branch_filter_combo"):
return
filter_options = ["-- All History --"] + branches
self.history_branch_filter_combo["values"] = filter_options
# Set default selection
if current_branch and current_branch in branches:
self.history_branch_filter_var.set(current_branch)
else:
self.history_branch_filter_var.set(filter_options[0])
# --- Dialog Wrappers (Unchanged) ---
def ask_new_profile_name(self):
return simpledialog.askstring("Add Profile", "Enter name:", parent=self.master)
def show_error(self, title, message):
messagebox.showerror(title, message, parent=self.master)
def show_info(self, title, message):
messagebox.showinfo(title, message, parent=self.master)
def show_warning(self, title, message):
messagebox.showwarning(title, message, parent=self.master)
def ask_yes_no(self, title, message):
return messagebox.askyesno(title, message, parent=self.master)
# --- Tooltip Helpers (Unchanged) ---
def create_tooltip(self, widget, text):
tt = Tooltip(widget, text)
widget.bind("<Enter>", lambda e, t=tt: t.showtip(), add="+")
widget.bind("<Leave>", lambda e, t=tt: t.hidetip(), add="+")
widget.bind("<ButtonPress>", lambda e, t=tt: t.hidetip(), add="+")
def update_tooltip(self, widget, text):
widget.unbind("<Enter>")
widget.unbind("<Leave>")
widget.unbind("<ButtonPress>")
self.create_tooltip(widget, text)