Initial commit
Dieser Commit ist enthalten in:
554
gui/gitea_explorer.py
Normale Datei
554
gui/gitea_explorer.py
Normale Datei
@ -0,0 +1,554 @@
|
||||
"""
|
||||
Gitea Repository Explorer for the sidebar
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from tkinter import ttk, messagebox
|
||||
import threading
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, List, Dict
|
||||
from gui.styles import COLORS, FONTS
|
||||
from src.gitea.repository_manager import RepositoryManager
|
||||
from src.gitea.gitea_client import GiteaClient
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GiteaExplorer(ctk.CTkFrame):
|
||||
def __init__(self, parent, on_repo_select: Optional[Callable] = None, **kwargs):
|
||||
super().__init__(parent, fg_color=COLORS['bg_secondary'], **kwargs)
|
||||
|
||||
self.on_repo_select = on_repo_select
|
||||
self.repo_manager = RepositoryManager()
|
||||
self.repositories = []
|
||||
self.selected_repo = None
|
||||
self.view_mode = "organization" # Always organization mode
|
||||
self.organization_name = "IntelSight" # Fixed to IntelSight
|
||||
self.organizations = [] # List of user's organizations
|
||||
self.main_window = None # Will be set by main window
|
||||
|
||||
self.setup_ui()
|
||||
# Skip load_organizations - we always use IntelSight
|
||||
# Automatisches Laden der Repositories beim Start nach kurzer Verzögerung
|
||||
self.after(500, self.refresh_repositories)
|
||||
|
||||
def _show_gitea_menu(self, repo: Dict, button: ctk.CTkButton):
|
||||
"""Show Gitea operations menu for repository"""
|
||||
import tkinter as tk
|
||||
|
||||
# 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 repository is already cloned
|
||||
local_path = Path.home() / "GiteaRepos" / repo['name']
|
||||
is_cloned = local_path.exists()
|
||||
|
||||
if is_cloned:
|
||||
# Already cloned options
|
||||
menu.add_command(label="📂 Im Explorer öffnen", command=lambda: self._open_in_explorer(repo))
|
||||
menu.add_command(label="📊 Git Status", command=lambda: self._git_status(repo))
|
||||
menu.add_separator()
|
||||
menu.add_command(label="⬇️ Pull (Aktualisieren)", command=lambda: self._git_pull(repo))
|
||||
menu.add_command(label="🔄 Fetch", command=lambda: self._git_fetch(repo))
|
||||
menu.add_separator()
|
||||
menu.add_command(label="🗑️ Lokale Kopie löschen", command=lambda: self._delete_local_copy(repo))
|
||||
else:
|
||||
# Not cloned options
|
||||
menu.add_command(label="📥 Repository klonen", command=lambda: self._clone_repository(repo))
|
||||
|
||||
menu.add_separator()
|
||||
menu.add_command(label="ℹ️ Repository Info", command=lambda: self._show_repo_info(repo))
|
||||
menu.add_command(label="🌐 In Browser öffnen", command=lambda: self._open_in_browser(repo))
|
||||
|
||||
# Show menu at button position
|
||||
try:
|
||||
x = button.winfo_rootx()
|
||||
y = button.winfo_rooty() + button.winfo_height()
|
||||
menu.tk_popup(x, y)
|
||||
finally:
|
||||
menu.grab_release()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the explorer UI"""
|
||||
# Header
|
||||
header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
header_frame.pack(fill="x", padx=10, pady=(10, 5))
|
||||
|
||||
# Title
|
||||
self.title_label = ctk.CTkLabel(
|
||||
header_frame,
|
||||
text="🔧 Gitea Repositories",
|
||||
font=FONTS['subtitle'],
|
||||
text_color=COLORS['text_primary']
|
||||
)
|
||||
self.title_label.pack(side="left")
|
||||
|
||||
# Refresh button
|
||||
refresh_btn = ctk.CTkButton(
|
||||
header_frame,
|
||||
text="🔄",
|
||||
command=self.refresh_repositories,
|
||||
width=30,
|
||||
height=30,
|
||||
fg_color=COLORS['accent_primary'],
|
||||
hover_color=COLORS['accent_hover']
|
||||
)
|
||||
refresh_btn.pack(side="right")
|
||||
|
||||
# IntelSight label instead of toggle
|
||||
org_frame = ctk.CTkFrame(self, fg_color=COLORS['bg_secondary'])
|
||||
org_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
org_label = ctk.CTkLabel(
|
||||
org_frame,
|
||||
text="🏢 IntelSight Organization",
|
||||
font=FONTS['body'],
|
||||
text_color=COLORS['accent_primary']
|
||||
)
|
||||
org_label.pack()
|
||||
|
||||
# Search box
|
||||
self.search_var = ctk.StringVar()
|
||||
self.search_var.trace('w', self._on_search_changed)
|
||||
|
||||
search_frame = ctk.CTkFrame(self, fg_color=COLORS['bg_secondary'])
|
||||
search_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
self.search_entry = ctk.CTkEntry(
|
||||
search_frame,
|
||||
placeholder_text="Repository suchen...",
|
||||
textvariable=self.search_var,
|
||||
fg_color=COLORS['bg_primary'],
|
||||
text_color=COLORS['text_primary']
|
||||
)
|
||||
self.search_entry.pack(fill="x")
|
||||
|
||||
# Repository list with scrollbar
|
||||
list_frame = ctk.CTkFrame(self, fg_color=COLORS['bg_secondary'])
|
||||
list_frame.pack(fill="both", expand=True, padx=10, pady=(5, 10))
|
||||
|
||||
# Scrollable frame for repositories
|
||||
self.scroll_frame = ctk.CTkScrollableFrame(
|
||||
list_frame,
|
||||
fg_color=COLORS['bg_primary'],
|
||||
corner_radius=6
|
||||
)
|
||||
self.scroll_frame.pack(fill="both", expand=True)
|
||||
|
||||
# Repository items container
|
||||
self.repo_container = ctk.CTkFrame(self.scroll_frame, fg_color=COLORS['bg_primary'])
|
||||
self.repo_container.pack(fill="both", expand=True)
|
||||
|
||||
# Status label
|
||||
self.status_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Lade Repositories...",
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['text_secondary']
|
||||
)
|
||||
self.status_label.pack(pady=5)
|
||||
|
||||
def _get_clone_directory(self) -> Path:
|
||||
"""Get clone directory from settings or use default"""
|
||||
try:
|
||||
settings_file = Path.home() / ".claude_project_manager" / "ui_settings.json"
|
||||
if settings_file.exists():
|
||||
with open(settings_file, 'r') as f:
|
||||
settings = json.load(f)
|
||||
clone_dir = settings.get("gitea_clone_directory")
|
||||
if clone_dir:
|
||||
return Path(clone_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load clone directory from settings: {e}")
|
||||
|
||||
# Return default if not found in settings
|
||||
return Path.home() / "GiteaRepos"
|
||||
|
||||
def refresh_repositories(self):
|
||||
"""Refresh repository list from Gitea - only IntelSight"""
|
||||
self.status_label.configure(text="Lade IntelSight Repositories...")
|
||||
|
||||
def fetch_repos():
|
||||
try:
|
||||
# Always fetch IntelSight organization repositories
|
||||
all_repos = []
|
||||
page = 1
|
||||
while True:
|
||||
# Fetch page by page to ensure we get all repositories
|
||||
repos = self.repo_manager.list_organization_repositories("IntelSight", page=page, per_page=50)
|
||||
if not repos:
|
||||
break
|
||||
all_repos.extend(repos)
|
||||
page += 1
|
||||
# Break if we got less than the requested amount (last page)
|
||||
if len(repos) < 50:
|
||||
break
|
||||
|
||||
self.repositories = all_repos
|
||||
logger.info(f"Fetched {len(self.repositories)} repositories from IntelSight")
|
||||
|
||||
self.after(0, self._update_repository_list)
|
||||
self.after(0, lambda: self.status_label.configure(
|
||||
text=f"{len(self.repositories)} IntelSight Repositories"
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching repositories: {str(e)}")
|
||||
self.after(0, lambda: self.status_label.configure(
|
||||
text=f"Fehler: {str(e)}",
|
||||
text_color=COLORS['accent_error']
|
||||
))
|
||||
|
||||
threading.Thread(target=fetch_repos, daemon=True).start()
|
||||
|
||||
def _update_repository_list(self):
|
||||
"""Update the displayed repository list"""
|
||||
# Filter repositories based on search and remove duplicates
|
||||
search_term = self.search_var.get().lower()
|
||||
|
||||
# Use a dict to track unique repos by name (preferring non-forks)
|
||||
unique_repos = {}
|
||||
for repo in self.repositories:
|
||||
repo_name = repo['name']
|
||||
# If we haven't seen this repo, or this one is not a fork (prefer originals)
|
||||
if repo_name not in unique_repos or not repo.get('fork', False):
|
||||
unique_repos[repo_name] = repo
|
||||
|
||||
# Filter based on search
|
||||
filtered_repos = [
|
||||
repo for repo in unique_repos.values()
|
||||
if search_term in repo['name'].lower() or
|
||||
search_term in repo.get('description', '').lower()
|
||||
]
|
||||
|
||||
# Sort by name
|
||||
filtered_repos.sort(key=lambda r: r['name'].lower())
|
||||
|
||||
# Get existing repo widgets
|
||||
existing_widgets = {}
|
||||
for widget in self.repo_container.winfo_children():
|
||||
if hasattr(widget, 'repo_data'):
|
||||
existing_widgets[widget.repo_data['name']] = widget
|
||||
|
||||
# Remove widgets for repos no longer in filtered list
|
||||
filtered_names = {repo['name'] for repo in filtered_repos}
|
||||
for name, widget in existing_widgets.items():
|
||||
if name not in filtered_names:
|
||||
widget.destroy()
|
||||
|
||||
# Update or create repository items
|
||||
for i, repo in enumerate(filtered_repos):
|
||||
if repo['name'] in existing_widgets:
|
||||
# Update existing widget
|
||||
widget = existing_widgets[repo['name']]
|
||||
self._update_repo_item(widget, repo)
|
||||
# Ensure correct order
|
||||
widget.pack_forget()
|
||||
widget.pack(fill="x", pady=2)
|
||||
else:
|
||||
# Create new widget
|
||||
self._create_repo_item(repo)
|
||||
|
||||
def _create_repo_item(self, repo: Dict):
|
||||
"""Create a repository item widget"""
|
||||
# Main frame
|
||||
item_frame = ctk.CTkFrame(
|
||||
self.repo_container,
|
||||
fg_color=COLORS['bg_gitea_tile'],
|
||||
corner_radius=6,
|
||||
height=70
|
||||
)
|
||||
item_frame.pack(fill="x", pady=2)
|
||||
item_frame.pack_propagate(False)
|
||||
|
||||
# Store repo data
|
||||
item_frame.repo_data = repo
|
||||
repo['_widget'] = item_frame
|
||||
|
||||
# Content frame
|
||||
content_frame = ctk.CTkFrame(item_frame, fg_color="transparent")
|
||||
content_frame.pack(fill="both", expand=True, padx=10, pady=8)
|
||||
|
||||
# Top row - name and status
|
||||
top_row = ctk.CTkFrame(content_frame, fg_color="transparent")
|
||||
top_row.pack(fill="x")
|
||||
|
||||
# Repository name
|
||||
name_label = ctk.CTkLabel(
|
||||
top_row,
|
||||
text=repo['name'],
|
||||
font=FONTS['body'],
|
||||
text_color=COLORS['text_primary'],
|
||||
anchor="w"
|
||||
)
|
||||
name_label.pack(side="left", fill="x", expand=True)
|
||||
|
||||
# Clone/Status indicator
|
||||
clone_dir = self._get_clone_directory()
|
||||
local_path = clone_dir / repo['name']
|
||||
if local_path.exists():
|
||||
status_label = ctk.CTkLabel(
|
||||
top_row,
|
||||
text="✓ Cloned",
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['accent_success']
|
||||
)
|
||||
status_label.pack(side="right", padx=(5, 0))
|
||||
|
||||
# Check if it's a CPM project
|
||||
if self.main_window and hasattr(self.main_window, 'project_manager'):
|
||||
for project_data in self.main_window.project_manager.projects:
|
||||
# project_data is a Project object, check if it has gitea_repo attribute
|
||||
if hasattr(project_data, 'gitea_repo') and project_data.gitea_repo == f"{self.organization_name}/{repo['name']}":
|
||||
cpm_label = ctk.CTkLabel(
|
||||
top_row,
|
||||
text="📁 CPM",
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['accent_primary']
|
||||
)
|
||||
cpm_label.pack(side="right", padx=(5, 0))
|
||||
break
|
||||
|
||||
# Description
|
||||
if repo.get('description'):
|
||||
desc_label = ctk.CTkLabel(
|
||||
content_frame,
|
||||
text=repo['description'][:60] + '...' if len(repo.get('description', '')) > 60 else repo['description'],
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['text_secondary'],
|
||||
anchor="w"
|
||||
)
|
||||
desc_label.pack(fill="x", pady=(2, 0))
|
||||
|
||||
# Bottom row - stats
|
||||
stats_row = ctk.CTkFrame(content_frame, fg_color="transparent")
|
||||
stats_row.pack(fill="x", pady=(4, 0))
|
||||
|
||||
# Private/Public
|
||||
visibility = "🔒 Private" if repo.get('private', False) else "🌍 Public"
|
||||
vis_label = ctk.CTkLabel(
|
||||
stats_row,
|
||||
text=visibility,
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['text_dim']
|
||||
)
|
||||
vis_label.pack(side="left", padx=(0, 10))
|
||||
|
||||
# Stars if available
|
||||
if 'stars_count' in repo:
|
||||
star_label = ctk.CTkLabel(
|
||||
stats_row,
|
||||
text=f"⭐ {repo['stars_count']}",
|
||||
font=FONTS['small'],
|
||||
text_color=COLORS['text_dim']
|
||||
)
|
||||
star_label.pack(side="left", padx=(0, 10))
|
||||
|
||||
# Menu button
|
||||
menu_btn = ctk.CTkButton(
|
||||
stats_row,
|
||||
text="⋮",
|
||||
width=30,
|
||||
height=24,
|
||||
fg_color=COLORS['bg_tile'],
|
||||
hover_color=COLORS['bg_tile_hover'],
|
||||
font=('Segoe UI', 16)
|
||||
)
|
||||
menu_btn.configure(command=lambda: self._show_gitea_menu(repo, menu_btn))
|
||||
menu_btn.pack(side="right")
|
||||
|
||||
# Click to select
|
||||
def on_click(event):
|
||||
self._select_repository(repo)
|
||||
|
||||
self._bind_click_recursive(item_frame, on_click)
|
||||
|
||||
# Hover effect
|
||||
def on_enter(e):
|
||||
if self.selected_repo != repo:
|
||||
item_frame.configure(fg_color=COLORS['bg_gitea_hover'])
|
||||
|
||||
def on_leave(e):
|
||||
if self.selected_repo != repo:
|
||||
item_frame.configure(fg_color=COLORS['bg_gitea_tile'])
|
||||
|
||||
item_frame.bind("<Enter>", on_enter)
|
||||
item_frame.bind("<Leave>", on_leave)
|
||||
|
||||
def _update_repo_item(self, item_frame: ctk.CTkFrame, repo: Dict):
|
||||
"""Update an existing repository item widget"""
|
||||
# Update stored data
|
||||
item_frame.repo_data = repo
|
||||
repo['_widget'] = item_frame
|
||||
|
||||
# Update clone status
|
||||
local_path = Path.home() / "GiteaRepos" / repo['name']
|
||||
|
||||
# Find status label in widget hierarchy
|
||||
for widget in item_frame.winfo_children():
|
||||
if isinstance(widget, ctk.CTkFrame):
|
||||
for child in widget.winfo_children():
|
||||
if isinstance(child, ctk.CTkFrame):
|
||||
for subchild in child.winfo_children():
|
||||
if isinstance(subchild, ctk.CTkLabel) and "✓ Cloned" in subchild.cget("text"):
|
||||
if not local_path.exists():
|
||||
subchild.destroy()
|
||||
break
|
||||
|
||||
def _select_repository(self, repo: Dict):
|
||||
"""Select a repository"""
|
||||
# Deselect previous
|
||||
if self.selected_repo and '_widget' in self.selected_repo:
|
||||
self.selected_repo['_widget'].configure(
|
||||
fg_color=COLORS['bg_gitea_tile']
|
||||
)
|
||||
|
||||
# Select new
|
||||
self.selected_repo = repo
|
||||
if '_widget' in repo:
|
||||
repo['_widget'].configure(
|
||||
fg_color=COLORS['bg_selected']
|
||||
)
|
||||
|
||||
# Notify callback
|
||||
if self.on_repo_select:
|
||||
self.on_repo_select(repo)
|
||||
|
||||
def _on_search_changed(self, *args):
|
||||
"""Handle search text change"""
|
||||
self._update_repository_list()
|
||||
|
||||
def update_theme(self):
|
||||
"""Update colors when theme changes"""
|
||||
# Import updated colors
|
||||
from gui.styles import get_colors, FONTS
|
||||
global COLORS
|
||||
COLORS = get_colors()
|
||||
|
||||
# Update main frame
|
||||
self.configure(fg_color=COLORS['bg_secondary'])
|
||||
|
||||
# Update labels
|
||||
self.title_label.configure(text_color=COLORS['text_primary'])
|
||||
|
||||
# Update search entry
|
||||
self.search_entry.configure(
|
||||
fg_color=COLORS['bg_primary'],
|
||||
text_color=COLORS['text_primary'],
|
||||
border_color=COLORS['border_primary']
|
||||
)
|
||||
|
||||
# Update scroll frame and container
|
||||
self.scroll_frame.configure(fg_color=COLORS['bg_primary'])
|
||||
self.repo_container.configure(fg_color=COLORS['bg_primary'])
|
||||
|
||||
# Update status label
|
||||
self.status_label.configure(text_color=COLORS['text_secondary'])
|
||||
|
||||
# Refresh the repository list to update item colors
|
||||
self._update_repository_list()
|
||||
|
||||
# Clear any previous selection when theme changes
|
||||
if self.selected_repo and '_widget' in self.selected_repo:
|
||||
self.selected_repo['_widget'].configure(fg_color=COLORS['bg_gitea_tile'])
|
||||
self.selected_repo = None
|
||||
|
||||
def _clone_repository(self, repo: Dict):
|
||||
"""Clone repository and create CPM project"""
|
||||
if self.main_window and hasattr(self.main_window, 'clone_repository'):
|
||||
self.main_window.clone_repository(repo)
|
||||
|
||||
def _open_in_explorer(self, repo: Dict):
|
||||
"""Open repository folder in file explorer"""
|
||||
import platform
|
||||
import subprocess
|
||||
local_path = Path.home() / "GiteaRepos" / repo['name']
|
||||
if local_path.exists():
|
||||
if platform.system() == 'Windows':
|
||||
os.startfile(str(local_path))
|
||||
elif platform.system() == 'Darwin':
|
||||
subprocess.Popen(['open', str(local_path)])
|
||||
else:
|
||||
subprocess.Popen(['xdg-open', str(local_path)])
|
||||
|
||||
def _git_status(self, repo: Dict):
|
||||
"""Show git status for repository"""
|
||||
if self.main_window and hasattr(self.main_window, 'show_git_status'):
|
||||
# Create a pseudo-project object for compatibility
|
||||
from types import SimpleNamespace
|
||||
project = SimpleNamespace()
|
||||
project.name = repo['name']
|
||||
project.path = str(Path.home() / "GiteaRepos" / repo['name'])
|
||||
self.main_window.show_git_status(project)
|
||||
|
||||
def _git_pull(self, repo: Dict):
|
||||
"""Pull changes from remote"""
|
||||
if self.main_window and hasattr(self.main_window, 'pull_from_gitea'):
|
||||
from types import SimpleNamespace
|
||||
project = SimpleNamespace()
|
||||
project.name = repo['name']
|
||||
project.path = str(Path.home() / "GiteaRepos" / repo['name'])
|
||||
self.main_window.pull_from_gitea(project)
|
||||
|
||||
def _git_fetch(self, repo: Dict):
|
||||
"""Fetch changes from remote"""
|
||||
if self.main_window and hasattr(self.main_window, 'fetch_from_gitea'):
|
||||
self.main_window.fetch_from_gitea(repo)
|
||||
|
||||
def _delete_local_copy(self, repo: Dict):
|
||||
"""Delete local repository copy"""
|
||||
from tkinter import messagebox
|
||||
local_path = Path.home() / "GiteaRepos" / repo['name']
|
||||
if messagebox.askyesno("Lokale Kopie löschen",
|
||||
f"Möchten Sie die lokale Kopie von '{repo['name']}' wirklich löschen?\n\n"
|
||||
f"Pfad: {local_path}"):
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(local_path)
|
||||
messagebox.showinfo("Erfolg", f"Lokale Kopie von '{repo['name']}' wurde gelöscht.")
|
||||
self.refresh_repositories()
|
||||
except Exception as e:
|
||||
messagebox.showerror("Fehler", f"Fehler beim Löschen: {str(e)}")
|
||||
|
||||
def _show_repo_info(self, repo: Dict):
|
||||
"""Show repository information"""
|
||||
from tkinter import messagebox
|
||||
info = f"Repository: {repo['name']}\n"
|
||||
info += f"Beschreibung: {repo.get('description', 'Keine Beschreibung')}\n"
|
||||
info += f"Privat: {'Ja' if repo.get('private', False) else 'Nein'}\n"
|
||||
info += f"Erstellt: {repo.get('created_at', 'Unbekannt')}\n"
|
||||
info += f"Aktualisiert: {repo.get('updated_at', 'Unbekannt')}\n"
|
||||
messagebox.showinfo("Repository Info", info)
|
||||
|
||||
def _open_in_browser(self, repo: Dict):
|
||||
"""Open repository in browser"""
|
||||
import webbrowser
|
||||
if 'html_url' in repo:
|
||||
webbrowser.open(repo['html_url'])
|
||||
|
||||
def _bind_click_recursive(self, widget, callback):
|
||||
"""Recursively bind click event to widget and all children"""
|
||||
# Don't bind to buttons to avoid interfering with their functionality
|
||||
if not isinstance(widget, ctk.CTkButton):
|
||||
widget.bind("<Button-1>", callback)
|
||||
|
||||
for child in widget.winfo_children():
|
||||
# Skip buttons
|
||||
if not isinstance(child, ctk.CTkButton):
|
||||
self._bind_click_recursive(child, callback)
|
||||
|
||||
def clear_selection(self):
|
||||
"""Clear the current selection"""
|
||||
if self.selected_repo and '_widget' in self.selected_repo:
|
||||
# Remove border from selection
|
||||
self.selected_repo['_widget'].configure(border_width=0)
|
||||
self.selected_repo = None
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren