""" 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, border_width=3, border_color=COLORS['bg_vps'] if is_vps else COLORS['bg_tile'] ) 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.activity_animation_id = None self.activity_pulse_value = 0 self.has_team_activity = 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)) # Service status indicator for Admin Panel and Activity Server if self.project.id in ["admin-panel-permanent", "activity-server-permanent"]: self.service_status_indicator = ctk.CTkLabel( title_row, text="⚫", # Gray circle by default font=('Segoe UI', 10), text_color=COLORS['text_dim'], width=20 ) self.service_status_indicator.pack(side="left", padx=(0, 5)) # Initialize tooltip self._update_service_tooltip("Wird geprüft...") # 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 for VPS Server and Activity Server if self.project.id in ["vps-permanent", "activity-server-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 and Activity button (not for VPS and not for Activity Server) if not self.is_vps and self.project.id != "activity-server-permanent": 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 and not for Activity Server) - keep it in first row if not self.is_vps and self.on_delete and self.project.id != "activity-server-permanent": 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 and not for Activity Server) if not self.is_vps and self.project.id != "activity-server-permanent": 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: # Different content based on which tile's CMD button was pressed if self.project.id == "activity-server-permanent": batch_content = f'''@echo off echo Connecting to CPM Activity Server... echo. echo Target Directory: /home/claude-dev/cpm-activity-server 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... echo. echo After connection, please run: echo cd /home/claude-dev/cpm-activity-server echo. 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. echo After login, run: cd /home/claude-dev/cpm-activity-server echo. pause ssh {ssh_user}@{ssh_host} ) pause ''' else: # Regular VPS Server connection 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""" from services.activity_sync import activity_service logger.info(f"Starting activity for project: {self.project.name}") # Update UI optimistically immediately self.update_activity_status(True, activity_service.user_name, True) success = activity_service.start_activity( self.project.name, self.project.path, self.project.description if hasattr(self.project, 'description') else "" ) logger.info(f"Activity start result for {self.project.name}: success={success}") if not success: # Revert on failure logger.error(f"Failed to start activity for {self.project.name}, reverting UI") self.update_activity_status(False) from tkinter import messagebox messagebox.showerror( "Fehler", "Aktivität konnte nicht gestartet werden." ) def _stop_activity(self): """Stop current activity""" from services.activity_sync import activity_service logger.info(f"Stopping activity for project: {self.project.name}") # Update UI optimistically immediately self.update_activity_status(False) success = activity_service.stop_activity() logger.info(f"Activity stop result: success={success}") if not success: # Revert on failure - check if we're still the active project current = activity_service.get_current_activity() if current and current.get('projectName') == self.project.name: logger.error(f"Failed to stop activity for {self.project.name}, reverting UI") self.update_activity_status(True, activity_service.user_name, True) else: logger.error(f"Failed to stop activity, but no current activity found") 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, is_own_activity: bool = False): """Update activity indicator on tile""" logger.debug(f"update_activity_status called for {self.project.name}: is_active={is_active}, user_name={user_name}, is_own_activity={is_own_activity}") if is_active: # Start border animation for team activity if not is_own_activity: self.has_team_activity = True self._start_activity_animation() else: self.has_team_activity = False self._stop_activity_animation() # Show indicator with appropriate color if is_own_activity: # Green for own activity self.activity_indicator.configure(text="🟢", text_color=COLORS['accent_success']) else: # Orange for others' activity self.activity_indicator.configure(text="🟠", text_color=COLORS['accent_warning']) 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'): if is_own_activity: self.activity_button.configure(text="⏹") else: # Keep play button if someone else is active self.activity_button.configure(text="▶") else: # Hide indicator self.activity_indicator.pack_forget() # Stop border animation self.has_team_activity = False self._stop_activity_animation() # Update activity button if exists if hasattr(self, 'activity_button'): self.activity_button.configure(text="▶") def update_service_status(self, is_online: bool): """Update service status indicator""" if hasattr(self, 'service_status_indicator'): if is_online: self.service_status_indicator.configure( text="🟢", text_color=COLORS['accent_success'] ) self._update_service_tooltip("Online") else: self.service_status_indicator.configure( text="🔴", text_color=COLORS['accent_error'] ) self._update_service_tooltip("Offline") def _update_service_tooltip(self, status: str): """Update tooltip for service status""" if hasattr(self, 'service_status_indicator'): # Store status for tooltip self.service_status = status # Bind hover events if not already bound if not hasattr(self, '_service_tooltip_bound'): self.service_status_indicator.bind("", self._show_service_tooltip) self.service_status_indicator.bind("", self._hide_service_tooltip) self._service_tooltip_bound = True def _show_service_tooltip(self, event): """Show service status tooltip""" if hasattr(self, 'service_tooltip'): self.service_tooltip.destroy() self.service_tooltip = ctk.CTkToplevel(self) self.service_tooltip.wm_overrideredirect(True) self.service_tooltip.configure(fg_color=COLORS['bg_secondary']) # Get status text status_text = getattr(self, 'service_status', 'Wird geprüft...') if status_text == "Online": display_text = "✓ Service ist online" elif status_text == "Offline": display_text = "✗ Service ist offline" else: display_text = "⟳ Status wird geprüft..." label = ctk.CTkLabel( self.service_tooltip, text=display_text, font=FONTS['small'], text_color=COLORS['text_primary'], fg_color=COLORS['bg_secondary'] ) label.pack(padx=8, pady=5) # Position tooltip x = self.service_status_indicator.winfo_rootx() y = self.service_status_indicator.winfo_rooty() + 25 self.service_tooltip.geometry(f"+{x}+{y}") def _hide_service_tooltip(self, event): """Hide service status tooltip""" if hasattr(self, 'service_tooltip'): self.service_tooltip.destroy() 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']) # Format text for multiple users if ", " in user_name: users = user_name.split(", ") if len(users) == 1: text = f"{users[0]} arbeitet hieran" else: text = f"{len(users)} Personen arbeiten hieran:\n" + "\n".join(f"• {u}" for u in users) else: text = f"{user_name} arbeitet hieran" label = ctk.CTkLabel( self.tooltip, text=text, font=FONTS['small'], text_color=COLORS['text_primary'], fg_color=COLORS['bg_secondary'], justify="left" ) label.pack(padx=8, pady=5) # 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 logger.debug(f"check_activity called for project: {self.project.name}") # First check if this is our own current activity current_activity = activity_service.get_current_activity() is_own_current = (current_activity and current_activity.get('projectName') == self.project.name) logger.debug(f"Current activity check - is_own_current: {is_own_current}, current_activity: {current_activity}") # Get all activities for this project from server active_users = [] is_own_activity = False has_other_users = False for activity in activity_service.activities: if activity.get('projectName') == self.project.name and activity.get('isActive'): user_name = activity.get('userName', 'Unknown') active_users.append(user_name) # Check if it's the current user's activity if activity.get('userId') == activity_service.user_id: is_own_activity = True else: has_other_users = True logger.debug(f"Server activities - active_users: {active_users}, is_own_activity: {is_own_activity}, has_other_users: {has_other_users}") # If we have a local current activity, ensure it's included if is_own_current: is_own_activity = True if activity_service.user_name not in active_users: active_users.append(activity_service.user_name) logger.debug(f"Added local user to active_users: {activity_service.user_name}") if active_users: # Show indicator with all active users user_text = ", ".join(active_users) # If both own and others are active, show as others (orange) to indicate collaboration final_is_own = is_own_activity and not has_other_users logger.info(f"Updating activity status for {self.project.name}: active=True, users={user_text}, is_own={final_is_own}") self.update_activity_status(True, user_text, final_is_own) else: logger.info(f"Updating activity status for {self.project.name}: active=False") self.update_activity_status(False) def _start_activity_animation(self): """Start animated border for team activity""" if self.activity_animation_id: return # Animation already running def animate(): if not self.has_team_activity: self.activity_animation_id = None return # Calculate pulsing color self.activity_pulse_value = (self.activity_pulse_value + 5) % 100 pulse = abs(50 - self.activity_pulse_value) / 50 # 0 to 1 pulsing # Interpolate between orange and a brighter orange base_color = COLORS['accent_warning'] # Orange bright_factor = 0.3 + (0.7 * pulse) # Pulse between 0.3 and 1.0 brightness # Create pulsing border color if base_color.startswith('#'): # Convert hex to RGB, apply brightness, convert back r = int(base_color[1:3], 16) g = int(base_color[3:5], 16) b = int(base_color[5:7], 16) # Apply brightness r = min(255, int(r + (255 - r) * (1 - bright_factor))) g = min(255, int(g + (255 - g) * (1 - bright_factor))) b = min(255, int(b + (255 - b) * (1 - bright_factor))) pulse_color = f"#{r:02x}{g:02x}{b:02x}" else: pulse_color = base_color self.configure(border_color=pulse_color) # Continue animation self.activity_animation_id = self.after(50, animate) animate() def _stop_activity_animation(self): """Stop animated border""" if self.activity_animation_id: self.after_cancel(self.activity_animation_id) self.activity_animation_id = None # Reset border to default default_color = COLORS['bg_vps'] if self.is_vps else COLORS['bg_tile'] self.configure(border_color=default_color) 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)