""" Project Tile Component Individual tile for each project in the grid """ import customtkinter as ctk import tkinter as tk from datetime import datetime import os import subprocess import platform import time from typing import Callable, Optional from pathlib import Path from gui.styles import COLORS, FONTS, TILE_SIZE, BUTTON_STYLES from utils.logger import logger class ProjectTile(ctk.CTkFrame): def __init__(self, parent, project, on_open: Callable, on_readme: Callable, on_delete: Optional[Callable] = None, is_vps: bool = False, on_stop: Optional[Callable] = None, is_running: bool = False, on_rename: Optional[Callable] = None, on_select: Optional[Callable] = None): super().__init__( parent, width=TILE_SIZE['width'], height=TILE_SIZE['height'], fg_color=COLORS['bg_vps'] if is_vps else COLORS['bg_tile'], corner_radius=10 ) self.project = project self.on_open = on_open self.on_readme = on_readme self.on_delete = on_delete self.on_stop = on_stop self.on_rename = on_rename self.on_select = on_select self.is_vps = is_vps self.is_running = is_running self.is_selected = False self.grid_propagate(False) self.setup_ui() # Hover effect self.bind("", self.on_hover_enter) self.bind("", self.on_hover_leave) # Click to select - only bind to the main frame self.bind("", self._on_click) def setup_ui(self): """Create tile UI elements""" # Main container with padding container = ctk.CTkFrame(self, fg_color="transparent") container.pack(fill="both", expand=True, padx=TILE_SIZE['padding'], pady=TILE_SIZE['padding']) # Bind click to container for selection (but not buttons) container.bind("", self._on_click) # Title row with edit button title_row = ctk.CTkFrame(container, fg_color="transparent") title_row.pack(fill="x", pady=(0, 5)) # Activity indicator (initially hidden) self.activity_indicator = ctk.CTkLabel( title_row, text="๐ŸŸข", font=('Segoe UI', 10), text_color=COLORS['accent_success'], width=20 ) # Don't pack initially # Title title_text = self.project.name if self.is_vps: title_text = "๐ŸŒ " + title_text self.title_label = ctk.CTkLabel( title_row, text=title_text, font=FONTS['tile_title'], text_color=COLORS['text_primary'], anchor="w" ) self.title_label.pack(side="left", fill="x", expand=True) # Edit button (not for VPS) if not self.is_vps and self.on_rename: self.edit_button = ctk.CTkButton( title_row, text="๐Ÿ“", command=lambda: self.on_rename(self.project), width=25, height=25, fg_color="transparent", hover_color=COLORS['bg_secondary'], text_color=COLORS['text_secondary'], font=('Segoe UI', 12) ) self.edit_button.pack(side="right", padx=(5, 0)) # Path/Description path_text = self.project.path if len(path_text) > 40: path_text = "..." + path_text[-37:] self.path_label = ctk.CTkLabel( container, text=path_text, font=FONTS['small'], text_color=COLORS['text_secondary'], anchor="w" ) self.path_label.pack(fill="x") # Tags if available if self.project.tags: tags_text = " โ€ข ".join(self.project.tags[:3]) self.tags_label = ctk.CTkLabel( container, text=tags_text, font=FONTS['small'], text_color=COLORS['text_dim'], anchor="w" ) self.tags_label.pack(fill="x", pady=(2, 0)) # Gitea repo link if available if hasattr(self.project, 'gitea_repo') and self.project.gitea_repo: self.gitea_label = ctk.CTkLabel( container, text=f"๐Ÿ”— {self.project.gitea_repo}", font=FONTS['small'], text_color=COLORS['accent_success'], anchor="w" ) self.gitea_label.pack(fill="x", pady=(2, 0)) # Spacer ctk.CTkFrame(container, height=1, fg_color="transparent").pack(expand=True) # Last accessed try: last_accessed = datetime.fromisoformat(self.project.last_accessed) time_diff = datetime.now() - last_accessed if time_diff.days > 0: time_text = f"{time_diff.days} days ago" elif time_diff.seconds > 3600: hours = time_diff.seconds // 3600 time_text = f"{hours} hours ago" else: minutes = time_diff.seconds // 60 time_text = f"{minutes} minutes ago" if minutes > 0 else "Just now" except: time_text = "Never" self.time_label = ctk.CTkLabel( container, text=f"Last opened: {time_text}", font=FONTS['small'], text_color=COLORS['text_dim'] ) self.time_label.pack(fill="x", pady=(0, 10)) # Status message (initially hidden) self.status_message = ctk.CTkLabel( container, text="", font=FONTS['small'], text_color=COLORS['accent_warning'], anchor="w" ) # Don't pack it initially - will be shown when needed # Buttons - First row button_frame = ctk.CTkFrame(container, fg_color="transparent") button_frame.pack(fill="x") # Open/Start/Stop button if self.is_vps: # VPS also uses red when running btn_style = BUTTON_STYLES['danger'] if self.is_running else BUTTON_STYLES['vps'] # Special text for VPS Docker if self.project.id == "vps-docker-permanent": button_text = "Restart" else: button_text = "Connect" else: # Use danger (red) style when running, primary (cyan) when not btn_style = BUTTON_STYLES['danger'] if self.is_running else BUTTON_STYLES['primary'] button_text = "Start Claude" self.open_button = ctk.CTkButton( button_frame, text=button_text, command=self._handle_open_click, width=60 if (self.is_vps and self.project.id == "vps-permanent") else 95, # Smaller width only for main VPS Server **btn_style ) self.open_button.pack(side="left", padx=(0, 5)) # CMD button only for main VPS Server tile (not Admin Panel or Docker) if self.is_vps and self.project.id == "vps-permanent": self.cmd_button = ctk.CTkButton( button_frame, text="CMD", command=self._open_cmd_ssh, width=50, **BUTTON_STYLES['secondary'] ) self.cmd_button.pack(side="left", padx=(0, 5)) # Gitea button (not for VPS) if not self.is_vps: self.gitea_button = ctk.CTkButton( button_frame, text="Gitea โ–ผ", command=self._show_gitea_menu, width=60, **BUTTON_STYLES['secondary'] ) self.gitea_button.pack(side="left", padx=(0, 2)) # Activity button - show even if not connected for better UX from services.activity_sync import activity_service self.activity_button = ctk.CTkButton( button_frame, text="โ–ถ", command=self._toggle_activity, width=30, **BUTTON_STYLES['secondary'] ) self.activity_button.pack(side="left") # Update button appearance based on connection status if not activity_service.is_configured() or not activity_service.connected: self.activity_button.configure(state="normal", text_color=COLORS['text_dim']) # Delete button (not for VPS) - keep it in first row if not self.is_vps and self.on_delete: self.delete_button = ctk.CTkButton( button_frame, text="โœ•", command=lambda: self.on_delete(self.project), width=30, fg_color=COLORS['bg_secondary'], hover_color=COLORS['accent_error'], text_color=COLORS['text_secondary'] ) self.delete_button.pack(side="right") # Second row - Open Explorer button (only for non-VPS tiles) if not self.is_vps: explorer_frame = ctk.CTkFrame(container, fg_color="transparent") explorer_frame.pack(fill="x", pady=(5, 0)) self.explorer_button = ctk.CTkButton( explorer_frame, text="Open Explorer", command=self._open_explorer, width=195, # Full width to match the buttons above **BUTTON_STYLES['secondary'] ) self.explorer_button.pack() # Don't apply click bindings - they interfere with button functionality def on_hover_enter(self, event): """Handle mouse enter""" if not self.is_vps: self.configure(fg_color=COLORS['bg_tile_hover']) def on_hover_leave(self, event): """Handle mouse leave""" if not self.is_vps and not self.is_selected: self.configure(fg_color=COLORS['bg_tile']) def _on_click(self, event): """Handle click event""" # Don't process if click was on a button or functional element clicked_widget = event.widget # Check if clicked on any button if isinstance(clicked_widget, ctk.CTkButton): return # Walk up the widget hierarchy to check parent widgets parent = clicked_widget while parent: if isinstance(parent, ctk.CTkButton): return # Stop at the tile itself if parent == self: break parent = parent.master if hasattr(parent, 'master') else None if self.on_select: self.on_select(self.project) def set_selected(self, selected: bool): """Set the selected state of the tile""" self.is_selected = selected if selected: # Show border for selected tile self.configure(border_width=2, border_color=COLORS['accent_primary']) else: # Remove border for unselected tile self.configure(border_width=0) def _handle_open_click(self): """Handle open/stop button click""" if self.is_running and self.on_stop: self.on_stop(self.project) else: # Disable button immediately to prevent multiple clicks self.open_button.configure(state="disabled") self.on_open(self.project) def update_status(self, is_running: bool): """Update the running status of the tile""" self.is_running = is_running # Update button color and state if self.is_vps: btn_style = BUTTON_STYLES['danger'] if is_running else BUTTON_STYLES['vps'] else: btn_style = BUTTON_STYLES['danger'] if is_running else BUTTON_STYLES['primary'] self.open_button.configure( fg_color=btn_style['fg_color'], hover_color=btn_style['hover_color'], state="normal" # Re-enable button when status updates ) # Update status message if is_running: self.status_message.configure(text="Claude instance is running. Close it to start a new one.") self.status_message.pack(fill="x", pady=(0, 5), before=self.open_button.master) else: self.status_message.pack_forget() def update_project(self, project): """Update tile with new project data""" self.project = project # Update last accessed time self.time_label.configure(text=f"Last opened: Just now") # Update title title_text = self.project.name if self.is_vps: title_text = "๐ŸŒ " + title_text self.title_label.configure(text=title_text) # Update Gitea label if exists if hasattr(self.project, 'gitea_repo') and self.project.gitea_repo: if hasattr(self, 'gitea_label'): self.gitea_label.configure(text=f"๐Ÿ”— {self.project.gitea_repo}") else: # Create new Gitea label if it doesn't exist self.gitea_label = ctk.CTkLabel( self.winfo_children()[0], # Get container text=f"๐Ÿ”— {self.project.gitea_repo}", font=FONTS['small'], text_color=COLORS['accent_success'], anchor="w" ) # Pack it after tags label or path label if hasattr(self, 'tags_label'): self.gitea_label.pack(fill="x", pady=(2, 0), after=self.tags_label) else: self.gitea_label.pack(fill="x", pady=(2, 0), after=self.path_label) def _bind_click_recursive(self, widget): """Recursively bind left-click to all children except buttons""" # Don't bind to buttons - they have their own click handlers if not isinstance(widget, ctk.CTkButton): widget.bind("", self._on_click) # Recursively bind to children only if not a button for child in widget.winfo_children(): self._bind_click_recursive(child) def _open_explorer(self): """Open the project folder in the file explorer""" path = self.project.path if os.path.exists(path): if platform.system() == 'Windows': os.startfile(path) elif platform.system() == 'Darwin': # macOS subprocess.Popen(['open', path]) else: # Linux and other Unix-like subprocess.Popen(['xdg-open', path]) def _open_cmd_ssh(self): """Open CMD with SSH connection to VPS server with automatic authentication""" import tempfile import os # SSH credentials ssh_host = "91.99.192.14" ssh_user = "claude-dev" ssh_pass = "z0E1Al}q2H?Yqd!O" if platform.system() == 'Windows': # Create a temporary batch file for Windows with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: batch_content = f'''@echo off echo Connecting to VPS Server... echo. echo Please wait while establishing connection... echo. REM Try using plink if available (PuTTY command line) where plink >nul 2>&1 if %ERRORLEVEL% EQU 0 ( echo Using PuTTY plink for connection... plink -ssh -l {ssh_user} -pw "{ssh_pass}" {ssh_host} ) else ( echo PuTTY plink not found. Trying ssh with manual password entry... echo. echo Password: {ssh_pass} echo. echo Please copy the password above and paste it when prompted. echo. pause ssh {ssh_user}@{ssh_host} ) pause ''' f.write(batch_content) temp_batch = f.name # Open CMD with the batch file cmd = f'start "VPS SSH Connection" cmd /k "{temp_batch}"' subprocess.Popen(cmd, shell=True) # Clean up temp file after a delay def cleanup(): time.sleep(2) try: os.unlink(temp_batch) except: pass import threading threading.Thread(target=cleanup, daemon=True).start() else: # For Unix-like systems, use sshpass if available ssh_command = f'sshpass -p "{ssh_pass}" ssh {ssh_user}@{ssh_host}' fallback_command = f'echo "Password: {ssh_pass}"; echo "Copy and paste when prompted:"; ssh {ssh_user}@{ssh_host}' if platform.system() == 'Darwin': # macOS # Check if sshpass is available check_cmd = 'which sshpass' result = subprocess.run(check_cmd, shell=True, capture_output=True) if result.returncode == 0: cmd = f'''osascript -e 'tell app "Terminal" to do script "{ssh_command}"' ''' else: cmd = f'''osascript -e 'tell app "Terminal" to do script "{fallback_command}"' ''' subprocess.Popen(cmd, shell=True) else: # Linux # Try common terminal emulators terminals = ['gnome-terminal', 'konsole', 'xterm', 'terminal'] for term in terminals: try: # Check if sshpass is available check_cmd = 'which sshpass' result = subprocess.run(check_cmd, shell=True, capture_output=True) if result.returncode == 0: if term == 'gnome-terminal': subprocess.Popen([term, '--', 'bash', '-c', ssh_command]) else: subprocess.Popen([term, '-e', ssh_command]) else: if term == 'gnome-terminal': subprocess.Popen([term, '--', 'bash', '-c', fallback_command]) else: subprocess.Popen([term, '-e', fallback_command]) break except FileNotFoundError: continue def _show_gitea_menu(self): """Show Gitea operations menu""" # Create menu menu = tk.Menu(self, tearoff=0) menu.configure( bg=COLORS['bg_secondary'], fg=COLORS['text_primary'], activebackground=COLORS['bg_tile_hover'], activeforeground=COLORS['text_primary'], borderwidth=0, relief="flat" ) # Check if project is a git repository project_path = Path(self.project.path) is_git_repo = (project_path / ".git").exists() # Check if has remote has_remote = False if is_git_repo: try: # Hide console window on Windows startupinfo = None if hasattr(subprocess, 'STARTUPINFO'): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE result = subprocess.run( ["git", "remote", "-v"], cwd=project_path, capture_output=True, text=True, startupinfo=startupinfo ) has_remote = bool(result.stdout.strip()) except: pass if is_git_repo: # Git repository options menu.add_command(label="๐Ÿ“Š Status anzeigen", command=lambda: self._gitea_operation("status")) menu.add_separator() menu.add_command(label="๐Ÿ”ง Repository reparieren", command=lambda: self._gitea_operation("fix_repo")) menu.add_separator() menu.add_command(label="๐Ÿ’พ Commit", command=lambda: self._gitea_operation("commit")) if has_remote: menu.add_command(label="โฌ†๏ธ Push", command=lambda: self._gitea_operation("push")) menu.add_command(label="โฌ‡๏ธ Pull", command=lambda: self._gitea_operation("pull")) menu.add_command(label="๐Ÿ”„ Fetch", command=lambda: self._gitea_operation("fetch")) else: menu.add_command(label="๐Ÿ”— Mit Gitea verknรผpfen", command=lambda: self._gitea_operation("link")) menu.add_separator() menu.add_command(label="๐ŸŒฟ Branch verwalten", command=lambda: self._gitea_operation("branch")) menu.add_separator() menu.add_command(label="๐Ÿ—‚๏ธ GroรŸe Dateien verwalten", command=lambda: self._gitea_operation("manage_large")) menu.add_command(label="๐Ÿ“ฆ Git LFS einrichten", command=lambda: self._gitea_operation("lfs")) menu.add_separator() menu.add_command(label="๐Ÿ” Verbindung testen", command=lambda: self._gitea_operation("test")) menu.add_command(label="๐Ÿ”Ž Repository verifizieren", command=lambda: self._gitea_operation("verify")) else: # Not a git repository menu.add_command(label="๐Ÿš€ Zu Gitea pushen", command=lambda: self._gitea_operation("init_push")) menu.add_command(label="๐Ÿ“ฅ Git initialisieren", command=lambda: self._gitea_operation("init")) menu.add_separator() menu.add_command(label="๐Ÿ” Verbindung testen", command=lambda: self._gitea_operation("test")) # Show menu at button position try: # Get button position x = self.gitea_button.winfo_rootx() y = self.gitea_button.winfo_rooty() + self.gitea_button.winfo_height() menu.tk_popup(x, y) finally: menu.grab_release() def _gitea_operation(self, operation: str): """Trigger Gitea operation callback""" # Get the main window reference main_window = self.winfo_toplevel() if hasattr(main_window, 'master') and hasattr(main_window.master, 'gitea_operation'): main_window.master.gitea_operation(self.project, operation) else: # Try to find the main window root = self.winfo_toplevel() if hasattr(root, 'gitea_operation'): root.gitea_operation(self.project, operation) def _start_activity(self): """Start activity for this project""" # Use main_window reference if available if hasattr(self, 'main_window') and self.main_window: self.main_window.start_activity(self.project) return # Otherwise try to find in widget hierarchy widget = self while widget: if hasattr(widget, 'start_activity'): widget.start_activity(self.project) return widget = widget.master if hasattr(widget, 'master') else None # If not found, log error logger.error("Could not find start_activity method in widget hierarchy") def _stop_activity(self): """Stop current activity""" # Use main_window reference if available if hasattr(self, 'main_window') and self.main_window: self.main_window.stop_activity() return # Otherwise try to find in widget hierarchy widget = self while widget: if hasattr(widget, 'stop_activity'): widget.stop_activity() return widget = widget.master if hasattr(widget, 'master') else None # If not found, log error logger.error("Could not find stop_activity method in widget hierarchy") def _toggle_activity(self): """Toggle activity for this project""" logger.info(f"Activity button clicked for project: {self.project.name}") from services.activity_sync import activity_service if not activity_service.is_configured(): logger.warning("Activity service not configured") from tkinter import messagebox messagebox.showinfo( "Activity Server", "Bitte konfigurieren Sie den Activity Server in den Einstellungen." ) return if not activity_service.connected: logger.warning("Activity service not connected") from tkinter import messagebox messagebox.showwarning( "Nicht verbunden", "Keine Verbindung zum Activity Server.\n\nDer Server ist mรถglicherweise nicht erreichbar." ) return current_activity = activity_service.get_current_activity() is_this_project_active = (current_activity and current_activity.get('projectName') == self.project.name) logger.info(f"Current activity status: {is_this_project_active}") if is_this_project_active: self._stop_activity() else: self._start_activity() def update_activity_status(self, is_active: bool = False, user_name: str = None): """Update activity indicator on tile""" if is_active: # Show indicator self.activity_indicator.pack(side="left", padx=(0, 5)) if user_name: # Create tooltip with user name self.activity_indicator.configure(cursor="hand2") self.activity_indicator.bind("", lambda e: self._show_activity_tooltip(user_name)) self.activity_indicator.bind("", lambda e: self._hide_activity_tooltip()) # Update activity button if exists if hasattr(self, 'activity_button'): self.activity_button.configure(text="โน") else: # Hide indicator self.activity_indicator.pack_forget() # Update activity button if exists if hasattr(self, 'activity_button'): self.activity_button.configure(text="โ–ถ") def _show_activity_tooltip(self, user_name: str): """Show tooltip with active user name""" # Create tooltip self.tooltip = ctk.CTkToplevel(self) self.tooltip.wm_overrideredirect(True) self.tooltip.configure(fg_color=COLORS['bg_secondary']) label = ctk.CTkLabel( self.tooltip, text=f"{user_name} arbeitet hieran", font=FONTS['small'], text_color=COLORS['text_primary'], fg_color=COLORS['bg_secondary'] ) label.pack(padx=5, pady=2) # Position tooltip x = self.activity_indicator.winfo_rootx() y = self.activity_indicator.winfo_rooty() + 20 self.tooltip.geometry(f"+{x}+{y}") def _hide_activity_tooltip(self): """Hide activity tooltip""" if hasattr(self, 'tooltip'): self.tooltip.destroy() def check_activity(self): """Check if this project has active users""" from services.activity_sync import activity_service activity = activity_service.is_project_active(self.project.name) if activity: self.update_activity_status(True, activity.get('userName')) else: self.update_activity_status(False) class AddProjectTile(ctk.CTkFrame): """Special tile for adding new projects""" def __init__(self, parent, on_add: Callable): super().__init__( parent, width=TILE_SIZE['width'], height=TILE_SIZE['height'], fg_color=COLORS['bg_secondary'], corner_radius=10, border_width=2, border_color=COLORS['border_primary'] ) self.on_add = on_add self.grid_propagate(False) self.setup_ui() # Hover effect self.bind("", self.on_hover_enter) self.bind("", self.on_hover_leave) # Make entire tile clickable with cursor change self.configure(cursor="hand2") self.bind("", lambda e: self.on_add()) for child in self.winfo_children(): child.configure(cursor="hand2") child.bind("", lambda e: self.on_add()) # Also bind to all nested children self._bind_children_recursive(child) def setup_ui(self): """Create add tile UI""" container = ctk.CTkFrame(self, fg_color="transparent") container.pack(fill="both", expand=True) # Folder icon instead of plus folder_icon = ctk.CTkLabel( container, text="๐Ÿ“", font=('Segoe UI', 48), text_color=COLORS['text_dim'] ) folder_icon.pack(expand=True) # Text text_label = ctk.CTkLabel( container, text="Add New Project", font=FONTS['tile_title'], text_color=COLORS['text_secondary'] ) text_label.pack(pady=(0, 10)) # Instruction text instruction_label = ctk.CTkLabel( container, text="Click to select folder", font=FONTS['small'], text_color=COLORS['text_dim'] ) instruction_label.pack(pady=(0, 20)) def on_hover_enter(self, event): """Handle mouse enter""" self.configure(border_color=COLORS['accent_primary']) def on_hover_leave(self, event): """Handle mouse leave""" self.configure(border_color=COLORS['border_primary']) def _bind_children_recursive(self, widget): """Recursively bind click event to all children""" for child in widget.winfo_children(): child.configure(cursor="hand2") child.bind("", lambda e: self.on_add()) self._bind_children_recursive(child)