Files
ClaudeProjectManager-main/gui/project_tile.py
Claude Project Manager ec92da8a64 Initial commit
2025-07-07 22:11:38 +02:00

789 Zeilen
30 KiB
Python

"""
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("<Enter>", self.on_hover_enter)
self.bind("<Leave>", self.on_hover_leave)
# Click to select - only bind to the main frame
self.bind("<Button-1>", 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("<Button-1>", 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("<Button-1>", 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("<Enter>", lambda e: self._show_activity_tooltip(user_name))
self.activity_indicator.bind("<Leave>", 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("<Enter>", self.on_hover_enter)
self.bind("<Leave>", self.on_hover_leave)
# Make entire tile clickable with cursor change
self.configure(cursor="hand2")
self.bind("<Button-1>", lambda e: self.on_add())
for child in self.winfo_children():
child.configure(cursor="hand2")
child.bind("<Button-1>", 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("<Button-1>", lambda e: self.on_add())
self._bind_children_recursive(child)