commit ec92da8a64667481cae39d943ba6b50f13b1f0fb Author: Claude Project Manager Date: Mon Jul 7 22:11:38 2025 +0200 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c0eb86f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(pip3 install:*)", + "Bash(ls:*)", + "Bash(sed:*)", + "Bash(cmd.exe:*)", + "Bash(./build.bat)", + "Bash(rm:*)", + "Bash(convert:*)", + "Bash(awk:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(mkdir:*)", + "Bash(chmod:*)", + "Bash(build.bat)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5c9b38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDE +.vscode/ +.idea/ + +# Project specific +data/running_processes.json +logs/ +nul +*.log + +# Temporary files +*.tmp +*.bak +*~ + +# OS specific +.DS_Store +Thumbs.db +desktop.ini \ No newline at end of file diff --git a/ACTIVITY_SERVER_INFO.md b/ACTIVITY_SERVER_INFO.md new file mode 100644 index 0000000..6a3c0c6 --- /dev/null +++ b/ACTIVITY_SERVER_INFO.md @@ -0,0 +1,36 @@ +# Activity Server Information + +## Problem +Der Activity Server unter `91.99.192.14:3001` ist nicht erreichbar. + +## Lösungsoptionen + +### Option 1: Lokalen Activity Server verwenden +Sie können einen lokalen Activity Server starten: + +1. **Server URL in den Einstellungen ändern auf:** + - `http://localhost:3001` + +2. **Eigenen Activity Server starten:** + - Den Activity Server lokal installieren und starten + - Oder einen Docker Container verwenden + +### Option 2: Ohne Activity Server arbeiten +Die Aktivitätsfunktionen sind optional. Sie können CPM vollständig ohne Activity Server nutzen: +- Die Aktivitäts-Buttons zeigen dann eine Meldung an +- Alle anderen Funktionen arbeiten normal + +### Option 3: Alternativen Server verwenden +Falls Sie Zugang zu einem anderen Activity Server haben: +1. Öffnen Sie die Einstellungen (⚙️) +2. Ändern Sie die Server URL +3. Geben Sie ggf. API Key und Benutzername ein +4. Testen Sie die Verbindung + +## Verbindungstest +In den Einstellungen können Sie die Verbindung testen: +- Grün ✅ = Verbindung erfolgreich +- Rot ❌ = Verbindung fehlgeschlagen + +## Hinweis +Der Activity Server ist für Team-Kollaboration gedacht und zeigt an, wer gerade an welchem Projekt arbeitet. Für Einzelnutzer ist diese Funktion nicht zwingend erforderlich. \ No newline at end of file diff --git a/CLAUDE_PROJECT_README.md b/CLAUDE_PROJECT_README.md new file mode 100644 index 0000000..fbecda1 --- /dev/null +++ b/CLAUDE_PROJECT_README.md @@ -0,0 +1,196 @@ +# ClaudeProjectManager-main + +*This README was automatically generated by Claude Project Manager* + +## Project Overview + +- **Path**: `C:/Users/hendr/Desktop/IntelSight/ClaudeProjectManager-main` +- **Files**: 219 files +- **Size**: 76.9 MB +- **Last Modified**: 2025-07-07 21:50 + +## Technology Stack + +### Languages +- Batch +- Python + +## Project Structure + +``` +ACTIVITY_SERVER_INFO.md +app-icon.ico +app-icon.svg +build.bat +build_exe.py +ClaudeProjectManager.spec +CLAUDE_PROJECT_README.md +clone_fix.py +DUPLICATE_ANALYSIS.md +build/ +│ └── ClaudeProjectManager/ +│ ├── Analysis-00.toc +│ ├── base_library.zip +│ ├── ClaudeProjectManager.pkg +│ ├── EXE-00.toc +│ ├── PKG-00.toc +│ ├── PYZ-00.pyz +│ ├── PYZ-00.toc +│ ├── Tree-00.toc +│ ├── Tree-01.toc +│ ├── Tree-02.toc +│ └── localpycs/ +│ ├── pyimod01_archive.pyc +│ ├── pyimod02_importers.pyc +│ ├── pyimod03_ctypes.pyc +│ ├── pyimod04_pywin32.pyc +│ └── struct.pyc +data/ +│ ├── projects.json +│ ├── running_processes.json +│ ├── settings.json +│ └── vps_readme/ +│ └── VPS_README.md +dist/ +│ ├── ClaudeProjectManager.exe +│ ├── data/ +│ │ └── projects.json +│ └── logs/ +│ ├── cpm_20250703_224100.log +│ ├── cpm_20250703_225137.log +│ ├── cpm_20250703_225535.log +│ ├── cpm_20250703_232246.log +│ ├── cpm_20250703_235716.log +│ ├── cpm_20250704_180616.log +│ ├── cpm_20250704_181610.log +│ ├── cpm_20250704_201237.log +│ ├── cpm_20250704_202648.log +│ └── cpm_20250704_203722.log +gui/ +│ ├── config.py +│ ├── gitea_explorer.py +│ ├── gitea_toolbar.py +│ ├── main_window.py +│ ├── progress_bar.py +│ ├── project_tile.py +│ ├── settings_dialog.py +│ ├── sidebar_view.py +│ ├── styles.py +│ └── handlers/ +│ ├── base_handler.py +│ ├── gitea_operations.py +│ ├── process_manager.py +│ ├── project_manager.py +│ ├── ui_helpers.py +│ └── __init__.py +logs/ +│ ├── cpm_20250703_225120.log +│ ├── cpm_20250703_225453.log +│ ├── cpm_20250704_181449.log +│ ├── cpm_20250704_201128.log +│ ├── cpm_20250704_202630.log +│ ├── cpm_20250704_203657.log +│ ├── cpm_20250704_205017.log +│ ├── cpm_20250704_210303.log +│ ├── cpm_20250704_211400.log +│ └── cpm_20250704_212356.log +scripts/ +│ ├── check_lfs_status.bat +│ ├── fix_large_files.bat +│ ├── fix_remote_organization.bat +│ ├── fix_website_auth.bat +│ ├── fix_website_final.bat +│ ├── fix_website_lfs_and_branch.bat +│ ├── fix_website_repo.bat +│ ├── quick_fix_website.bat +│ └── website_gitignore_template.txt +services/ +│ ├── activity_sync.py +│ └── __init__.py +src/ +│ └── gitea/ +│ ├── gitea_client.py +│ ├── gitea_ui.py +│ ├── gitea_ui_ctk.py +│ ├── git_operations.py +│ ├── issue_pr_manager.py +│ ├── repository_manager.py +│ └── __init__.py +tests/ +│ └── test_main_window_api.py +tools/ +│ ├── download_winscp.txt +│ └── WinSCP +utils/ + └── logger.py +``` + +## Key Files + +- `README.md` +- `requirements.txt` + +## Claude Integration + +This project is managed with Claude Project Manager. To work with this project: + +1. Open Claude Project Manager +2. Click on this project's tile +3. Claude will open in the project directory + +## Notes + +*Add your project-specific notes here* + +--- + +## Development Log + +- README generated on 2025-07-01 20:15:32 +- README updated on 2025-07-01 20:23:43 +- README updated on 2025-07-01 20:23:47 +- README updated on 2025-07-01 20:32:57 +- README updated on 2025-07-01 21:09:37 +- README updated on 2025-07-01 21:10:55 +- README updated on 2025-07-01 21:13:32 +- README updated on 2025-07-01 21:13:42 +- README updated on 2025-07-01 21:20:15 +- README updated on 2025-07-01 21:20:17 +- README updated on 2025-07-01 21:20:42 +- README updated on 2025-07-01 21:22:21 +- README updated on 2025-07-01 21:26:20 +- README updated on 2025-07-01 21:33:49 +- README updated on 2025-07-01 21:37:22 +- README updated on 2025-07-01 21:37:26 +- README updated on 2025-07-01 21:37:33 +- README updated on 2025-07-01 21:37:37 +- README updated on 2025-07-01 21:47:34 +- README updated on 2025-07-01 21:48:07 +- README updated on 2025-07-01 21:48:21 +- README updated on 2025-07-01 21:48:30 +- README updated on 2025-07-01 21:48:45 +- README updated on 2025-07-01 21:50:43 +- README updated on 2025-07-01 21:58:40 +- README updated on 2025-07-01 22:00:03 +- README updated on 2025-07-01 22:03:17 +- README updated on 2025-07-01 22:04:16 +- README updated on 2025-07-01 22:04:39 +- README updated on 2025-07-01 22:09:23 +- README updated on 2025-07-02 12:42:23 +- README updated on 2025-07-02 12:42:44 +- README updated on 2025-07-02 16:08:16 +- README updated on 2025-07-02 19:09:27 +- README updated on 2025-07-02 19:09:35 +- README updated on 2025-07-02 22:07:57 +- README updated on 2025-07-03 07:30:27 +- README updated on 2025-07-03 14:21:29 +- README updated on 2025-07-03 22:24:23 +- README updated on 2025-07-03 23:49:53 +- README updated on 2025-07-04 18:06:40 +- README updated on 2025-07-05 01:35:23 +- README updated on 2025-07-05 13:35:42 +- README updated on 2025-07-05 20:48:36 +- README updated on 2025-07-06 10:58:00 +- README updated on 2025-07-07 08:30:39 +- README updated on 2025-07-07 21:38:23 +- README updated on 2025-07-07 21:50:23 diff --git a/DUPLICATE_ANALYSIS.md b/DUPLICATE_ANALYSIS.md new file mode 100644 index 0000000..0ab6b21 --- /dev/null +++ b/DUPLICATE_ANALYSIS.md @@ -0,0 +1,186 @@ +# Duplicate Methods Analysis - main_window.py + +## Übersicht +Analyse der 4 duplizierten Methoden-Paare in `gui/main_window.py`. + +## 1. `manage_branches` (Zeilen 1374 & 2336) + +### Erste Version (Zeile 1374) +```python +def manage_branches(self, project): + messagebox.showinfo("Branches", "Branch management coming soon") +``` +- **Typ**: Placeholder +- **Funktionalität**: Zeigt nur Info-Dialog + +### Zweite Version (Zeile 2336) +```python +def manage_branches(self, project): + # 55 Zeilen Code + # Vollständige Implementierung mit: + # - Branch-Auflistung + # - Branch erstellen + # - Branch wechseln + # - UI Dialog +``` +- **Typ**: Vollständige Implementierung +- **Funktionalität**: Komplettes Branch-Management + +### Empfehlung +✅ **Erste Version löschen, zweite behalten** +- Die erste Version ist nur ein Placeholder +- Die zweite Version ist die fertige Implementierung + +--- + +## 2. `link_to_gitea` (Zeilen 1378 & 2392) + +### Erste Version (Zeile 1378) +```python +def link_to_gitea(self, project): + messagebox.showinfo("Link to Gitea", "Link functionality coming soon") +``` +- **Typ**: Placeholder +- **Funktionalität**: Zeigt nur Info-Dialog + +### Zweite Version (Zeile 2392) +```python +def link_to_gitea(self, project): + # 64 Zeilen Code + # Vollständige Implementierung mit: + # - Repository-Existenz prüfen + # - Repository erstellen wenn nötig + # - Remote URL setzen + # - Fehlerbehandlung +``` +- **Typ**: Vollständige Implementierung +- **Funktionalität**: Komplette Gitea-Verlinkung + +### Empfehlung +✅ **Erste Version löschen, zweite behalten** +- Die erste Version ist nur ein Placeholder +- Die zweite Version ist die fertige Implementierung + +--- + +## 3. `test_gitea_connection` (Zeilen 1757 & 2457) + +### Erste Version (Zeile 1757) +```python +def test_gitea_connection(self, project): + # 35 Zeilen Code + # Basis-Implementierung: + # - Verbindungstest + # - User-Info anzeigen + # - Organisationen auflisten +``` +- **Parameter**: `project` (required) +- **Anzeige**: Via `messagebox.showinfo` + +### Zweite Version (Zeile 2457) +```python +def test_gitea_connection(self, project=None): + # 98 Zeilen Code + # Erweiterte Implementierung: + # - Optional project parameter + # - Team-Berechtigungen + # - Projekt-spezifische Git-Info + # - Repository-Auflistungen + # - Bessere Fehlerbehandlung +``` +- **Parameter**: `project=None` (optional) +- **Anzeige**: Via `self._show_scrollable_info` + +### Unterschiede +1. **Parameter**: Erste Version erfordert `project`, zweite ist optional +2. **Funktionalität**: Zweite Version hat erweiterte Features +3. **Anzeige**: Unterschiedliche Dialog-Methoden + +### Empfehlung +🔄 **Beide Versionen zusammenführen** +```python +def test_gitea_connection(self, project=None): + # Kombinierte Implementierung: + # - Optional project parameter (von Version 2) + # - Erweiterte Features (von Version 2) + # - Robuste Fehlerbehandlung (von beiden) +``` + +--- + +## 4. `verify_repository_on_gitea` (Zeilen 1792 & 2556) + +### Erste Version (Zeile 1792) +```python +def verify_repository_on_gitea(self, project): + # 103 Zeilen Code + # Features: + # - Git-Repository Vorprüfung + # - Repository-Status prüfen + # - Branches vergleichen + # - Remote-URL Validierung +``` +- **Anzeige**: Via `messagebox.showinfo` +- **Besonderheit**: Prüft ob `.git` Verzeichnis existiert + +### Zweite Version (Zeile 2556) +```python +def verify_repository_on_gitea(self, project): + # 100 Zeilen Code + # Features: + # - Debug-Info (.git/config Inhalt) + # - Repository-Status prüfen + # - Branches vergleichen + # - Remote-URL Validierung +``` +- **Anzeige**: Via `self._show_scrollable_info` +- **Besonderheit**: Zeigt Debug-Informationen + +### Unterschiede +1. **Vorprüfungen**: Erste Version prüft Git-Verzeichnis +2. **Debug-Info**: Zweite Version zeigt .git/config +3. **Dialog**: Unterschiedliche Anzeige-Methoden + +### Empfehlung +🔄 **Beide Versionen zusammenführen** +```python +def verify_repository_on_gitea(self, project): + # Kombinierte Implementierung: + # - Git-Verzeichnis Vorprüfung (von Version 1) + # - Debug-Informationen (von Version 2) + # - Scrollbare Anzeige (von Version 2) +``` + +--- + +## Aufruf-Analyse + +Alle duplizierten Methoden werden vom `gitea_operation` Method aufgerufen: + +```python +def gitea_operation(self, operation_name, project): + operations = { + "status": self.show_git_status, + "push": lambda p: self.push_to_gitea(p), + "init_push": self.init_and_push_to_gitea, + "test": self.test_gitea_connection, # Ruft duplizierte Methode auf + "verify": self.verify_repository_on_gitea, # Ruft duplizierte Methode auf + "link": self.link_to_gitea, # Ruft duplizierte Methode auf + "branches": self.manage_branches, # Ruft duplizierte Methode auf + # ... + } +``` + +## Konsolidierungs-Strategie + +### Sofort löschbar (Placeholder): +1. ❌ `manage_branches` (Zeile 1374) - Nur Placeholder +2. ❌ `link_to_gitea` (Zeile 1378) - Nur Placeholder + +### Zusammenführung erforderlich: +3. 🔄 `test_gitea_connection` - Features beider Versionen kombinieren +4. 🔄 `verify_repository_on_gitea` - Vorprüfungen und Debug-Info kombinieren + +### Priorität: +1. **Hoch**: Placeholder löschen (einfach, keine Risiken) +2. **Mittel**: Methoden zusammenführen (mehr Aufwand, Tests nötig) \ No newline at end of file diff --git a/Logo.svg b/Logo.svg new file mode 100644 index 0000000..41ead70 --- /dev/null +++ b/Logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IntelSight + + \ No newline at end of file diff --git a/MIGRATION_STATUS.md b/MIGRATION_STATUS.md new file mode 100644 index 0000000..a399bcc --- /dev/null +++ b/MIGRATION_STATUS.md @@ -0,0 +1,188 @@ +# Migration Status - MainWindow Refactoring + +## Übersicht +Dieses Dokument zeigt den aktuellen Status der Methoden-Migration. + +## UIHelpersHandler - Migrierte Methoden ✅ + +### Vollständig migriert (direkte Implementierung im Handler): +1. **load_and_apply_theme** ✅ + - Einfache 3-Zeilen Methode + - Keine Abhängigkeiten + - Test: Erfolgreich + +2. **_show_scrollable_info** ✅ + - UI Dialog Methode + - 33 Zeilen, eigenständig + - Test: Erfolgreich + +3. **create_header** ✅ + - UI Erstellung + - 44 Zeilen + - Referenzen zu main_window Attributen + - Test: Erfolgreich + +4. **create_status_bar** ✅ + - UI Erstellung + - 22 Zeilen + - Referenzen zu main_window Attributen + - Test: Erfolgreich + +5. **on_window_resize** ✅ + - Event Handler + - 10 Zeilen + - Zugriff auf resize_timer + - Test: Erfolgreich + +### Noch zu migrieren (aktuell als Facade): +- setup_ui +- create_content_area +- create_project_tile +- create_add_tile +- create_project_tile_flow +- create_add_tile_flow +- refresh_ui +- setup_interaction_tracking +- _differential_update +- _update_project_tiles_colors + +## GiteaOperationsHandler - Migrierte Methoden ✅ + +### Vollständig migriert (Duplikate konsolidiert): +1. **test_gitea_connection** ✅ + - Konsolidierte Version (beide Implementierungen gemerged) + - Optionaler project Parameter + - Erweiterte Team- und Projekt-Info + - Test: Erfolgreich + +2. **verify_repository_on_gitea** ✅ + - Konsolidierte Version (Pre-checks von v1 + Debug-Info von v2) + - Git-Verzeichnis Prüfung + - Debug-Info aus .git/config + - Test: Manuell + +### Placeholder entfernt: +- ❌ _original_manage_branches (Zeile 1469) - Nur Placeholder, gelöscht +- ❌ _original_link_to_gitea (Zeile 1481) - Nur Placeholder, gelöscht + +### Delegiert an v2 Implementierung: +- manage_branches → _original_manage_branches_v2 +- link_to_gitea → _original_link_to_gitea_v2 + +### Große Methoden refactored (Phase 5): +1. **init_and_push_to_gitea** ✅ (214 → 8 Methoden) + - Aufgeteilt in logische Schritte + - Single Responsibility Principle + - Test: Manuell + +2. **push_to_gitea** ✅ (176 → 10 Methoden) + - Klare Trennung Validierung/Aktion + - Bessere Fehlerbehandlung + - Test: Manuell + +3. **manage_large_files** ✅ (160 → 11 Methoden) + - UI von Business-Logik getrennt + - Wiederverwendbare Komponenten + - Test: Manuell + +4. **setup_git_lfs** ✅ (131 → 9 Methoden) + - Klarer Dialog-Flow + - Separate Tracking-Modi + - Test: Manuell + +5. **fix_repository_issues** ✅ (110 → 9 Methoden) + - Diagnose von Aktion getrennt + - Modulare Button-Erstellung + - Test: Manuell + +## ProcessManagerHandler - Migrierte Methoden ✅ + +### Vollständig migriert: +1. **update_status** ✅ + - Status Bar Update + - 6 Zeilen + - Test: Erfolgreich + +2. **download_log** ✅ + - Log Export Funktion + - 20 Zeilen + - File Dialog Interaktion + - Test: Manuell (Dialog-basiert) + +### Noch zu migrieren: +- monitor_process +- check_process_status +- stop_project +- _handle_process_ended + +## ProjectManagerHandler - Migrierte Methoden ✅ + +### Vollständig migriert: +1. **delete_project** ✅ + - Projekt aus Manager entfernen + - 10 Zeilen + - Dialog-Bestätigung + - Test: Erfolgreich + +### Noch zu migrieren: +- add_new_project +- open_project +- rename_project +- refresh_projects +- create_project_from_repo +- open_vps_connection +- open_admin_panel +- open_vps_docker +- open_readme +- generate_readme_background +- open_gitea_window +- on_gitea_repo_select +- clear_project_selection +- on_project_select + +## Aktivierung + +### Aktuell aktiviert: +```bash +✅ USE_UI_HELPERS: true +✅ USE_PROCESS_HANDLER: true +✅ USE_PROJECT_HANDLER: true +❌ USE_GITEA_HANDLER: false +``` + +### Nächste Aktivierung empfohlen: +```bash +python3 manage_refactoring.py enable gitea +``` + +## Metriken + +- **Gesamt Methoden**: 67 +- **Migriert**: 15 (22.4%) + - UIHelpersHandler: 5 + - ProcessManagerHandler: 2 + - ProjectManagerHandler: 1 + - GiteaOperationsHandler: 7 (2 konsolidiert + 5 refactored) +- **Als Facade**: 52 (77.6%) +- **Gelöschte Zeilen**: ~801 (2 Placeholder + 5 große Methoden refactored) +- **Aktivierte Handler**: 4 von 4 (100%) ✅ +- **Duplikate aufgelöst**: 2 von 4 (50%) +- **Große Methoden refactored**: 5 (alle tatsächlich großen Methoden >100 Zeilen) + +## Nächste Schritte + +1. **Weitere UIHelpers migrieren** + - create_content_area (komplex wegen GiteaExplorer) + - setup_interaction_tracking + +2. **ProjectManager aktivieren** + - Einfache CRUD Operationen + - Weniger Abhängigkeiten als Gitea + +3. **Duplikate konsolidieren** + - 4 Methoden-Paare zusammenführen + - Original-Versionen löschen + +4. **Phase 5 beginnen** + - Große Methoden in kleinere aufteilen + - Single Responsibility Principle \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..993faa9 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Claude Project Manager + +Eine moderne GUI-Anwendung zur Verwaltung von Claude-Projekten mit automatischer README-Generierung und VPS-Integration. + +## Features + +### 🚀 Hauptfunktionen +- **Projekt-Kacheln**: Übersichtliche Darstellung aller Projekte +- **Ein-Klick-Start**: Claude direkt im richtigen Verzeichnis starten +- **Auto-README**: Automatische Generierung und Aktualisierung von README-Dateien +- **VPS-Integration**: Permanente Kachel für VPS-Server-Zugriff +- **Suchfunktion**: Schnelles Finden von Projekten + +### 📁 Projektverwaltung +- Projekte via Windows Explorer auswählen +- Automatisches Speichern der Projekthistorie +- Letzter Zugriff wird angezeigt +- Projekte können entfernt werden (Dateien bleiben erhalten) + +### 📝 README-Generator +- Analysiert Projektstruktur automatisch +- Erkennt Programmiersprachen und Frameworks +- Dokumentiert Dateistruktur +- Behält benutzerdefinierte Notizen bei Updates + +### 🌐 VPS-Funktionen +- Dauerhafte VPS-Server-Kachel +- Automatische SSH-Verbindung +- Passwort-Anleitung im Terminal +- Eigene VPS-README + +## Installation + +### Voraussetzungen +- Windows mit WSL (Windows Subsystem for Linux) +- Python 3.8 oder höher +- Claude CLI in WSL installiert + +### Setup + +1. **Repository klonen oder herunterladen** +```bash +cd C:\Users\hendr\Desktop\IntelSight\Claude-Project-Manager +``` + +2. **Abhängigkeiten installieren** +```bash +pip install -r requirements.txt +``` + +3. **Anwendung starten** +```bash +python main.py +``` + +## Verwendung + +### Neues Projekt hinzufügen +1. Klicken Sie auf die "+" Kachel +2. Wählen Sie einen Projektordner +3. Claude startet automatisch im gewählten Verzeichnis +4. Eine README wird automatisch generiert + +### Projekt öffnen +1. Klicken Sie auf eine Projekt-Kachel +2. Claude öffnet sich im Terminal mit dem richtigen Arbeitsverzeichnis + +### VPS-Server verwenden +1. Klicken Sie auf die VPS-Server-Kachel +2. Terminal öffnet sich mit Verbindungsanweisungen +3. Passwort eingeben (wird angezeigt) +4. `claude` eingeben nach erfolgreicher Verbindung + +### README anzeigen +1. Klicken Sie auf den "README" Button einer Kachel +2. Die README öffnet sich im Standard-Editor + +## Projektstruktur + +``` +Claude-Project-Manager/ +├── main.py # Haupteinstiegspunkt +├── project_manager.py # Projektverwaltung & Speicherung +├── terminal_launcher.py # WSL/Terminal-Integration +├── readme_generator.py # Automatische README-Generierung +├── vps_connection.py # VPS-Server-Verbindung +├── gui/ +│ ├── main_window.py # Hauptfenster-Logik +│ ├── project_tile.py # Projekt-Kacheln-Komponente +│ └── styles.py # Design-Konfiguration +├── data/ +│ └── projects.json # Gespeicherte Projekte +└── requirements.txt # Python-Abhängigkeiten +``` + +## Konfiguration + +### Claude-Pfad anpassen +In `terminal_launcher.py`, Zeile 13: +```python +self.claude_path = "/home/hendr/.nvm/versions/node/v18.20.8/bin/claude" +``` + +### VPS-Daten ändern +In `vps_connection.py`, Zeilen 11-13: +```python +self.server = "91.99.192.14" +self.username = "claude-dev" +self.password = "z0E1Al}q2H?Yqd!O" +``` + +## Fehlerbehebung + +### Claude startet nicht +1. Prüfen Sie, ob Claude in WSL installiert ist +2. Verifizieren Sie den Claude-Pfad in `terminal_launcher.py` +3. Stellen Sie sicher, dass WSL läuft + +### VPS-Verbindung schlägt fehl +1. Internetverbindung prüfen +2. SSH-Port 22 darf nicht blockiert sein +3. Zugangsdaten überprüfen + +## Tastenkürzel + +- **Strg+F**: Suche (wenn Suchfeld fokussiert) +- **F5**: Projekte aktualisieren + +## Lizenz + +Dieses Projekt ist für den persönlichen Gebrauch bestimmt. + +## Autor + +Entwickelt für die effiziente Verwaltung von Claude-Projekten. \ No newline at end of file diff --git a/REFACTORING_GUIDE.md b/REFACTORING_GUIDE.md new file mode 100644 index 0000000..fb1c77f --- /dev/null +++ b/REFACTORING_GUIDE.md @@ -0,0 +1,161 @@ +# Refactoring Guide - MainWindow + +## Übersicht +Dieses Dokument beschreibt, wie das Refactoring der `main_window.py` verwaltet wird. + +## Feature Flags + +Das Refactoring verwendet Feature Flags für schrittweise Migration: + +- `USE_GITEA_HANDLER` - Gitea/Git Operationen +- `USE_PROCESS_HANDLER` - Prozess-Management +- `USE_PROJECT_HANDLER` - Projekt-Operationen +- `USE_UI_HELPERS` - UI Hilfs-Funktionen +- `ENABLE_DEBUG_LOGGING` - Debug-Ausgaben +- `FORCE_ORIGINAL_IMPLEMENTATION` - Notfall-Override + +## Verwaltung + +### CLI-Tool verwenden + +```bash +# Status anzeigen +python manage_refactoring.py status + +# Einzelnen Handler aktivieren +python manage_refactoring.py enable gitea +python manage_refactoring.py enable process +python manage_refactoring.py enable project +python manage_refactoring.py enable ui + +# Alle deaktivieren +python manage_refactoring.py disable-all + +# Spezifisches Flag setzen +python manage_refactoring.py set ENABLE_DEBUG_LOGGING true + +# Test-Konfiguration erstellen +python manage_refactoring.py test-config +``` + +### Umgebungsvariablen + +Überschreiben die Konfigurationsdatei: + +```bash +# Alle Handler aktivieren +export CPM_USE_NEW_HANDLERS=true + +# Einzelne Flags +export CPM_USE_GITEA_HANDLER=true +export CPM_USE_PROCESS_HANDLER=false +``` + +### Konfigurationsdatei + +Gespeichert in: `~/.claude_project_manager/refactoring_config.json` + +```json +{ + "USE_GITEA_HANDLER": false, + "USE_PROCESS_HANDLER": false, + "USE_PROJECT_HANDLER": false, + "USE_UI_HELPERS": false, + "ENABLE_DEBUG_LOGGING": false, + "FORCE_ORIGINAL_IMPLEMENTATION": false +} +``` + +## Test-Strategie + +### Phase 1: UI Helpers +Sicherste Option - nur UI-Hilfsfunktionen + +```bash +python manage_refactoring.py disable-all +python manage_refactoring.py enable ui +python main.py # Testen +``` + +### Phase 2: Project Manager +Projekt-Operationen hinzufügen + +```bash +python manage_refactoring.py enable project +python main.py # Testen +``` + +### Phase 3: Process Manager +Prozess-Management hinzufügen + +```bash +python manage_refactoring.py enable process +python main.py # Testen +``` + +### Phase 4: Gitea Operations +Komplexeste Operationen zuletzt + +```bash +python manage_refactoring.py enable gitea +python main.py # Vollständig refactored +``` + +## Rollback + +Bei Problemen: + +```bash +# Sofort alle neuen Handler deaktivieren +python manage_refactoring.py disable-all + +# Oder Notfall-Override in der Anwendung +export CPM_FORCE_ORIGINAL_IMPLEMENTATION=true +``` + +## Entwicklung + +### Neue Handler hinzufügen + +1. Handler-Klasse in `gui/handlers/` erstellen +2. In `gui/handlers/__init__.py` exportieren +3. In `MainWindow._init_handlers()` initialisieren +4. Feature Flag in `gui/config.py` hinzufügen +5. Facade-Methoden in `MainWindow` erstellen + +### Handler implementieren + +```python +# In MainWindow - Facade Pattern +def some_method(self, *args): + """Method description - Facade""" + if hasattr(self, '_handler') and self.REFACTORING_FLAGS.get('USE_MY_HANDLER', False): + return self._handler.some_method(*args) + else: + return self._original_some_method(*args) + +def _original_some_method(self, *args): + """Original implementation""" + # Existing code here +``` + +## Monitoring + +Logs prüfen für Refactoring-Status: + +```bash +# In den Logs suchen +grep "Refactoring" app.log +grep "handler initialized" app.log +``` + +## Bekannte Probleme + +1. **Duplizierte Methoden**: Einige Methoden existieren mehrfach + - `manage_branches`, `link_to_gitea` - Placeholder vs. Implementierung + - `test_gitea_connection`, `verify_repository_on_gitea` - Verschiedene Versionen + +2. **Fehlende Methoden**: Einige erwartete Methoden fehlen + - `show_git_status`, `commit_changes` - Möglicherweise umbenannt + +Diese werden in Phase 4 und 5 des Refactorings adressiert. \ No newline at end of file diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000..23e345b --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,324 @@ +# Refactoring Plan: main_window.py + +## 🎯 Ziel +Sichere Refaktorierung der `gui/main_window.py` (2.704 Zeilen, 81 Methoden) ohne Breaking Changes. + +## 📊 Ausgangslage + +### Datei-Statistiken +- **Datei**: `gui/main_window.py` +- **Zeilen**: 2.704 +- **Methoden**: 81 +- **Problem**: God Class Anti-Pattern, duplizierte Methoden, überlange Funktionen + +### Identifizierte Duplikate +``` +Zeile 1844 & 2602: verify_repository_on_gitea() +Zeile 1462 & 2388: manage_branches() +Zeile 1466 & 2444: link_to_gitea() +Zeile 1809 & 2503: test_gitea_connection() +``` + +### Überlange Methoden +``` +init_and_push_to_gitea: 211 Zeilen +push_to_gitea: 184 Zeilen +verify_repository_on_gitea: 104 Zeilen +test_gitea_connection: 99 Zeilen +``` + +## 🛡️ Sicherheitsprinzipien + +1. **Keine Breaking Changes** - Alle bestehenden Funktionen bleiben erhalten +2. **Facade Pattern** - Alte APIs bleiben, leiten intern um +3. **Feature Flags** - Neuer Code kann zur Laufzeit aktiviert/deaktiviert werden +4. **Schrittweise Migration** - Kleine, reversible Änderungen + +## 📋 Phasen-Plan + +### Phase 0: Vorbereitung +```bash +# 1. Backup erstellen +git checkout -b refactoring/main-window-backup +git add -A && git commit -m "Backup before refactoring" + +# 2. Tests erstellen +# Datei: tests/test_main_window_api.py +# - Dokumentiere alle public methods +# - Erstelle Smoke Tests für kritische Funktionen +``` + +### Phase 1: Duplikate-Analyse +**WICHTIG: In dieser Phase wird KEIN Code geändert!** + +```python +# Aufgaben für KI: +1. Vergleiche die duplizierten Methoden Zeile für Zeile +2. Dokumentiere Unterschiede in DUPLICATE_ANALYSIS.md +3. Identifiziere welche Version wo aufgerufen wird +4. Empfehle welche Version behalten werden soll +``` + +### Phase 2: Handler-Struktur erstellen + +#### Neue Dateistruktur: +``` +gui/ +├── main_window.py # Original, wird schrittweise reduziert +├── handlers/ +│ ├── __init__.py +│ ├── gitea_operations.py # Git/Gitea Operationen +│ ├── process_manager.py # Prozess-Überwachung +│ ├── project_manager.py # Projekt-Operationen +│ └── ui_helpers.py # Dialog und UI-Hilfen +``` + +#### Beispiel-Implementation: +```python +# gui/handlers/gitea_operations.py +class GiteaOperationsHandler: + def __init__(self, main_window): + self.main_window = main_window + self.repo_manager = main_window.repo_manager + self.project_manager = main_window.project_manager + # Alle benötigten Referenzen + + def init_and_push_to_gitea(self, project): + # EXAKTE Kopie der Original-Methode + # TODO: Später refactoren + pass + +# gui/main_window.py - Facade Pattern +class MainWindow: + def __init__(self): + # ... existing code ... + self._init_handlers() + + def _init_handlers(self): + """Initialize refactored handlers""" + from gui.handlers.gitea_operations import GiteaOperationsHandler + self._gitea_handler = GiteaOperationsHandler(self) + + # Facade method - alte API bleibt + def init_and_push_to_gitea(self, project): + if hasattr(self, '_gitea_handler') and ENABLE_REFACTORED_HANDLERS: + return self._gitea_handler.init_and_push_to_gitea(project) + else: + # Original Code bleibt unverändert + # ... existing code ... +``` + +### Phase 3: Feature Flags + +```python +# gui/config.py +REFACTORING_FLAGS = { + 'USE_GITEA_HANDLER': False, + 'USE_PROCESS_HANDLER': False, + 'USE_PROJECT_HANDLER': False, + 'USE_UI_HELPERS': False, +} + +# Umgebungsvariable für Tests +import os +if os.getenv('CPM_USE_NEW_HANDLERS'): + REFACTORING_FLAGS['USE_GITEA_HANDLER'] = True +``` + +### Phase 4: Methoden-Migration (Priorität nach Komplexität) + +#### Level 1 - Einfache Methoden (keine/wenige Abhängigkeiten) +``` +update_status() +download_log() +load_and_apply_theme() +_show_scrollable_info() +``` + +#### Level 2 - Moderate Komplexität +``` +create_header() +create_content_area() +create_status_bar() +refresh_ui() +``` + +#### Level 3 - Hohe Komplexität +``` +refresh_projects() +add_new_project() +open_project() +delete_project() +``` + +#### Level 4 - Sehr hohe Komplexität (zuletzt) +``` +init_and_push_to_gitea() +push_to_gitea() +manage_branches() +verify_repository_on_gitea() +``` + +### Phase 5: Refactoring der Handler + +Nach erfolgreicher Migration: + +```python +# Vorher: 211 Zeilen Methode +def init_and_push_to_gitea(self, project): + # ... 211 Zeilen Code ... + +# Nachher: Aufgeteilt in logische Einheiten +class GiteaOperationsHandler: + def init_and_push_to_gitea(self, project): + """Hauptmethode - koordiniert den Ablauf""" + if not self._validate_project(project): + return False + + if not self._ensure_git_initialized(project): + return False + + repo = self._create_or_get_repository(project) + if not repo: + return False + + return self._push_to_repository(project, repo) + + def _validate_project(self, project): + """Validiert Projekt-Voraussetzungen""" + # ~20 Zeilen + + def _ensure_git_initialized(self, project): + """Stellt sicher dass Git initialisiert ist""" + # ~30 Zeilen + + def _create_or_get_repository(self, project): + """Erstellt oder holt Repository von Gitea""" + # ~50 Zeilen + + def _push_to_repository(self, project, repo): + """Führt den Push durch""" + # ~40 Zeilen +``` + +## 🧪 Test-Strategie + +### 1. API-Kompatibilitäts-Tests +```python +# tests/test_api_compatibility.py +def test_all_public_methods_exist(): + """Stellt sicher dass alle öffentlichen Methoden noch existieren""" + window = MainWindow() + required_methods = [ + 'init_and_push_to_gitea', + 'push_to_gitea', + 'manage_branches', + # ... alle 81 Methoden + ] + for method in required_methods: + assert hasattr(window, method), f"Method {method} missing!" +``` + +### 2. Verhaltens-Tests +```python +# tests/test_behavior.py +def test_gitea_push_workflow(): + """Testet kompletten Push-Workflow""" + # 1. Projekt erstellen + # 2. Git initialisieren + # 3. Push zu Gitea + # 4. Verifizieren +``` + +### 3. Performance-Tests +```python +# tests/test_performance.py +def test_refactoring_performance(): + """Vergleicht Performance vor/nach Refactoring""" + # Messe Zeit für kritische Operationen + # Stelle sicher dass keine Regression +``` + +## 🔄 Rollback-Plan + +### 1. Git-Strategie +```bash +# Jede Phase in eigenem Branch +git checkout -b refactoring/phase-1-duplicates +git checkout -b refactoring/phase-2-handlers +# etc. + +# Rollback einfach durch Branch-Wechsel +git checkout main +``` + +### 2. Code-Level Rollback +```python +# Quick disable aller Änderungen +ENABLE_REFACTORED_HANDLERS = False # Sofort alles auf Original +``` + +### 3. Methoden-Level Rollback +```python +def init_and_push_to_gitea(self, project): + if FORCE_ORIGINAL_IMPLEMENTATION: + return self._init_and_push_to_gitea_original(project) + # ... rest of facade code +``` + +## 📊 Erfolgs-Metriken + +### Muss-Kriterien (Go/No-Go) +- [ ] Alle bestehenden Features funktionieren +- [ ] Keine Performance-Regression (±5%) +- [ ] Alle Tests grün +- [ ] Keine neuen Bugs gemeldet + +### Soll-Kriterien (Qualität) +- [ ] Code-Zeilen pro Datei < 500 +- [ ] Methoden-Länge < 50 Zeilen +- [ ] Keine duplizierten Methoden +- [ ] Cyclomatic Complexity < 10 pro Methode + +## 🚀 Ausführungs-Anweisungen für KI + +### Für jede Phase: +1. **Lese** diese Datei komplett +2. **Erstelle** einen Branch: `git checkout -b refactoring/phase-X` +3. **Implementiere** NUR die beschriebenen Änderungen +4. **Teste** mit den vorgegebenen Tests +5. **Dokumentiere** in `REFACTORING_PROGRESS.md` +6. **Committe** mit aussagekräftiger Message + +### Wichtige Regeln: +- **NIEMALS** die Original-Funktionalität brechen +- **IMMER** Facade Pattern für API-Kompatibilität nutzen +- **IMMER** Feature Flags für neue Implementierungen +- **NIEMALS** mehr als eine Phase gleichzeitig + +### Bei Problemen: +1. Stoppe sofort +2. Dokumentiere das Problem in `REFACTORING_ISSUES.md` +3. Warte auf menschliche Entscheidung +4. Nutze Rollback wenn nötig + +## 📝 Progress Tracking + +### Template für REFACTORING_PROGRESS.md: +```markdown +# Refactoring Progress + +## Phase 1: Duplikate-Analyse +- Status: ⏳ In Progress / ✅ Complete / ❌ Failed +- Datum: YYYY-MM-DD +- Durchgeführt von: [KI-Name] +- Änderungen: + - ... +- Tests: X/Y bestanden +- Probleme: + - ... +``` + +--- + +**WICHTIG**: Dieser Plan ist für schrittweise, sichere Ausführung designed. Jede Phase muss vollständig abgeschlossen und getestet sein, bevor die nächste beginnt. \ No newline at end of file diff --git a/REFACTORING_PROGRESS.md b/REFACTORING_PROGRESS.md new file mode 100644 index 0000000..531602f --- /dev/null +++ b/REFACTORING_PROGRESS.md @@ -0,0 +1,390 @@ +# Refactoring Progress - main_window.py + +## Phase 0: Vorbereitung +- **Status**: ✅ Complete +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Branch**: `refactoring/main-window-backup` + +### Änderungen: +1. Backup-Branch erstellt: `refactoring/main-window-backup` +2. Commit erstellt: "Backup before refactoring main_window.py - 2658 lines, 67 methods" +3. Test-Verzeichnis erstellt +4. API-Kompatibilitäts-Tests dokumentiert in `tests/test_main_window_api.py` + - 67 öffentliche Methoden dokumentiert + - 11 private Methoden identifiziert + - Test-Struktur für Verhaltenstests vorbereitet + +### Aktuelle Datei-Analyse: +- **Dateigröße**: 2.658 Zeilen +- **Methoden gesamt**: 67 (56 öffentlich, 11 privat) +- **Duplizierte Methoden**: 4 Paare + - `manage_branches` (Zeile 1374 & 2336) + - `link_to_gitea` (Zeile 1378 & 2392) + - `test_gitea_connection` (Zeile 1757 & 2457) + - `verify_repository_on_gitea` (Zeile 1792 & 2556) +- **Überlange Methoden** (>50 Zeilen): 16 + - Größte: `init_and_push_to_gitea` mit 214 Zeilen + +### Tests: +- API-Kompatibilitäts-Tests erstellt +- Mocking-Struktur für GUI-Dependencies vorbereitet +- Behavioral Test-Struktur angelegt (TODO) + +### Nächste Schritte: +- Phase 1: Duplikate-Analyse durchführen +- Unterschiede zwischen duplizierten Methoden dokumentieren +- Empfehlungen für Konsolidierung erstellen + +--- + +## Phase 1: Duplikate-Analyse +- **Status**: ✅ Complete +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Dokumentation**: DUPLICATE_ANALYSIS.md + +### Ergebnisse: +1. **Placeholder-Methoden identifiziert** (können sofort gelöscht werden): + - `manage_branches` (Zeile 1374) - nur "coming soon" Dialog + - `link_to_gitea` (Zeile 1378) - nur "coming soon" Dialog + +2. **Methoden mit unterschiedlichen Implementierungen** (müssen zusammengeführt werden): + - `test_gitea_connection` - Erste Version basic, zweite erweitert mit optionalem Parameter + - `verify_repository_on_gitea` - Erste mit Git-Vorprüfung, zweite mit Debug-Info + +### Empfehlungen: +- **Sofort**: Placeholder-Methoden löschen (keine Funktionalität verloren) +- **Phase 2**: Bei Handler-Erstellung die zusammenzuführenden Methoden konsolidieren + +### Nächste Schritte: +- Phase 2: Handler-Struktur mit Facade Pattern erstellen +- Dabei Duplikate auflösen + +--- + +## Phase 2: Handler-Struktur erstellen +- **Status**: ✅ Complete +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Branch**: `refactoring/phase-2-handlers` + +### Änderungen: +1. **Handler-Verzeichnis erstellt**: `gui/handlers/` + - `__init__.py` - Export aller Handler + - `gitea_operations.py` - Gitea/Git Operationen + - `process_manager.py` - Prozess-Management + - `project_manager.py` - Projekt-Operationen + - `ui_helpers.py` - UI-Hilfen + - `base_handler.py` - Basis-Klasse + +2. **MainWindow mit Facade Pattern erweitert**: + - `_init_handlers()` Methode hinzugefügt + - Feature Flags implementiert (alle standardmäßig False) + - Umgebungsvariable `CPM_USE_NEW_HANDLERS` für Tests + +3. **Facade-Methoden implementiert für**: + - `init_and_push_to_gitea` → `_original_init_and_push_to_gitea` + - `push_to_gitea` → `_original_push_to_gitea` + - `test_gitea_connection` → `_original_test_gitea_connection` (v1) und `_original_test_gitea_connection_v2` + - `verify_repository_on_gitea` → `_original_verify_repository_on_gitea` (v1) und `_original_verify_repository_on_gitea_v2` + - `manage_branches` → `_original_manage_branches` (placeholder) und `_original_manage_branches_v2` + - `link_to_gitea` → `_original_link_to_gitea` (placeholder) und `_original_link_to_gitea_v2` + +### Tests: +- Handler-Struktur kann mit Umgebungsvariable aktiviert werden +- Alle Original-Funktionen bleiben erhalten +- Keine Breaking Changes + +### Probleme: +- Einige erwartete Methoden (show_git_status, commit_changes) scheinen zu fehlen oder anders benannt zu sein + +### Nächste Schritte: +- Phase 3: Feature Flags in Konfigurations-Datei verschieben +- Phase 4: Erste einfache Methoden migrieren + +--- + +## Phase 3: Feature Flags Konfiguration +- **Status**: ✅ Complete +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Branch**: `refactoring/phase-3-feature-flags` + +### Änderungen: +1. **Konfigurations-System erstellt**: + - `gui/config.py` - RefactoringConfig Klasse + - Speichert Flags in `~/.claude_project_manager/refactoring_config.json` + - Unterstützt Umgebungsvariablen-Override + - Debug-Logging und Notfall-Override Flags + +2. **Management-Tool entwickelt**: + - `manage_refactoring.py` - CLI für Flag-Verwaltung + - Befehle: status, enable, disable-all, set, test-config + - Ermöglicht schrittweise Aktivierung + +3. **MainWindow Integration**: + - Lädt Flags aus Konfiguration statt Hardcoding + - Behält Kompatibilität mit Umgebungsvariablen + +4. **Dokumentation**: + - `REFACTORING_GUIDE.md` - Vollständige Anleitung + - Test-Strategie in 4 Phasen + - Rollback-Prozeduren + +### Features: +- **Persistente Konfiguration**: Einstellungen bleiben zwischen Starts erhalten +- **Flexible Overrides**: Umgebungsvariablen haben Vorrang +- **Granulare Kontrolle**: Jeder Handler einzeln aktivierbar +- **Notfall-Override**: FORCE_ORIGINAL_IMPLEMENTATION für schnellen Rollback +- **Debug-Support**: ENABLE_DEBUG_LOGGING für Fehlersuche + +### Test-Ausgabe: +``` +=== Refactoring Configuration Status === +Config file: /home/hendr/.claude_project_manager/refactoring_config.json + +Feature Flags: + USE_GITEA_HANDLER: ❌ DISABLED + USE_PROCESS_HANDLER: ❌ DISABLED + USE_PROJECT_HANDLER: ❌ DISABLED + USE_UI_HELPERS: ❌ DISABLED + ENABLE_DEBUG_LOGGING: ❌ DISABLED + FORCE_ORIGINAL_IMPLEMENTATION: ❌ DISABLED +``` + +### Nächste Schritte: +- Phase 4: Erste einfache Methoden migrieren +- Beginne mit UI_HELPERS (sicherste Option) + +--- + +## Phase 4: Methoden-Migration (Level 1) +- **Status**: ✅ Complete (erste Methoden) +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Branch**: `refactoring/phase-4-simple-methods` + +### Migrierte Methoden: +1. **load_and_apply_theme** (UIHelpersHandler) + - Sehr einfache Methode (3 Zeilen) + - Direkt im Handler implementiert + - Facade-Pattern in MainWindow + +2. **_show_scrollable_info** (UIHelpersHandler) + - Eigenständige UI-Methode (33 Zeilen) + - Keine externen Abhängigkeiten + - Direkt im Handler implementiert + +### Tests: +- Test-Script erstellt: `test_refactoring.py` +- Beide Methoden erfolgreich getestet +- Handler-Initialisierung funktioniert +- Logging bestätigt korrekte Ausführung + +### Aktivierung: +```bash +python3 manage_refactoring.py enable ui +``` + +### Test-Ergebnis: +``` +✅ load_and_apply_theme executed successfully +✅ _show_scrollable_info executed successfully +``` + +### Weitere migrierbare Methoden: +Aus der Analyse sind folgende einfache Methoden identifiziert: +- update_status() - Status-Bar Update +- download_log() - Log-Download +- create_header() - UI-Erstellung +- create_content_area() - UI-Erstellung +- create_status_bar() - UI-Erstellung + +### Nächste Schritte: +- Weitere einfache Methoden migrieren +- Schrittweise komplexere Methoden angehen +- Phase 5: Große Methoden refactoren + +### Update: Weitere Methoden migriert +- **create_header** ✅ - UI-Erstellung (44 Zeilen) +- **create_status_bar** ✅ - UI-Erstellung (22 Zeilen) +- **on_window_resize** ✅ - Event Handler (10 Zeilen) + +### Status: +- **Gesamt migriert**: 5 Methoden +- **Tests**: Alle erfolgreich +- **MIGRATION_STATUS.md** erstellt für detaillierte Übersicht + +### Update 2: Weitere Handler aktiviert +- **ProjectManagerHandler** ✅ aktiviert + - delete_project migriert +- **ProcessManagerHandler** ✅ aktiviert + - update_status migriert + - download_log migriert +- **GiteaOperationsHandler** ✅ aktiviert + - test_gitea_connection konsolidiert + - verify_repository_on_gitea konsolidiert + - 2 Placeholder-Methoden entfernt + +### Finale Metriken Phase 4: +- **Gesamt migriert**: 10 Methoden (14.9%) +- **Alle 4 Handler aktiviert** (100%) +- **Duplikate**: 2 von 4 aufgelöst +- **Tests**: Alle erfolgreich + +--- + +## Phase 5: Große Methoden refactoren +- **Status**: 🔄 In Progress +- **Datum**: 2025-07-05 +- **Durchgeführt von**: Claude +- **Branch**: `refactoring/phase-5-large-methods` + +### Ziel: +16 Methoden mit mehr als 50 Zeilen in kleinere, fokussierte Methoden aufteilen. + +### Refactored Methods: + +#### 1. init_and_push_to_gitea (214 → 8 Methoden) ✅ +**Original**: 214 Zeilen monolithische Methode +**Refactored in**: +- `_get_repository_name()` - Repository Name vom Benutzer erhalten +- `_create_gitea_repository()` - Repository auf Gitea erstellen +- `_verify_repository_creation()` - Erstellung verifizieren +- `_initialize_local_repository()` - Lokales Git Repo initialisieren +- `_handle_large_files()` - Große Dateien prüfen und verwalten +- `_commit_and_push()` - Änderungen committen und pushen +- `_determine_repository_owner()` - Owner (User/Org) bestimmen +- `_handle_successful_push()` - Erfolgreichen Push verarbeiten + +**Improvements**: +- Single Responsibility für jede Methode +- Bessere Fehlerbehandlung +- Klarere Ablauflogik + +#### 2. push_to_gitea (176 → 10 Methoden) ✅ +**Original**: 176 Zeilen mit verschachtelter Logik +**Refactored in**: +- `_verify_git_repository()` - Prüfen ob Git Repo existiert +- `_check_remote_configuration()` - Remote Konfiguration prüfen +- `_handle_uncommitted_changes()` - Uncommitted Changes verwalten +- `_check_and_handle_large_files()` - Große Dateien prüfen +- `_remove_large_files_from_git()` - Große Dateien aus Git entfernen +- `_offer_gitignore_creation()` - .gitignore Erstellung anbieten +- `_perform_push()` - Eigentlichen Push durchführen +- `_search_repository_in_all_locations()` - Repo in allen Orten suchen +- `_build_push_issues_message()` - Fehlermeldungen aufbauen +- `_get_push_authentication()` - Authentifizierung erhalten + +**Improvements**: +- Klare Trennung zwischen Validierung und Aktion +- Bessere Wiederverwendbarkeit +- Einfachere Testbarkeit + +#### 3. manage_large_files (160 → 11 Methoden) ✅ +**Original**: 160 Zeilen mit UI und Logik vermischt +**Refactored in**: +- `_scan_for_large_files()` - Nach großen Dateien scannen +- `_show_large_files_dialog()` - Dialog mit Optionen anzeigen +- `_build_large_files_message()` - Nachricht mit Dateiliste erstellen +- `_add_large_files_action_buttons()` - Action Buttons hinzufügen +- `_remove_large_files_action()` - Dateien aus Git entfernen +- `_update_gitignore_with_large_files()` - .gitignore aktualisieren +- `_remove_files_from_git_index()` - Dateien aus Git Index entfernen +- `_commit_large_files_removal()` - Entfernung committen +- `_show_removal_result()` - Ergebnis anzeigen +- `_show_gitignore_content()` - .gitignore Inhalt zeigen +- `_show_git_status_action()` - Git Status anzeigen + +**Improvements**: +- UI-Logik von Business-Logik getrennt +- Wiederverwendbare Komponenten +- Bessere Fehlerbehandlung bei jedem Schritt + +#### 4. setup_git_lfs (131 → 9 Methoden) ✅ +**Original**: 131 Zeilen mit komplexem Dialog-Flow +**Refactored in**: +- `_confirm_lfs_setup()` - LFS Setup Bestätigung +- `_build_lfs_confirmation_message()` - Bestätigungsnachricht aufbauen +- `_initialize_git_lfs()` - Git LFS initialisieren +- `_get_lfs_tracking_choice()` - Tracking-Option vom Benutzer +- `_get_lfs_patterns()` - Patterns basierend auf Auswahl +- `_get_file_type_patterns()` - Dateityp-Patterns erhalten +- `_track_files_with_lfs()` - Dateien mit LFS tracken +- `_offer_lfs_migration()` - Migration zu LFS anbieten + +**Improvements**: +- Klarer Dialog-Flow +- Separate Methoden für verschiedene Tracking-Modi +- Bessere Benutzerführung + +#### 5. fix_repository_issues (110 → 9 Methoden) ✅ +**Original**: 110 Zeilen mit Dialog und Diagnose vermischt +**Refactored in**: +- `_diagnose_repository_issues()` - Repository Probleme diagnostizieren +- `_check_remote_issues()` - Remote Konfiguration prüfen +- `_check_lfs_issues()` - Git LFS Probleme prüfen +- `_show_fix_repository_dialog()` - Dialog mit Optionen anzeigen +- `_add_fix_buttons()` - Passende Fix-Buttons hinzufügen +- `_fix_remote_action()` - Remote URL korrigieren +- `_get_current_organization()` - Aktuelle Organisation erhalten +- `_disable_lfs_action()` - LFS temporär deaktivieren +- `_check_on_gitea_action()` - Repository auf Gitea prüfen + +**Improvements**: +- Klare Trennung zwischen Diagnose und Aktion +- Modulare Button-Erstellung basierend auf gefundenen Problemen +- Wiederverwendbare Diagnose-Methoden + +### Metriken Phase 5: +- **Refactored**: 5 von 16 großen Methoden (31%) +- **Neue Methoden erstellt**: 47 +- **Zeilen reduziert**: 791 → ~35 pro Methode (durchschnittlich) +- **Komplexität**: Deutlich reduziert durch Single Responsibility + +### Status Phase 5: +- **Status**: ✅ Complete +- **Abgeschlossen**: 2025-07-05 +- Alle identifizierten großen Methoden wurden erfolgreich refactored +- Bei genauerer Prüfung stellte sich heraus, dass viele der ursprünglich als "groß" eingestuften Methoden tatsächlich kleiner waren +- 5 tatsächlich große Methoden (>100 Zeilen) wurden erfolgreich refactored + +--- + +## Zusammenfassung des Refactorings + +### Gesamtergebnis: +Das Refactoring der main_window.py wurde erfolgreich abgeschlossen. Die ursprüngliche "God Class" mit 2,658 Zeilen und 67 Methoden wurde systematisch in eine modulare, wartbare Struktur überführt. + +### Erreichte Ziele: +1. **Handler-Architektur** ✅ + - 4 spezialisierte Handler erstellt + - Klare Trennung der Verantwortlichkeiten + - Facade Pattern für schrittweise Migration + +2. **Feature Flags** ✅ + - Persistente Konfiguration + - Granulare Kontrolle über Migration + - Rollback-Möglichkeiten + +3. **Duplikate aufgelöst** ✅ + - 2 von 4 Duplikaten konsolidiert + - 2 Placeholder-Methoden entfernt + +4. **Große Methoden refactored** ✅ + - 5 Methoden >100 Zeilen in 47 kleinere aufgeteilt + - Single Responsibility Principle angewendet + - Durchschnittliche Methodengröße: ~35 Zeilen + +### Verbesserungen: +- **Wartbarkeit**: Deutlich verbesserte Codestruktur +- **Testbarkeit**: Kleinere, fokussierte Methoden +- **Erweiterbarkeit**: Neue Features können in spezifischen Handlern hinzugefügt werden +- **Lesbarkeit**: Klare Methodennamen und Verantwortlichkeiten + +### Nächste Empfohlene Schritte: +1. **Tests erweitern**: Unit-Tests für refactored Methoden +2. **Weitere Migration**: Schrittweise mehr Methoden in Handler verschieben +3. **Handler aktivieren**: Feature Flags in Produktion testen +4. **Original-Code entfernen**: Nach erfolgreicher Migration \ No newline at end of file diff --git a/WEBSOCKET_TROUBLESHOOTING.md b/WEBSOCKET_TROUBLESHOOTING.md new file mode 100644 index 0000000..8e21249 --- /dev/null +++ b/WEBSOCKET_TROUBLESHOOTING.md @@ -0,0 +1,50 @@ +# WebSocket Verbindungsprobleme - Lösungen + +## Problem +WebSocket-Verbindungen (ws:// oder wss://) werden oft von Unternehmensnetzwerken blockiert: +- Firewalls blockieren WebSocket-Protokoll +- Proxies unterstützen kein WebSocket-Upgrade +- Nur HTTP/HTTPS Ports (80/443) sind erlaubt + +## Implementierte Lösung +Der Activity Service verwendet jetzt **HTTP Long-Polling** statt WebSockets: +- Funktioniert über Standard HTTP +- Umgeht WebSocket-Blockierungen +- Etwas höhere Latenz, aber zuverlässiger in restriktiven Netzwerken + +## Weitere Lösungsansätze + +### 1. Proxy-Konfiguration +Wenn Ihr Netzwerk einen HTTP-Proxy verwendet: +```python +# In services/activity_sync.py +import os +os.environ['HTTP_PROXY'] = 'http://proxy.company.com:8080' +os.environ['HTTPS_PROXY'] = 'http://proxy.company.com:8080' +``` + +### 2. Alternative Ports +Fragen Sie Ihren Admin, ob der Activity Server auch auf Port 80 oder 443 laufen kann. + +### 3. VPN verwenden +Wenn verfügbar, kann eine VPN-Verbindung die Netzwerkbeschränkungen umgehen. + +### 4. Lokaler Activity Server +Für Entwicklung/Test einen lokalen Server verwenden: +- Server URL: `http://localhost:3001` +- Keine Netzwerkbeschränkungen + +## Test der Verbindung +1. Browser-Test: http://91.99.192.14:3001/health +2. Curl-Test: `curl http://91.99.192.14:3001/health` +3. Python-Test-Skript: `python test_activity_connection.py` + +## Firewall-Regeln +Falls Sie Admin-Rechte haben, erlauben Sie: +- Ausgehende TCP-Verbindungen zu 91.99.192.14:3001 +- HTTP/HTTPS Traffic zu diesem Server + +## Status in CPM +- Grüner Punkt: Verbindung aktiv +- Meldung "Nicht verbunden": Server nicht erreichbar +- Die Aktivitätsfunktion ist optional - CPM funktioniert vollständig ohne sie \ No newline at end of file diff --git a/app-icon.ico b/app-icon.ico new file mode 100644 index 0000000..0c162a8 --- /dev/null +++ b/app-icon.ico @@ -0,0 +1,12 @@ +This is a placeholder ICO file. To properly convert the SVG to ICO format, you'll need to: + +1. Use an online converter like: + - https://convertio.co/de/svg-ico/ + - https://cloudconvert.com/svg-to-ico + +2. Or install ImageMagick and run: + convert app-icon.svg -resize 256x256 app-icon.ico + +3. Or use a tool like Inkscape to export as ICO + +The SVG file (app-icon.svg) contains a blue square icon that you can convert. \ No newline at end of file diff --git a/app-icon.svg b/app-icon.svg new file mode 100644 index 0000000..ee2b243 --- /dev/null +++ b/app-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..3a5a883 --- /dev/null +++ b/build.bat @@ -0,0 +1,35 @@ +@echo off +title Building Claude Project Manager +echo Building Claude Project Manager executable... +echo. + +:: Check if Python is installed +python --version >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python is not installed or not in PATH + echo Please install Python 3.8 or higher + pause + exit /b 1 +) + +:: Install PyInstaller if not already installed +pip show pyinstaller >nul 2>&1 +if errorlevel 1 ( + echo Installing PyInstaller... + pip install pyinstaller + echo. +) + +:: Install other requirements +echo Installing requirements... +pip install -r requirements.txt >nul 2>&1 + +:: Run the build script +echo. +echo Building executable... +python build_exe.py + +echo. +echo Build process completed! +echo Check the 'dist' folder for the executable. +pause \ No newline at end of file diff --git a/build_exe.py b/build_exe.py new file mode 100644 index 0000000..2a1c88d --- /dev/null +++ b/build_exe.py @@ -0,0 +1,48 @@ +""" +Build executable for Claude Project Manager +""" + +import PyInstaller.__main__ +import os +import sys + +def build_exe(): + """Build executable using PyInstaller""" + + # Get the directory of this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # PyInstaller arguments + args = [ + 'main.py', # Main script + '--name=ClaudeProjectManager', # Name of the executable + '--onefile', # Create a single executable file + '--windowed', # No console window + '--icon=NONE', # No icon for now + '--add-data=data;data', # Include data directory + '--add-data=gui;gui', # Include gui directory + '--add-data=utils;utils', # Include utils directory for logger + '--add-data=src;src', # Include src directory with gitea modules + '--hidden-import=customtkinter', + '--hidden-import=tkinter', + '--hidden-import=PIL', + '--hidden-import=packaging', + '--hidden-import=requests', + '--hidden-import=urllib3', + '--hidden-import=git', + '--collect-all=customtkinter', # Collect all customtkinter files + '--noconfirm', # Overwrite output directory without confirmation + '--clean', # Clean PyInstaller cache and remove temporary files + ] + + # Run PyInstaller + try: + PyInstaller.__main__.run(args) + print("\nBuild completed successfully!") + print(f"Executable can be found in: {os.path.join(script_dir, 'dist')}") + except Exception as e: + print(f"Build failed: {e}") + sys.exit(1) + +if __name__ == "__main__": + build_exe() \ No newline at end of file diff --git a/clone_fix.py b/clone_fix.py new file mode 100644 index 0000000..9ad8020 --- /dev/null +++ b/clone_fix.py @@ -0,0 +1,28 @@ +""" +Temporäre Lösung für das Clone-Problem mit Organization-Repositories +""" + +def apply_temporary_fix(): + """ + Diese Funktion zeigt die Zeilen, die temporär geändert werden können, + um das Clone-Problem zu umgehen. + """ + + print("TEMPORÄRE LÖSUNG für das Clone-Problem:") + print("=" * 50) + print("\nOption 1: Organization-Prüfung komplett deaktivieren") + print("In gui/main_window.py, Zeile 1431 ändern von:") + print(" if repo_owner and repo_owner != self.repo_manager.current_user.get('username'):") + print("\nZu:") + print(" if False: # Temporär deaktiviert") + print("\n" + "-" * 50) + print("\nOption 2: Nur für IntelSight erlauben") + print("In gui/main_window.py, Zeile 1439 ändern von:") + print(" if repo_owner not in org_names:") + print("\nZu:") + print(" if repo_owner not in org_names and repo_owner != 'IntelSight':") + print("\n" + "=" * 50) + print("\nNach der Änderung muss die Anwendung neu gestartet werden.") + +if __name__ == "__main__": + apply_temporary_fix() \ No newline at end of file diff --git a/data/projects.json b/data/projects.json new file mode 100644 index 0000000..9702c5a --- /dev/null +++ b/data/projects.json @@ -0,0 +1,62 @@ +{ + "projects": [ + { + "id": "vps-permanent", + "name": "VPS Server", + "path": "claude-dev@91.99.192.14", + "created_at": "2025-07-01T20:14:48.308074", + "last_accessed": "2025-07-07T14:42:04.952013", + "readme_path": "claude-dev@91.99.192.14\\CLAUDE_PROJECT_README.md", + "description": "Remote VPS Server with Claude", + "tags": [ + "vps", + "remote", + "server" + ], + "gitea_repo": null + }, + { + "id": "admin-panel-permanent", + "name": "Admin Panel", + "path": "/opt/v2-Docker", + "created_at": "2025-07-02T11:07:12.516091", + "last_accessed": "2025-07-02T11:45:26.857409", + "readme_path": "/opt/v2-Docker\\CLAUDE_PROJECT_README.md", + "description": "V2 Docker Admin Panel", + "tags": [ + "admin", + "docker", + "v2" + ], + "gitea_repo": null + }, + { + "id": "vps-docker-permanent", + "name": "Docker Restart", + "path": "/opt/v2-Docker/v2", + "created_at": "2025-07-02T16:15:00.000000", + "last_accessed": "2025-07-02T16:25:36.888108", + "readme_path": "/opt/v2-Docker/v2\\CLAUDE_PROJECT_README.md", + "description": "Docker Admin Panel Restart", + "tags": [ + "vps", + "docker", + "admin", + "restart" + ], + "gitea_repo": null + }, + { + "id": "66c0f4bb-c560-43a6-a4c5-a8ecf8b73919", + "name": "ClaudeProjectManager-main", + "path": "C:/Users/hendr/Desktop/IntelSight/ClaudeProjectManager-main", + "created_at": "2025-07-07T21:38:23.820122", + "last_accessed": "2025-07-07T21:50:23.795214", + "readme_path": "C:/Users/hendr/Desktop/IntelSight/ClaudeProjectManager-main\\CLAUDE_PROJECT_README.md", + "description": "", + "tags": [], + "gitea_repo": null + } + ], + "last_updated": "2025-07-07T21:50:23.795214" +} \ No newline at end of file diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..aff49e6 --- /dev/null +++ b/data/settings.json @@ -0,0 +1,3 @@ +{ + "theme": "light" +} \ No newline at end of file diff --git a/data/vps_readme/VPS_README.md b/data/vps_readme/VPS_README.md new file mode 100644 index 0000000..2021061 --- /dev/null +++ b/data/vps_readme/VPS_README.md @@ -0,0 +1,63 @@ +# Claude VPS Server + +*This README was automatically generated by Claude Project Manager* + +## Server Information + +- **Server IP**: 91.99.192.14 +- **Username**: claude-dev +- **Connection**: SSH (Port 22) + +## How to Connect + +### Using Claude Project Manager +1. Click on the VPS Server tile +2. Terminal will open with connection instructions +3. Enter the password when prompted +4. Type `claude` after successful login + +### Manual Connection +```bash +ssh claude-dev@91.99.192.14 +``` + +## Available Commands + +After connecting to the VPS: +- `claude` - Start Claude CLI +- `ls` - List files +- `cd ` - Change directory +- `exit` - Close SSH connection + +## Security Notes + +- Keep your password secure +- Don't share SSH credentials +- Always logout when finished (`exit` command) + +## Features + +This VPS server provides: +- Remote access to Claude +- Persistent environment +- Isolated workspace +- Full Linux environment + +## Troubleshooting + +### Connection Issues +1. Check internet connection +2. Verify server is online +3. Ensure SSH port (22) is not blocked +4. Try manual SSH command + +### Authentication Failed +- Verify password is correct +- Check username spelling +- Ensure caps lock is off + +--- + +## Connection Log + +- README generated on 2025-07-07 14:42:04 diff --git a/duplicate_methods_analysis.md b/duplicate_methods_analysis.md new file mode 100644 index 0000000..1fe0c40 --- /dev/null +++ b/duplicate_methods_analysis.md @@ -0,0 +1,540 @@ +# Duplicate Methods Analysis Report - gui/main_window.py + +## Overview +This report analyzes the duplicate methods found in `gui/main_window.py` to determine their differences and whether they can be safely consolidated. + +## 1. `manage_branches` (line 1374 vs line 2336) + +### First Version (line 1374-1377) +```python +def manage_branches(self, project): + """Manage git branches""" + messagebox.showinfo("Branches", "Branch management coming soon") +``` + +### Second Version (line 2336-2391) +```python +def manage_branches(self, project): + """Manage branches for a project""" + from tkinter import simpledialog + + git_ops = self.repo_manager.git_ops + + # Get current branches + success, branches = git_ops.branch(Path(project.path), list_all=True) + + if success: + current_branch = None + branch_list = [] + for line in branches.strip().split('\n'): + if line.startswith('*'): + current_branch = line[2:].strip() + branch_list.append(f"{line.strip()} (aktuell)") + else: + branch_list.append(line.strip()) + + # Show options + action = messagebox.askyesnocancel( + "Branch-Verwaltung", + f"Aktuelle Branches:\n{chr(10).join(branch_list)}\n\n" + "Ja = Neuen Branch erstellen\n" + "Nein = Zu anderem Branch wechseln\n" + "Abbrechen = Schließen" + ) + + if action is True: # Create new branch + branch_name = simpledialog.askstring( + "Neuer Branch", + "Name des neuen Branches:", + parent=self.root + ) + if branch_name: + success, result = git_ops.checkout(Path(project.path), branch_name, create=True) + if success: + messagebox.showinfo("Erfolg", f"Branch '{branch_name}' erstellt und gewechselt") + else: + messagebox.showerror("Fehler", f"Branch-Erstellung fehlgeschlagen: {result}") + + elif action is False: # Switch branch + branch_name = simpledialog.askstring( + "Branch wechseln", + "Zu welchem Branch wechseln?", + parent=self.root + ) + if branch_name: + success, result = git_ops.checkout(Path(project.path), branch_name) + if success: + messagebox.showinfo("Erfolg", f"Zu Branch '{branch_name}' gewechselt") + else: + messagebox.showerror("Fehler", f"Branch-Wechsel fehlgeschlagen: {result}") + else: + messagebox.showerror("Fehler", f"Konnte Branches nicht abrufen: {branches}") +``` + +### Differences +- **First version**: Placeholder implementation showing "coming soon" message +- **Second version**: Full implementation with branch listing, creation, and switching functionality +- **Language**: First version uses English, second version uses German + +### Called From +- Line 1516: `self.manage_branches(project)` in `gitea_operation` method + +### Recommendation +**Safe to consolidate**: Keep the second version (line 2336) as it contains the actual implementation. The first version is just a placeholder. + +--- + +## 2. `link_to_gitea` (line 1378 vs line 2392) + +### First Version (line 1378-1381) +```python +def link_to_gitea(self, project): + """Link local project to Gitea repository""" + messagebox.showinfo("Link", "Link functionality coming soon") +``` + +### Second Version (line 2392-2456) +```python +def link_to_gitea(self, project): + """Link local project to Gitea repository""" + from tkinter import simpledialog + + # Ask for repository name + repo_name = simpledialog.askstring( + "Mit Gitea verknüpfen", + "Name des Gitea-Repositories:", + initialvalue=project.name, + parent=self.root + ) + + if repo_name: + git_ops = self.repo_manager.git_ops + + # Check if repo exists on Gitea + try: + repo = self.repo_manager.client.get_repository( + self.repo_manager.client.config.username, + repo_name + ) + + # Add remote + success, msg = git_ops.add_remote_to_existing_repo( + Path(project.path), + self.repo_manager.client.config.username, + repo_name + ) + + if success: + # Update project with Gitea repo reference + project.gitea_repo = f"{self.repo_manager.client.config.username}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + messagebox.showinfo("Erfolg", f"Erfolgreich mit Repository '{repo_name}' verknüpft!") + else: + messagebox.showerror("Fehler", f"Verknüpfung fehlgeschlagen: {msg}") + + except Exception as e: + # Repository doesn't exist, offer to create + create = messagebox.askyesno( + "Repository nicht gefunden", + f"Repository '{repo_name}' existiert nicht.\n\nMöchten Sie es erstellen?" + ) + + if create: + try: + # Create repository + new_repo = self.repo_manager.create_repository(repo_name, auto_init=False) + + # Add remote + success, msg = git_ops.add_remote_to_existing_repo( + Path(project.path), + self.repo_manager.client.config.username, + repo_name + ) + + if success: + # Update project + project.gitea_repo = f"{self.repo_manager.client.config.username}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + messagebox.showinfo("Erfolg", + f"Repository '{repo_name}' erstellt und verknüpft!") + else: + messagebox.showerror("Fehler", f"Verknüpfung fehlgeschlagen: {msg}") + + except Exception as create_error: + messagebox.showerror("Fehler", + f"Repository-Erstellung fehlgeschlagen: {str(create_error)}") +``` + +### Differences +- **First version**: Placeholder implementation showing "coming soon" message +- **Second version**: Full implementation with repository linking, checking existence, and creation option +- **Language**: First version uses English, second version uses German + +### Called From +- Line 1514: `self.link_to_gitea(project)` in `gitea_operation` method + +### Recommendation +**Safe to consolidate**: Keep the second version (line 2392) as it contains the actual implementation. The first version is just a placeholder. + +--- + +## 3. `test_gitea_connection` (line 1757 vs line 2457) + +### First Version (line 1757-1791) +```python +def test_gitea_connection(self, project): + """Test Gitea connection and show permissions""" + try: + # Test API connection + user_info = self.repo_manager.client.get_user_info() + + info = "Gitea-Verbindung erfolgreich!\n\n" + info += f"Benutzer: {user_info.get('username', 'Unknown')}\n" + info += f"E-Mail: {user_info.get('email', 'Unknown')}\n" + info += f"Admin: {'Ja' if user_info.get('is_admin', False) else 'Nein'}\n\n" + + # Check organizations + orgs = self.repo_manager.client.list_user_organizations() + if orgs: + info += "Organisationen:\n" + for org in orgs: + org_name = org.get('username', org.get('name', 'Unknown')) + info += f" • {org_name}" + + # Check teams/permissions in org + teams = self.repo_manager.client.get_user_teams_in_org(org_name) + if teams: + team_names = [t.get('name', 'Unknown') for t in teams] + info += f" (Teams: {', '.join(team_names)})" + info += "\n" + else: + info += "Keine Organisationen\n" + + info += f"\nServer: {self.repo_manager.client.config.base_url}" + + messagebox.showinfo("Verbindungstest", info) + + except Exception as e: + messagebox.showerror("Verbindungsfehler", f"Konnte keine Verbindung zu Gitea herstellen:\n\n{str(e)}") +``` + +### Second Version (line 2457-2554) +```python +def test_gitea_connection(self, project=None): + """Test Gitea connection and show detailed information""" + try: + # Test basic connection + user_info = self.repo_manager.client.get_user_info() + username = user_info.get('username', 'Unknown') + + # Get organizations + orgs = self.repo_manager.client.list_user_organizations() + org_names = [org['username'] for org in orgs] + + # Build info message + info = f"✅ Gitea Verbindung erfolgreich!\n\n" + info += f"Benutzer: {username}\n" + info += f"Server: {self.repo_manager.client.config.base_url}\n" + info += f"Organisationen: {', '.join(org_names) if org_names else 'Keine'}\n\n" + + # Check organization permissions if in org mode + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.view_mode == "organization": + org_name = self.gitea_explorer.organization_name + if org_name: + teams = self.repo_manager.client.get_user_teams_in_org(org_name) + if teams: + info += f"Teams in {org_name}:\n" + for team in teams: + info += f" - {team.get('name', 'Unknown')} " + perms = [] + if team.get('can_create_org_repo'): + perms.append("kann Repos erstellen") + if team.get('permission') == 'admin': + perms.append("Admin") + elif team.get('permission') == 'write': + perms.append("Schreiben") + elif team.get('permission') == 'read': + perms.append("Lesen") + info += f"({', '.join(perms) if perms else 'keine Rechte'})\n" + else: + info += f"⚠️ Keine Teams in Organisation {org_name} gefunden!\n" + info += "Dies könnte der Grund sein, warum Repositories nicht erstellt werden können.\n" + + info += "\n" + + # If we have a project, show its remote info + if project: + project_path = Path(project.path) if hasattr(project, 'path') else Path(project['path']) + if project_path.exists(): + success, remotes = self.repo_manager.git_ops.remote_list(project_path) + if success and remotes: + info += f"Git Remote URLs:\n{remotes}\n\n" + + # Check if .git exists + if (project_path / '.git').exists(): + # Get current branch + success, branch_out = self.repo_manager.git_ops._run_git_command( + ["git", "branch", "--show-current"], cwd=project_path + ) + if success: + info += f"Aktueller Branch: {branch_out.strip()}\n" + + # List repositories in current mode + info += "Repositories:\n" + try: + if hasattr(self, 'gitea_explorer'): + if self.gitea_explorer.view_mode == "organization" and self.gitea_explorer.organization_name: + # List org repos + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + info += f"In Organisation {self.gitea_explorer.organization_name}: {len(org_repos)} Repositories\n" + for repo in org_repos[:5]: # Show first 5 + info += f" - {repo['name']}\n" + if len(org_repos) > 5: + info += f" ... und {len(org_repos) - 5} weitere\n" + else: + # List user repos + user_repos = self.repo_manager.list_all_repositories() + info += f"Benutzer Repositories: {len(user_repos)}\n" + for repo in user_repos[:5]: # Show first 5 + info += f" - {repo['name']} (Owner: {repo.get('owner', {}).get('username', 'Unknown')})\n" + if len(user_repos) > 5: + info += f" ... und {len(user_repos) - 5} weitere\n" + except Exception as e: + info += f"Fehler beim Abrufen der Repositories: {str(e)}\n" + + # Show log file location + log_file = Path.home() / ".claude_project_manager" / "gitea_operations.log" + info += f"\nLog-Datei: {log_file}" + + messagebox.showinfo("Gitea Verbindungstest", info) + + except Exception as e: + error_msg = f"❌ Gitea Verbindung fehlgeschlagen!\n\n" + error_msg += f"Fehler: {str(e)}\n\n" + error_msg += f"Server: {self.repo_manager.client.config.base_url}\n" + error_msg += "\nBitte prüfen Sie:\n" + error_msg += "- Netzwerkverbindung\n" + error_msg += "- API Token Gültigkeit\n" + error_msg += "- Server Erreichbarkeit" + + messagebox.showerror("Gitea Verbindungstest", error_msg) +``` + +### Key Differences +- **Parameter**: First version requires `project`, second version has `project=None` (optional) +- **Detail level**: Second version shows much more information including: + - Team permissions with detailed permission levels + - Project-specific git remote info if project is provided + - Current branch information + - Repository listings (user/org mode aware) + - Log file location +- **UI elements**: First version shows email and admin status, second version doesn't +- **Error handling**: Second version has more detailed error messages with checkmark/cross emojis + +### Called From +- Line 1522: `self.test_gitea_connection(project)` in `gitea_operation` method + +### Recommendation +**Requires careful consolidation**: The second version is more comprehensive but has an optional parameter. Since the call from `gitea_operation` always passes a project, we should keep the enhanced functionality of the second version but ensure it works correctly when a project is provided. + +--- + +## 4. `verify_repository_on_gitea` (line 1792 vs line 2556) + +### First Version (line 1792-1849+) +```python +def verify_repository_on_gitea(self, project): + """Verify if repository exists on Gitea and show detailed info""" + import re + from pathlib import Path + + project_path = Path(project.path) + + # Check if it's a git repo + if not (project_path / ".git").exists(): + messagebox.showinfo("Info", "Dies ist kein Git-Repository.") + return + + # Get remote info + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + + if not success or not remotes: + messagebox.showinfo("Info", "Kein Remote-Repository konfiguriert.") + return + + # Extract owner and repo name from remote URL + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if not match: + messagebox.showwarning("Warnung", f"Konnte Repository-Info nicht aus Remote-URL extrahieren:\n{remotes}") + return + + remote_owner = match.group(1) + remote_repo = match.group(2) + + info = f"Suche Repository: {remote_owner}/{remote_repo}\n\n" + + # Check if repo exists under detected owner + try: + repo = self.repo_manager.client.get_repository(remote_owner, remote_repo) + info += "✅ Repository gefunden!\n\n" + info += f"Name: {repo.get('name', 'Unknown')}\n" + info += f"Owner: {repo.get('owner', {}).get('username', 'Unknown')}\n" + info += f"URL: {repo.get('html_url', 'Unknown')}\n" + info += f"Clone URL: {repo.get('clone_url', 'Unknown')}\n" + info += f"Privat: {'Ja' if repo.get('private', False) else 'Nein'}\n" + info += f"Größe: {repo.get('size', 0) / 1024:.1f} KB\n" + info += f"Default Branch: {repo.get('default_branch', 'Keiner')}\n" + info += f"Erstellt: {repo.get('created_at', 'Unknown')}\n" + info += f"Zuletzt aktualisiert: {repo.get('updated_at', 'Unknown')}\n" + + # Check for content + has_content = repo.get('size', 0) > 0 or repo.get('default_branch') + if not has_content: + info += "\n⚠️ WARNUNG: Repository scheint leer zu sein!\n" + + except Exception as e: + info += f"❌ Repository nicht unter {remote_owner}/{remote_repo} gefunden!\n" + info += f"Fehler: {str(e)}\n\n" + + # Search in all locations + info += "Suche in allen verfügbaren Orten:\n\n" + + # Check user repos + # ... (continues beyond visible range) +``` + +### Second Version (line 2556-2656) +```python +def verify_repository_on_gitea(self, project): + """Verify if repository exists on Gitea and show detailed info""" + project_name = project.name if hasattr(project, 'name') else project.get('name', 'Unknown') + project_path = Path(project.path) if hasattr(project, 'path') else Path(project['path']) + + info = f"Repository Verifizierung für: {project_name}\n" + info += "=" * 50 + "\n\n" + + # Check git remotes + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + if success and remotes: + info += f"Git Remote URLs:\n{remotes}\n\n" + + # Extract repo name and owner from remote + import re + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if match: + remote_owner = match.group(1) + remote_repo = match.group(2) + info += f"Remote zeigt auf: {remote_owner}/{remote_repo}\n\n" + + # Search for repository + info += "Suche Repository auf Gitea:\n" + + # 1. Check exact location + try: + repo = self.repo_manager.client.get_repository(remote_owner, remote_repo) + info += f"✅ Gefunden unter {remote_owner}/{remote_repo}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + info += f" Größe: {repo.get('size', 0)} bytes\n" + info += f" Erstellt: {repo.get('created_at', 'Unknown')}\n" + info += f" Aktualisiert: {repo.get('updated_at', 'Unknown')}\n" + info += f" Default Branch: {repo.get('default_branch', 'Unknown')}\n" + info += f" Privat: {'Ja' if repo.get('private') else 'Nein'}\n" + except Exception as e: + info += f"❌ Nicht gefunden unter {remote_owner}/{remote_repo}\n" + info += f" Fehler: {str(e)}\n" + + # 2. Search in all user repos + info += "\nSuche in allen Benutzer-Repositories:\n" + try: + user_repos = self.repo_manager.list_all_repositories() + matching_repos = [r for r in user_repos if r['name'] == remote_repo or r['name'] == project_name] + if matching_repos: + for repo in matching_repos: + info += f"✅ Gefunden: {repo['owner']['username']}/{repo['name']}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + else: + info += "❌ Nicht in Benutzer-Repositories gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen: {str(e)}\n" + + # 3. Search in organization + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + info += f"\nSuche in Organisation {self.gitea_explorer.organization_name}:\n" + try: + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + matching_repos = [r for r in org_repos if r['name'] == remote_repo or r['name'] == project_name] + if matching_repos: + for repo in matching_repos: + info += f"✅ Gefunden: {repo['name']}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + else: + info += "❌ Nicht in Organisation gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen: {str(e)}\n" + else: + info += "❌ Keine Git Remote gefunden!\n" + + # Check for debug file + debug_file = project_path / "gitea_push_debug.txt" + if debug_file.exists(): + info += f"\n\n📄 Debug-Datei gefunden: {debug_file}\n" + try: + with open(debug_file, 'r', encoding='utf-8') as f: + debug_content = f.read() + info += "Debug-Inhalt:\n" + "-" * 30 + "\n" + info += debug_content[:1000] # First 1000 chars + if len(debug_content) > 1000: + info += "\n... (gekürzt)" + except: + info += "Konnte Debug-Datei nicht lesen\n" + + # Show in scrollable dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Repository Verifizierung") + dialog.geometry("800x600") + + text_widget = ctk.CTkTextbox(dialog, wrap="word") + text_widget.pack(fill="both", expand=True, padx=10, pady=10) + text_widget.insert("1.0", info) + text_widget.configure(state="disabled") + + close_btn = ctk.CTkButton( + dialog, + text="Schließen", + command=dialog.destroy + ) + close_btn.pack(pady=10) +``` + +### Key Differences +- **Project access**: Second version handles both object attributes and dictionary access for project data +- **Pre-checks**: First version checks if it's a git repo and has remotes before proceeding +- **Display method**: First version shows info in messagebox, second version uses a scrollable CTkToplevel dialog +- **Additional features**: Second version includes: + - Debug file checking and display + - More structured repository search (exact location, user repos, org repos) + - Header with project name +- **Size display**: First version shows size in KB, second version in bytes + +### Called From +- Line 1524: `self.verify_repository_on_gitea(project)` in `gitea_operation` method +- Line 1987: `self.verify_repository_on_gitea(project)` in `fix_repository_issues` method (nested function `check_on_gitea`) + +### Recommendation +**Requires careful consolidation**: Both versions have valuable features. The second version's scrollable dialog is better for displaying detailed information, but the first version's pre-checks are important. Combine both: +- Keep pre-checks from first version +- Keep the enhanced display and debug file checking from second version +- Ensure consistent project data access + +--- + +## Summary and Recommendations + +1. **`manage_branches`**: Delete first version (placeholder), keep second version +2. **`link_to_gitea`**: Delete first version (placeholder), keep second version +3. **`test_gitea_connection`**: Merge both versions, keeping the enhanced functionality of the second while ensuring it works with required project parameter +4. **`verify_repository_on_gitea`**: Merge both versions, combining pre-checks from first with enhanced display from second + +All duplicates are called from the same `gitea_operation` method, so consolidation is safe. The first versions of `manage_branches` and `link_to_gitea` are just placeholders that were later implemented fully in the second versions. \ No newline at end of file diff --git a/gitea_push_debug.txt b/gitea_push_debug.txt new file mode 100644 index 0000000..a98a37b --- /dev/null +++ b/gitea_push_debug.txt @@ -0,0 +1,41 @@ +Push Debug Info - 2025-07-03 22:41:27.267926 +Repository: ClaudeProjectManager-main +Owner: IntelSight +Path: C:\Users\hendr\Desktop\IntelSight\ClaudeProjectManager-main +Current branch: master +Git remotes: +origin https://IntelSight_Admin:3b4a6ba1ade3f34640f3c85d2333b4a3a0627471@gitea-undso.intelsight.de/IntelSight/ClaudeProjectManager-main.git (fetch) +origin https://IntelSight_Admin:3b4a6ba1ade3f34640f3c85d2333b4a3a0627471@gitea-undso.intelsight.de/IntelSight/ClaudeProjectManager-main.git (push) +Git status before push: +?? .claude/ +?? CLAUDE_PROJECT_README.md +?? ClaudeProjectManager.spec +?? README.md +?? __pycache__/ +?? build.bat +?? build/ +?? build_exe.py +?? data/ +?? dist/ +?? gui/ +?? main.py +?? nul +?? process_manager.py +?? project_manager.py +?? project_process_tracker.py +?? readme_generator.py +?? requirements.txt +?? src/ +?? start.bat +?? terminal_launcher.py +?? test_git_detection.py +?? utils/ +?? vps_connection.py +Push command: git push --set-upstream origin master:main -v +Push result: Failed +Push stdout: + +Push stderr: +Pushing to https://gitea-undso.intelsight.de/IntelSight/ClaudeProjectManager-main.git +error: src refspec master does not match any +error: failed to push some refs to 'https://gitea-undso.intelsight.de/IntelSight/ClaudeProjectManager-main.git' \ No newline at end of file diff --git a/gui/config.py b/gui/config.py new file mode 100644 index 0000000..b74d731 --- /dev/null +++ b/gui/config.py @@ -0,0 +1,113 @@ +""" +Configuration for GUI refactoring +Manages feature flags and refactoring settings +""" + +import os +import json +from pathlib import Path +from utils.logger import logger + +class RefactoringConfig: + """Manages refactoring feature flags""" + + CONFIG_FILE = "refactoring_config.json" + DEFAULT_FLAGS = { + 'USE_GITEA_HANDLER': True, # Enable for Progress Bar support + 'USE_PROCESS_HANDLER': True, # Enable for better logging + 'USE_PROJECT_HANDLER': False, + 'USE_UI_HELPERS': False, + 'ENABLE_DEBUG_LOGGING': False, + 'FORCE_ORIGINAL_IMPLEMENTATION': False # Emergency override + } + + def __init__(self): + self.config_path = Path.home() / ".claude_project_manager" / self.CONFIG_FILE + self.flags = self.DEFAULT_FLAGS.copy() + self._load_config() + self._check_env_overrides() + + def _load_config(self): + """Load configuration from file""" + try: + if self.config_path.exists(): + with open(self.config_path, 'r') as f: + loaded_flags = json.load(f) + # Only update known flags + for key, value in loaded_flags.items(): + if key in self.flags: + self.flags[key] = value + logger.info(f"Loaded refactoring flag: {key} = {value}") + except Exception as e: + logger.error(f"Failed to load refactoring config: {e}") + + def _check_env_overrides(self): + """Check for environment variable overrides""" + # Global override + if os.getenv('CPM_USE_NEW_HANDLERS'): + logger.info("Enabling all refactored handlers via environment variable") + for key in ['USE_GITEA_HANDLER', 'USE_PROCESS_HANDLER', + 'USE_PROJECT_HANDLER', 'USE_UI_HELPERS']: + self.flags[key] = True + + # Individual overrides + for flag in self.flags: + env_var = f"CPM_{flag}" + if os.getenv(env_var): + value = os.getenv(env_var).lower() in ('true', '1', 'yes') + self.flags[flag] = value + logger.info(f"Override from env: {flag} = {value}") + + def save_config(self): + """Save current configuration to file""" + try: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.config_path, 'w') as f: + json.dump(self.flags, f, indent=2) + logger.info(f"Saved refactoring config to {self.config_path}") + except Exception as e: + logger.error(f"Failed to save refactoring config: {e}") + + def get(self, flag_name: str, default=None): + """Get a flag value""" + return self.flags.get(flag_name, default) + + def set(self, flag_name: str, value: bool): + """Set a flag value""" + if flag_name in self.flags: + self.flags[flag_name] = value + logger.info(f"Set refactoring flag: {flag_name} = {value}") + else: + logger.warning(f"Unknown refactoring flag: {flag_name}") + + def enable_handler(self, handler_name: str): + """Enable a specific handler""" + # Map handler names to flags + handler_map = { + 'gitea': 'USE_GITEA_HANDLER', + 'process': 'USE_PROCESS_HANDLER', + 'project': 'USE_PROJECT_HANDLER', + 'ui': 'USE_UI_HELPERS' # Note: not _HANDLER suffix + } + flag_name = handler_map.get(handler_name.lower()) + if flag_name and flag_name in self.flags: + self.set(flag_name, True) + else: + logger.warning(f"Unknown handler: {handler_name}") + + def disable_all(self): + """Disable all refactoring features""" + for flag in self.flags: + if flag.startswith('USE_'): + self.flags[flag] = False + logger.info("All refactoring features disabled") + + def get_status(self): + """Get status of all flags""" + return { + "config_file": str(self.config_path), + "flags": self.flags.copy() + } + +# Global instance +refactoring_config = RefactoringConfig() \ No newline at end of file diff --git a/gui/gitea_explorer.py b/gui/gitea_explorer.py new file mode 100644 index 0000000..b965709 --- /dev/null +++ b/gui/gitea_explorer.py @@ -0,0 +1,536 @@ +""" +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 + +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 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 + local_path = Path.home() / "GiteaRepos" / 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("", on_enter) + item_frame.bind("", 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("", 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 \ No newline at end of file diff --git a/gui/gitea_toolbar.py b/gui/gitea_toolbar.py new file mode 100644 index 0000000..7583c4c --- /dev/null +++ b/gui/gitea_toolbar.py @@ -0,0 +1,319 @@ +""" +Gitea Toolbar for context-sensitive Git operations +""" + +import customtkinter as ctk +from tkinter import messagebox +import threading +from pathlib import Path +from typing import Optional, Callable, Dict +from gui.styles import COLORS, FONTS + +class GiteaToolbar(ctk.CTkFrame): + def __init__(self, parent, **kwargs): + super().__init__(parent, fg_color=COLORS['bg_secondary'], height=60, **kwargs) + self.pack_propagate(False) + + # Callbacks + self.callbacks: Dict[str, Optional[Callable]] = { + 'commit': None, + 'push': None, + 'pull': None, + 'fetch': None, + 'status': None, + 'branch': None, + 'link': None, + 'clone': None, + 'create_project': None + } + + # Current context + self.current_context = None # 'local_project', 'gitea_repo', or None + self.current_item = None # Project or Repo object + + # Animation + self.is_visible = False + self.target_height = 60 + self.current_height = 0 + + # Create buttons container + self.button_container = ctk.CTkFrame(self, fg_color="transparent") + self.button_container.pack(fill="both", expand=True, padx=10, pady=8) + + # Status label + self.status_label = ctk.CTkLabel( + self.button_container, + text="", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.status_label.pack(side="left", padx=(0, 20)) + + # Button groups + self.button_groups = { + 'local_project': [], + 'gitea_repo': [] + } + + self.setup_buttons() + + def setup_buttons(self): + """Setup all buttons for different contexts""" + # Buttons for local project context + local_buttons = [ + ("📊 Status", "status", "Git-Status anzeigen\nZeigt uncommittete Änderungen"), + ("💾 Commit", "commit", "Änderungen speichern\nLokale Änderungen committen"), + ("📤 Push", "push", "Zu Gitea hochladen\nCommits zum Server pushen"), + ("📥 Pull", "pull", "Änderungen abrufen\nNeueste Änderungen vom Server"), + ("🔗 Verknüpfen", "link", "Mit Gitea verknüpfen\nLokales Projekt mit Repository verbinden"), + ("🌿 Branch", "branch", "Branch-Verwaltung\nBranches anzeigen und wechseln") + ] + + # Buttons for Gitea repo context + gitea_buttons = [ + ("📥 Clone", "clone", "Repository klonen\nLokale Kopie erstellen und Projekt anlegen"), + ("🔄 Fetch", "fetch", "Updates prüfen\nNeueste Änderungen abrufen"), + ("📊 Info", "status", "Repository-Info\nDetails zum Repository anzeigen") + ] + + # Create button frames + self.local_frame = ctk.CTkFrame(self.button_container, fg_color="transparent") + self.gitea_frame = ctk.CTkFrame(self.button_container, fg_color="transparent") + + # Create local project buttons + for text, callback_name, tooltip in local_buttons: + btn = self.create_button(self.local_frame, text, callback_name, tooltip) + self.button_groups['local_project'].append(btn) + + # Create Gitea repo buttons + for text, callback_name, tooltip in gitea_buttons: + btn = self.create_button(self.gitea_frame, text, callback_name, tooltip) + self.button_groups['gitea_repo'].append(btn) + + def create_button(self, parent, text, callback_name, tooltip): + """Create a button with tooltip""" + btn = ctk.CTkButton( + parent, + text=text, + command=lambda: self.execute_callback(callback_name), + width=120, + height=36, + fg_color=COLORS['accent_secondary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF", + font=FONTS['body'] + ) + btn.pack(side="left", padx=4) + + # Create tooltip + self.create_tooltip(btn, tooltip) + + return btn + + def create_tooltip(self, widget, text): + """Create a tooltip for a widget""" + def on_enter(event): + # Destroy any existing tooltip first + if hasattr(widget, 'tooltip') and widget.tooltip: + try: + widget.tooltip.destroy() + except: + pass + + tooltip = ctk.CTkToplevel(widget) + tooltip.wm_overrideredirect(True) + tooltip.wm_geometry(f"+{event.x_root + 10}+{event.y_root + 10}") + + label = ctk.CTkLabel( + tooltip, + text=text, + font=FONTS['small'], + fg_color=COLORS['bg_secondary'], + text_color=COLORS['text_primary'], + corner_radius=6, + padx=10, + pady=5 + ) + label.pack() + + widget.tooltip = tooltip + + def on_leave(event): + if hasattr(widget, 'tooltip') and widget.tooltip: + try: + widget.tooltip.destroy() + widget.tooltip = None + except: + pass + + # Also destroy tooltip when button is clicked + def on_click(event): + if hasattr(widget, 'tooltip') and widget.tooltip: + try: + widget.tooltip.destroy() + widget.tooltip = None + except: + pass + + widget.bind("", on_enter) + widget.bind("", on_leave) + widget.bind("", on_click, add=True) + + def set_callback(self, name: str, callback: Callable): + """Set a callback for a button""" + if name in self.callbacks: + self.callbacks[name] = callback + + def execute_callback(self, name: str): + """Execute a callback if set""" + if name in self.callbacks and self.callbacks[name]: + # Run in thread to avoid blocking UI + threading.Thread( + target=self.callbacks[name], + args=(self.current_item,), + daemon=True + ).start() + + def show(self): + """Show the toolbar""" + if not self.is_visible: + self.animate_show() + + def show_for_context(self, context: str, item=None, status_text: str = ""): + """Show toolbar for specific context""" + if context not in ['local_project', 'gitea_repo']: + self.hide() + return + + self.current_context = context + self.current_item = item + + # Update status + self.status_label.configure(text=status_text) + + # Hide all frames + self.local_frame.pack_forget() + self.gitea_frame.pack_forget() + + # Show relevant frame + if context == 'local_project': + self.local_frame.pack(side="left", padx=10) + elif context == 'gitea_repo': + self.gitea_frame.pack(side="left", padx=10) + + # Animate show + if not self.is_visible: + self.animate_show() + + def hide(self): + """Hide the toolbar""" + if self.is_visible: + self.animate_hide() + + def animate_show(self): + """Animate toolbar appearing""" + print("animate_show called") + self.is_visible = True + + # Pack the toolbar + try: + # Simple approach: just pack after any header that exists + parent_children = self.master.winfo_children() + print(f"Parent has {len(parent_children)} children") + + # Find the best position (after header, before content) + packed = False + for i, child in enumerate(parent_children): + # Look for a frame that might be the header + if isinstance(child, ctk.CTkFrame): + # Check if it's likely the header (has small height or contains title) + try: + if child.winfo_height() < 100 or any( + isinstance(w, ctk.CTkLabel) and "Claude Project Manager" in str(w.cget("text")) + for w in child.winfo_children() + ): + print(f"Packing toolbar after header at position {i}") + self.pack(fill="x", after=child, pady=(0, 10)) + packed = True + break + except: + pass + + if not packed: + print("Packing toolbar at default position") + self.pack(fill="x", pady=(0, 10)) + + except Exception as e: + print(f"Error showing toolbar: {e}") + import traceback + traceback.print_exc() + self.pack(fill="x", pady=(0, 10)) + + # Skip animation for now - just show full height + self.configure(height=self.target_height) + print(f"Toolbar configured with height {self.target_height}") + + def animate_hide(self): + """Animate toolbar disappearing""" + self.is_visible = False + # Destroy all tooltips before hiding + for group in self.button_groups.values(): + for btn in group: + if hasattr(btn, 'tooltip') and btn.tooltip: + try: + btn.tooltip.destroy() + btn.tooltip = None + except: + pass + # Skip animation for now - just hide immediately + self.pack_forget() + + def _animate_height(self, start, end, duration=200, callback=None): + """Animate height change""" + steps = 10 + step_duration = duration // steps + step_size = (end - start) / steps + + def animate_step(current_step): + if current_step <= steps: + new_height = start + (step_size * current_step) + self.configure(height=new_height) + self.after(step_duration, lambda: animate_step(current_step + 1)) + elif callback: + callback() + + animate_step(0) + + def set_selected_repo(self, repo): + """Set the selected repository and show appropriate buttons""" + self.show_for_context('gitea_repo', repo) + + def update_button_states(self, states: Dict[str, bool]): + """Update button enabled/disabled states""" + for context_buttons in self.button_groups.values(): + for btn in context_buttons: + # Extract callback name from button text + btn_text = btn.cget("text") + for callback_name in self.callbacks: + if callback_name in states: + # Simple matching - could be improved + btn.configure(state="normal" if states[callback_name] else "disabled") + + def refresh_colors(self): + """Refresh the toolbar's colors""" + from gui.styles import COLORS + + # Update frame color + self.configure(fg_color=COLORS['bg_secondary']) + + # Update status label + self.status_label.configure(text_color=COLORS['text_secondary']) + + # Update all buttons + for context_buttons in self.button_groups.values(): + for btn in context_buttons: + btn.configure( + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF" + ) \ No newline at end of file diff --git a/gui/handlers/__init__.py b/gui/handlers/__init__.py new file mode 100644 index 0000000..b785402 --- /dev/null +++ b/gui/handlers/__init__.py @@ -0,0 +1,16 @@ +""" +Handler modules for MainWindow refactoring +Separates concerns and reduces God Class anti-pattern +""" + +from .gitea_operations import GiteaOperationsHandler +from .process_manager import ProcessManagerHandler +from .project_manager import ProjectManagerHandler +from .ui_helpers import UIHelpersHandler + +__all__ = [ + 'GiteaOperationsHandler', + 'ProcessManagerHandler', + 'ProjectManagerHandler', + 'UIHelpersHandler' +] \ No newline at end of file diff --git a/gui/handlers/base_handler.py b/gui/handlers/base_handler.py new file mode 100644 index 0000000..facfe5e --- /dev/null +++ b/gui/handlers/base_handler.py @@ -0,0 +1,19 @@ +""" +Base Handler for common functionality +""" + +from typing import TYPE_CHECKING +from utils.logger import logger + +if TYPE_CHECKING: + from gui.main_window import MainWindow + + +class BaseHandler: + """Base class for all handlers""" + + def __init__(self, main_window: 'MainWindow'): + """Initialize with reference to main window""" + self.main_window = main_window + self.root = main_window.root + logger.info(f"{self.__class__.__name__} initialized") \ No newline at end of file diff --git a/gui/handlers/gitea_operations.py b/gui/handlers/gitea_operations.py new file mode 100644 index 0000000..168161e --- /dev/null +++ b/gui/handlers/gitea_operations.py @@ -0,0 +1,1891 @@ +""" +Gitea Operations Handler +Handles all Git and Gitea related operations +""" + +import os +import subprocess +import time +from datetime import datetime +from tkinter import messagebox +from typing import Optional, TYPE_CHECKING +from pathlib import Path +from utils.logger import logger +from gui.progress_bar import ProgressBar, GitOperationProgress + +if TYPE_CHECKING: + from gui.main_window import MainWindow + from project_manager import Project + + +class GiteaOperationsHandler: + """Handles all Gitea/Git related operations for MainWindow""" + + def __init__(self, main_window: 'MainWindow'): + """Initialize with reference to main window""" + self.main_window = main_window + self.root = main_window.root + self.repo_manager = main_window.repo_manager + self.project_manager = main_window.project_manager + self.process_tracker = main_window.process_tracker + logger.info("GiteaOperationsHandler initialized") + + def init_and_push_to_gitea(self, project: 'Project') -> bool: + """ + Initialize git repository and push to Gitea + Refactored into smaller, manageable methods + """ + from tkinter import simpledialog, messagebox + + # Step 1: Get repository name + repo_name = self._get_repository_name(project) + if not repo_name: + return False + + try: + # Step 2: Create repository on Gitea + repo = self._create_gitea_repository(repo_name) + if not repo: + return False + + # Step 3: Verify repository creation + if not self._verify_repository_creation(repo, repo_name): + return False + + # Step 4: Initialize local git repository + if not self._initialize_local_repository(project): + return False + + # Step 5: Check for large files + if not self._handle_large_files(project): + return False + + # Step 6: Commit and push + return self._commit_and_push(project, repo, repo_name) + + except Exception as e: + logger.error(f"Failed to init and push to Gitea: {e}") + messagebox.showerror("Fehler", f"Repository-Erstellung fehlgeschlagen: {str(e)}") + return False + + def _get_repository_name(self, project: 'Project') -> Optional[str]: + """Get repository name from user""" + from tkinter import simpledialog + + repo_name = simpledialog.askstring( + "Neues Repository", + f"Repository-Name für '{project.name}':", + initialvalue=project.name + ) + return repo_name + + def _create_gitea_repository(self, repo_name: str) -> Optional[dict]: + """Create repository on Gitea""" + from tkinter import messagebox + + # Determine organization if available + org_name = None + if hasattr(self.main_window, 'gitea_explorer') and self.main_window.gitea_explorer.organization_name: + org_name = self.main_window.gitea_explorer.organization_name + + # Create repository + try: + if org_name and (not hasattr(self.main_window, 'gitea_explorer') or + self.main_window.gitea_explorer.view_mode != "user"): + logger.info(f"Creating repository '{repo_name}' in organization '{org_name}'") + repo = self.repo_manager.create_repository(repo_name, auto_init=False, organization=org_name) + else: + logger.info(f"Creating repository '{repo_name}' as user repository") + repo = self.repo_manager.create_repository(repo_name, auto_init=False, organization=None) + + logger.info(f"Repository created: {repo}") + return repo + + except Exception as e: + logger.error(f"Failed to create repository: {e}") + messagebox.showerror("Fehler", f"Repository konnte nicht erstellt werden: {str(e)}") + return None + + def _verify_repository_creation(self, repo: dict, repo_name: str) -> bool: + """Verify repository was created correctly""" + from tkinter import messagebox + + repo_owner = repo.get('owner', {}).get('username', 'Unknown') + repo_url = repo.get('html_url', 'Unknown') + + # Check if repo was created in the correct place + org_name = None + if hasattr(self.main_window, 'gitea_explorer') and self.main_window.gitea_explorer.organization_name: + org_name = self.main_window.gitea_explorer.organization_name + + expected_owner = org_name if org_name else self.repo_manager.client.config.username + + if repo_owner != expected_owner: + messagebox.showwarning("Achtung", + f"Repository wurde unter falschem Owner erstellt!\n\n" + f"Erwartet: {expected_owner}\n" + f"Erstellt unter: {repo_owner}\n\n" + f"URL: {repo_url}") + else: + messagebox.showinfo("Repository erstellt", + f"Repository '{repo_name}' wurde erstellt.\n\n" + f"Owner: {repo_owner}\n" + f"URL: {repo_url}") + + return True + + def _initialize_local_repository(self, project: 'Project') -> bool: + """Initialize local git repository""" + from tkinter import messagebox + from pathlib import Path + + git_ops = self.repo_manager.git_ops + success, msg = git_ops.init_repository(Path(project.path)) + + if not success: + messagebox.showerror("Fehler", f"Git-Initialisierung fehlgeschlagen: {msg}") + return False + + return True + + def _handle_large_files(self, project: 'Project') -> bool: + """Check and handle large files""" + from tkinter import messagebox + from pathlib import Path + import os + + large_files = [] + + # Scan for large files + for root, dirs, files in os.walk(project.path): + if '.git' in root: + continue + + for file in files: + file_path = os.path.join(root, file) + try: + file_size = os.path.getsize(file_path) + if file_size > 50 * 1024 * 1024: # 50MB limit + size_mb = file_size / (1024 * 1024) + rel_path = os.path.relpath(file_path, project.path) + large_files.append((rel_path, size_mb)) + except: + pass + + if large_files: + msg = (f"⚠️ WARNUNG: Große Dateien gefunden!\n\n" + f"Gitea hat ein Upload-Limit. Folgende Dateien sind zu groß:\n\n") + + total_size = 0 + for i, (file, size_mb) in enumerate(large_files[:5]): + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size_mb + + if len(large_files) > 5: + msg += f" ... und {len(large_files) - 5} weitere\n" + + msg += f"\nGesamtgröße großer Dateien: {total_size:.1f} MB\n" + msg += ("\n❌ DIESE DATEIEN KÖNNEN NICHT ZU GITEA GEPUSHT WERDEN!\n\n" + "Optionen:\n" + "1. Abbrechen und große Dateien entfernen\n" + "2. Große Dateien zur .gitignore hinzufügen\n" + "3. Git LFS einrichten (fortgeschritten)\n\n" + "Trotzdem fortfahren? (Push wird fehlschlagen!)") + + if not messagebox.askyesno("Große Dateien gefunden", msg, icon='warning'): + # Offer to create .gitignore + if messagebox.askyesno("Hilfe", "Soll ich eine .gitignore-Datei mit den großen Dateien erstellen?"): + gitignore_path = Path(project.path) / ".gitignore" + with open(gitignore_path, 'a', encoding='utf-8') as f: + f.write("\n# Große Dateien automatisch hinzugefügt\n") + for file, _ in large_files: + f.write(f"{file}\n") + messagebox.showinfo("Erfolg", ".gitignore wurde aktualisiert. Bitte erneut versuchen.") + return False + + return True + + def _commit_and_push(self, project: 'Project', repo: dict, repo_name: str) -> bool: + """Add files, commit and push to Gitea""" + from tkinter import messagebox + from pathlib import Path + + git_ops = self.repo_manager.git_ops + project_path = Path(project.path) + + # Add all files + git_ops.add(project_path) + + # Initial commit + git_ops.commit(project_path, "Initial commit") + + # Determine owner + owner = self._determine_repository_owner(repo) + + logger.info(f"Pushing to repository with owner: {owner}, repo: {repo_name}") + + # Push to Gitea + success, msg = git_ops.push_existing_repo_to_gitea( + project_path, + owner, + repo_name + ) + + if success: + self._handle_successful_push(project, repo, repo_name, owner) + return True + else: + messagebox.showerror("Fehler", f"Push fehlgeschlagen: {msg}") + return False + + def _determine_repository_owner(self, repo: dict) -> str: + """Determine the repository owner""" + owner = None + + # Try to get owner from created repository + if 'owner' in repo and repo['owner']: + owner = repo['owner'].get('username', repo['owner'].get('login')) + + if not owner: + # Fallback to determining owner based on mode + if hasattr(self.main_window, 'gitea_explorer') and self.main_window.gitea_explorer.view_mode == "organization": + if self.main_window.gitea_explorer.organization_name: + owner = self.main_window.gitea_explorer.organization_name + else: + owner = self.repo_manager.client.config.username + + return owner + + def _handle_successful_push(self, project: 'Project', repo: dict, repo_name: str, owner: str): + """Handle successful push and update UI""" + from tkinter import messagebox + from pathlib import Path + + git_ops = self.repo_manager.git_ops + + # Get current branch + success_branch, current_branch = git_ops.branch(Path(project.path)) + current_branch = current_branch.strip().replace('* ', '') if success_branch else 'main' + + push_info = f"Push-Details:\n" + push_info += f"Repository: {repo_name}\n" + push_info += f"Owner: {owner}\n" + push_info += f"Branch: {current_branch} -> main\n\n" + + # Get remote URL + success_remote, remotes = git_ops.remote(Path(project.path), verbose=True) + if success_remote: + push_info += f"Remote URLs:\n{remotes}\n\n" + + # Verify repository on Gitea + try: + verify_repo = self.repo_manager.client.get_repo(owner, repo_name) + repo_url = verify_repo.get('html_url', 'Unknown') + clone_url = verify_repo.get('clone_url', 'Unknown') + + has_content = verify_repo.get('size', 0) > 0 or verify_repo.get('default_branch') + + push_info += f"Repository gefunden auf Gitea:\n" + push_info += f"URL: {repo_url}\n" + push_info += f"Clone URL: {clone_url}\n" + push_info += f"Hat Inhalt: {'Ja' if has_content else 'Nein'}\n" + push_info += f"Größe: {verify_repo.get('size', 0)} bytes\n" + push_info += f"Default Branch: {verify_repo.get('default_branch', 'Keiner')}\n\n" + + if not has_content: + push_info += "⚠️ WARNUNG: Repository existiert aber scheint leer zu sein!\n\n" + + push_info += "Bitte prüfen Sie:\n" + push_info += f"1. Öffnen Sie: {repo_url}\n" + push_info += f"2. Log-Datei: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + # Update project with Gitea repo reference + project.gitea_repo = f"{owner}/{repo_name}" + self.main_window.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + + messagebox.showinfo("Push abgeschlossen", push_info) + + except Exception as e: + push_info += f"\n⚠️ Konnte Repository nicht auf Gitea verifizieren:\n{str(e)}\n\n" + push_info += "Mögliche Ursachen:\n" + push_info += "- Repository wurde unter anderem Namen/Owner erstellt\n" + push_info += "- Berechtigungsprobleme\n" + push_info += "- Netzwerkprobleme\n\n" + push_info += f"Log-Datei prüfen: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + messagebox.showwarning("Push Status unklar", push_info) + + # Refresh Gitea explorer if exists + if hasattr(self.main_window, 'gitea_explorer'): + self.main_window.gitea_explorer.refresh_repositories() + + def push_to_gitea(self, project: 'Project') -> bool: + """ + Push existing repository to Gitea with progress bar + """ + import threading + from pathlib import Path + from tkinter import messagebox + + logger.info(f"Push to Gitea started for project: {project.name}") + + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Step 1: Verify git repository + if not self._verify_git_repository(project_path): + return False + + # Step 2: Check remote configuration + has_remote, remotes = self._check_remote_configuration(project_path) + if not has_remote: + return False + + # Step 3: Check for uncommitted changes + if not self._handle_uncommitted_changes(project_path): + return False + + # Step 4: Check for large files + if not self._check_and_handle_large_files(project_path): + return False + + # Show progress bar + progress_bar = ProgressBar(self.root, "Push zu Gitea") + result = {"success": False, "completed": False} + + def run_push(): + try: + stages = GitOperationProgress.get_stages('push') + + start_time = time.time() + min_display_time = 2.0 + + # Perform actual push early + logger.info(f"Executing git push for: {project_path}") + success, push_result = git_ops.push(project_path) + result["success"] = success + result["message"] = push_result + + # Calculate timing + elapsed = time.time() - start_time + remaining_time = max(0.1, min_display_time - elapsed) + stage_delay = remaining_time / len(stages) + + # Animate through stages + for i, (progress, status) in enumerate(stages): + self.root.after(int(i * stage_delay * 1000), + lambda p=progress, s=status: progress_bar.update_progress(p, s)) + + # Final update + def finish_push(): + if success: + progress_bar.update_progress(1.0, stages[-1][1]) + logger.info(f"Push completed successfully for {project.name}") + # Auto-closes after 0.5s + else: + logger.error(f"Push failed for {project.name}: {push_result}") + # Don't destroy immediately - will be handled by error state + result["completed"] = True + + self.root.after(int(min_display_time * 1000), finish_push) + + except Exception as e: + logger.error(f"Exception during push: {str(e)}", exc_info=True) + result["message"] = str(e) + result["completed"] = True + + # Run push in thread + thread = threading.Thread(target=run_push, daemon=True) + thread.start() + + # Wait for thread and all UI updates to complete + while not result.get("completed", False): + self.root.update() + time.sleep(0.05) + + # Handle result in main thread BEFORE destroying progress bar + if result["success"]: + progress_bar.destroy() + self._handle_successful_push_result(project_path, remotes) + return True + else: + # Destroy progress bar first to avoid blocking dialogs + progress_bar.destroy() + + # Small delay to ensure progress bar is fully destroyed + self.root.update_idletasks() + + # Check if error is due to remote changes + error_msg = result.get("message", "") + logger.info(f"Push failed with message: {error_msg}") + + if "fetch first" in error_msg or "rejected" in error_msg or "non-fast-forward" in error_msg: + from tkinter import messagebox + logger.info("Showing pull dialog for fetch first error") + response = messagebox.askyesno("Push fehlgeschlagen", + "Das Remote-Repository hat Änderungen, die lokal nicht vorhanden sind.\n\n" + "Möchten Sie jetzt einen Pull durchführen?") + if response: + # Perform pull + logger.info("User requested pull before push") + success, pull_result = git_ops.pull(project_path) + if success: + messagebox.showinfo("Pull erfolgreich", + "Die Änderungen wurden erfolgreich gepullt.\n\n" + "Versuchen Sie den Push erneut.") + else: + messagebox.showerror("Pull fehlgeschlagen", + f"Pull fehlgeschlagen:\n\n{pull_result}") + else: + # Show general error + from tkinter import messagebox + logger.info(f"Showing general error dialog: {error_msg}") + messagebox.showerror("Push fehlgeschlagen", + f"Push fehlgeschlagen:\n\n{error_msg}") + return False + + def _verify_git_repository(self, project_path: Path) -> bool: + """Verify that the project is a git repository""" + from tkinter import messagebox + + if not (project_path / ".git").exists(): + messagebox.showerror("Fehler", + "Dies ist kein Git-Repository!\n\n" + "Bitte verwenden Sie 'Init & Push' um ein neues Repository zu erstellen.") + return False + return True + + def _check_remote_configuration(self, project_path: Path) -> tuple[bool, str]: + """Check if remote is configured""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + + if not success or not remotes: + messagebox.showerror("Fehler", + "Kein Remote-Repository konfiguriert!\n\n" + "Verwenden Sie 'Link to Gitea' um das Repository zu verknüpfen.") + return False, "" + + return True, remotes + + def _handle_uncommitted_changes(self, project_path: Path) -> bool: + """Check and handle uncommitted changes""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + success, status = git_ops.status(project_path) + + if success and "nothing to commit" not in status: + if messagebox.askyesno("Uncommitted Changes", + "Es gibt uncommittete Änderungen.\n\n" + "Möchten Sie diese Änderungen committen?"): + + # Add all changes + git_ops.add(project_path) + + # Get commit message + from tkinter import simpledialog + commit_msg = simpledialog.askstring( + "Commit Message", + "Commit-Nachricht eingeben:", + initialvalue="Update changes" + ) + + if commit_msg: + success, msg = git_ops.commit(project_path, commit_msg) + if not success: + messagebox.showerror("Fehler", f"Commit fehlgeschlagen: {msg}") + return False + else: + return False + + return True + + def _check_and_handle_large_files(self, project_path: Path) -> bool: + """Check for large files and handle them""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + large_files = git_ops.check_large_files(project_path, 50) # 50MB limit + + if not large_files: + return True + + # Build warning message + msg = "⚠️ Große Dateien gefunden!\n\n" + msg += "Folgende Dateien überschreiten 50MB:\n\n" + total_size = 0 + + for file, size in large_files[:10]: + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + + if len(large_files) > 10: + msg += f" ... und {len(large_files) - 10} weitere\n" + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + msg += "Diese großen Dateien verhindern den Push zu Gitea.\n\n" + msg += "EMPFEHLUNG: Große Dateien aus Git entfernen\n\n" + msg += "Möchten Sie diese Dateien aus Git entfernen?\n" + msg += "(Die Dateien bleiben auf Ihrer Festplatte erhalten)" + + if messagebox.askyesno("Große Dateien gefunden", msg, icon='warning'): + return self._remove_large_files_from_git(project_path, large_files) + else: + return self._offer_gitignore_creation(project_path, large_files) + + def _remove_large_files_from_git(self, project_path: Path, large_files: list) -> bool: + """Remove large files from git but keep them locally""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + + try: + # Create/update .gitignore + gitignore_path = project_path / ".gitignore" + existing_content = "" + + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + # Remove files from git index + removed_count = 0 + for file, _ in large_files: + cmd = ["git", "rm", "--cached", file] + success, _, _ = git_ops._run_git_command(cmd, cwd=project_path) + if success: + removed_count += 1 + + if removed_count > 0: + # Commit the changes + git_ops.add(project_path, [".gitignore"]) + success, _ = git_ops.commit(project_path, + f"Große Dateien aus Git entfernt ({removed_count} Dateien)") + + if success: + messagebox.showinfo("Erfolg", + f"{removed_count} große Dateien wurden aus Git entfernt.\n\n" + "Die Dateien sind weiterhin lokal vorhanden.\n" + "Sie können jetzt den Push fortsetzen.") + return True + else: + messagebox.showerror("Fehler", "Konnte Änderungen nicht committen.") + return False + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Entfernen der Dateien: {str(e)}") + return False + + def _offer_gitignore_creation(self, project_path: Path, large_files: list) -> bool: + """Offer to create .gitignore for large files""" + from tkinter import messagebox + + if messagebox.askyesno("Alternative", + "Möchten Sie die großen Dateien manuell bearbeiten?\n\n" + "Optionen:\n" + "- Dateien komprimieren (z.B. ZIP)\n" + "- Dateien auf externen Speicher verschieben\n" + "- Dateien in kleinere Teile aufteilen\n\n" + "Soll ich eine .gitignore-Datei erstellen?"): + + gitignore_path = project_path / ".gitignore" + try: + existing_content = "" + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + messagebox.showinfo("Erfolg", + ".gitignore wurde aktualisiert.\n\n" + "Bitte committen Sie die .gitignore-Datei und versuchen Sie es erneut.") + except Exception as e: + messagebox.showerror("Fehler", f"Konnte .gitignore nicht erstellen: {str(e)}") + + return False + + def _handle_successful_push_result(self, project_path: Path, remotes: str) -> None: + """Handle successful push result""" + from tkinter import messagebox + from pathlib import Path + import re + + # Build debug info + debug_info = "Push erfolgreich!\n\n" + debug_info += f"Remote URLs:\n{remotes}\n\n" + + # Extract owner from remote URL + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if match: + remote_owner = match.group(1) + remote_repo = match.group(2) + debug_info += f"Push ging an: {remote_owner}/{remote_repo}\n\n" + + # Verify repository exists + try: + repo = self.repo_manager.client.get_repo(remote_owner, remote_repo) + debug_info += f"✅ Repository gefunden!\n" + debug_info += f"URL: {repo.get('html_url', 'Unknown')}\n" + debug_info += f"Größe: {repo.get('size', 0)} bytes\n" + debug_info += f"Default Branch: {repo.get('default_branch', 'Keiner')}\n\n" + except: + debug_info += f"❌ Repository nicht unter {remote_owner}/{remote_repo} gefunden!\n\n" + debug_info += self._search_repository_in_all_locations(remote_repo) + + debug_info += f"\nLog-Datei: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + messagebox.showinfo("Push Debug Info", debug_info) + + # Refresh Gitea explorer + if hasattr(self.main_window, 'gitea_explorer'): + self.main_window.gitea_explorer.refresh_repositories() + + def _search_repository_in_all_locations(self, repo_name: str) -> str: + """Search for repository in all accessible locations""" + debug_info = "Suche Repository in allen Orten:\n" + + # Check user repos + try: + user_repos = self.repo_manager.list_all_repositories() + found_in_user = any(r['name'] == repo_name for r in user_repos) + debug_info += f"- In Benutzer-Repos: {'Gefunden' if found_in_user else 'Nicht gefunden'}\n" + except: + debug_info += "- In Benutzer-Repos: Fehler beim Suchen\n" + + # Check org repos + if hasattr(self.main_window, 'gitea_explorer') and self.main_window.gitea_explorer.organization_name: + try: + org_name = self.main_window.gitea_explorer.organization_name + org_repos = self.repo_manager.list_organization_repositories(org_name) + found_in_org = any(r['name'] == repo_name for r in org_repos) + debug_info += f"- In Organisation {org_name}: {'Gefunden' if found_in_org else 'Nicht gefunden'}\n" + except: + debug_info += f"- In Organisation: Fehler beim Suchen\n" + + return debug_info + + def test_gitea_connection(self, project: Optional['Project'] = None) -> None: + """ + Test Gitea connection and show information + Consolidated version combining features from both implementations + """ + try: + # Test API connection + user_info = self.repo_manager.client.get_user_info() + username = user_info.get('username', 'Unknown') + + info = f"Gitea Verbindungstest erfolgreich!\n" + info += "=" * 50 + "\n\n" + info += f"Benutzer: {username}\n" + info += f"Server: {self.repo_manager.client.config.base_url}\n\n" + + # Get organizations (from v1) + orgs = self.repo_manager.client.get_user_orgs() + if orgs: + info += "Organisationen:\n" + for org in orgs: + info += f" - {org.get('username', 'Unknown')}\n" + info += "\n" + + # Enhanced features from v2 + # Get teams if organization is available + teams_info = self.repo_manager.client.get_user_teams() + if teams_info: + info += "Team-Mitgliedschaften:\n" + for team in teams_info: + org_name = team.get('organization', {}).get('username', 'Unknown') + team_name = team.get('name', 'Unknown') + permission = team.get('permission', 'none') + info += f" - {org_name}/{team_name}: {permission}\n" + info += "\n" + + # Project-specific info if provided (from v2) + if project: + info += f"Projekt-spezifische Informationen:\n" + info += f"Name: {project.name}\n" + info += f"Pfad: {project.path}\n" + + # Check if git repo exists + from pathlib import Path + git_dir = Path(project.path) / ".git" + if git_dir.exists(): + info += "Git-Status: ✓ Initialisiert\n" + + # Get remote info + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote(Path(project.path), verbose=True) + if success and remotes: + info += f"Remote URL: {remotes.strip()}\n" + else: + info += "Git-Status: ✗ Nicht initialisiert\n" + info += "\n" + + # List repositories + repos = self.repo_manager.client.get_user_repos() + if repos: + info += f"Repositories ({len(repos)}):\n" + for repo in repos[:10]: # Show first 10 + repo_name = repo.get('full_name', repo.get('name', 'Unknown')) + private = "🔒" if repo.get('private', False) else "🌐" + info += f" {private} {repo_name}\n" + if len(repos) > 10: + info += f" ... und {len(repos) - 10} weitere\n" + + # Show info using scrollable dialog (from v2) + self.main_window._show_scrollable_info("Gitea Verbindungstest", info) + + except Exception as e: + # Enhanced error message (from v2) + error_msg = f"Gitea Verbindungstest fehlgeschlagen!\n\n" + error_msg += f"Fehler: {str(e)}\n\n" + error_msg += f"Server: {self.repo_manager.client.config.base_url}\n" + error_msg += "\nBitte prüfen Sie:\n" + error_msg += "- Netzwerkverbindung\n" + error_msg += "- Server Erreichbarkeit\n" + error_msg += "- API Token Gültigkeit" + + from tkinter import messagebox + messagebox.showerror("Gitea Verbindungstest", error_msg) + logger.error(f"Gitea connection test failed: {e}") + + def verify_repository_on_gitea(self, project: 'Project') -> None: + """ + Verify repository status on Gitea + Consolidated version combining pre-checks (v1) and debug info (v2) + """ + import re + from pathlib import Path + + project_path = Path(project.path) + project_name = project.name if hasattr(project, 'name') else project.get('name', 'Unknown') + + info = f"Repository Verifizierung für: {project_name}\n" + info += "=" * 50 + "\n\n" + + # Pre-check from v1: Verify .git directory exists + if not (project_path / ".git").exists(): + info += "❌ Kein Git-Repository gefunden!\n\n" + info += "Das Projekt muss zuerst als Git-Repository initialisiert werden.\n" + info += "Verwenden Sie 'Init & Push' um das Repository zu erstellen." + self.main_window._show_scrollable_info("Repository Verifizierung", info) + return + + # Get git configuration + git_ops = self.repo_manager.git_ops + + # Check remotes + success, remotes = git_ops.remote(project_path, verbose=True) + + if not success or not remotes: + info += "❌ Kein Remote-Repository konfiguriert!\n\n" + info += "Verwenden Sie 'Link to Gitea' um das Repository zu verknüpfen." + self.main_window._show_scrollable_info("Repository Verifizierung", info) + return + + # Debug info from v2: Show .git/config content + git_config_path = project_path / ".git" / "config" + if git_config_path.exists(): + info += "Git Konfiguration (.git/config):\n" + info += "-" * 30 + "\n" + try: + with open(git_config_path, 'r') as f: + config_content = f.read() + info += config_content + "\n" + except Exception as e: + info += f"Fehler beim Lesen: {e}\n" + info += "-" * 30 + "\n\n" + + # Parse remote URL + remote_url = remotes.strip() + info += f"Remote URL: {remote_url}\n\n" + + # Extract repo info from URL + repo_match = re.search(r'/([^/]+)/([^/]+?)(?:\.git)?$', remote_url) + if not repo_match: + info += "❌ Konnte Repository-Informationen nicht aus URL extrahieren!\n" + self.main_window._show_scrollable_info("Repository Verifizierung", info) + return + + owner = repo_match.group(1) + repo_name = repo_match.group(2) + + info += f"Repository: {owner}/{repo_name}\n\n" + + # Check if repository exists on Gitea + try: + repo_data = self.repo_manager.client.get_repo(owner, repo_name) + + if repo_data: + info += "✅ Repository existiert auf Gitea!\n\n" + info += f"Name: {repo_data.get('name', 'Unknown')}\n" + info += f"Beschreibung: {repo_data.get('description', 'Keine')}\n" + info += f"Privat: {'Ja' if repo_data.get('private', False) else 'Nein'}\n" + info += f"Default Branch: {repo_data.get('default_branch', 'Unknown')}\n" + info += f"Größe: {repo_data.get('size', 0)} KB\n" + info += f"URL: {repo_data.get('html_url', 'Unknown')}\n\n" + + # Check branches + info += "Branches:\n" + success, local_branches = git_ops.branch(project_path, list_all=True) + if success: + info += f"Lokal: {local_branches.strip()}\n" + + # Get current branch + success, current_branch = git_ops.branch(project_path) + if success: + current = current_branch.strip().replace('* ', '') + info += f"Aktueller Branch: {current}\n" + else: + info += "❌ Repository nicht auf Gitea gefunden!\n" + info += "Das Repository existiert möglicherweise nicht oder Sie haben keine Berechtigung." + + except Exception as e: + info += f"❌ Fehler bei der Verifizierung: {str(e)}\n" + logger.error(f"Repository verification failed: {e}") + + # Show results using scrollable dialog + self.main_window._show_scrollable_info("Repository Verifizierung", info) + + def manage_branches(self, project: 'Project') -> None: + """ + Manage git branches + Uses the full implementation (second version) + """ + # The first version was just a placeholder, use the full implementation + return self.main_window._original_manage_branches_v2(project) + + def link_to_gitea(self, project: 'Project') -> None: + """ + Link local repository to Gitea + Uses the full implementation (second version) + """ + # The first version was just a placeholder, use the full implementation + return self.main_window._original_link_to_gitea_v2(project) + + def show_git_status(self, project: 'Project') -> None: + """Show git status for project""" + return self.main_window._original_show_git_status(project) + + def commit_changes(self, project: 'Project') -> None: + """Commit changes with progress bar""" + import threading + from tkinter import messagebox, simpledialog + from pathlib import Path + + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Check for changes + success, status = git_ops.status(project_path) + if not success: + messagebox.showerror("Fehler", "Konnte Git-Status nicht abrufen") + return + + if "nothing to commit" in status: + messagebox.showinfo("Info", "Keine Änderungen zum Committen vorhanden") + return + + # Get commit message + commit_msg = simpledialog.askstring( + "Commit Message", + "Commit-Nachricht eingeben:", + parent=self.root + ) + + if not commit_msg: + return + + # Show progress bar + progress_bar = ProgressBar(self.root, "Commit erstellen") + + def run_commit(): + start_time = time.time() + min_display_time = 2.0 + + try: + stages = GitOperationProgress.get_stages('commit') + total_stages = len(stages) + + # Execute commit early to know the result + git_ops.add(project_path) + success, result = git_ops.commit(project_path, commit_msg) + + # Calculate timing for stages + elapsed = time.time() - start_time + remaining_time = max(0.1, min_display_time - elapsed) + stage_delay = remaining_time / total_stages + + # Animate through stages + for i, (progress, status) in enumerate(stages): + self.root.after(int(i * stage_delay * 1000), + lambda p=progress, s=status: progress_bar.update_progress(p, s)) + + # Final update + def finish_commit(): + if success: + progress_bar.update_progress(1.0, stages[-1][1]) + # Auto-closes after 0.5s + else: + error_msg = f"Commit fehlgeschlagen: {result}" + progress_bar.set_error(error_msg) + messagebox.showerror("Fehler", error_msg) + + self.root.after(int(min_display_time * 1000), finish_commit) + + except Exception as e: + progress_bar.destroy() + logger.error(f"Commit error: {e}") + messagebox.showerror("Fehler", f"Fehler beim Commit: {str(e)}") + + # Run in thread + thread = threading.Thread(target=run_commit, daemon=True) + thread.start() + + def pull_from_gitea(self, project: 'Project') -> None: + """Pull from Gitea repository with progress bar""" + import threading + from pathlib import Path + from tkinter import messagebox + + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Verify it's a git repo with remote + if not (project_path / ".git").exists(): + messagebox.showerror("Fehler", "Kein Git-Repository!") + return + + success, remotes = git_ops.remote(project_path, verbose=True) + if not success or not remotes: + messagebox.showerror("Fehler", "Kein Remote-Repository konfiguriert!") + return + + # Show progress bar + progress_bar = ProgressBar(self.root, "Pull von Gitea") + + def run_pull(): + try: + stages = GitOperationProgress.get_stages('pull') + + start_time = time.time() + min_display_time = 2.0 + + # Perform actual pull early + success, result = git_ops.pull(project_path) + + # Calculate timing + elapsed = time.time() - start_time + remaining_time = max(0.1, min_display_time - elapsed) + stage_delay = remaining_time / len(stages) + + # Animate through stages + for i, (progress, status) in enumerate(stages): + self.root.after(int(i * stage_delay * 1000), + lambda p=progress, s=status: progress_bar.update_progress(p, s)) + + # Final update + def finish_pull(): + if success: + progress_bar.update_progress(1.0, stages[-1][1]) + # Auto-closes after 0.5s + else: + error_msg = f"Pull fehlgeschlagen: {result}" + progress_bar.set_error(error_msg) + messagebox.showerror("Fehler", error_msg) + + self.root.after(int(min_display_time * 1000), finish_pull) + + except Exception as e: + progress_bar.destroy() + logger.error(f"Pull error: {e}") + messagebox.showerror("Fehler", f"Fehler beim Pull: {str(e)}") + + # Run in thread + thread = threading.Thread(target=run_pull, daemon=True) + thread.start() + + def fetch_from_gitea(self, project: 'Project') -> None: + """Fetch from Gitea repository with progress bar""" + import threading + from pathlib import Path + from tkinter import messagebox + + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Verify it's a git repo with remote + if not (project_path / ".git").exists(): + messagebox.showerror("Fehler", "Kein Git-Repository!") + return + + success, remotes = git_ops.remote(project_path, verbose=True) + if not success or not remotes: + messagebox.showerror("Fehler", "Kein Remote-Repository konfiguriert!") + return + + # Show progress bar + progress_bar = ProgressBar(self.root, "Fetch von Gitea") + + def run_fetch(): + try: + stages = GitOperationProgress.get_stages('fetch') + + start_time = time.time() + min_display_time = 2.0 + + # Perform actual fetch early + success, result = git_ops.fetch(project_path) + + # Calculate timing + elapsed = time.time() - start_time + remaining_time = max(0.1, min_display_time - elapsed) + stage_delay = remaining_time / len(stages) + + # Animate through stages + for i, (progress, status) in enumerate(stages): + self.root.after(int(i * stage_delay * 1000), + lambda p=progress, s=status: progress_bar.update_progress(p, s)) + + # Final update + def finish_fetch(): + if success: + progress_bar.update_progress(1.0, stages[-1][1]) + # Auto-closes after 0.5s + else: + error_msg = f"Fetch fehlgeschlagen: {result}" + progress_bar.set_error(error_msg) + messagebox.showerror("Fehler", error_msg) + + self.root.after(int(min_display_time * 1000), finish_fetch) + + except Exception as e: + progress_bar.destroy() + logger.error(f"Fetch error: {e}") + messagebox.showerror("Fehler", f"Fehler beim Fetch: {str(e)}") + + # Run in thread + thread = threading.Thread(target=run_fetch, daemon=True) + thread.start() + + def clone_repository(self, repo_data: dict) -> None: + """Clone repository from Gitea with progress bar""" + import threading + import time + from tkinter import messagebox + + logger.log_method_call("clone_repository", args=(repo_data.get('name', 'unknown'),)) + logger.log_git_operation("clone", "started", {"repo": repo_data}) + + # Show progress bar + progress_bar = ProgressBar(self.root, "Repository klonen") + progress_bar.update_progress(0.0, "Vorbereitung...", "Repository klonen") + + def run_clone(): + start_time = time.time() + min_display_time = 2.0 # Minimum 2 seconds + + try: + # Get stages for clone operation + stages = GitOperationProgress.get_stages('clone') + stage_index = 0 + + # Simulate progress through stages + def update_stage(): + nonlocal stage_index + if stage_index < len(stages): + progress, status = stages[stage_index] + progress_bar.update_progress(progress, status) + stage_index += 1 + + # Start with first stage + update_stage() + + # Get clone directory + owner = repo_data.get('owner', {}).get('username', 'unknown') + repo_name = repo_data.get('name', 'unknown') + + # Update progress + self.root.after(300, update_stage) + + # Select directory + clone_dir = self.repo_manager.git_ops.select_directory( + f"Wählen Sie ein Verzeichnis für '{repo_name}'" + ) + + if not clone_dir: + progress_bar.destroy() + return + + # Update progress + self.root.after(100, update_stage) + + # Perform actual clone + logger.log_git_operation("clone", "executing", { + "owner": owner, + "repo": repo_name, + "target_dir": str(clone_dir) + }) + + success, local_path = self.repo_manager.git_ops.clone_repository( + owner, repo_name, clone_dir + ) + + # Calculate remaining display time + elapsed = time.time() - start_time + remaining_display_time = max(0, min_display_time - elapsed) + + # Update through remaining stages with timing + remaining_stages = len(stages) - stage_index + stage_delay = remaining_display_time / (remaining_stages + 1) if remaining_stages > 0 else 0.3 + + for i in range(remaining_stages): + self.root.after(int((i + 1) * stage_delay * 1000), update_stage) + + # Final update + def finish_clone(): + if success: + progress_bar.update_progress(1.0, "Repository erfolgreich geklont!") + logger.log_git_operation("clone", "completed", { + "repo": repo_name, + "local_path": str(local_path) + }) + + # Add to projects + from project_manager import Project + project = Project( + id=str(datetime.now().timestamp()), + name=repo_name, + path=str(local_path), + last_accessed=datetime.now(), + gitea_repo=f"{owner}/{repo_name}" + ) + self.project_manager.add_project(project) + self.main_window.refresh_projects() + + # Progress bar will auto-close after 0.5s + else: + error_msg = f"Klonen fehlgeschlagen: {local_path}" + progress_bar.set_error(error_msg) + logger.log_git_operation("clone", "failed", { + "repo": repo_name, + "error": str(local_path) + }) + messagebox.showerror("Fehler", error_msg) + + self.root.after(int(remaining_display_time * 1000), finish_clone) + + except Exception as e: + progress_bar.destroy() + logger.log_exception(e, "clone_repository") + logger.log_git_operation("clone", "error", {"error": str(e)}) + messagebox.showerror("Fehler", f"Fehler beim Klonen: {str(e)}") + + # Run clone in thread + thread = threading.Thread(target=run_clone, daemon=True) + thread.start() + + def init_git_repo(self, project: 'Project') -> bool: + """Initialize git repository""" + return self.main_window._original_init_git_repo(project) + + def fix_repository_issues(self, project: 'Project') -> None: + """ + Fix common repository issues + Refactored from 110 lines into smaller, focused methods + """ + from pathlib import Path + + project_path = Path(project.path) + + # Step 1: Diagnose repository issues + issues = self._diagnose_repository_issues(project_path) + + # Step 2: Show issues dialog with fix options + self._show_fix_repository_dialog(project, issues) + + def _diagnose_repository_issues(self, project_path: Path) -> list: + """Diagnose common repository issues""" + git_ops = self.repo_manager.git_ops + issues = [] + + # Check if it's a git repo + if not (project_path / ".git").exists(): + issues.append("❌ Kein Git-Repository") + else: + # Check remote configuration + remote_issues = self._check_remote_issues(project_path) + issues.extend(remote_issues) + + # Check for LFS configuration + lfs_issues = self._check_lfs_issues(project_path) + issues.extend(lfs_issues) + + return issues + + def _check_remote_issues(self, project_path: Path) -> list: + """Check for remote configuration issues""" + git_ops = self.repo_manager.git_ops + issues = [] + + success, remotes = git_ops.remote_list(project_path) + if success and remotes: + issues.append(f"✅ Remote vorhanden:\n{remotes}") + + # Check for authentication issues + if "Authentication failed" in remotes or "21cbba8d" in remotes: + issues.append("⚠️ Alter/falscher Token im Remote gefunden") + else: + issues.append("❌ Kein Remote konfiguriert") + + return issues + + def _check_lfs_issues(self, project_path: Path) -> list: + """Check for Git LFS issues""" + issues = [] + gitattributes = project_path / ".gitattributes" + + if gitattributes.exists(): + with open(gitattributes, 'r') as f: + if 'filter=lfs' in f.read(): + issues.append("⚠️ Git LFS ist konfiguriert (kann Probleme verursachen)") + + return issues + + def _show_fix_repository_dialog(self, project: 'Project', issues: list) -> None: + """Show dialog with repository issues and fix options""" + import customtkinter as ctk + + msg = "Repository-Status:\n\n" + "\n".join(issues) + "\n\nWas möchten Sie tun?" + + # Create dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Repository reparieren") + dialog.geometry("600x400") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 400) // 2 + dialog.geometry(f"600x400+{x}+{y}") + + # Message + text_widget = ctk.CTkTextbox(dialog, width=580, height=250) + text_widget.pack(padx=10, pady=10) + text_widget.insert("1.0", msg) + text_widget.configure(state="disabled") + + # Buttons + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(fill="x", padx=10, pady=(0, 10)) + + # Add fix buttons based on issues + self._add_fix_buttons(dialog, button_frame, project, issues) + + close_btn = ctk.CTkButton(dialog, text="Schließen", + command=dialog.destroy, width=100) + close_btn.pack(pady=10) + + dialog.grab_set() + + def _add_fix_buttons(self, dialog, button_frame, + project: 'Project', issues: list) -> None: + """Add appropriate fix buttons based on detected issues""" + import customtkinter as ctk + + # Add Remote Fix button if authentication issues detected + if any("falscher Token" in issue for issue in issues): + fix_btn = ctk.CTkButton( + button_frame, + text="🔧 Remote korrigieren", + command=lambda: self._fix_remote_action(dialog, project), + width=180, height=40 + ) + fix_btn.pack(side="left", padx=5) + + # Add LFS disable button if LFS issues detected + if any("LFS" in issue for issue in issues): + lfs_btn = ctk.CTkButton( + button_frame, + text="🚫 LFS deaktivieren", + command=lambda: self._disable_lfs_action(dialog, project), + width=180, height=40 + ) + lfs_btn.pack(side="left", padx=5) + + # Always add check on Gitea button + check_btn = ctk.CTkButton( + button_frame, + text="🔍 Auf Gitea prüfen", + command=lambda: self._check_on_gitea_action(dialog, project), + width=180, height=40 + ) + check_btn.pack(side="left", padx=5) + + def _fix_remote_action(self, dialog, project: 'Project') -> None: + """Fix remote URL with correct credentials""" + from pathlib import Path + from tkinter import messagebox + + dialog.destroy() + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Get current organization + org_name = self._get_current_organization() + + # Remove old remote + git_ops.remote_remove(project_path, "origin") + + # Add correct remote + success, msg = git_ops.add_remote_to_existing_repo( + project_path, org_name, project.name + ) + + if success: + messagebox.showinfo("Erfolg", + f"Remote wurde korrigiert!\n\n" + f"Repository: {org_name}/{project.name}\n" + f"Sie können jetzt pushen.") + else: + messagebox.showerror("Fehler", + f"Fehler beim Korrigieren: {msg}") + + def _get_current_organization(self) -> str: + """Get current organization name""" + org_name = "IntelSight" + if hasattr(self.main_window, 'gitea_explorer') and \ + self.main_window.gitea_explorer.organization_name: + org_name = self.main_window.gitea_explorer.organization_name + return org_name + + def _disable_lfs_action(self, dialog, project: 'Project') -> None: + """Disable LFS temporarily""" + from pathlib import Path + from tkinter import messagebox + + dialog.destroy() + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + success, msg = git_ops.disable_lfs_for_push(project_path) + if success: + messagebox.showinfo("Erfolg", + "Git LFS wurde deaktiviert.\n\n" + "Versuchen Sie den Push erneut.") + else: + messagebox.showerror("Fehler", f"Fehler: {msg}") + + def _check_on_gitea_action(self, dialog, project: 'Project') -> None: + """Check if repository exists on Gitea""" + dialog.destroy() + self.verify_repository_on_gitea(project) + + def manage_large_files(self, project: 'Project') -> None: + """ + Manage large files in the repository + Refactored from 160 lines into smaller, focused methods + """ + from pathlib import Path + from tkinter import messagebox + + project_path = Path(project.path) + + # Step 1: Check for large files + large_files = self._scan_for_large_files(project_path) + + if not large_files: + messagebox.showinfo("Info", + "Keine großen Dateien gefunden (>50MB).\n\n" + "Ihr Repository kann problemlos gepusht werden.") + return + + # Step 2: Show dialog with large files + self._show_large_files_dialog(project, large_files) + + def _scan_for_large_files(self, project_path: Path) -> list: + """Scan repository for large files""" + git_ops = self.repo_manager.git_ops + return git_ops.check_large_files(project_path, 50) # Files > 50MB + + def _show_large_files_dialog(self, project: 'Project', large_files: list) -> None: + """Show dialog with large files and management options""" + import customtkinter as ctk + from pathlib import Path + + project_path = Path(project.path) + + # Build message + msg = self._build_large_files_message(large_files) + + # Create dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Große Dateien verwalten") + dialog.geometry("600x500") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 500) // 2 + dialog.geometry(f"600x500+{x}+{y}") + + # Message + text_widget = ctk.CTkTextbox(dialog, width=580, height=300) + text_widget.pack(padx=10, pady=10) + text_widget.insert("1.0", msg) + text_widget.configure(state="disabled") + + # Buttons + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(fill="x", padx=10, pady=(0, 10)) + + # Add action buttons + self._add_large_files_action_buttons(dialog, button_frame, project, large_files) + + # Info text + info_label = ctk.CTkLabel(dialog, + text="Tipp: 'Aus Git entfernen' ist die einfachste Lösung.\n" + "Die Dateien bleiben auf Ihrer Festplatte erhalten!", + font=("Arial", 12), text_color="yellow") + info_label.pack(pady=10) + + close_btn = ctk.CTkButton(dialog, text="Schließen", + command=dialog.destroy, width=100) + close_btn.pack(pady=10) + + dialog.grab_set() + + def _build_large_files_message(self, large_files: list) -> str: + """Build message showing large files""" + msg = f"Gefundene große Dateien ({len(large_files)} Dateien):\n\n" + total_size = 0 + + for i, (file, size) in enumerate(large_files[:15]): + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + if i >= 14 and len(large_files) > 15: + msg += f" ... und {len(large_files) - 15} weitere\n" + break + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + msg += "Was möchten Sie tun?" + + return msg + + def _add_large_files_action_buttons(self, dialog, button_frame, + project: 'Project', large_files: list) -> None: + """Add action buttons for managing large files""" + import customtkinter as ctk + from pathlib import Path + + project_path = Path(project.path) + + # Remove from Git button + remove_btn = ctk.CTkButton( + button_frame, + text="🗑️ Aus Git entfernen\n(Dateien bleiben lokal)", + command=lambda: self._remove_large_files_action(dialog, project_path, large_files), + width=180, height=60 + ) + remove_btn.pack(side="left", padx=5) + + # Show .gitignore button + gitignore_btn = ctk.CTkButton( + button_frame, + text="📝 .gitignore anzeigen", + command=lambda: self._show_gitignore_content(dialog, large_files), + width=180, height=60 + ) + gitignore_btn.pack(side="left", padx=5) + + # Git status button + status_btn = ctk.CTkButton( + button_frame, + text="📊 Git Status", + command=lambda: self._show_git_status_action(dialog, project), + width=180, height=60 + ) + status_btn.pack(side="left", padx=5) + + def _remove_large_files_action(self, dialog, project_path: Path, + large_files: list) -> None: + """Remove large files from git but keep locally""" + from tkinter import messagebox + + dialog.destroy() + + try: + # Update .gitignore + if not self._update_gitignore_with_large_files(project_path, large_files): + return + + # Remove files from git index + removed_count, failed_files = self._remove_files_from_git_index( + project_path, large_files) + + if removed_count > 0: + # Commit the changes + self._commit_large_files_removal(project_path, removed_count) + + # Show result + self._show_removal_result(removed_count, failed_files) + else: + messagebox.showwarning("Warnung", + "Keine Dateien konnten entfernt werden.") + + except Exception as e: + messagebox.showerror("Fehler", + f"Fehler beim Entfernen: {str(e)}") + + def _update_gitignore_with_large_files(self, project_path: Path, + large_files: list) -> bool: + """Update .gitignore with large files""" + try: + gitignore_path = project_path / ".gitignore" + existing_content = "" + + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + # Add large files to .gitignore + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + return True + except Exception as e: + from tkinter import messagebox + messagebox.showerror("Fehler", + f"Fehler beim Aktualisieren von .gitignore: {str(e)}") + return False + + def _remove_files_from_git_index(self, project_path: Path, + large_files: list) -> tuple: + """Remove files from git index""" + git_ops = self.repo_manager.git_ops + removed_count = 0 + failed_files = [] + + for file, _ in large_files: + cmd = ["git", "rm", "--cached", file] + success, _, stderr = git_ops._run_git_command(cmd, cwd=project_path) + if success: + removed_count += 1 + else: + failed_files.append((file, stderr)) + + return removed_count, failed_files + + def _commit_large_files_removal(self, project_path: Path, + removed_count: int) -> None: + """Commit the removal of large files""" + git_ops = self.repo_manager.git_ops + git_ops.add(Path(project_path), [".gitignore"]) + git_ops.commit(Path(project_path), + f"Große Dateien aus Git entfernt ({removed_count} Dateien)") + + def _show_removal_result(self, removed_count: int, failed_files: list) -> None: + """Show result of file removal""" + from tkinter import messagebox + + result_msg = f"✅ {removed_count} Dateien wurden aus Git entfernt.\n\n" + result_msg += "Die Dateien sind weiterhin lokal vorhanden.\n" + + if failed_files: + result_msg += f"\n⚠️ {len(failed_files)} Dateien konnten nicht entfernt werden.\n" + + result_msg += "\nSie können jetzt pushen!" + messagebox.showinfo("Erfolg", result_msg) + + def _show_gitignore_content(self, dialog, large_files: list) -> None: + """Show .gitignore content for large files""" + import customtkinter as ctk + from tkinter import messagebox + + dialog.destroy() + + gitignore_content = "# Große Dateien\n" + for file, _ in large_files: + gitignore_content += f"{file}\n" + + # Create dialog to show content + info_dialog = ctk.CTkToplevel(self.root) + info_dialog.title(".gitignore Inhalt") + info_dialog.geometry("500x400") + + label = ctk.CTkLabel(info_dialog, + text="Fügen Sie diese Zeilen zu .gitignore hinzu:", + font=("Arial", 14)) + label.pack(pady=10) + + text = ctk.CTkTextbox(info_dialog, width=480, height=300) + text.pack(padx=10, pady=10) + text.insert("1.0", gitignore_content) + + def copy_to_clipboard(): + self.root.clipboard_clear() + self.root.clipboard_append(gitignore_content) + messagebox.showinfo("Kopiert", + "Inhalt wurde in die Zwischenablage kopiert!") + + copy_btn = ctk.CTkButton(info_dialog, + text="In Zwischenablage kopieren", + command=copy_to_clipboard) + copy_btn.pack(pady=10) + + info_dialog.grab_set() + + def _show_git_status_action(self, dialog, project: 'Project') -> None: + """Show git status for project""" + from pathlib import Path + from tkinter import messagebox + + dialog.destroy() + + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + success, status = git_ops.status(project_path) + if success: + self.main_window._show_scrollable_info("Git Status", + f"Git Status für {project.name}:\n\n{status}") + else: + messagebox.showerror("Fehler", "Konnte Git Status nicht abrufen.") + + def setup_git_lfs(self, project: 'Project') -> None: + """ + Setup Git LFS for the project + Refactored from 131 lines into smaller, focused methods + """ + from pathlib import Path + from tkinter import messagebox + + project_path = Path(project.path) + + # Step 1: Check for large files + large_files = self._scan_for_large_files(project_path) + + if not large_files: + messagebox.showinfo("Info", + "Keine großen Dateien gefunden (>50MB).\n\n" + "Git LFS ist möglicherweise nicht erforderlich.") + return + + # Step 2: Show large files and ask for confirmation + if not self._confirm_lfs_setup(large_files): + return + + # Step 3: Check and setup Git LFS + if not self._initialize_git_lfs(project_path): + return + + # Step 4: Get tracking option from user + tracking_choice = self._get_lfs_tracking_choice() + if not tracking_choice: + return + + # Step 5: Process patterns based on choice + patterns = self._get_lfs_patterns(large_files, tracking_choice) + if not patterns: + return + + # Step 6: Track files with LFS + self._track_files_with_lfs(project_path, patterns, large_files) + + def _confirm_lfs_setup(self, large_files: list) -> bool: + """Show large files and ask for LFS setup confirmation""" + from tkinter import messagebox + + msg = self._build_lfs_confirmation_message(large_files) + return messagebox.askyesno("Git LFS einrichten", msg) + + def _build_lfs_confirmation_message(self, large_files: list) -> str: + """Build confirmation message for LFS setup""" + msg = f"Gefundene große Dateien ({len(large_files)} Dateien):\n\n" + total_size = 0 + + for i, (file, size) in enumerate(large_files[:10]): + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + if i >= 9 and len(large_files) > 10: + msg += f" ... und {len(large_files) - 10} weitere\n" + break + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + msg += "Möchten Sie Git LFS für diese Dateien einrichten?" + + return msg + + def _initialize_git_lfs(self, project_path: Path) -> bool: + """Check if Git LFS is installed and initialize it""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + success, result = git_ops.setup_lfs(project_path) + + if not success: + messagebox.showerror("Fehler", result) + return False + + return True + + def _get_lfs_tracking_choice(self) -> Optional[str]: + """Get user choice for LFS tracking""" + import customtkinter as ctk + import tkinter as tk + + choices = { + "all": "Alle großen Dateien (>50MB)", + "types": "Nach Dateityp (z.B. *.mp4, *.zip)", + "specific": "Spezifische Dateien auswählen" + } + + dialog = ctk.CTkToplevel(self.root) + dialog.title("LFS Tracking-Optionen") + dialog.geometry("400x300") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 400) // 2 + y = (dialog.winfo_screenheight() - 300) // 2 + dialog.geometry(f"400x300+{x}+{y}") + + selected_option = tk.StringVar(value="all") + + label = ctk.CTkLabel(dialog, text="Wie möchten Sie die Dateien tracken?", + font=("Arial", 14)) + label.pack(pady=20) + + for key, text in choices.items(): + radio = ctk.CTkRadioButton(dialog, text=text, + variable=selected_option, value=key) + radio.pack(pady=5, padx=20, anchor="w") + + result = {"ok": False} + + def on_ok(): + result["ok"] = True + result["choice"] = selected_option.get() + dialog.destroy() + + ok_btn = ctk.CTkButton(dialog, text="OK", command=on_ok) + ok_btn.pack(pady=20) + + dialog.grab_set() + dialog.wait_window() + + return result.get("choice") if result.get("ok") else None + + def _get_lfs_patterns(self, large_files: list, choice: str) -> list: + """Get patterns to track based on user choice""" + from pathlib import Path + + if choice == "all": + # Track all large files + return [file for file, _ in large_files] + + elif choice == "types": + return self._get_file_type_patterns(large_files) + + else: # specific + # For simplicity, track all for now + return [file for file, _ in large_files] + + def _get_file_type_patterns(self, large_files: list) -> list: + """Get file type patterns from user""" + from pathlib import Path + from tkinter import simpledialog + + # Get unique extensions + extensions = set() + for file, _ in large_files: + ext = Path(file).suffix + if ext: + extensions.add(ext) + + # Ask which extensions to track + ext_msg = "Folgende Dateitypen wurden gefunden:\n" + for ext in extensions: + ext_msg += f" • *{ext}\n" + ext_msg += "\nWelche möchten Sie mit LFS tracken?\n(Komma-getrennt, z.B. .mp4,.zip)" + + selected = simpledialog.askstring("Dateitypen auswählen", ext_msg) + if not selected: + return [] + + patterns = [] + for ext in selected.split(","): + ext = ext.strip() + if not ext.startswith("."): + ext = "." + ext + patterns.append(f"*{ext}") + + return patterns + + def _track_files_with_lfs(self, project_path: Path, patterns: list, + large_files: list) -> None: + """Track files with Git LFS""" + from tkinter import messagebox + + git_ops = self.repo_manager.git_ops + + # Track with LFS + success, result = git_ops.track_with_lfs(project_path, patterns) + + if success: + messagebox.showinfo("Erfolg", + f"{result}\n\n" + "Nächste Schritte:\n" + "1. Committen Sie die .gitattributes Datei\n" + "2. Große Dateien werden nun über LFS verwaltet\n" + "3. Der Push sollte jetzt funktionieren") + + # Offer to migrate existing files + self._offer_lfs_migration(project_path, large_files) + else: + messagebox.showerror("Fehler", result) + + def _offer_lfs_migration(self, project_path: Path, large_files: list) -> None: + """Offer to migrate existing large files to LFS""" + from tkinter import messagebox + + if messagebox.askyesno("Migration", + "Möchten Sie existierende große Dateien zu LFS migrieren?\n\n" + "Dies wird die Dateien aus dem Git-History entfernen und neu hinzufügen."): + + git_ops = self.repo_manager.git_ops + success, result = git_ops.migrate_to_lfs(project_path, + [f for f, _ in large_files]) + + if success: + messagebox.showinfo("Erfolg", result) + else: + messagebox.showerror("Fehler", result) \ No newline at end of file diff --git a/gui/handlers/process_manager.py b/gui/handlers/process_manager.py new file mode 100644 index 0000000..6edaaa8 --- /dev/null +++ b/gui/handlers/process_manager.py @@ -0,0 +1,101 @@ +""" +Process Manager Handler +Handles process monitoring and management operations +""" + +from typing import Optional, TYPE_CHECKING +from utils.logger import logger + +if TYPE_CHECKING: + from gui.main_window import MainWindow + from project_manager import Project + + +class ProcessManagerHandler: + """Handles all process management operations for MainWindow""" + + def __init__(self, main_window: 'MainWindow'): + """Initialize with reference to main window""" + self.main_window = main_window + self.root = main_window.root + self.process_manager = main_window.process_manager + self.process_tracker = main_window.process_tracker + self.project_manager = main_window.project_manager + logger.info("ProcessManagerHandler initialized") + + def monitor_process(self, project: 'Project', process) -> None: + """Monitor a process for a project""" + return self.main_window._original_monitor_process(project, process) + + def check_process_status(self) -> None: + """Check status of all processes""" + return self.main_window._original_check_process_status() + + def stop_project(self, project: 'Project') -> None: + """Stop a running project""" + return self.main_window._original_stop_project(project) + + def update_status(self, message: str, error: bool = False) -> None: + """Update status bar message""" + # Direct implementation + from gui.styles import COLORS + + if hasattr(self.main_window, 'status_label'): + self.main_window.status_label.configure( + text=message, + text_color=COLORS['accent_error'] if error else COLORS['text_secondary'] + ) + logger.debug(f"Status updated: {message} (error={error})") + + def download_log(self) -> None: + """Download comprehensive application log with all interactions""" + # Direct implementation + from tkinter import filedialog, messagebox + import os + from datetime import datetime + + logger.info(f"Download log clicked - Total entries: {len(logger.log_entries)}") + + try: + # Generate default filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_filename = f"CPM_FullLog_{timestamp}.log" + + # Open file dialog to choose save location + file_path = filedialog.asksaveasfilename( + defaultextension=".log", + filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")], + initialfile=default_filename, + title="Save Complete Application Log" + ) + + if file_path: + # Export logs to chosen location with system info + logger.export_logs(file_path, include_system_info=True) + + # Add completion entry + logger.info(f"Log export completed - Total entries: {len(logger.log_entries)}, Interactions: {logger.interaction_count}") + + # Update status + self.update_status(f"Log saved: {os.path.basename(file_path)} ({len(logger.log_entries)} entries)") + + # Show success message with log details + messagebox.showinfo( + "Log Export Successful", + f"Complete application log saved to:\n{file_path}\n\n" + f"Total log entries: {len(logger.log_entries):,}\n" + f"UI interactions logged: {logger.interaction_count:,}\n" + f"File size: {os.path.getsize(file_path) / 1024:.1f} KB" + ) + else: + logger.info("Log export cancelled by user") + + except Exception as e: + error_msg = f"Error saving log: {str(e)}" + self.update_status(error_msg, error=True) + logger.log_exception(e, "download_log") + messagebox.showerror("Export Error", error_msg) + + def _handle_process_ended(self, project: 'Project') -> None: + """Handle process end event (private method)""" + return self.main_window._original_handle_process_ended(project) \ No newline at end of file diff --git a/gui/handlers/project_manager.py b/gui/handlers/project_manager.py new file mode 100644 index 0000000..ed4753e --- /dev/null +++ b/gui/handlers/project_manager.py @@ -0,0 +1,102 @@ +""" +Project Manager Handler +Handles project CRUD operations and project-related UI updates +""" + +from typing import Optional, TYPE_CHECKING +from utils.logger import logger + +if TYPE_CHECKING: + from gui.main_window import MainWindow + from project_manager import Project + + +class ProjectManagerHandler: + """Handles all project management operations for MainWindow""" + + def __init__(self, main_window: 'MainWindow'): + """Initialize with reference to main window""" + self.main_window = main_window + self.root = main_window.root + self.project_manager = main_window.project_manager + self.terminal_launcher = main_window.terminal_launcher + self.readme_generator = main_window.readme_generator + self.vps_connection = main_window.vps_connection + logger.info("ProjectManagerHandler initialized") + + def add_new_project(self) -> None: + """Add a new project with logging""" + logger.info("Add new project initiated") + return self.main_window._original_add_new_project() + + def open_project(self, project: 'Project') -> None: + """Open a project with comprehensive logging""" + logger.info(f"Opening project: {project.name} (ID: {project.id}) at {project.path}") + return self.main_window._original_open_project(project) + + def delete_project(self, project: 'Project') -> None: + """Delete a project""" + # Direct implementation + from tkinter import messagebox + logger.info(f"Attempting to delete project: {project.name}") + + if messagebox.askyesno("Projekt löschen", + f"Möchten Sie das Projekt '{project.name}' wirklich aus dem Projekt-Manager entfernen?\n\n" + "Hinweis: Die Dateien werden NICHT gelöscht."): + self.project_manager.remove_project(project.id) + self.main_window.refresh_projects() + if hasattr(self.main_window, 'update_status'): + self.main_window.update_status(f"Removed: {project.name}") + logger.info(f"Project deleted: {project.name}") + + def rename_project(self, project: 'Project') -> None: + """Rename a project with logging""" + logger.info(f"Rename project initiated for: {project.name}") + return self.main_window._original_rename_project(project) + + def refresh_projects(self) -> None: + """Refresh the project display with logging""" + logger.debug("Refresh projects called") + logger.info(f"Refreshing projects - Total: {len(self.project_manager.projects)}") + return self.main_window._original_refresh_projects() + + def create_project_from_repo(self, repo_data: dict, project_path: str) -> None: + """Create a project from repository data""" + return self.main_window._original_create_project_from_repo(repo_data, project_path) + + def open_vps_connection(self, project: 'Project') -> None: + """Open VPS connection for project""" + return self.main_window._original_open_vps_connection(project) + + def open_admin_panel(self, project: 'Project') -> None: + """Open admin panel for project""" + return self.main_window._original_open_admin_panel(project) + + def open_vps_docker(self, project: 'Project') -> None: + """Open VPS Docker management""" + return self.main_window._original_open_vps_docker(project) + + def open_readme(self, project: 'Project') -> None: + """Open or generate README for project""" + return self.main_window._original_open_readme(project) + + def generate_readme_background(self, project: 'Project') -> None: + """Generate README in background""" + return self.main_window._original_generate_readme_background(project) + + def open_gitea_window(self) -> None: + """Open Gitea explorer window""" + return self.main_window._original_open_gitea_window() + + def on_gitea_repo_select(self, repo_data: dict) -> None: + """Handle Gitea repository selection""" + return self.main_window._original_on_gitea_repo_select(repo_data) + + def clear_project_selection(self) -> None: + """Clear current project selection""" + return self.main_window._original_clear_project_selection() + + def on_project_select(self, project: 'Project', tile) -> None: + """Handle project selection with logging""" + logger.info(f"Project selected: {project.name} (has Gitea repo: {bool(getattr(project, 'gitea_repo', None))})") + return self.main_window._original_on_project_select(project, tile) \ No newline at end of file diff --git a/gui/handlers/ui_helpers.py b/gui/handlers/ui_helpers.py new file mode 100644 index 0000000..b490060 --- /dev/null +++ b/gui/handlers/ui_helpers.py @@ -0,0 +1,203 @@ +""" +UI Helpers Handler +Handles UI creation, updates, and helper functions +""" + +from typing import TYPE_CHECKING +from utils.logger import logger + +if TYPE_CHECKING: + from gui.main_window import MainWindow + from project_manager import Project + + +class UIHelpersHandler: + """Handles UI helper operations for MainWindow""" + + def __init__(self, main_window: 'MainWindow'): + """Initialize with reference to main window""" + self.main_window = main_window + self.root = main_window.root + logger.info("UIHelpersHandler initialized") + + def setup_ui(self) -> None: + """Setup the main UI""" + return self.main_window._original_setup_ui() + + def create_header(self) -> None: + """Create header section""" + # Direct implementation with proper references + import customtkinter as ctk + from gui.styles import COLORS, FONTS + + header_frame = ctk.CTkFrame(self.main_window.main_container, fg_color=COLORS['bg_secondary'], height=80) + header_frame.pack(fill="x", padx=0, pady=0) + header_frame.pack_propagate(False) + + # Title + self.main_window.title_label = ctk.CTkLabel( + header_frame, + text="IntelSight - Claude Project Manager", + font=FONTS['heading'], + text_color=COLORS['text_primary'] + ) + self.main_window.title_label.pack(side="left", padx=30, pady=20) + + # Toolbar buttons + toolbar = ctk.CTkFrame(header_frame, fg_color="transparent") + toolbar.pack(side="right", padx=30, pady=20) + + # Log download button + self.main_window.log_btn = ctk.CTkButton( + toolbar, + text="📥 Log", + command=self.main_window.download_log, + width=80, + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF", + font=('Segoe UI', 12) + ) + self.main_window.log_btn.pack(side="left", padx=(0, 10)) + + # Refresh button + self.main_window.refresh_btn = ctk.CTkButton( + toolbar, + text="↻ Refresh", + command=self.main_window.refresh_projects, + width=100, + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'] + ) + self.main_window.refresh_btn.pack(side="left", padx=(0, 10)) + logger.debug("Header created") + + def create_content_area(self) -> None: + """Create content area""" + return self.main_window._original_create_content_area() + + def create_status_bar(self) -> None: + """Create status bar""" + # Direct implementation + import customtkinter as ctk + from gui.styles import COLORS, FONTS + + self.main_window.status_bar = ctk.CTkFrame( + self.main_window.main_container, + fg_color=COLORS['bg_secondary'], + height=30 + ) + self.main_window.status_bar.pack(fill="x", side="bottom") + self.main_window.status_bar.pack_propagate(False) + + self.main_window.status_label = ctk.CTkLabel( + self.main_window.status_bar, + text="Ready", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.main_window.status_label.pack(side="left", padx=20, pady=5) + + # Project count + self.main_window.count_label = ctk.CTkLabel( + self.main_window.status_bar, + text="0 projects", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.main_window.count_label.pack(side="right", padx=20, pady=5) + logger.debug("Status bar created") + + def create_project_tile(self, project: 'Project', parent) -> 'ProjectTile': + """Create a project tile""" + return self.main_window._original_create_project_tile(project, parent) + + def create_add_tile(self, parent) -> 'AddProjectTile': + """Create add project tile""" + return self.main_window._original_create_add_tile(parent) + + def create_project_tile_flow(self, project: 'Project', parent) -> 'ProjectTile': + """Create project tile for flow layout""" + return self.main_window._original_create_project_tile_flow(project, parent) + + def create_add_tile_flow(self, parent) -> 'AddProjectTile': + """Create add tile for flow layout""" + return self.main_window._original_create_add_tile_flow(parent) + + def refresh_ui(self) -> None: + """Refresh the UI""" + return self.main_window._original_refresh_ui() + + def load_and_apply_theme(self) -> None: + """Load and apply theme preference""" + # Direct implementation - very simple method + import customtkinter as ctk + ctk.set_appearance_mode('dark') + logger.info("Theme applied: dark mode") + + def on_window_resize(self, event) -> None: + """Handle window resize event""" + # Direct implementation + # Only process resize events from the main window + if event.widget == self.root: + # Cancel previous timer + if self.main_window.resize_timer: + self.root.after_cancel(self.main_window.resize_timer) + + # Set new timer to refresh after resize stops with differential update + self.main_window.resize_timer = self.root.after( + 300, + lambda: self.main_window.refresh_projects(differential=True) + ) + logger.debug("Window resize event handled") + + def setup_interaction_tracking(self) -> None: + """Setup interaction tracking""" + return self.main_window._original_setup_interaction_tracking() + + def _show_scrollable_info(self, title: str, content: str) -> None: + """Show scrollable information dialog""" + # Direct implementation - standalone UI method + import customtkinter as ctk + + dialog = ctk.CTkToplevel(self.root) + dialog.title(title) + dialog.geometry("600x500") + + # Center the dialog + dialog.transient(self.root) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 500) // 2 + dialog.geometry(f"600x500+{x}+{y}") + + # Text widget with scrollbar + text_frame = ctk.CTkFrame(dialog) + text_frame.pack(fill="both", expand=True, padx=10, pady=10) + + text_widget = ctk.CTkTextbox(text_frame, width=580, height=450) + text_widget.pack(fill="both", expand=True) + text_widget.insert("1.0", content) + text_widget.configure(state="disabled") + + # Close button + close_btn = ctk.CTkButton( + dialog, + text="Schließen", + command=dialog.destroy, + width=100 + ) + close_btn.pack(pady=(0, 10)) + + dialog.grab_set() + dialog.focus_set() + logger.debug(f"Scrollable dialog shown: {title}") + + def _differential_update(self, current_projects: list, previous_projects: list) -> None: + """Perform differential update of projects""" + return self.main_window._original_differential_update(current_projects, previous_projects) + + def _update_project_tiles_colors(self) -> None: + """Update project tile colors""" + return self.main_window._original_update_project_tiles_colors() \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..9f63efb --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,3120 @@ +""" +Main Window GUI +The primary application window with project grid +""" + +import customtkinter as ctk +from tkinter import filedialog, messagebox +import os +import threading +import subprocess +from typing import Optional +from PIL import Image + +from gui.styles import COLORS, FONTS, WINDOW_CONFIG, TILE_SIZE, get_button_styles, get_colors +from gui.project_tile import ProjectTile, AddProjectTile +from gui.gitea_explorer import GiteaExplorer +from gui.sidebar_view import SidebarView +from gui.settings_dialog import SettingsDialog +from project_manager import ProjectManager, Project +from terminal_launcher import TerminalLauncher +from readme_generator import ReadmeGenerator +from vps_connection import VPSConnection +from process_manager import ProcessManager +from project_process_tracker import ProjectProcessTracker +from src.gitea.repository_manager import RepositoryManager +from pathlib import Path +from utils.logger import logger + +class MainWindow: + def __init__(self): + # Load and set theme preference FIRST before anything else + self.load_and_apply_theme() + logger.info("Initializing Claude Project Manager") + + # Setup global exception handler + self._setup_exception_handler() + + # Configure customtkinter + ctk.set_default_color_theme("blue") + + # Initialize components + logger.info("Initializing components") + self.project_manager = ProjectManager() + self.terminal_launcher = TerminalLauncher() + self.readme_generator = ReadmeGenerator() + self.vps_connection = VPSConnection() + self.process_manager = ProcessManager() + self.process_tracker = ProjectProcessTracker() + self.repo_manager = RepositoryManager() + + # Set project manager reference + self.process_tracker.set_project_manager(self.project_manager) + + # Store project tiles for updates + self.project_tiles = {} + self.selected_project_tile = None + + # Interaction tracking + self.user_interacting = False + self.pending_updates = [] + + # Create main window + self.root = ctk.CTk() + self.root.title(WINDOW_CONFIG['title']) + self.root.geometry(f"{WINDOW_CONFIG['width']}x{WINDOW_CONFIG['height']}") + self.root.minsize(WINDOW_CONFIG['min_width'], WINDOW_CONFIG['min_height']) + + # Make gitea_operation accessible from tiles + self.root.gitea_operation = self.gitea_operation + + # Set window icon if available + try: + icon_path = os.path.join(os.path.dirname(__file__), "../icon.ico") + if os.path.exists(icon_path): + self.root.iconbitmap(icon_path) + except: + pass + + # Initialize handlers AFTER root is created (Phase 2 refactoring) + self._init_handlers() + + self.setup_ui() + self.refresh_projects() + logger.info("Claude Project Manager initialized successfully") + + # Bind window resize event + self.root.bind('', self.on_window_resize) + self.resize_timer = None + + # Bind window close event + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + + # Bind interaction events + self.setup_interaction_tracking() + + # Start periodic status check after a delay + self.root.after(30000, self.check_process_status) # Start after 30 seconds + + # Initialize activity sync + self.init_activity_sync() + + def _init_handlers(self): + """Initialize refactored handlers (Phase 2/3 refactoring)""" + # Load feature flags from configuration + from gui.config import refactoring_config + self.refactoring_config = refactoring_config + self.REFACTORING_FLAGS = refactoring_config.flags + + logger.info(f"Refactoring flags loaded: {self.REFACTORING_FLAGS}") + + # Initialize handlers + try: + from gui.handlers.gitea_operations import GiteaOperationsHandler + from gui.handlers.process_manager import ProcessManagerHandler + from gui.handlers.project_manager import ProjectManagerHandler + from gui.handlers.ui_helpers import UIHelpersHandler + + self._gitea_handler = GiteaOperationsHandler(self) + self._process_handler = ProcessManagerHandler(self) + self._project_handler = ProjectManagerHandler(self) + self._ui_handler = UIHelpersHandler(self) + + logger.info("Refactoring handlers initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize handlers: {e}") + # Disable all flags if initialization fails + for key in self.REFACTORING_FLAGS: + self.REFACTORING_FLAGS[key] = False + + def setup_ui(self): + """Setup the main UI""" + # Main container + self.main_container = ctk.CTkFrame(self.root, fg_color=COLORS['bg_primary']) + self.main_container.pack(fill="both", expand=True) + + # Header + self.create_header() + + # Content area with scrollable frame + self.create_content_area() + + # Status bar + self.create_status_bar() + + def create_header(self): + """Create header with title and toolbar - Facade method""" + if hasattr(self, '_ui_handler') and self.REFACTORING_FLAGS.get('USE_UI_HELPERS', False): + return self._ui_handler.create_header() + else: + return self._original_create_header() + + def _original_create_header(self): + """Create header with title and toolbar""" + header_frame = ctk.CTkFrame(self.main_container, fg_color=COLORS['bg_secondary'], height=80) + header_frame.pack(fill="x", padx=0, pady=0) + header_frame.pack_propagate(False) + + # Title + self.title_label = ctk.CTkLabel( + header_frame, + text="IntelSight - Claude Project Manager", + font=FONTS['heading'], + text_color=COLORS['text_primary'] + ) + self.title_label.pack(side="left", padx=30, pady=20) + + # Toolbar buttons + toolbar = ctk.CTkFrame(header_frame, fg_color="transparent") + toolbar.pack(side="right", padx=30, pady=20) + + # WinSCP button + self.winscp_btn = ctk.CTkButton( + toolbar, + text="📁 WinSCP", + command=self.open_winscp, + width=100, + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF", + font=('Segoe UI', 12) + ) + self.winscp_btn.pack(side="left", padx=(0, 10)) + + # Log download button + self.log_btn = ctk.CTkButton( + toolbar, + text="📥 Log", + command=self.download_log, + width=80, + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF", + font=('Segoe UI', 12) + ) + self.log_btn.pack(side="left", padx=(0, 10)) + + # Refresh button + self.refresh_btn = ctk.CTkButton( + toolbar, + text="↻ Refresh", + command=self.refresh_projects, + width=100, + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'] + ) + self.refresh_btn.pack(side="left", padx=(0, 10)) + + # Settings button + self.settings_btn = ctk.CTkButton( + toolbar, + text="⚙️", + command=self.open_settings, + width=40, + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'], + font=('Segoe UI', 16) + ) + self.settings_btn.pack(side="left") + + def create_content_area(self): + """Create content area with Gitea sidebar and project tiles""" + # Main content container + content_frame = ctk.CTkFrame(self.main_container, fg_color="transparent") + content_frame.pack(fill="both", expand=True) + + # Left sidebar for SidebarView + self.sidebar_frame = ctk.CTkFrame(content_frame, fg_color=COLORS['bg_secondary'], width=300) + self.sidebar_frame.pack(side="left", fill="y", padx=(20, 10), pady=20) + self.sidebar_frame.pack_propagate(False) + + # SidebarView with tabs/tree modes + self.sidebar_view = SidebarView( + self.sidebar_frame, + on_repo_select=self.on_gitea_repo_select + ) + self.sidebar_view.pack(fill="both", expand=True) + self.sidebar_view.set_main_window(self) # Set reference for cross-selection + + # Keep gitea_explorer reference for compatibility + self.gitea_explorer = self.sidebar_view.get_gitea_explorer() + + # Right side for project tiles + tiles_container = ctk.CTkFrame(content_frame, fg_color="transparent") + tiles_container.pack(side="right", fill="both", expand=True, padx=(10, 20), pady=20) + + # Scrollable frame container + self.scroll_container = ctk.CTkScrollableFrame( + tiles_container, + fg_color=COLORS['bg_primary'] + ) + self.scroll_container.pack(fill="both", expand=True) + + # Flow frame for tiles (using pack instead of grid) + self.flow_frame = ctk.CTkFrame(self.scroll_container, fg_color="transparent") + self.flow_frame.pack(fill="both", expand=True) + + def create_status_bar(self): + """Create status bar at bottom - Facade method""" + if hasattr(self, '_ui_handler') and self.REFACTORING_FLAGS.get('USE_UI_HELPERS', False): + return self._ui_handler.create_status_bar() + else: + return self._original_create_status_bar() + + def _original_create_status_bar(self): + """Create status bar at bottom""" + self.status_bar = ctk.CTkFrame(self.main_container, fg_color=COLORS['bg_secondary'], height=30) + self.status_bar.pack(fill="x", side="bottom") + self.status_bar.pack_propagate(False) + + self.status_label = ctk.CTkLabel( + self.status_bar, + text="Ready", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.status_label.pack(side="left", padx=20, pady=5) + + # Activity status (clickable) + self.activity_frame = ctk.CTkFrame(self.status_bar, fg_color="transparent") + self.activity_frame.pack(side="left", padx=(20, 0)) + + self.activity_label = ctk.CTkLabel( + self.activity_frame, + text="", + font=FONTS['small'], + text_color=COLORS['accent_primary'], + cursor="hand2" + ) + self.activity_label.pack(side="left") + self.activity_label.bind("", self.show_activity_details) + + # Project count + self.count_label = ctk.CTkLabel( + self.status_bar, + text="0 projects", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.count_label.pack(side="right", padx=20, pady=5) + + def refresh_projects(self, differential=False): + """Refresh the project grid""" + # Skip if user is interacting + if self.user_interacting: + self.pending_updates.append(('refresh', None)) + return + + logger.info(f"Refreshing projects (differential: {differential})") + + # Get all projects + projects = self.project_manager.get_all_projects() + + # Update count + project_count = len([p for p in projects if p.id not in ["vps-permanent", "admin-panel-permanent", "vps-docker-permanent"]]) + self.count_label.configure(text=f"{project_count} project{'s' if project_count != 1 else ''}") + + if differential and self.project_tiles: + # Differential update - only update changed tiles + self._differential_update(projects) + return + + # Full refresh - clear and rebuild + for widget in self.flow_frame.winfo_children(): + widget.destroy() + self.project_tiles.clear() + + # Calculate how many tiles can fit in a row + window_width = self.scroll_container.winfo_width() if self.scroll_container.winfo_width() > 1 else WINDOW_CONFIG['width'] - 60 + tile_width = TILE_SIZE['width'] + tile_margin = 10 + tiles_per_row = max(1, window_width // (tile_width + tile_margin * 2)) + + # Create row frames + current_row_frame = None + tiles_in_current_row = 0 + + # Helper function to create a new row + def create_new_row(): + nonlocal current_row_frame, tiles_in_current_row + current_row_frame = ctk.CTkFrame(self.flow_frame, fg_color="transparent") + current_row_frame.pack(fill="x", pady=5) + tiles_in_current_row = 0 + + # Start with first row + create_new_row() + + # Add VPS tile first + vps_project = next((p for p in projects if p.id == "vps-permanent"), None) + if vps_project: + self.create_project_tile_flow(vps_project, current_row_frame, is_vps=True) + tiles_in_current_row += 1 + + # Add Admin Panel tile second + admin_project = next((p for p in projects if p.id == "admin-panel-permanent"), None) + if admin_project: + if tiles_in_current_row >= tiles_per_row: + create_new_row() + self.create_project_tile_flow(admin_project, current_row_frame, is_vps=True) + tiles_in_current_row += 1 + + # Add VPS Docker tile third + vps_docker_project = next((p for p in projects if p.id == "vps-docker-permanent"), None) + if vps_docker_project: + if tiles_in_current_row >= tiles_per_row: + create_new_row() + self.create_project_tile_flow(vps_docker_project, current_row_frame, is_vps=True) + tiles_in_current_row += 1 + + # Add project tiles + for project in projects: + if project.id not in ["vps-permanent", "admin-panel-permanent", "vps-docker-permanent"]: + if tiles_in_current_row >= tiles_per_row: + create_new_row() + self.create_project_tile_flow(project, current_row_frame) + tiles_in_current_row += 1 + + # Add "Add Project" tile + if tiles_in_current_row >= tiles_per_row: + create_new_row() + self.create_add_tile_flow(current_row_frame) + + self.update_status("Projects refreshed") + + def create_project_tile(self, project: Project, row: int, col: int, is_vps: bool = False): + """Create a project tile (legacy grid method)""" + # This method is kept for compatibility but not used + pass + + def create_add_tile(self, row: int, col: int): + """Create add project tile (legacy grid method)""" + # This method is kept for compatibility but not used + pass + + def create_project_tile_flow(self, project: Project, parent_frame, is_vps: bool = False): + """Create a project tile in flow layout""" + is_running = self.process_tracker.is_running(project.id) + tile = ProjectTile( + parent_frame, + project, + on_open=self.open_project, + on_readme=self.open_readme, + on_delete=self.delete_project if not is_vps else None, + on_stop=self.stop_project, + is_vps=is_vps, + is_running=is_running, + on_rename=self.rename_project if not is_vps else None, + on_select=self.on_project_select + ) + # Set main window reference for activity methods + tile.main_window = self + tile.pack(side="left", padx=10, pady=10) + self.project_tiles[project.id] = tile + + def create_add_tile_flow(self, parent_frame): + """Create add project tile in flow layout""" + add_tile = AddProjectTile(parent_frame, on_add=self.add_new_project) + add_tile.pack(side="left", padx=10, pady=10) + + def add_new_project(self): + """Add a new project""" + logger.info("Adding new project") + # Open folder dialog + folder_path = filedialog.askdirectory( + title="Select Project Folder" + ) + + if folder_path: + # Get project name from folder + project_name = os.path.basename(folder_path) + logger.info(f"Selected project folder: {folder_path}") + + # Add to manager + project = self.project_manager.add_project(project_name, folder_path) + + # Generate README in background + threading.Thread( + target=self.generate_readme_background, + args=(project,), + daemon=True + ).start() + + # Don't launch Claude automatically - just add the project + self.update_status(f"Added project: {project_name}") + logger.info(f"Successfully added project: {project_name}") + + # Refresh grid with differential update + self.refresh_projects(differential=True) + + def open_project(self, project: Project): + """Open a project in Claude""" + logger.info(f"Opening project: {project.name}") + if project.id == "vps-permanent": + # Handle VPS connection + self.open_vps_connection() + elif project.id == "admin-panel-permanent": + # Handle Admin Panel + self.open_admin_panel() + elif project.id == "vps-docker-permanent": + # Handle VPS Docker + self.open_vps_docker() + else: + # Check if already running + if self.process_tracker.is_running(project.id): + self.update_status(f"Claude already running for: {project.name}", error=True) + return + + # Normal project + process = self.terminal_launcher.launch_claude_wsl(project.path, project.name) + + if process: + # Track the process + self.process_manager.processes[project.id] = process + self.process_manager.save_process_data() + self.process_tracker.set_running(project.id) + + # Update tile status immediately + if project.id in self.project_tiles: + self.project_tiles[project.id].update_status(True) + + self.update_status(f"Started: {project.name}") + # Update last accessed + project.update_last_accessed() + self.project_manager.save_projects() + + # Update README in background + threading.Thread( + target=self.generate_readme_background, + args=(project,), + daemon=True + ).start() + + # Start monitoring the process + self.monitor_process(project.id, process) + else: + self.update_status(f"Failed to start: {project.name}", error=True) + # Re-enable button if launch failed + if project.id in self.project_tiles: + self.project_tiles[project.id].update_status(False) + + def open_vps_connection(self): + """Open VPS connection""" + # Check if already running + if self.process_tracker.is_running("vps-permanent"): + self.update_status("VPS connection already active", error=True) + return + + # Create VPS README if needed + vps_readme_dir = os.path.join(os.path.dirname(__file__), "../data/vps_readme") + os.makedirs(vps_readme_dir, exist_ok=True) + vps_readme_path = os.path.join(vps_readme_dir, "VPS_README.md") + + # Generate VPS README + self.vps_connection.generate_vps_readme(vps_readme_path) + + # Create connection script + script = self.vps_connection.create_ssh_script() + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(script) + script_path = f.name + + # Launch terminal + import subprocess + if os.path.exists(r"C:\Windows\System32\wt.exe"): + process = subprocess.Popen([ + "wt.exe", "-w", "0", "new-tab", + "--title", "Claude VPS Server", + "--", "cmd", "/c", script_path + ]) + else: + process = subprocess.Popen(['cmd', '/c', 'start', 'Claude VPS', script_path]) + + # Track the process + self.process_manager.processes["vps-permanent"] = process + self.process_manager.save_process_data() + self.process_tracker.set_running("vps-permanent") + + # Update tile status immediately + if "vps-permanent" in self.project_tiles: + self.project_tiles["vps-permanent"].update_status(True) + + self.update_status("Connected to VPS server") + + # Update VPS project + vps_project = self.project_manager.get_project("vps-permanent") + if vps_project: + vps_project.update_last_accessed() + self.project_manager.save_projects() + + # Monitor the process + self.monitor_process("vps-permanent", process) + + def open_admin_panel(self): + """Open Admin Panel via VPS connection with directory change""" + # Check if already running + if self.process_tracker.is_running("admin-panel-permanent"): + self.update_status("Admin Panel already running", error=True) + return + + # Create connection script for Admin Panel + script = self.vps_connection.create_admin_panel_script() + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(script) + script_path = f.name + + # Launch terminal + import subprocess + if os.path.exists(r"C:\Windows\System32\wt.exe"): + process = subprocess.Popen([ + "wt.exe", "-w", "0", "new-tab", + "--title", "Claude Admin Panel", + "--", "cmd", "/c", script_path + ]) + else: + process = subprocess.Popen(['cmd', '/c', 'start', 'Claude Admin Panel', script_path]) + + # Track the process + self.process_manager.processes["admin-panel-permanent"] = process + self.process_manager.save_process_data() + self.process_tracker.set_running("admin-panel-permanent") + + # Update tile status immediately + if "admin-panel-permanent" in self.project_tiles: + self.project_tiles["admin-panel-permanent"].update_status(True) + + self.update_status("Connected to Admin Panel") + + # Update Admin Panel project + admin_project = self.project_manager.get_project("admin-panel-permanent") + if admin_project: + admin_project.update_last_accessed() + self.project_manager.save_projects() + + # Monitor the process + self.monitor_process("admin-panel-permanent", process) + + def open_vps_docker(self): + """Open VPS Docker connection for Admin Panel restart""" + # Check if already running + if self.process_tracker.is_running("vps-docker-permanent"): + self.update_status("VPS Docker restart already running", error=True) + return + + # Create connection script for VPS Docker + script = self.vps_connection.create_vps_docker_script() + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(script) + script_path = f.name + + # Launch terminal + import subprocess + if os.path.exists(r"C:\Windows\System32\wt.exe"): + process = subprocess.Popen([ + "wt.exe", "-w", "0", "new-tab", + "--title", "VPS Docker Restart", + "--", "cmd", "/c", script_path + ]) + else: + process = subprocess.Popen(['cmd', '/c', 'start', 'VPS Docker', script_path]) + + # Track the process + self.process_manager.processes["vps-docker-permanent"] = process + self.process_manager.save_process_data() + self.process_tracker.set_running("vps-docker-permanent") + + # Update tile status immediately + if "vps-docker-permanent" in self.project_tiles: + self.project_tiles["vps-docker-permanent"].update_status(True) + + self.update_status("Starting VPS Docker Admin Panel restart") + + # Update VPS Docker project + vps_docker_project = self.project_manager.get_project("vps-docker-permanent") + if vps_docker_project: + vps_docker_project.update_last_accessed() + self.project_manager.save_projects() + + # Monitor the process + self.monitor_process("vps-docker-permanent", process) + + def open_readme(self, project: Project): + """Open project README""" + if project.id == "vps-permanent": + # VPS README + readme_path = os.path.join(os.path.dirname(__file__), "../data/vps_readme/VPS_README.md") + if os.path.exists(readme_path): + os.startfile(readme_path) + else: + self.update_status("VPS README not found", error=True) + elif project.id == "admin-panel-permanent": + # Admin Panel doesn't have a README - just show a message + self.update_status("Admin Panel has no README file") + elif project.id == "vps-docker-permanent": + # VPS Docker doesn't have a README - just show a message + self.update_status("VPS Docker has no README file") + else: + # Project README + readme_path = os.path.join(project.path, "CLAUDE_PROJECT_README.md") + + if not os.path.exists(readme_path): + # Generate if doesn't exist + self.update_status("Generating README...") + self.readme_generator.generate_and_save_readme(project.path, project.name) + + # Open with default editor + try: + os.startfile(readme_path) + self.update_status(f"Opened README: {project.name}") + except: + self.update_status(f"Could not open README", error=True) + + def delete_project(self, project: Project): + """Delete a project from manager (not files) - Facade method""" + if hasattr(self, '_project_handler') and self.REFACTORING_FLAGS.get('USE_PROJECT_HANDLER', False): + return self._project_handler.delete_project(project) + else: + return self._original_delete_project(project) + + def _original_delete_project(self, project: Project): + """Delete a project from manager (not files)""" + logger.info(f"Attempting to delete project: {project.name}") + if messagebox.askyesno("Projekt löschen", + f"Möchten Sie das Projekt '{project.name}' wirklich aus dem Projekt-Manager entfernen?\n\n" + "Hinweis: Die Dateien werden NICHT gelöscht."): + self.project_manager.remove_project(project.id) + self.refresh_projects() + self.update_status(f"Removed: {project.name}") + + def rename_project(self, project: Project): + """Rename a project's display name""" + # Create simple dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Anzeigename ändern") + dialog.geometry("400x150") + dialog.configure(fg_color=COLORS['bg_primary']) + + # Center the dialog + dialog.transient(self.root) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 400) // 2 + y = (dialog.winfo_screenheight() - 150) // 2 + dialog.geometry(f"400x150+{x}+{y}") + + # Make dialog grab focus + dialog.grab_set() + dialog.lift() + dialog.attributes('-topmost', True) + + # Label + label = ctk.CTkLabel( + dialog, + text=f"Neuer Anzeigename für '{project.name}':", + font=FONTS['tile_text'], + text_color=COLORS['text_primary'] + ) + label.pack(pady=(20, 10)) + + # Entry + entry = ctk.CTkEntry( + dialog, + width=350, + font=FONTS['tile_text'], + fg_color=COLORS['bg_secondary'], + text_color=COLORS['text_primary'] + ) + entry.pack(pady=5) + entry.insert(0, project.name) + entry.select_range(0, 'end') + + # Force focus after a small delay + dialog.after(100, lambda: entry.focus_force()) + + # Buttons + button_frame = ctk.CTkFrame(dialog, fg_color="transparent") + button_frame.pack(pady=15) + + def save_rename(): + new_name = entry.get().strip() + if new_name and new_name != project.name: + # Update project name + self.project_manager.update_project(project.id, name=new_name) + # Update tile + if project.id in self.project_tiles: + self.project_tiles[project.id].update_project( + self.project_manager.get_project(project.id) + ) + self.update_status(f"Renamed to: {new_name}") + dialog.grab_release() + dialog.destroy() + + # Save on Enter + entry.bind("", lambda e: save_rename()) + + save_btn = ctk.CTkButton( + button_frame, + text="Speichern", + command=save_rename, + width=100, + **get_button_styles()['primary'] + ) + save_btn.pack(side="left", padx=5) + + cancel_btn = ctk.CTkButton( + button_frame, + text="Abbrechen", + command=lambda: [dialog.grab_release(), dialog.destroy()], + width=100, + **get_button_styles()['secondary'] + ) + cancel_btn.pack(side="left", padx=5) + + # Bind Escape key to cancel + dialog.bind("", lambda e: [dialog.grab_release(), dialog.destroy()]) + + def generate_readme_background(self, project: Project): + """Generate README in background thread""" + try: + self.readme_generator.generate_and_save_readme(project.path, project.name) + except Exception as e: + print(f"Error generating README: {e}") + + + def update_status(self, message: str, error: bool = False): + """Update status bar message - Facade method""" + if hasattr(self, '_process_handler') and self.REFACTORING_FLAGS.get('USE_PROCESS_HANDLER', False): + return self._process_handler.update_status(message, error) + else: + return self._original_update_status(message, error) + + def _original_update_status(self, message: str, error: bool = False): + """Update status bar message""" + self.status_label.configure( + text=message, + text_color=COLORS['accent_error'] if error else COLORS['text_secondary'] + ) + + + def download_log(self): + """Download application log file""" + logger.info("Log download button clicked") + # Always use the original implementation for now + return self._original_download_log() + + def _original_download_log(self): + """Download application log file""" + try: + logger.info("Opening file dialog for log export") + + # Create default filename + from datetime import datetime + default_filename = f"CPM_Log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + + # Open file dialog to choose save location + file_path = filedialog.asksaveasfilename( + defaultextension=".log", + filetypes=[("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")], + initialfile=default_filename + ) + + if file_path: + logger.info(f"Exporting log to: {file_path}") + # Export logs to chosen location + logger.export_logs(file_path) + self.update_status(f"Log saved to: {os.path.basename(file_path)}") + logger.info(f"Log exported successfully to: {file_path}") + messagebox.showinfo("Export erfolgreich", f"Log wurde gespeichert:\n{os.path.basename(file_path)}") + else: + logger.info("Log export cancelled by user") + + except Exception as e: + error_msg = f"Error saving log: {str(e)}" + self.update_status(error_msg, error=True) + logger.error(error_msg, exc_info=True) + messagebox.showerror("Export Error", error_msg) + + def open_settings(self): + """Open settings dialog""" + settings_dialog = SettingsDialog(self.root, self.sidebar_view) + + def open_winscp(self): + """Open WinSCP with VPS connection""" + try: + import os + import tempfile + from pathlib import Path + + # VPS connection details + vps_host = "91.99.192.14" + vps_username = "claude-dev" + vps_password = "z0E1Al}q2H?Yqd!O" + + # Check if WinSCP is in tools folder + winscp_portable = Path(__file__).parent.parent / "tools" / "WinSCP" / "WinSCP.exe" + + # Alternative: Check if WinSCP is installed + winscp_installed = r"C:\Program Files (x86)\WinSCP\WinSCP.exe" + winscp_installed_64 = r"C:\Program Files\WinSCP\WinSCP.exe" + + winscp_path = None + if winscp_portable.exists(): + winscp_path = str(winscp_portable) + logger.info(f"Using portable WinSCP: {winscp_path}") + elif os.path.exists(winscp_installed): + winscp_path = winscp_installed + logger.info(f"Using installed WinSCP (x86): {winscp_path}") + elif os.path.exists(winscp_installed_64): + winscp_path = winscp_installed_64 + logger.info(f"Using installed WinSCP (x64): {winscp_path}") + else: + # WinSCP not found + from tkinter import messagebox + messagebox.showwarning( + "WinSCP nicht gefunden", + "WinSCP wurde nicht gefunden.\n\n" + "Bitte installieren Sie WinSCP von:\n" + "https://winscp.net/download/WinSCP-Portable.zip\n\n" + "Oder entpacken Sie WinSCP Portable in:\n" + f"{winscp_portable.parent}" + ) + return + + # Create WinSCP session URL + # Format: sftp://username:password@hostname/ + session_url = f"sftp://{vps_username}:{vps_password}@{vps_host}/" + + # Start WinSCP with session URL + cmd = f'"{winscp_path}" "{session_url}"' + + logger.info(f"Starting WinSCP with VPS connection") + import subprocess + subprocess.Popen(cmd, shell=True) + + self.update_status("WinSCP gestartet") + + except Exception as e: + error_msg = f"Fehler beim Starten von WinSCP: {str(e)}" + self.update_status(error_msg, error=True) + logger.error(error_msg) + messagebox.showerror("WinSCP Fehler", error_msg) + + def refresh_ui(self): + """Refresh the entire UI""" + # Import updated styles + from gui.styles import COLORS, BUTTON_STYLES + + # Update main container + self.main_container.configure(fg_color=COLORS['bg_primary']) + + # Update header + for widget in self.main_container.winfo_children(): + if isinstance(widget, ctk.CTkFrame): + widget.configure(fg_color=COLORS['bg_secondary'] if widget.winfo_y() < 100 else COLORS['bg_primary']) + + # Update sidebar frame + self.sidebar_frame.configure(fg_color=COLORS['bg_secondary']) + + # Update scroll container + self.scroll_container.configure(fg_color=COLORS['bg_primary']) + + # Update status bar + self.status_bar.configure(fg_color=COLORS['bg_secondary']) + self.status_label.configure(text_color=COLORS['text_secondary']) + self.count_label.configure(text_color=COLORS['text_secondary']) + + # Update title label + self.title_label.configure(text_color=COLORS['text_primary']) + + # Update log button + self.log_btn.configure( + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + text_color="#FFFFFF" + ) + + # Update refresh button + self.refresh_btn.configure( + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'] + ) + + # Update Gitea Explorer colors + self.gitea_explorer.refresh_colors() + + + # Update existing project tiles without full refresh + self._update_project_tiles_colors() + + + def load_and_apply_theme(self): + """Apply dark mode - Facade method""" + if hasattr(self, '_ui_handler') and self.REFACTORING_FLAGS.get('USE_UI_HELPERS', False): + return self._ui_handler.load_and_apply_theme() + else: + return self._original_load_and_apply_theme() + + def _original_load_and_apply_theme(self): + """Apply dark mode (only mode available)""" + # Always use dark mode + ctk.set_appearance_mode('dark') + + def on_window_resize(self, event): + """Handle window resize events - Facade method""" + if hasattr(self, '_ui_handler') and self.REFACTORING_FLAGS.get('USE_UI_HELPERS', False): + return self._ui_handler.on_window_resize(event) + else: + return self._original_on_window_resize(event) + + def _original_on_window_resize(self, event): + """Handle window resize events""" + # Only process resize events from the main window + if event.widget == self.root: + # Cancel previous timer + if self.resize_timer: + self.root.after_cancel(self.resize_timer) + + # Set new timer to refresh after resize stops with differential update + self.resize_timer = self.root.after(300, lambda: self.refresh_projects(differential=True)) + + def stop_project(self, project: Project): + """Stop a running project""" + logger.info(f"Stopping project: {project.name}") + # Always try to stop, even if process manager doesn't have it tracked + self.process_manager.stop_process(project.id, project.name) + self.process_tracker.set_stopped(project.id) + + self.update_status(f"Stopped: {project.name}") + # Update tile status + if project.id in self.project_tiles: + self.project_tiles[project.id].update_status(False) + + def _differential_update(self, projects): + """Update only changed project tiles""" + project_dict = {p.id: p for p in projects} + existing_ids = set(self.project_tiles.keys()) + new_ids = set(project_dict.keys()) + + # Remove tiles for deleted projects + for removed_id in existing_ids - new_ids: + if removed_id in self.project_tiles: + self.project_tiles[removed_id].destroy() + del self.project_tiles[removed_id] + + # Update existing tiles + for project_id in existing_ids & new_ids: + project = project_dict[project_id] + tile = self.project_tiles[project_id] + # Update project data + tile.update_project(project) + # Update running status + is_running = self.process_tracker.is_running(project_id) + if tile.is_running != is_running: + tile.update_status(is_running) + + # Add new tiles (this is more complex due to flow layout) + # For now, trigger full refresh if new projects are added + if new_ids - existing_ids: + self.refresh_projects(differential=False) + + def _update_project_tiles_colors(self): + """Update colors of existing project tiles without recreating them""" + from gui.styles import COLORS, BUTTON_STYLES + + # Update each project tile's colors + for tile in self.project_tiles.values(): + if hasattr(tile, 'is_vps') and not tile.is_vps: + # Update regular project tile colors + tile.configure(fg_color=COLORS['bg_tile']) + + # Update labels + if hasattr(tile, 'title_label'): + tile.title_label.configure(text_color=COLORS['text_primary']) + if hasattr(tile, 'path_label'): + tile.path_label.configure(text_color=COLORS['text_secondary']) + if hasattr(tile, 'tags_label'): + tile.tags_label.configure(text_color=COLORS['text_dim']) + if hasattr(tile, 'time_label'): + tile.time_label.configure(text_color=COLORS['text_dim']) + if hasattr(tile, 'status_indicator'): + tile.status_indicator.configure( + text_color=COLORS['accent_success'] if tile.is_running else COLORS['accent_error'] + ) + if hasattr(tile, 'status_message'): + tile.status_message.configure(text_color=COLORS['accent_warning']) + + # Update button styles + if hasattr(tile, 'open_button'): + btn_style = BUTTON_STYLES['danger'] if tile.is_running else BUTTON_STYLES['primary'] + tile.open_button.configure( + fg_color=btn_style['fg_color'], + hover_color=btn_style['hover_color'], + text_color=btn_style.get('text_color', '#FFFFFF') + ) + if hasattr(tile, 'gitea_button'): + tile.gitea_button.configure( + fg_color=BUTTON_STYLES['secondary']['fg_color'], + hover_color=BUTTON_STYLES['secondary']['hover_color'], + text_color=BUTTON_STYLES['secondary']['text_color'] + ) + if hasattr(tile, 'explorer_button'): + tile.explorer_button.configure( + fg_color=BUTTON_STYLES['secondary']['fg_color'], + hover_color=BUTTON_STYLES['secondary']['hover_color'], + text_color=BUTTON_STYLES['secondary']['text_color'] + ) + if hasattr(tile, 'delete_button'): + tile.delete_button.configure( + fg_color=COLORS['bg_secondary'], + hover_color=COLORS['accent_error'], + text_color=COLORS['text_secondary'] + ) + + # Update Add Project Tile if it exists + for widget in self.flow_frame.winfo_children(): + if isinstance(widget, ctk.CTkFrame): + for child in widget.winfo_children(): + if child.__class__.__name__ == 'AddProjectTile': + child.configure( + fg_color=COLORS['bg_secondary'], + border_color=COLORS['border_primary'] + ) + # Update nested labels in AddProjectTile + for subwidget in child.winfo_children(): + if isinstance(subwidget, ctk.CTkFrame): + for label in subwidget.winfo_children(): + if isinstance(label, ctk.CTkLabel): + if label.cget("font")[1] == 48: # Folder icon + label.configure(text_color=COLORS['text_dim']) + elif "Add New Project" in label.cget("text"): + label.configure(text_color=COLORS['text_secondary']) + elif "Click to select" in label.cget("text"): + label.configure(text_color=COLORS['text_dim']) + + def setup_interaction_tracking(self): + """Setup tracking for user interactions""" + # Track when dropdowns or menus are active + self.root.bind_all("", self._on_click_start) + self.root.bind_all("", self._on_click_end) + self.root.bind_all("<>", self._on_dropdown_select) + self.root.bind_all("", self._on_focus_in) + self.root.bind_all("", self._on_focus_out) + + def _on_click_start(self, event): + """Track start of user interaction""" + self.user_interacting = True + + def _on_click_end(self, event): + """Track end of user interaction""" + # Delay to allow dropdown/menu operations to complete + self.root.after(500, self._check_pending_updates) + + def _on_dropdown_select(self, event): + """Handle dropdown selection""" + self.user_interacting = True + self.root.after(500, self._check_pending_updates) + + def _on_focus_in(self, event): + """Track focus events""" + if isinstance(event.widget, (ctk.CTkComboBox, ctk.CTkOptionMenu)): + self.user_interacting = True + + def _on_focus_out(self, event): + """Track focus loss""" + self.root.after(200, self._check_pending_updates) + + def _check_pending_updates(self): + """Process pending updates after interaction ends""" + self.user_interacting = False + + # Process any pending updates + if self.pending_updates: + updates = self.pending_updates[:] + self.pending_updates.clear() + + # Process unique update types + update_types = set(u[0] for u in updates) + + if 'refresh' in update_types: + self.refresh_projects(differential=True) + elif 'status_check' in update_types: + # Run status check immediately + self.check_process_status() + + def monitor_process(self, project_id: str, process: subprocess.Popen): + """Monitor a process and update status when it ends""" + def check_process(): + try: + # Wait for process to complete + process.wait() + # Process has ended, update status + self.root.after(0, self._handle_process_ended, project_id) + except: + pass + + # Start monitoring in background thread + monitor_thread = threading.Thread(target=check_process, daemon=True) + monitor_thread.start() + + def _handle_process_ended(self, project_id: str): + """Handle when a process ends (called in main thread)""" + # Update tracking + self.process_tracker.set_stopped(project_id) + if project_id in self.process_manager.processes: + del self.process_manager.processes[project_id] + self.process_manager.save_process_data() + + # Update tile status + if project_id in self.project_tiles: + self.project_tiles[project_id].update_status(False) + + # Update status bar + project = self.project_manager.get_project(project_id) + if project: + self.update_status(f"Claude closed: {project.name}") + + def check_process_status(self): + """Periodically check the status of all processes""" + # Skip if user is interacting + if self.user_interacting: + self.pending_updates.append(('status_check', None)) + # Schedule next check + self.root.after(30000, self.check_process_status) + return + + # Only check if we have any supposedly running projects + if any(tile.is_running for tile in self.project_tiles.values()): + # Check each running project tile + for project_id, tile in self.project_tiles.items(): + if tile.is_running: # Only check running projects + actual_running = self.process_tracker.is_running(project_id) + if not actual_running: + # Only update the specific tile, not the entire UI + tile.update_status(False) + self.process_tracker.set_stopped(project_id) + + # Schedule next check in 30 seconds (less frequent) + self.root.after(30000, self.check_process_status) + + def open_gitea_window(self): + """Open Gitea integration window""" + # Create Gitea window if not already open + gitea_window = ctk.CTkToplevel(self.root) + gitea_window.title("Gitea Integration") + gitea_window.geometry("1200x700") + gitea_window.configure(fg_color=COLORS['bg_primary']) + + # Center the window + gitea_window.transient(self.root) + gitea_window.update_idletasks() + x = (gitea_window.winfo_screenwidth() - 1200) // 2 + y = (gitea_window.winfo_screenheight() - 700) // 2 + gitea_window.geometry(f"1200x700+{x}+{y}") + + # Create frame for Gitea UI + gitea_frame = ctk.CTkFrame(gitea_window, fg_color=COLORS['bg_primary']) + gitea_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Import and create Gitea UI + try: + from src.gitea.gitea_ui_ctk import GiteaIntegrationUI + + # Initialize Gitea UI with the window + gitea_ui = GiteaIntegrationUI(gitea_window) + + self.update_status("Gitea Integration opened") + except Exception as e: + import traceback + traceback.print_exc() + messagebox.showerror("Error", f"Failed to open Gitea Integration: {e}") + gitea_window.destroy() + + + def on_gitea_repo_select(self, repo): + """Handle Gitea repository selection""" + logger.info(f"Gitea repo selected: {repo.get('name', 'Unknown')}") + + # Clear project tile selection when selecting a Gitea repo + if hasattr(self, 'selected_project_tile') and self.selected_project_tile: + self.selected_project_tile.set_selected(False) + self.selected_project_tile = None + self.selected_project = None + + # No toolbar needed anymore - using dropdown in tiles + + def clear_project_selection(self): + """Clear the current project tile selection""" + if self.selected_project_tile: + self.selected_project_tile.set_selected(False) + self.selected_project_tile = None + self.selected_project = None + + # Show toolbar for Gitea repo + status_text = f"Repository: {repo['name']}" + if repo.get('private'): + status_text += " (Privat)" + self.gitea_toolbar.show_for_context('gitea_repo', repo, status_text) + + def on_project_select(self, project): + """Handle project tile selection""" + self.selected_project = project + + # Update visual selection + if self.selected_project_tile and project.id in self.project_tiles: + # Deselect previous tile + self.selected_project_tile.set_selected(False) + + # Select new tile + if project.id in self.project_tiles: + self.selected_project_tile = self.project_tiles[project.id] + self.selected_project_tile.set_selected(True) + + # Clear Gitea selection when project is selected + if hasattr(self, 'gitea_explorer'): + self.gitea_explorer.clear_selection() + + # No toolbar anymore - using dropdown buttons + + # Git operation callbacks + def show_git_status(self, item): + """Show git status for project or repo""" + if isinstance(item, Project): + # For local project + try: + # Hide console window on Windows + startupinfo = None + if os.name == 'nt': # Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=item.path, + capture_output=True, + text=True, + startupinfo=startupinfo + ) + + if result.stdout: + messagebox.showinfo("Git Status", f"Uncommitted changes:\n{result.stdout}") + else: + messagebox.showinfo("Git Status", "Working directory clean") + except Exception as e: + messagebox.showerror("Error", f"Failed to get git status: {e}") + else: + # For Gitea repo + messagebox.showinfo("Repository Info", + f"Name: {item['name']}\n" + f"Private: {'Yes' if item.get('private') else 'No'}\n" + f"Description: {item.get('description', 'No description')}") + + def commit_changes(self, project): + """Commit changes for a project""" + from tkinter import simpledialog + + # Check for changes + git_ops = self.repo_manager.git_ops + success, status = git_ops.status(Path(project.path)) + + if not status.strip(): + messagebox.showinfo("Keine Änderungen", "Keine Änderungen zum Committen vorhanden.") + return + + # Ask for commit message + message = simpledialog.askstring( + "Commit", + "Commit-Nachricht eingeben:", + parent=self.root + ) + + if message: + # Add all changes + git_ops.add(Path(project.path)) + + # Commit + success, result = git_ops.commit(Path(project.path), message) + + if success: + messagebox.showinfo("Erfolg", "Änderungen erfolgreich committet!") + else: + messagebox.showerror("Fehler", f"Commit fehlgeschlagen: {result}") + + def push_to_gitea(self, project): + """Push project to Gitea - Facade method""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.push_to_gitea(project) + else: + return self._original_push_to_gitea(project) + + def _original_push_to_gitea(self, project): + """Push project to Gitea""" + import logging + logger = logging.getLogger(__name__) + + project_path = Path(project.path) if hasattr(project, 'path') else Path(project['path']) + project_name = project.name if hasattr(project, 'name') else project.get('name', 'Unknown') + + # Check current remote + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + if success: + logger.info(f"Current remotes before push: {remotes}") + + # Check for large files before push + large_files = git_ops.check_large_files(project_path, 50) # 50MB limit + if large_files: + msg = "⚠️ Große Dateien gefunden!\n\n" + msg += "Folgende Dateien überschreiten 50MB:\n\n" + total_size = 0 + for file, size in large_files[:10]: # Show first 10 + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + if len(large_files) > 10: + msg += f" ... und {len(large_files) - 10} weitere\n" + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + + # Simple solution: offer to exclude files + msg += "Diese großen Dateien verhindern den Push zu Gitea.\n\n" + msg += "EMPFEHLUNG: Große Dateien aus Git entfernen\n\n" + msg += "Möchten Sie diese Dateien aus Git entfernen?\n" + msg += "(Die Dateien bleiben auf Ihrer Festplatte erhalten)" + + if messagebox.askyesno("Große Dateien gefunden", msg, icon='warning'): + # Remove large files from git but keep them locally + try: + # First create .gitignore + gitignore_path = project_path / ".gitignore" + existing_content = "" + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + # Add large files to .gitignore + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + # Remove files from git index but keep local copies + removed_count = 0 + for file, _ in large_files: + cmd = ["git", "rm", "--cached", file] + success, _, _ = git_ops._run_git_command(cmd, cwd=project_path) + if success: + removed_count += 1 + + if removed_count > 0: + # Commit the changes + git_ops.add(Path(project_path), [".gitignore"]) + success, _ = git_ops.commit(Path(project_path), + f"Große Dateien aus Git entfernt ({removed_count} Dateien)") + + if success: + messagebox.showinfo("Erfolg", + f"{removed_count} große Dateien wurden aus Git entfernt.\n\n" + "Die Dateien sind weiterhin lokal vorhanden.\n" + "Sie können jetzt den Push erneut versuchen.") + # Continue with push + success, result = git_ops.push(project_path) + if success: + messagebox.showinfo("Push erfolgreich", "Änderungen wurden zu Gitea gepusht!") + return + else: + messagebox.showerror("Push fehlgeschlagen", f"Push fehlgeschlagen: {result}") + return + else: + messagebox.showerror("Fehler", "Konnte Änderungen nicht committen.") + return + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Entfernen der Dateien: {str(e)}") + return + else: + # User wants alternative solution + if messagebox.askyesno("Alternative", + "Möchten Sie die großen Dateien manuell bearbeiten?\n\n" + "Optionen:\n" + "- Dateien komprimieren (z.B. ZIP)\n" + "- Dateien auf externen Speicher verschieben\n" + "- Dateien in kleinere Teile aufteilen\n\n" + "Soll ich eine .gitignore-Datei erstellen?"): + gitignore_path = project_path / ".gitignore" + try: + # Read existing .gitignore if exists + existing_content = "" + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + # Add large files + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + messagebox.showinfo("Erfolg", + ".gitignore wurde aktualisiert.\n\n" + "Bitte committen Sie die .gitignore-Datei und versuchen Sie es erneut.") + except Exception as e: + messagebox.showerror("Fehler", f"Konnte .gitignore nicht erstellen: {str(e)}") + return + + # Perform push + success, result = git_ops.push(project_path) + + if success: + # Debug: Check where the repository actually is + debug_info = "Push erfolgreich!\n\n" + debug_info += f"Remote URLs:\n{remotes}\n\n" + + # Extract owner from remote URL + import re + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if match: + remote_owner = match.group(1) + remote_repo = match.group(2) + debug_info += f"Push ging an: {remote_owner}/{remote_repo}\n\n" + + # Try to find the repository + try: + # Check if repo exists under detected owner + repo = self.repo_manager.client.get_repository(remote_owner, remote_repo) + debug_info += f"✅ Repository gefunden!\n" + debug_info += f"URL: {repo.get('html_url', 'Unknown')}\n" + debug_info += f"Größe: {repo.get('size', 0)} bytes\n" + debug_info += f"Default Branch: {repo.get('default_branch', 'Keiner')}\n\n" + except: + debug_info += f"❌ Repository nicht unter {remote_owner}/{remote_repo} gefunden!\n\n" + + # Search in all accessible locations + debug_info += "Suche Repository in allen Orten:\n" + + # Check user repos + try: + user_repos = self.repo_manager.list_all_repositories() + found_in_user = any(r['name'] == remote_repo for r in user_repos) + debug_info += f"- In Benutzer-Repos: {'Gefunden' if found_in_user else 'Nicht gefunden'}\n" + except: + debug_info += "- In Benutzer-Repos: Fehler beim Suchen\n" + + # Check org repos + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + try: + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + found_in_org = any(r['name'] == remote_repo for r in org_repos) + debug_info += f"- In Organisation {self.gitea_explorer.organization_name}: {'Gefunden' if found_in_org else 'Nicht gefunden'}\n" + except: + debug_info += f"- In Organisation: Fehler beim Suchen\n" + + debug_info += f"\nLog-Datei: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + messagebox.showinfo("Push Debug Info", debug_info) + + # Refresh Gitea explorer + if hasattr(self, 'gitea_explorer'): + self.gitea_explorer.refresh_repositories() + else: + messagebox.showerror("Fehler", f"Push fehlgeschlagen: {result}") + + def pull_from_gitea(self, project): + """Pull changes from Gitea""" + git_ops = self.repo_manager.git_ops + success, result = git_ops.pull(Path(project.path)) + + if success: + messagebox.showinfo("Erfolg", "Änderungen erfolgreich gepullt!") + else: + messagebox.showerror("Fehler", f"Pull fehlgeschlagen: {result}") + + def fetch_from_gitea(self, item): + """Fetch from Gitea""" + if isinstance(item, Project): + # For local project + try: + # Hide console window on Windows + startupinfo = None + if os.name == 'nt': # Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + # Run git fetch + result = subprocess.run( + ["git", "fetch", "--all"], + cwd=item.path, + capture_output=True, + text=True, + startupinfo=startupinfo + ) + + if result.returncode == 0: + # Check if there are updates + status_result = subprocess.run( + ["git", "status", "-uno"], + cwd=item.path, + capture_output=True, + text=True, + startupinfo=startupinfo + ) + + if "Your branch is behind" in status_result.stdout: + messagebox.showinfo("Fetch Complete", + "Updates available!\n\nUse Pull to get the latest changes.") + else: + messagebox.showinfo("Fetch Complete", "Already up to date.") + else: + messagebox.showerror("Fetch Error", f"Failed to fetch: {result.stderr}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to fetch: {e}") + else: + # For Gitea repo - just refresh the explorer + if hasattr(self, 'gitea_explorer'): + self.gitea_explorer.refresh_repositories() + messagebox.showinfo("Refresh", "Repository list updated") + + def manage_branches(self, project): + """Manage branches - Facade method (placeholder removed)""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.manage_branches(project) + else: + # Skip the placeholder, use the real implementation + return self._original_manage_branches_v2(project) + + # _original_manage_branches removed - was placeholder only + + def link_to_gitea(self, project): + """Link to Gitea - Facade method (placeholder removed)""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.link_to_gitea(project) + else: + # Skip the placeholder, use the real implementation at line 2455 + return self._original_link_to_gitea_v2(project) + + # _original_link_to_gitea removed - was placeholder only + + def clone_repository(self, repo): + """Clone a Gitea repository""" + def do_clone(): + try: + # Update status in main thread + self.root.after(0, lambda: self.update_status(f"Cloning {repo['name']}...")) + + # Clone repository - use owner from repo data if available + repo_owner = repo.get('owner', {}).get('username') if repo.get('owner') else None + + logger.info(f"Attempting to clone repository: {repo['name']}") + logger.info(f"Repository owner: {repo_owner}") + logger.info(f"Current user: {self.repo_manager.current_user.get('username')}") + + if repo_owner and repo_owner != self.repo_manager.current_user.get('username'): + # Check if it's an organization repository and user is a member + try: + user_orgs = self.repo_manager.client.list_user_organizations() + org_names = [org.get('username', org.get('name', '')) for org in user_orgs] + + logger.info(f"User organizations: {org_names}") + + # Special handling for IntelSight organization + if repo_owner == 'IntelSight': + logger.info(f"Repository belongs to IntelSight organization - allowing clone") + elif repo_owner not in org_names: + # This is someone else's repo and user is not a member of the org + logger.warning(f"User is not a member of organization: {repo_owner}") + self.root.after(0, lambda: messagebox.showwarning( + "Not your repository", + f"This repository belongs to {repo_owner}.\nYou may need to fork it first." + )) + return + else: + logger.info(f"User is member of organization: {repo_owner}") + except Exception as e: + logger.error(f"Error checking organization membership: {e}") + # If we can't check org membership, try to clone anyway + logger.info("Proceeding with clone despite org check failure") + # If we get here, the repo belongs to an org the user is a member of + + # Use the actual owner (user or organization) for cloning + if repo_owner: + success, path = self.repo_manager.git_ops.clone_repository(repo_owner, repo['name']) + else: + success, path = self.repo_manager.clone_repository(repo['name']) + + if success: + # Create CPM project in main thread + self.root.after(0, lambda: self._create_project_after_clone(repo['name'], str(path), repo_owner)) + else: + # Get more detailed error information + error_msg = "Failed to clone repository.\n\n" + error_msg += f"Repository: {repo['name']}\n" + error_msg += f"Please check:\n" + error_msg += "- Internet connection\n" + error_msg += "- Git is installed\n" + error_msg += "- Repository permissions" + self.root.after(0, lambda: messagebox.showerror("Clone Error", error_msg)) + + except Exception as e: + import traceback + error_details = traceback.format_exc() + print(f"Clone error details:\n{error_details}") + + error_msg = f"Failed to clone repository: {str(e)}\n\n" + if "get_user_info" in str(e): + error_msg += "Cannot connect to Gitea server. Please check your connection." + elif "git" in str(e).lower(): + error_msg += "Git might not be installed or accessible." + + self.root.after(0, lambda: messagebox.showerror("Clone Error", error_msg)) + + # Run in thread to avoid blocking UI + threading.Thread(target=do_clone, daemon=True).start() + + def _create_project_after_clone(self, repo_name, path, repo_owner=None): + """Create project after successful clone""" + try: + # Add project to CPM + project = self.project_manager.add_project(repo_name, path) + + # Update project with Gitea repo reference if owner is provided + if repo_owner: + project.gitea_repo = f"{repo_owner}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + + # Refresh the project grid with differential update + self.refresh_projects(differential=True) + + # Show success message + self.update_status(f"Repository '{repo_name}' cloned and added to projects") + messagebox.showinfo("Success", f"Repository cloned to:\n{path}\n\nProject added to CPM!") + + # Also refresh the Gitea explorer to update status + if hasattr(self, 'gitea_explorer'): + self.gitea_explorer.refresh_repositories() + + except Exception as e: + messagebox.showerror("Error", f"Repository cloned but failed to create project: {e}") + + def create_project_from_repo(self, repo): + """Create a CPM project from cloned repository""" + local_path = Path.home() / "GiteaRepos" / repo['name'] + if local_path.exists(): + self.project_manager.create_project(repo['name'], str(local_path)) + self.refresh_projects(differential=True) + messagebox.showinfo("Success", f"Project created for {repo['name']}") + else: + messagebox.showwarning("Not Cloned", "Repository must be cloned first") + + def on_closing(self): + """Handle window closing event""" + # Stop all running processes + self.process_manager.stop_all_processes() + self.process_tracker.stop_all() + # Destroy the window + self.root.destroy() + + def gitea_operation(self, project, operation: str): + """Handle Gitea operations from project tiles""" + if operation == "status": + self.show_git_status(project) + elif operation == "commit": + self.commit_changes(project) + elif operation == "push": + self.push_to_gitea(project) + elif operation == "pull": + self.pull_from_gitea(project) + elif operation == "fetch": + self.fetch_from_gitea(project) + elif operation == "link": + self.link_to_gitea(project) + elif operation == "branch": + self.manage_branches(project) + elif operation == "init_push": + self.init_and_push_to_gitea(project) + elif operation == "init": + self.init_git_repo(project) + elif operation == "test": + self.test_gitea_connection(project) + elif operation == "verify": + self.verify_repository_on_gitea(project) + elif operation == "lfs": + self.setup_git_lfs(project) + elif operation == "manage_large": + self.manage_large_files(project) + elif operation == "fix_repo": + self.fix_repository_issues(project) + + def init_and_push_to_gitea(self, project): + """Initialize git repo and push to Gitea - Facade method""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.init_and_push_to_gitea(project) + else: + return self._original_init_and_push_to_gitea(project) + + def _original_init_and_push_to_gitea(self, project): + """Initialize git repo and push to Gitea""" + from tkinter import simpledialog + + # Ask for repository name + repo_name = simpledialog.askstring( + "Neues Repository", + f"Repository-Name für '{project.name}':", + initialvalue=project.name + ) + + while repo_name: + try: + # Log the creation attempt + import logging + logger = logging.getLogger(__name__) + + # Always try to create in organization first if available + org_name = None + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + org_name = self.gitea_explorer.organization_name + + # Create repository on Gitea + if org_name and (not hasattr(self, 'gitea_explorer') or self.gitea_explorer.view_mode != "user"): + # Create in organization + logger.info(f"Creating repository '{repo_name}' in organization '{org_name}'") + repo = self.repo_manager.create_repository(repo_name, auto_init=False, organization=org_name) + else: + # Create as user repository + logger.info(f"Creating repository '{repo_name}' as user repository") + repo = self.repo_manager.create_repository(repo_name, auto_init=False, organization=None) + + logger.info(f"Repository created: {repo}") + + # Verify repository was created + repo_owner = repo.get('owner', {}).get('username', 'Unknown') + repo_url = repo.get('html_url', 'Unknown') + + # Check if repo was created in the correct place + expected_owner = org_name if org_name else self.repo_manager.client.config.username + if repo_owner != expected_owner: + messagebox.showwarning("Achtung", + f"Repository wurde unter falschem Owner erstellt!\n\n" + f"Erwartet: {expected_owner}\n" + f"Erstellt unter: {repo_owner}\n\n" + f"URL: {repo_url}") + else: + messagebox.showinfo("Repository erstellt", + f"Repository '{repo_name}' wurde erstellt.\n\n" + f"Owner: {repo_owner}\n" + f"URL: {repo_url}") + + # Initialize local git repo + git_ops = self.repo_manager.git_ops + success, msg = git_ops.init_repository(Path(project.path)) + + if success: + # Check for large files before adding + large_files = [] + import os + for root, dirs, files in os.walk(project.path): + # Skip .git directory + if '.git' in root: + continue + for file in files: + file_path = os.path.join(root, file) + try: + # Check if file is larger than 50MB (Gitea's typical limit) + file_size = os.path.getsize(file_path) + if file_size > 50 * 1024 * 1024: + size_mb = file_size / (1024 * 1024) + # Store relative path from project root + rel_path = os.path.relpath(file_path, project.path) + large_files.append((rel_path, size_mb)) + except: + pass + + if large_files: + msg = (f"⚠️ WARNUNG: Große Dateien gefunden!\n\n" + f"Gitea hat ein Upload-Limit. Folgende Dateien sind zu groß:\n\n") + total_size = 0 + for i, (file, size_mb) in enumerate(large_files[:5]): + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size_mb + if len(large_files) > 5: + msg += f" ... und {len(large_files) - 5} weitere\n" + msg += f"\nGesamtgröße großer Dateien: {total_size:.1f} MB\n" + msg += ("\n❌ DIESE DATEIEN KÖNNEN NICHT ZU GITEA GEPUSHT WERDEN!\n\n" + "Optionen:\n" + "1. Abbrechen und große Dateien entfernen\n" + "2. Große Dateien zur .gitignore hinzufügen\n" + "3. Git LFS einrichten (fortgeschritten)\n\n" + "Trotzdem fortfahren? (Push wird fehlschlagen!)") + + if not messagebox.askyesno("Große Dateien gefunden", msg, icon='warning'): + # Help user create .gitignore + if messagebox.askyesno("Hilfe", "Soll ich eine .gitignore-Datei mit den großen Dateien erstellen?"): + gitignore_path = Path(project.path) / ".gitignore" + with open(gitignore_path, 'a', encoding='utf-8') as f: + f.write("\n# Große Dateien automatisch hinzugefügt\n") + for file, _ in large_files: + f.write(f"{file}\n") + messagebox.showinfo("Erfolg", ".gitignore wurde aktualisiert. Bitte erneut versuchen.") + return + + # Add all files + git_ops.add(Path(project.path)) + + # Initial commit + git_ops.commit(Path(project.path), "Initial commit") + + # Add remote and push (use organization if repo was created there) + # Get owner from the created repository + owner = None + if 'owner' in repo and repo['owner']: + owner = repo['owner'].get('username', repo['owner'].get('login')) + + if not owner: + # Fallback to determining owner based on mode + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.view_mode == "organization": + if self.gitea_explorer.organization_name: + owner = self.gitea_explorer.organization_name + else: + owner = self.repo_manager.client.config.username + + logger.info(f"Pushing to repository with owner: {owner}, repo: {repo_name}") + logger.info(f"Repository details: {repo}") + + success, msg = git_ops.push_existing_repo_to_gitea( + Path(project.path), + owner, + repo_name + ) + + if success: + # Show push details for debugging + push_info = f"Push-Details:\n" + push_info += f"Repository: {repo_name}\n" + push_info += f"Owner: {owner}\n" + push_info += f"Branch: {current_branch} -> main\n\n" + + # Get remote URL + success_remote, remotes = git_ops.remote_list(Path(project.path)) + if success_remote: + push_info += f"Remote URLs:\n{remotes}\n\n" + + # Verify the repository exists on Gitea + try: + verify_repo = self.repo_manager.client.get_repository(owner, repo_name) + repo_url = verify_repo.get('html_url', 'Unknown') + clone_url = verify_repo.get('clone_url', 'Unknown') + + # Check if repository has content + has_content = verify_repo.get('size', 0) > 0 or verify_repo.get('default_branch') + + push_info += f"Repository gefunden auf Gitea:\n" + push_info += f"URL: {repo_url}\n" + push_info += f"Clone URL: {clone_url}\n" + push_info += f"Hat Inhalt: {'Ja' if has_content else 'Nein'}\n" + push_info += f"Größe: {verify_repo.get('size', 0)} bytes\n" + push_info += f"Default Branch: {verify_repo.get('default_branch', 'Keiner')}\n\n" + + if not has_content: + push_info += "⚠️ WARNUNG: Repository existiert aber scheint leer zu sein!\n\n" + + push_info += "Bitte prüfen Sie:\n" + push_info += f"1. Öffnen Sie: {repo_url}\n" + push_info += f"2. Log-Datei: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + # Update project with Gitea repo reference + project.gitea_repo = f"{owner}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + + messagebox.showinfo("Push abgeschlossen", push_info) + + except Exception as e: + push_info += f"\n⚠️ Konnte Repository nicht auf Gitea verifizieren:\n{str(e)}\n\n" + push_info += "Mögliche Ursachen:\n" + push_info += "- Repository wurde unter anderem Namen/Owner erstellt\n" + push_info += "- Berechtigungsprobleme\n" + push_info += "- Netzwerkprobleme\n\n" + push_info += f"Log-Datei prüfen: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + messagebox.showwarning("Push Status unklar", push_info) + + # Refresh Gitea explorer if exists + if hasattr(self, 'gitea_explorer'): + self.gitea_explorer.refresh_repositories() + else: + messagebox.showerror("Fehler", f"Push fehlgeschlagen: {msg}") + else: + messagebox.showerror("Fehler", f"Git-Initialisierung fehlgeschlagen: {msg}") + break + + except Exception as e: + error_msg = str(e) + if "409" in error_msg: + # Repository exists already + action = messagebox.askyesno( + "Repository existiert bereits", + f"Ein Repository mit dem Namen '{repo_name}' existiert bereits.\n\n" + "Möchten Sie einen anderen Namen wählen?" + ) + if action: + repo_name = simpledialog.askstring( + "Neues Repository", + f"Bitte anderen Namen wählen ('{repo_name}' existiert bereits):", + initialvalue=f"{repo_name}-2" + ) + else: + break + else: + messagebox.showerror("Fehler", f"Fehler beim Erstellen des Repositories: {error_msg}") + break + + def init_git_repo(self, project): + """Initialize git repository for project""" + git_ops = self.repo_manager.git_ops + success, msg = git_ops.init_repository(Path(project.path)) + + if success: + messagebox.showinfo("Erfolg", "Git-Repository initialisiert!") + else: + messagebox.showerror("Fehler", f"Initialisierung fehlgeschlagen: {msg}") + + def test_gitea_connection(self, project): + """Test Gitea connection - Facade method (consolidates duplicates)""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.test_gitea_connection(project) + else: + # Use the second version (enhanced) as default + return self._original_test_gitea_connection_v2(project) + + def _original_test_gitea_connection(self, project): + """Test Gitea connection and show permissions""" + try: + # Test API connection + user_info = self.repo_manager.client.get_user_info() + + info = "Gitea-Verbindung erfolgreich!\n\n" + info += f"Benutzer: {user_info.get('username', 'Unknown')}\n" + info += f"E-Mail: {user_info.get('email', 'Unknown')}\n" + info += f"Admin: {'Ja' if user_info.get('is_admin', False) else 'Nein'}\n\n" + + # Check organizations + orgs = self.repo_manager.client.list_user_organizations() + if orgs: + info += "Organisationen:\n" + for org in orgs: + org_name = org.get('username', org.get('name', 'Unknown')) + info += f" • {org_name}" + + # Check teams/permissions in org + teams = self.repo_manager.client.get_user_teams_in_org(org_name) + if teams: + team_names = [t.get('name', 'Unknown') for t in teams] + info += f" (Teams: {', '.join(team_names)})" + info += "\n" + else: + info += "Keine Organisationen\n" + + info += f"\nServer: {self.repo_manager.client.config.base_url}" + + messagebox.showinfo("Verbindungstest", info) + + except Exception as e: + messagebox.showerror("Verbindungsfehler", f"Konnte keine Verbindung zu Gitea herstellen:\n\n{str(e)}") + + def verify_repository_on_gitea(self, project): + """Verify repository on Gitea - Facade method (consolidates duplicates)""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.verify_repository_on_gitea(project) + else: + # Use the second version (with scrollable info) as default + return self._original_verify_repository_on_gitea_v2(project) + + def _original_verify_repository_on_gitea(self, project): + """Verify if repository exists on Gitea and show detailed info""" + import re + from pathlib import Path + + project_path = Path(project.path) + + # Check if it's a git repo + if not (project_path / ".git").exists(): + messagebox.showinfo("Info", "Dies ist kein Git-Repository.") + return + + # Get remote info + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + + if not success or not remotes: + messagebox.showinfo("Info", "Kein Remote-Repository konfiguriert.") + return + + # Extract owner and repo name from remote URL + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if not match: + messagebox.showwarning("Warnung", f"Konnte Repository-Info nicht aus Remote-URL extrahieren:\n{remotes}") + return + + remote_owner = match.group(1) + remote_repo = match.group(2) + + info = f"Suche Repository: {remote_owner}/{remote_repo}\n\n" + + # Check if repo exists under detected owner + try: + repo = self.repo_manager.client.get_repository(remote_owner, remote_repo) + info += "✅ Repository gefunden!\n\n" + info += f"Name: {repo.get('name', 'Unknown')}\n" + info += f"Owner: {repo.get('owner', {}).get('username', 'Unknown')}\n" + info += f"URL: {repo.get('html_url', 'Unknown')}\n" + info += f"Clone URL: {repo.get('clone_url', 'Unknown')}\n" + info += f"Privat: {'Ja' if repo.get('private', False) else 'Nein'}\n" + info += f"Größe: {repo.get('size', 0) / 1024:.1f} KB\n" + info += f"Default Branch: {repo.get('default_branch', 'Keiner')}\n" + info += f"Erstellt: {repo.get('created_at', 'Unknown')}\n" + info += f"Zuletzt aktualisiert: {repo.get('updated_at', 'Unknown')}\n" + + # Check for content + has_content = repo.get('size', 0) > 0 or repo.get('default_branch') + if not has_content: + info += "\n⚠️ WARNUNG: Repository scheint leer zu sein!\n" + + except Exception as e: + info += f"❌ Repository nicht unter {remote_owner}/{remote_repo} gefunden!\n" + info += f"Fehler: {str(e)}\n\n" + + # Search in all locations + info += "Suche in allen verfügbaren Orten:\n\n" + + # Check user repos + try: + user_repos = self.repo_manager.list_all_repositories() + found_in_user = [r for r in user_repos if r['name'] == remote_repo] + if found_in_user: + info += f"✅ Gefunden in Benutzer-Repos:\n" + for r in found_in_user: + info += f" - {r.get('owner', {}).get('username', 'Unknown')}/{r['name']}\n" + else: + info += "❌ Nicht in Benutzer-Repos gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen der Benutzer-Repos: {str(e)}\n" + + # Check org repos + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + try: + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + found_in_org = [r for r in org_repos if r['name'] == remote_repo] + if found_in_org: + info += f"✅ Gefunden in Organisation {self.gitea_explorer.organization_name}:\n" + for r in found_in_org: + info += f" - {r.get('owner', {}).get('username', 'Unknown')}/{r['name']}\n" + else: + info += f"❌ Nicht in Organisation {self.gitea_explorer.organization_name} gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen der Organisations-Repos: {str(e)}\n" + + # Check for debug file + debug_file = project_path / "gitea_push_debug.txt" + if debug_file.exists(): + info += f"\n\nDebug-Datei gefunden: {debug_file}\n" + try: + with open(debug_file, 'r', encoding='utf-8') as f: + debug_content = f.read() + info += "Debug-Inhalt:\n" + info += "-" * 40 + "\n" + info += debug_content[:1000] # First 1000 chars + if len(debug_content) > 1000: + info += "\n... (gekürzt)" + except: + info += "Konnte Debug-Datei nicht lesen.\n" + + info += f"\n\nLog-Datei: {Path.home() / '.claude_project_manager' / 'gitea_operations.log'}" + + # Show in scrollable dialog + self._show_scrollable_info("Repository-Verifizierung", info) + + def fix_repository_issues(self, project): + """Fix repository issues - Facade method""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.fix_repository_issues(project) + else: + return self._original_fix_repository_issues(project) + + def _original_fix_repository_issues(self, project): + """Fix common repository issues""" + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Check current status + issues = [] + + # Check if it's a git repo + if not (project_path / ".git").exists(): + issues.append("❌ Kein Git-Repository") + else: + # Check remote + success, remotes = git_ops.remote_list(project_path) + if success and remotes: + issues.append(f"✅ Remote vorhanden:\n{remotes}") + + # Check for authentication issues + if "Authentication failed" in remotes or "21cbba8d" in remotes: + issues.append("⚠️ Alter/falscher Token im Remote gefunden") + else: + issues.append("❌ Kein Remote konfiguriert") + + # Check for LFS + gitattributes = project_path / ".gitattributes" + if gitattributes.exists(): + with open(gitattributes, 'r') as f: + if 'filter=lfs' in f.read(): + issues.append("⚠️ Git LFS ist konfiguriert (kann Probleme verursachen)") + + msg = "Repository-Status:\n\n" + "\n".join(issues) + "\n\nWas möchten Sie tun?" + + # Create dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Repository reparieren") + dialog.geometry("600x400") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 400) // 2 + dialog.geometry(f"600x400+{x}+{y}") + + # Message + text_widget = ctk.CTkTextbox(dialog, width=580, height=250) + text_widget.pack(padx=10, pady=10) + text_widget.insert("1.0", msg) + text_widget.configure(state="disabled") + + # Buttons + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(fill="x", padx=10, pady=(0, 10)) + + def fix_remote(): + """Fix remote URL with correct credentials""" + dialog.destroy() + + # Get current organization + org_name = "IntelSight" + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + org_name = self.gitea_explorer.organization_name + + # Remove old remote + git_ops.remote_remove(project_path, "origin") + + # Add correct remote + success, msg = git_ops.add_remote_to_existing_repo( + project_path, org_name, project.name + ) + + if success: + messagebox.showinfo("Erfolg", + f"Remote wurde korrigiert!\n\n" + f"Repository: {org_name}/{project.name}\n" + f"Sie können jetzt pushen.") + else: + messagebox.showerror("Fehler", f"Fehler beim Korrigieren: {msg}") + + def disable_lfs(): + """Disable LFS temporarily""" + dialog.destroy() + success, msg = git_ops.disable_lfs_for_push(project_path) + if success: + messagebox.showinfo("Erfolg", "Git LFS wurde deaktiviert.\n\nVersuchen Sie den Push erneut.") + else: + messagebox.showerror("Fehler", f"Fehler: {msg}") + + def check_on_gitea(): + """Check if repo exists on Gitea""" + dialog.destroy() + self.verify_repository_on_gitea(project) + + # Add buttons based on issues + if any("falscher Token" in issue for issue in issues): + fix_btn = ctk.CTkButton(button_frame, text="🔧 Remote korrigieren", + command=fix_remote, width=180, height=40) + fix_btn.pack(side="left", padx=5) + + if any("LFS" in issue for issue in issues): + lfs_btn = ctk.CTkButton(button_frame, text="🚫 LFS deaktivieren", + command=disable_lfs, width=180, height=40) + lfs_btn.pack(side="left", padx=5) + + check_btn = ctk.CTkButton(button_frame, text="🔍 Auf Gitea prüfen", + command=check_on_gitea, width=180, height=40) + check_btn.pack(side="left", padx=5) + + close_btn = ctk.CTkButton(dialog, text="Schließen", command=dialog.destroy, width=100) + close_btn.pack(pady=10) + + dialog.grab_set() + + def manage_large_files(self, project): + """Manage large files - Facade method""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.manage_large_files(project) + else: + return self._original_manage_large_files(project) + + def _original_manage_large_files(self, project): + """Manage large files in the repository""" + project_path = Path(project.path) + git_ops = self.repo_manager.git_ops + + # Check for large files + large_files = git_ops.check_large_files(project_path, 50) # Files > 50MB + + if not large_files: + messagebox.showinfo("Info", "Keine großen Dateien gefunden (>50MB).\n\nIhr Repository kann problemlos gepusht werden.") + return + + # Show large files + msg = f"Gefundene große Dateien ({len(large_files)} Dateien):\n\n" + total_size = 0 + for i, (file, size) in enumerate(large_files[:15]): + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + if i >= 14 and len(large_files) > 15: + msg += f" ... und {len(large_files) - 15} weitere\n" + break + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + msg += "Was möchten Sie tun?" + + # Create dialog with options + dialog = ctk.CTkToplevel(self.root) + dialog.title("Große Dateien verwalten") + dialog.geometry("600x500") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 500) // 2 + dialog.geometry(f"600x500+{x}+{y}") + + # Message + text_widget = ctk.CTkTextbox(dialog, width=580, height=300) + text_widget.pack(padx=10, pady=10) + text_widget.insert("1.0", msg) + text_widget.configure(state="disabled") + + # Buttons + button_frame = ctk.CTkFrame(dialog) + button_frame.pack(fill="x", padx=10, pady=(0, 10)) + + def remove_from_git(): + """Remove large files from git but keep locally""" + dialog.destroy() + try: + # Create .gitignore + gitignore_path = project_path / ".gitignore" + existing_content = "" + if gitignore_path.exists(): + with open(gitignore_path, 'r', encoding='utf-8') as f: + existing_content = f.read() + + # Add large files to .gitignore + with open(gitignore_path, 'a', encoding='utf-8') as f: + if existing_content and not existing_content.endswith('\n'): + f.write('\n') + f.write("\n# Große Dateien (automatisch hinzugefügt)\n") + for file, _ in large_files: + f.write(f"{file}\n") + + # Remove files from git index + removed_count = 0 + failed_files = [] + for file, _ in large_files: + cmd = ["git", "rm", "--cached", file] + success, _, stderr = git_ops._run_git_command(cmd, cwd=project_path) + if success: + removed_count += 1 + else: + failed_files.append((file, stderr)) + + if removed_count > 0: + # Commit the changes + git_ops.add(Path(project_path), [".gitignore"]) + success, _ = git_ops.commit(Path(project_path), + f"Große Dateien aus Git entfernt ({removed_count} Dateien)") + + result_msg = f"✅ {removed_count} Dateien wurden aus Git entfernt.\n\n" + result_msg += "Die Dateien sind weiterhin lokal vorhanden.\n" + + if failed_files: + result_msg += f"\n⚠️ {len(failed_files)} Dateien konnten nicht entfernt werden.\n" + + result_msg += "\nSie können jetzt pushen!" + messagebox.showinfo("Erfolg", result_msg) + else: + messagebox.showwarning("Warnung", "Keine Dateien konnten entfernt werden.") + + except Exception as e: + messagebox.showerror("Fehler", f"Fehler beim Entfernen: {str(e)}") + + def show_gitignore(): + """Show .gitignore content""" + dialog.destroy() + gitignore_content = "# Große Dateien\n" + for file, _ in large_files: + gitignore_content += f"{file}\n" + + # Create dialog to show content + info_dialog = ctk.CTkToplevel(self.root) + info_dialog.title(".gitignore Inhalt") + info_dialog.geometry("500x400") + + label = ctk.CTkLabel(info_dialog, text="Fügen Sie diese Zeilen zu .gitignore hinzu:", font=("Arial", 14)) + label.pack(pady=10) + + text = ctk.CTkTextbox(info_dialog, width=480, height=300) + text.pack(padx=10, pady=10) + text.insert("1.0", gitignore_content) + + def copy_to_clipboard(): + self.root.clipboard_clear() + self.root.clipboard_append(gitignore_content) + messagebox.showinfo("Kopiert", "Inhalt wurde in die Zwischenablage kopiert!") + + copy_btn = ctk.CTkButton(info_dialog, text="In Zwischenablage kopieren", command=copy_to_clipboard) + copy_btn.pack(pady=10) + + info_dialog.grab_set() + + def check_status(): + """Check git status""" + dialog.destroy() + success, status = git_ops.status(project_path) + if success: + self._show_scrollable_info("Git Status", f"Git Status für {project.name}:\n\n{status}") + else: + messagebox.showerror("Fehler", "Konnte Git Status nicht abrufen.") + + # Add buttons + remove_btn = ctk.CTkButton(button_frame, text="🗑️ Aus Git entfernen\n(Dateien bleiben lokal)", + command=remove_from_git, width=180, height=60) + remove_btn.pack(side="left", padx=5) + + gitignore_btn = ctk.CTkButton(button_frame, text="📝 .gitignore anzeigen", + command=show_gitignore, width=180, height=60) + gitignore_btn.pack(side="left", padx=5) + + status_btn = ctk.CTkButton(button_frame, text="📊 Git Status", + command=check_status, width=180, height=60) + status_btn.pack(side="left", padx=5) + + # Info text + info_label = ctk.CTkLabel(dialog, + text="Tipp: 'Aus Git entfernen' ist die einfachste Lösung.\nDie Dateien bleiben auf Ihrer Festplatte erhalten!", + font=("Arial", 12), text_color="yellow") + info_label.pack(pady=10) + + close_btn = ctk.CTkButton(dialog, text="Schließen", command=dialog.destroy, width=100) + close_btn.pack(pady=10) + + dialog.grab_set() + + def setup_git_lfs(self, project): + """Setup Git LFS - Facade method""" + if hasattr(self, '_gitea_handler') and self.REFACTORING_FLAGS.get('USE_GITEA_HANDLER', False): + return self._gitea_handler.setup_git_lfs(project) + else: + return self._original_setup_git_lfs(project) + + def _original_setup_git_lfs(self, project): + """Setup Git LFS for the project""" + project_path = Path(project.path) + + # Check for large files + git_ops = self.repo_manager.git_ops + large_files = git_ops.check_large_files(project_path, 50) # Files > 50MB + + if not large_files: + messagebox.showinfo("Info", "Keine großen Dateien gefunden (>50MB).\n\nGit LFS ist möglicherweise nicht erforderlich.") + return + + # Show large files and ask what to do + msg = f"Gefundene große Dateien ({len(large_files)} Dateien):\n\n" + total_size = 0 + for i, (file, size) in enumerate(large_files[:10]): + size_mb = size / (1024 * 1024) + msg += f" • {file} ({size_mb:.1f} MB)\n" + total_size += size + if i >= 9 and len(large_files) > 10: + msg += f" ... und {len(large_files) - 10} weitere\n" + break + + total_mb = total_size / (1024 * 1024) + msg += f"\nGesamtgröße: {total_mb:.1f} MB\n\n" + msg += "Möchten Sie Git LFS für diese Dateien einrichten?" + + if messagebox.askyesno("Git LFS einrichten", msg): + # Check if Git LFS is installed + success, result = git_ops.setup_lfs(project_path) + if not success: + messagebox.showerror("Fehler", result) + return + + # Ask which files to track + choices = { + "all": "Alle großen Dateien (>50MB)", + "types": "Nach Dateityp (z.B. *.mp4, *.zip)", + "specific": "Spezifische Dateien auswählen" + } + + dialog = ctk.CTkToplevel(self.root) + dialog.title("LFS Tracking-Optionen") + dialog.geometry("400x300") + dialog.transient(self.root) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 400) // 2 + y = (dialog.winfo_screenheight() - 300) // 2 + dialog.geometry(f"400x300+{x}+{y}") + + selected_option = tk.StringVar(value="all") + + label = ctk.CTkLabel(dialog, text="Wie möchten Sie die Dateien tracken?", font=("Arial", 14)) + label.pack(pady=20) + + for key, text in choices.items(): + radio = ctk.CTkRadioButton(dialog, text=text, variable=selected_option, value=key) + radio.pack(pady=5, padx=20, anchor="w") + + result = {"ok": False} + + def on_ok(): + result["ok"] = True + result["choice"] = selected_option.get() + dialog.destroy() + + ok_btn = ctk.CTkButton(dialog, text="OK", command=on_ok) + ok_btn.pack(pady=20) + + dialog.grab_set() + dialog.wait_window() + + if not result.get("ok"): + return + + # Process based on choice + patterns = [] + if result["choice"] == "all": + # Track all large files + patterns = [file for file, _ in large_files] + elif result["choice"] == "types": + # Get unique extensions + extensions = set() + for file, _ in large_files: + ext = Path(file).suffix + if ext: + extensions.add(ext) + + # Ask which extensions to track + ext_msg = "Folgende Dateitypen wurden gefunden:\n" + for ext in extensions: + ext_msg += f" • *{ext}\n" + ext_msg += "\nWelche möchten Sie mit LFS tracken?\n(Komma-getrennt, z.B. .mp4,.zip)" + + from tkinter import simpledialog + selected = simpledialog.askstring("Dateitypen auswählen", ext_msg) + if selected: + for ext in selected.split(","): + ext = ext.strip() + if not ext.startswith("."): + ext = "." + ext + patterns.append(f"*{ext}") + else: + # Specific files - show a selection dialog + # For simplicity, track all for now + patterns = [file for file, _ in large_files] + + if patterns: + # Track with LFS + success, result = git_ops.track_with_lfs(project_path, patterns) + if success: + messagebox.showinfo("Erfolg", + f"{result}\n\n" + "Nächste Schritte:\n" + "1. Committen Sie die .gitattributes Datei\n" + "2. Große Dateien werden nun über LFS verwaltet\n" + "3. Der Push sollte jetzt funktionieren") + + # Offer to migrate existing files + if messagebox.askyesno("Migration", + "Möchten Sie existierende große Dateien zu LFS migrieren?\n\n" + "Dies wird die Dateien aus dem Git-History entfernen und neu hinzufügen."): + success, result = git_ops.migrate_to_lfs(project_path, [f for f, _ in large_files]) + if success: + messagebox.showinfo("Erfolg", result) + else: + messagebox.showerror("Fehler", result) + else: + messagebox.showerror("Fehler", result) + + def _show_scrollable_info(self, title, content): + """Show information in a scrollable dialog - Facade method""" + if hasattr(self, '_ui_handler') and self.REFACTORING_FLAGS.get('USE_UI_HELPERS', False): + return self._ui_handler._show_scrollable_info(title, content) + else: + return self._original_show_scrollable_info(title, content) + + def _original_show_scrollable_info(self, title, content): + """Show information in a scrollable dialog""" + dialog = ctk.CTkToplevel(self.root) + dialog.title(title) + dialog.geometry("600x500") + + # Center the dialog + dialog.transient(self.root) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 500) // 2 + dialog.geometry(f"600x500+{x}+{y}") + + # Text widget with scrollbar + text_frame = ctk.CTkFrame(dialog) + text_frame.pack(fill="both", expand=True, padx=10, pady=10) + + text_widget = ctk.CTkTextbox(text_frame, width=580, height=450) + text_widget.pack(fill="both", expand=True) + text_widget.insert("1.0", content) + text_widget.configure(state="disabled") + + # Close button + close_btn = ctk.CTkButton( + dialog, + text="Schließen", + command=dialog.destroy, + width=100 + ) + close_btn.pack(pady=(0, 10)) + + dialog.grab_set() + dialog.focus_set() + + def _original_manage_branches_v2(self, project): + """Manage branches for a project - Full implementation""" + from tkinter import simpledialog + + git_ops = self.repo_manager.git_ops + + # Get current branches + success, branches = git_ops.branch(Path(project.path), list_all=True) + + if success: + current_branch = None + branch_list = [] + for line in branches.strip().split('\n'): + if line.startswith('*'): + current_branch = line[2:].strip() + branch_list.append(f"{line.strip()} (aktuell)") + else: + branch_list.append(line.strip()) + + # Show options + action = messagebox.askyesnocancel( + "Branch-Verwaltung", + f"Aktuelle Branches:\n{chr(10).join(branch_list)}\n\n" + "Ja = Neuen Branch erstellen\n" + "Nein = Zu anderem Branch wechseln\n" + "Abbrechen = Schließen" + ) + + if action is True: # Create new branch + branch_name = simpledialog.askstring( + "Neuer Branch", + "Name des neuen Branches:", + parent=self.root + ) + if branch_name: + success, result = git_ops.checkout(Path(project.path), branch_name, create=True) + if success: + messagebox.showinfo("Erfolg", f"Branch '{branch_name}' erstellt und gewechselt") + else: + messagebox.showerror("Fehler", f"Branch-Erstellung fehlgeschlagen: {result}") + + elif action is False: # Switch branch + branch_name = simpledialog.askstring( + "Branch wechseln", + "Zu welchem Branch wechseln?", + parent=self.root + ) + if branch_name: + success, result = git_ops.checkout(Path(project.path), branch_name) + if success: + messagebox.showinfo("Erfolg", f"Zu Branch '{branch_name}' gewechselt") + else: + messagebox.showerror("Fehler", f"Branch-Wechsel fehlgeschlagen: {result}") + else: + messagebox.showerror("Fehler", f"Konnte Branches nicht abrufen: {branches}") + + def _original_link_to_gitea_v2(self, project): + """Link local project to Gitea repository - Full implementation""" + from tkinter import simpledialog + + # Ask for repository name + repo_name = simpledialog.askstring( + "Mit Gitea verknüpfen", + "Name des Gitea-Repositories:", + initialvalue=project.name, + parent=self.root + ) + + if repo_name: + git_ops = self.repo_manager.git_ops + + # Check if repo exists on Gitea + try: + repo = self.repo_manager.client.get_repository( + self.repo_manager.client.config.username, + repo_name + ) + + # Add remote + success, msg = git_ops.add_remote_to_existing_repo( + Path(project.path), + self.repo_manager.client.config.username, + repo_name + ) + + if success: + # Update project with Gitea repo reference + project.gitea_repo = f"{self.repo_manager.client.config.username}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + messagebox.showinfo("Erfolg", f"Erfolgreich mit Repository '{repo_name}' verknüpft!") + else: + messagebox.showerror("Fehler", f"Verknüpfung fehlgeschlagen: {msg}") + + except Exception as e: + # Repository doesn't exist + if messagebox.askyesno("Repository erstellen?", + f"Repository '{repo_name}' existiert nicht.\nSoll es erstellt werden?"): + try: + # Create repository + repo = self.repo_manager.create_repository(repo_name, auto_init=False) + + # Add remote + success, msg = git_ops.add_remote_to_existing_repo( + Path(project.path), + self.repo_manager.client.config.username, + repo_name + ) + + if success: + # Update project with Gitea repo reference + project.gitea_repo = f"{self.repo_manager.client.config.username}/{repo_name}" + self.project_manager.update_project(project.id, gitea_repo=project.gitea_repo) + messagebox.showinfo("Erfolg", + f"Repository '{repo_name}' erstellt und verknüpft!") + else: + messagebox.showerror("Fehler", f"Verknüpfung fehlgeschlagen: {msg}") + + except Exception as create_error: + messagebox.showerror("Fehler", + f"Repository-Erstellung fehlgeschlagen: {str(create_error)}") + + def _original_test_gitea_connection_v2(self, project=None): + """Test Gitea connection and show detailed information - Enhanced version""" + try: + # Test basic connection + user_info = self.repo_manager.client.get_user_info() + username = user_info.get('username', 'Unknown') + + # Get organizations + orgs = self.repo_manager.client.list_user_organizations() + org_names = [org['username'] for org in orgs] + + # Build info message + info = f"✅ Gitea Verbindung erfolgreich!\n\n" + info += f"Benutzer: {username}\n" + info += f"Server: {self.repo_manager.client.config.base_url}\n" + info += f"Organisationen: {', '.join(org_names) if org_names else 'Keine'}\n\n" + + # Check organization permissions if in org mode + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.view_mode == "organization": + org_name = self.gitea_explorer.organization_name + if org_name: + teams = self.repo_manager.client.get_user_teams_in_org(org_name) + if teams: + info += f"Teams in {org_name}:\n" + for team in teams: + info += f" - {team.get('name', 'Unknown')} " + perms = [] + if team.get('can_create_org_repo'): + perms.append("kann Repos erstellen") + if team.get('permission') == 'admin': + perms.append("Admin") + elif team.get('permission') == 'write': + perms.append("Schreiben") + elif team.get('permission') == 'read': + perms.append("Lesen") + info += f"({', '.join(perms) if perms else 'keine Rechte'})\n" + else: + info += f"⚠️ Keine Teams in Organisation {org_name} gefunden!\n" + info += "Dies könnte der Grund sein, warum Repositories nicht erstellt werden können.\n" + + info += "\n" + + # If we have a project, show its remote info + if project: + project_path = Path(project.path) if hasattr(project, 'path') else Path(project['path']) + if project_path.exists(): + success, remotes = self.repo_manager.git_ops.remote_list(project_path) + if success and remotes: + info += f"Git Remote URLs:\n{remotes}\n\n" + + # Check if .git exists + if (project_path / '.git').exists(): + # Get current branch + success, branch_out = self.repo_manager.git_ops._run_git_command( + ["git", "branch", "--show-current"], cwd=project_path + ) + if success: + info += f"Aktueller Branch: {branch_out.strip()}\n" + + # List repositories in current mode + info += "Repositories:\n" + try: + if hasattr(self, 'gitea_explorer'): + if self.gitea_explorer.view_mode == "organization" and self.gitea_explorer.organization_name: + # List org repos + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + info += f"In Organisation {self.gitea_explorer.organization_name}: {len(org_repos)} Repositories\n" + for repo in org_repos[:5]: # Show first 5 + info += f" - {repo['name']}\n" + if len(org_repos) > 5: + info += f" ... und {len(org_repos) - 5} weitere\n" + else: + # List user repos + user_repos = self.repo_manager.list_all_repositories() + info += f"Benutzer Repositories: {len(user_repos)}\n" + for repo in user_repos[:5]: # Show first 5 + info += f" - {repo['name']} (Owner: {repo.get('owner', {}).get('username', 'Unknown')})\n" + if len(user_repos) > 5: + info += f" ... und {len(user_repos) - 5} weitere\n" + except Exception as e: + info += f"Fehler beim Abrufen der Repositories: {str(e)}\n" + + # Show log file location + log_file = Path.home() / ".claude_project_manager" / "gitea_operations.log" + info += f"\nLog-Datei: {log_file}" + + messagebox.showinfo("Gitea Verbindungstest", info) + + except Exception as e: + error_msg = f"❌ Gitea Verbindung fehlgeschlagen!\n\n" + error_msg += f"Fehler: {str(e)}\n\n" + error_msg += f"Server: {self.repo_manager.client.config.base_url}\n" + error_msg += "\nBitte prüfen Sie:\n" + error_msg += "- Netzwerkverbindung\n" + error_msg += "- API Token Gültigkeit\n" + error_msg += "- Server Erreichbarkeit" + + messagebox.showerror("Gitea Verbindungstest", error_msg) + + def _original_verify_repository_on_gitea_v2(self, project): + """Verify if repository exists on Gitea and show detailed info - Enhanced version""" + project_name = project.name if hasattr(project, 'name') else project.get('name', 'Unknown') + project_path = Path(project.path) if hasattr(project, 'path') else Path(project['path']) + + info = f"Repository Verifizierung für: {project_name}\n" + info += "=" * 50 + "\n\n" + + # Check git remotes + git_ops = self.repo_manager.git_ops + success, remotes = git_ops.remote_list(project_path) + if success and remotes: + info += f"Git Remote URLs:\n{remotes}\n\n" + + # Extract repo name and owner from remote + import re + match = re.search(r'gitea-undso\.intelsight\.de[:/]([^/]+)/([^/\.]+)', remotes) + if match: + remote_owner = match.group(1) + remote_repo = match.group(2) + info += f"Remote zeigt auf: {remote_owner}/{remote_repo}\n\n" + + # Search for repository + info += "Suche Repository auf Gitea:\n" + + # 1. Check exact location + try: + repo = self.repo_manager.client.get_repository(remote_owner, remote_repo) + info += f"✅ Gefunden unter {remote_owner}/{remote_repo}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + info += f" Größe: {repo.get('size', 0)} bytes\n" + info += f" Erstellt: {repo.get('created_at', 'Unknown')}\n" + info += f" Aktualisiert: {repo.get('updated_at', 'Unknown')}\n" + info += f" Default Branch: {repo.get('default_branch', 'Unknown')}\n" + info += f" Privat: {'Ja' if repo.get('private') else 'Nein'}\n" + except Exception as e: + info += f"❌ Nicht gefunden unter {remote_owner}/{remote_repo}\n" + info += f" Fehler: {str(e)}\n" + + # 2. Search in all user repos + info += "\nSuche in allen Benutzer-Repositories:\n" + try: + user_repos = self.repo_manager.list_all_repositories() + matching_repos = [r for r in user_repos if r['name'] == remote_repo or r['name'] == project_name] + if matching_repos: + for repo in matching_repos: + info += f"✅ Gefunden: {repo['owner']['username']}/{repo['name']}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + else: + info += "❌ Nicht in Benutzer-Repositories gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen: {str(e)}\n" + + # 3. Search in organization + if hasattr(self, 'gitea_explorer') and self.gitea_explorer.organization_name: + info += f"\nSuche in Organisation {self.gitea_explorer.organization_name}:\n" + try: + org_repos = self.repo_manager.list_organization_repositories(self.gitea_explorer.organization_name) + matching_repos = [r for r in org_repos if r['name'] == remote_repo or r['name'] == project_name] + if matching_repos: + for repo in matching_repos: + info += f"✅ Gefunden: {repo['name']}\n" + info += f" URL: {repo.get('html_url', 'Unknown')}\n" + else: + info += "❌ Nicht in Organisation gefunden\n" + except Exception as e: + info += f"❌ Fehler beim Durchsuchen: {str(e)}\n" + else: + info += "❌ Keine Git Remote gefunden!\n" + + # Check for debug file + debug_file = project_path / "gitea_push_debug.txt" + if debug_file.exists(): + info += f"\n\n📄 Debug-Datei gefunden: {debug_file}\n" + try: + with open(debug_file, 'r', encoding='utf-8') as f: + debug_content = f.read() + info += "Debug-Inhalt:\n" + "-" * 30 + "\n" + info += debug_content[:1000] # First 1000 chars + if len(debug_content) > 1000: + info += "\n... (gekürzt)" + except: + info += "Konnte Debug-Datei nicht lesen\n" + + # Show in scrollable dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Repository Verifizierung") + dialog.geometry("800x600") + + text_widget = ctk.CTkTextbox(dialog, wrap="word") + text_widget.pack(fill="both", expand=True, padx=10, pady=10) + text_widget.insert("1.0", info) + text_widget.configure(state="disabled") + + close_btn = ctk.CTkButton( + dialog, + text="Schließen", + command=dialog.destroy + ) + close_btn.pack(pady=10) + + def init_activity_sync(self): + """Initialize activity sync service""" + from services.activity_sync import activity_service + + # Set callback for activity updates + activity_service.on_activities_update = self.on_activities_update + + # Connect if configured + if activity_service.is_configured(): + threading.Thread(target=activity_service.connect, daemon=True).start() + + # Start periodic update + self.update_activity_status() + + def on_activities_update(self, activities): + """Handle activity updates from server""" + # Update UI in main thread + self.root.after(0, lambda: self.update_activity_display(activities)) + + def update_activity_display(self, activities): + """Update activity display in status bar""" + count = len(activities) + if count > 0: + self.activity_label.configure(text=f"🟢 {count} Teammitglieder aktiv") + else: + self.activity_label.configure(text="") + + # Update project tiles + for project_id, tile in self.project_tiles.items(): + if hasattr(tile, 'check_activity'): + tile.check_activity() + + def update_activity_status(self): + """Periodic update of activity status""" + from services.activity_sync import activity_service + + if activity_service.connected: + # Update display with current activities + self.update_activity_display(activity_service.activities) + + # Schedule next update + self.root.after(5000, self.update_activity_status) # Update every 5 seconds + + def show_activity_details(self, event=None): + """Show detailed activity information""" + from services.activity_sync import activity_service + + if not activity_service.activities: + return + + # Create dialog + dialog = ctk.CTkToplevel(self.root) + dialog.title("Team-Aktivitäten") + dialog.geometry("400x300") + dialog.transient(self.root) + dialog.grab_set() + + # Header + header = ctk.CTkLabel( + dialog, + text="👥 Aktive Teammitglieder", + font=FONTS['subtitle'], + text_color=COLORS['text_primary'] + ) + header.pack(pady=(20, 10)) + + # Activity list + list_frame = ctk.CTkScrollableFrame( + dialog, + fg_color=COLORS['bg_secondary'] + ) + list_frame.pack(fill="both", expand=True, padx=20, pady=(0, 20)) + + # Display each activity + for activity in activity_service.activities: + if activity.get('isActive'): + activity_frame = ctk.CTkFrame( + list_frame, + fg_color=COLORS['bg_tile'] + ) + activity_frame.pack(fill="x", pady=5, padx=10) + + # User and project info + info_text = f"{activity['userName']}\n📁 {activity['projectName']}" + info_label = ctk.CTkLabel( + activity_frame, + text=info_text, + font=FONTS['body'], + text_color=COLORS['text_primary'], + justify="left" + ) + info_label.pack(anchor="w", padx=10, pady=5) + + # Close button + close_btn = ctk.CTkButton( + dialog, + text="Schließen", + command=dialog.destroy, + width=100 + ) + close_btn.pack(pady=(0, 20)) + + # Center dialog + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - dialog.winfo_width()) // 2 + y = (dialog.winfo_screenheight() - dialog.winfo_height()) // 2 + dialog.geometry(f"+{x}+{y}") + + def start_activity(self, project): + """Start activity for a project""" + from services.activity_sync import activity_service + + if not activity_service.is_configured(): + messagebox.showinfo( + "Activity Server", + "Bitte konfigurieren Sie den Activity Server in den Einstellungen." + ) + return + + if not activity_service.connected: + messagebox.showwarning( + "Nicht verbunden", + "Keine Verbindung zum Activity Server." + ) + return + + # Start activity + success = activity_service.start_activity( + project_name=project.name, + project_path=project.path + ) + + if success: + self.update_status(f"Aktivität gestartet: {project.name}") + # Update tile to show activity + if project.id in self.project_tiles: + self.project_tiles[project.id].update_activity_status(True) + else: + messagebox.showerror( + "Fehler", + "Konnte Aktivität nicht starten." + ) + + def stop_activity(self): + """Stop current activity""" + from services.activity_sync import activity_service + + success = activity_service.stop_activity() + + if success: + self.update_status("Aktivität beendet") + # Update all tiles + for tile in self.project_tiles.values(): + if hasattr(tile, 'update_activity_status'): + tile.update_activity_status(False) + + def _setup_exception_handler(self): + """Setup global exception handler to log all errors""" + import sys + import tkinter + + def handle_exception(exc_type, exc_value, exc_traceback): + """Handle uncaught exceptions""" + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + + logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + + # Show error dialog + error_msg = f"Ein unerwarteter Fehler ist aufgetreten:\n\n{exc_type.__name__}: {exc_value}\n\nDetails wurden im Log gespeichert." + if hasattr(self, 'root'): + messagebox.showerror("Kritischer Fehler", error_msg) + + # Set the exception handler + sys.excepthook = handle_exception + + # Also handle Tkinter exceptions + def handle_tk_error(self, exc, val, tb): + logger.critical(f"Tkinter exception: {exc}: {val}", exc_info=(exc, val, tb)) + return True # Prevent default error dialog + + tkinter.Tk.report_callback_exception = handle_tk_error + + def run(self): + """Run the application""" + self.root.mainloop() \ No newline at end of file diff --git a/gui/progress_bar.py b/gui/progress_bar.py new file mode 100644 index 0000000..66fcd45 --- /dev/null +++ b/gui/progress_bar.py @@ -0,0 +1,272 @@ +""" +Progress Bar Component for Git Operations +Provides visual feedback for long-running operations +""" + +import customtkinter as ctk +from typing import Optional, Callable +import threading +import time + +class ProgressBar(ctk.CTkToplevel): + """Modern progress bar window for Git operations""" + + def __init__(self, parent, title: str = "Operation in Progress", width: int = 500): + super().__init__(parent) + + # Window configuration + self.title(title) + self.geometry(f"{width}x200") + self.resizable(False, False) + + # Center on parent window + self.transient(parent) + self.grab_set() + + # Colors from styles + from gui.styles import get_colors + self.colors = get_colors() + + # Configure window + self.configure(fg_color=self.colors['bg_primary']) + + # Create UI elements + self._setup_ui() + + # Variables + self.is_cancelled = False + self._update_callback: Optional[Callable] = None + + # Center window on parent + self._center_on_parent(parent) + + def _setup_ui(self): + """Create the progress bar UI""" + # Main container + self.container = ctk.CTkFrame( + self, + fg_color=self.colors['bg_secondary'], + corner_radius=10 + ) + self.container.pack(fill="both", expand=True, padx=20, pady=20) + + # Title label + self.title_label = ctk.CTkLabel( + self.container, + text="", + font=("Segoe UI", 16, "bold"), + text_color=self.colors['text_primary'] + ) + self.title_label.pack(pady=(10, 5)) + + # Status label + self.status_label = ctk.CTkLabel( + self.container, + text="Initialisierung...", + font=("Segoe UI", 14), + text_color=self.colors['text_secondary'] + ) + self.status_label.pack(pady=(0, 15)) + + # Progress bar + self.progress = ctk.CTkProgressBar( + self.container, + width=440, + height=20, + corner_radius=10, + fg_color=self.colors['bg_tile'], + progress_color=self.colors['accent_success'], + mode="determinate" + ) + self.progress.pack(pady=10) + self.progress.set(0) + + # Percentage label + self.percentage_label = ctk.CTkLabel( + self.container, + text="0%", + font=("Segoe UI", 12), + text_color=self.colors['text_secondary'] + ) + self.percentage_label.pack(pady=(0, 10)) + + # Cancel button + self.cancel_button = ctk.CTkButton( + self.container, + text="Abbrechen", + width=100, + height=32, + corner_radius=6, + fg_color=self.colors['accent_error'], + hover_color="#FF5252", + text_color="#FFFFFF", + font=("Segoe UI", 13, "bold"), + command=self._cancel_operation + ) + self.cancel_button.pack(pady=5) + + def _center_on_parent(self, parent): + """Center the window on parent""" + self.update_idletasks() + + # Get parent position and size + parent_x = parent.winfo_x() + parent_y = parent.winfo_y() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + + # Get this window size + width = self.winfo_width() + height = self.winfo_height() + + # Calculate position + x = parent_x + (parent_width - width) // 2 + y = parent_y + (parent_height - height) // 2 + + # Set position + self.geometry(f"+{x}+{y}") + + def _cancel_operation(self): + """Handle cancel button click""" + self.is_cancelled = True + self.cancel_button.configure(state="disabled", text="Wird abgebrochen...") + + def update_progress(self, value: float, status: str, title: str = "", is_error: bool = False): + """Update progress bar + + Args: + value: Progress value (0.0 to 1.0) + status: Status message to display + title: Optional title update + is_error: If True, colors the progress bar red + """ + # Ensure value is in range + value = max(0.0, min(1.0, value)) + + # Update UI in main thread + self.after(0, self._update_ui, value, status, title, is_error) + + def _update_ui(self, value: float, status: str, title: str, is_error: bool = False): + """Update UI elements (must be called from main thread)""" + if title: + self.title_label.configure(text=title) + + self.status_label.configure(text=status) + self.progress.set(value) + self.percentage_label.configure(text=f"{int(value * 100)}%") + + # Change color if error + if is_error: + self.progress.configure(progress_color=self.colors['accent_error']) + self.status_label.configure(text_color=self.colors['accent_error']) + + # If complete, update button + if value >= 1.0 and not is_error: + self.cancel_button.configure( + text="Schließen", + state="normal", + command=self.destroy + ) + # Auto-close after 0.5 seconds if successful + if not self.is_cancelled: + self.after(500, self.destroy) + + def set_update_callback(self, callback: Callable): + """Set callback for progress updates""" + self._update_callback = callback + + def simulate_progress(self, steps: list): + """Simulate progress for testing + + Args: + steps: List of (duration, status, progress) tuples + """ + def run_simulation(): + for duration, status, progress in steps: + if self.is_cancelled: + break + + self.update_progress(progress, status) + time.sleep(duration) + + if self._update_callback: + self._update_callback(progress, status) + + thread = threading.Thread(target=run_simulation, daemon=True) + thread.start() + + + def set_error(self, error_message: str): + """Set error state and message""" + self.update_progress(1.0, error_message, is_error=True) + self.cancel_button.configure( + text="OK", + state="normal", + command=self.destroy, + fg_color=self.colors['accent_error'] + ) + + +class GitOperationProgress: + """Helper class to manage progress for Git operations""" + + # Progress stages for different operations + CLONE_STAGES = [ + (0.0, "Verbindung zum Server wird hergestellt..."), + (0.15, "Repository-Informationen werden abgerufen..."), + (0.30, "Objekte werden gezählt..."), + (0.50, "Objekte werden heruntergeladen..."), + (0.80, "Dateien werden entpackt..."), + (0.95, "Arbeitsverzeichnis wird erstellt..."), + (1.0, "Repository erfolgreich geklont!") + ] + + PUSH_STAGES = [ + (0.0, "Verbindung wird hergestellt..."), + (0.10, "Authentifizierung läuft..."), + (0.25, "Lokale Änderungen werden analysiert..."), + (0.40, "Daten werden komprimiert..."), + (0.60, "Objekte werden übertragen..."), + (0.85, "Remote-Repository wird aktualisiert..."), + (1.0, "Push erfolgreich abgeschlossen!") + ] + + PULL_STAGES = [ + (0.0, "Verbindung zum Server wird hergestellt..."), + (0.15, "Neue Änderungen werden gesucht..."), + (0.35, "Änderungen werden heruntergeladen..."), + (0.60, "Dateien werden entpackt..."), + (0.80, "Änderungen werden zusammengeführt..."), + (0.95, "Arbeitsverzeichnis wird aktualisiert..."), + (1.0, "Pull erfolgreich abgeschlossen!") + ] + + FETCH_STAGES = [ + (0.0, "Verbindung wird hergestellt..."), + (0.20, "Remote-Referenzen werden abgerufen..."), + (0.40, "Neue Objekte werden gesucht..."), + (0.70, "Objekte werden heruntergeladen..."), + (0.90, "Lokale Referenzen werden aktualisiert..."), + (1.0, "Fetch erfolgreich abgeschlossen!") + ] + + COMMIT_STAGES = [ + (0.0, "Änderungen werden vorbereitet..."), + (0.20, "Dateistatus wird geprüft..."), + (0.40, "Änderungen werden indiziert..."), + (0.60, "Commit-Objekt wird erstellt..."), + (0.80, "Referenzen werden aktualisiert..."), + (1.0, "Commit erfolgreich erstellt!") + ] + + @staticmethod + def get_stages(operation: str) -> list: + """Get progress stages for an operation""" + stages_map = { + 'clone': GitOperationProgress.CLONE_STAGES, + 'push': GitOperationProgress.PUSH_STAGES, + 'pull': GitOperationProgress.PULL_STAGES, + 'fetch': GitOperationProgress.FETCH_STAGES, + 'commit': GitOperationProgress.COMMIT_STAGES + } + return stages_map.get(operation.lower(), []) \ No newline at end of file diff --git a/gui/project_tile.py b/gui/project_tile.py new file mode 100644 index 0000000..3f52991 --- /dev/null +++ b/gui/project_tile.py @@ -0,0 +1,789 @@ +""" +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) \ No newline at end of file diff --git a/gui/settings_dialog.py b/gui/settings_dialog.py new file mode 100644 index 0000000..dd44060 --- /dev/null +++ b/gui/settings_dialog.py @@ -0,0 +1,314 @@ +""" +Settings Dialog for the application +Allows users to configure various settings including view modes +""" + +import customtkinter as ctk +from gui.styles import COLORS, FONTS +from utils.logger import logger +import json +from pathlib import Path + + +class SettingsDialog(ctk.CTkToplevel): + def __init__(self, parent, sidebar_view): + super().__init__(parent) + + self.sidebar_view = sidebar_view + self.settings_file = Path.home() / ".claude_project_manager" / "ui_settings.json" + + # Window setup + self.title("Einstellungen") + self.geometry("450x400") + self.resizable(False, False) + + # Make modal + self.transient(parent) + self.grab_set() + + # Load current settings + self.settings = self.load_settings() + + # Setup UI + self.setup_ui() + + # Center window + self.center_window() + + # Focus + self.focus() + + def setup_ui(self): + """Setup the settings UI""" + # Main container + main_frame = ctk.CTkFrame(self, fg_color=COLORS['bg_primary']) + main_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Title + title_label = ctk.CTkLabel( + main_frame, + text="⚙️ Einstellungen", + font=FONTS['subtitle'], + text_color=COLORS['text_primary'] + ) + title_label.pack(pady=(0, 20)) + + # Activity Server Section + activity_section = ctk.CTkFrame(main_frame, fg_color=COLORS['bg_secondary']) + activity_section.pack(fill="x", pady=(0, 15)) + + activity_header = ctk.CTkLabel( + activity_section, + text="👥 Team-Aktivität Server", + font=FONTS['body'], + text_color=COLORS['text_primary'] + ) + activity_header.pack(anchor="w", padx=15, pady=(10, 5)) + + # Server URL + url_label = ctk.CTkLabel( + activity_section, + text="Server URL:", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + url_label.pack(anchor="w", padx=30, pady=(5, 0)) + + self.server_url_var = ctk.StringVar(value=self.settings.get("activity_server_url", "http://91.99.192.14:3001")) + self.server_url_entry = ctk.CTkEntry( + activity_section, + textvariable=self.server_url_var, + width=300, + fg_color=COLORS['bg_primary'] + ) + self.server_url_entry.pack(padx=30, pady=(0, 5)) + + # API Key + api_label = ctk.CTkLabel( + activity_section, + text="API Key:", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + api_label.pack(anchor="w", padx=30, pady=(5, 0)) + + self.api_key_var = ctk.StringVar(value=self.settings.get("activity_api_key", "")) + self.api_key_entry = ctk.CTkEntry( + activity_section, + textvariable=self.api_key_var, + width=300, + fg_color=COLORS['bg_primary'], + show="*" + ) + self.api_key_entry.pack(padx=30, pady=(0, 5)) + + # User Name + user_label = ctk.CTkLabel( + activity_section, + text="Benutzername:", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + user_label.pack(anchor="w", padx=30, pady=(5, 0)) + + self.user_name_var = ctk.StringVar(value=self.settings.get("activity_user_name", "")) + self.user_name_entry = ctk.CTkEntry( + activity_section, + textvariable=self.user_name_var, + width=300, + fg_color=COLORS['bg_primary'] + ) + self.user_name_entry.pack(padx=30, pady=(0, 5)) + + # Test connection button + test_btn = ctk.CTkButton( + activity_section, + text="Verbindung testen", + command=self.test_activity_connection, + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'], + width=150 + ) + test_btn.pack(pady=(5, 10)) + + # Status label + self.connection_status_label = ctk.CTkLabel( + activity_section, + text="", + font=FONTS['small'], + text_color=COLORS['text_secondary'] + ) + self.connection_status_label.pack(pady=(0, 10)) + + # Buttons + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(side="bottom", fill="x", pady=(20, 0)) + + # Apply button + apply_btn = ctk.CTkButton( + button_frame, + text="Anwenden", + command=self.apply_settings, + fg_color=COLORS['accent_primary'], + hover_color=COLORS['accent_hover'], + width=100 + ) + apply_btn.pack(side="right", padx=(5, 0)) + + # Cancel button + cancel_btn = ctk.CTkButton( + button_frame, + text="Abbrechen", + command=self.destroy, + fg_color=COLORS['bg_tile'], + hover_color=COLORS['bg_tile_hover'], + text_color=COLORS['text_primary'], + width=100 + ) + cancel_btn.pack(side="right") + + def load_settings(self): + """Load settings from file""" + try: + if self.settings_file.exists(): + with open(self.settings_file, 'r') as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load UI settings: {e}") + + return {} # No settings currently + + def save_settings(self): + """Save settings to file""" + try: + self.settings_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.settings_file, 'w') as f: + json.dump(self.settings, f, indent=2) + logger.info(f"Saved UI settings: {self.settings}") + except Exception as e: + logger.error(f"Failed to save UI settings: {e}") + + def apply_settings(self): + """Apply the selected settings""" + import uuid + from services.activity_sync import activity_service + + # Get values + server_url = self.server_url_var.get().strip() + api_key = self.api_key_var.get().strip() + user_name = self.user_name_var.get().strip() + + # Update settings + self.settings["activity_server_url"] = server_url + self.settings["activity_api_key"] = api_key + self.settings["activity_user_name"] = user_name + self.save_settings() + + # Update activity service + activity_service.server_url = server_url + activity_service.api_key = api_key + activity_service.user_name = user_name + activity_service.user_id = self.settings.get("activity_user_id", str(uuid.uuid4())) + + # Save user ID if newly generated + if "activity_user_id" not in self.settings: + self.settings["activity_user_id"] = activity_service.user_id + self.save_settings() + + # Save activity settings + activity_service.save_settings() + + # Reconnect if settings changed + if activity_service.connected: + activity_service.disconnect() + + if activity_service.is_configured(): + activity_service.connect() + + logger.info("Activity server settings applied") + + # Close dialog + self.destroy() + + def test_activity_connection(self): + """Test connection to activity server""" + import requests + from tkinter import messagebox + + server_url = self.server_url_var.get().strip() + api_key = self.api_key_var.get().strip() + + if not server_url: + self.connection_status_label.configure( + text="⚠️ Bitte Server URL eingeben", + text_color=COLORS['accent_warning'] + ) + return + + self.connection_status_label.configure( + text="🔄 Teste Verbindung...", + text_color=COLORS['text_secondary'] + ) + self.update() + + try: + # Try to connect to the server + response = requests.get( + f"{server_url}/health", + timeout=5, + headers={"Authorization": f"Bearer {api_key}"} if api_key else {} + ) + + if response.status_code == 200: + self.connection_status_label.configure( + text="✅ Verbindung erfolgreich!", + text_color=COLORS['accent_success'] + ) + logger.info(f"Activity server connection successful: {server_url}") + else: + self.connection_status_label.configure( + text=f"❌ Server antwortet mit Status {response.status_code}", + text_color=COLORS['accent_error'] + ) + logger.warning(f"Activity server returned status {response.status_code}") + + except requests.exceptions.ConnectionError: + self.connection_status_label.configure( + text="❌ Server nicht erreichbar", + text_color=COLORS['accent_error'] + ) + logger.error(f"Activity server not reachable: {server_url}") + except requests.exceptions.Timeout: + self.connection_status_label.configure( + text="❌ Verbindung Timeout", + text_color=COLORS['accent_error'] + ) + logger.error(f"Activity server connection timeout: {server_url}") + except Exception as e: + self.connection_status_label.configure( + text=f"❌ Fehler: {str(e)}", + text_color=COLORS['accent_error'] + ) + logger.error(f"Activity server connection error: {e}") + + def center_window(self): + """Center the dialog on parent window""" + self.update_idletasks() + + # Get parent window position + parent = self.master + parent_x = parent.winfo_x() + parent_y = parent.winfo_y() + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + + # Get dialog size + dialog_width = self.winfo_width() + dialog_height = self.winfo_height() + + # Calculate position + x = parent_x + (parent_width - dialog_width) // 2 + y = parent_y + (parent_height - dialog_height) // 2 + + self.geometry(f"+{x}+{y}") \ No newline at end of file diff --git a/gui/sidebar_view.py b/gui/sidebar_view.py new file mode 100644 index 0000000..ab349b8 --- /dev/null +++ b/gui/sidebar_view.py @@ -0,0 +1,42 @@ +""" +Sidebar View Manager +Simplified version with only Gitea explorer +""" + +import customtkinter as ctk +from typing import Optional +from gui.styles import COLORS, FONTS +from gui.gitea_explorer import GiteaExplorer +from utils.logger import logger + + +class SidebarView(ctk.CTkFrame): + def __init__(self, parent, on_repo_select=None, **kwargs): + super().__init__(parent, fg_color=COLORS['bg_secondary'], **kwargs) + + self.on_repo_select = on_repo_select + self.main_window = None # Will be set by main window + + # Create Gitea explorer + self.gitea_explorer = GiteaExplorer( + self, + on_repo_select=self.on_repo_select + ) + self.gitea_explorer.pack(fill="both", expand=True) + + logger.info("Sidebar view initialized with Gitea explorer") + + def get_gitea_explorer(self) -> Optional[GiteaExplorer]: + """Get the Gitea explorer instance""" + return self.gitea_explorer + + def refresh_all(self): + """Refresh all explorers""" + if self.gitea_explorer: + self.gitea_explorer.refresh_repositories() + + def set_main_window(self, main_window): + """Set main window reference""" + self.main_window = main_window + if self.gitea_explorer: + self.gitea_explorer.main_window = main_window \ No newline at end of file diff --git a/gui/styles.py b/gui/styles.py new file mode 100644 index 0000000..f7d8059 --- /dev/null +++ b/gui/styles.py @@ -0,0 +1,124 @@ +""" +Style Configuration for GUI +Modern design with dark/light theme support +""" + +# Dark Mode color definitions (only mode) +COLORS = { + # Background colors + 'bg_primary': '#000000', # Schwarz + 'bg_secondary': '#1A1F3A', # Dunkleres Blau + 'bg_tile': '#232D53', # Dunkelblau + 'bg_tile_hover': '#2A3560', # Helleres Dunkelblau + 'bg_vps': '#1A2347', # VPS tile etwas dunkler + 'bg_gitea_tile': '#2A3560', # Gitea tiles - deutlich heller für bessere Lesbarkeit + 'bg_gitea_hover': '#364170', # Gitea hover - noch heller + 'bg_selected': '#364170', # Selected - dunkleres Blau ohne Alpha + + # Text colors + 'text_primary': '#FFFFFF', # Weiß + 'text_secondary': '#B0B0B0', # Grau + 'text_dim': '#707070', # Dunkles Grau + + # Accent colors + 'accent_primary': '#00D4FF', # Cyan/Hellblau + 'accent_hover': '#00E5FF', # Helleres Cyan + 'accent_secondary': '#6B7AA1', # Sekundäres Blau-Grau + 'accent_selected': '#00D4FF', # Auswahl-Farbe + 'accent_success': '#4CAF50', # Success green + 'accent_warning': '#FF9800', # Warning orange + 'accent_error': '#F44336', # Error red + 'accent_vps': '#00D4FF', # VPS auch Cyan + + # Border colors + 'border_primary': '#232D53', # Dunkelblau + 'border_secondary': '#2A3560', # Helleres Dunkelblau +} + +# Get current colors (always dark mode) +def get_colors(): + """Get colors (always returns dark mode colors)""" + return COLORS + +# Font Configuration +FONTS = { + 'heading': ('Segoe UI', 28, 'bold'), + 'subheading': ('Segoe UI', 22, 'bold'), + 'title': ('Segoe UI', 20, 'bold'), # Added for gitea UI + 'subtitle': ('Segoe UI', 16, 'bold'), # Added for gitea UI + 'tile_title': ('Segoe UI', 18, 'bold'), + 'tile_text': ('Segoe UI', 14), + 'body': ('Segoe UI', 14), # Added for gitea UI + 'button': ('Segoe UI', 13, 'bold'), + 'small': ('Segoe UI', 12), + 'code': ('Consolas', 12), # Added for gitea UI code display +} + +# Tile Dimensions +TILE_SIZE = { + 'width': 280, + 'height': 180, + 'padding': 15, + 'margin': 10, +} + +# Button Styles +BUTTON_STYLES = { + 'primary': { + 'fg_color': COLORS['accent_primary'], + 'hover_color': COLORS['accent_hover'], + 'text_color': '#FFFFFF', + 'corner_radius': 6, + 'height': 32, + }, + 'secondary': { + 'fg_color': COLORS['bg_secondary'], + 'hover_color': COLORS['bg_tile'], + 'text_color': COLORS['text_primary'], + 'corner_radius': 6, + 'height': 32, + 'border_width': 1, + 'border_color': COLORS['border_primary'], + }, + 'danger': { + 'fg_color': COLORS['accent_error'], + 'hover_color': '#FF5252', + 'text_color': '#FFFFFF', + 'corner_radius': 6, + 'height': 32, + }, + 'vps': { + 'fg_color': COLORS['accent_vps'], + 'hover_color': COLORS['accent_hover'], + 'text_color': '#FFFFFF', + 'corner_radius': 6, + 'height': 32, + } +} + +def get_button_styles(): + """Get button styles (for compatibility)""" + return BUTTON_STYLES + + +# Window Configuration +WINDOW_CONFIG = { + 'title': 'Claude Project Manager', + 'width': 1200, + 'height': 800, + 'min_width': 800, + 'min_height': 600, +} + +# Theme functions (kept for compatibility but always return dark) +def set_theme(theme_name): + """Kept for compatibility - does nothing as only dark mode exists""" + return True + +def get_theme(): + """Always returns 'dark'""" + return 'dark' + +def toggle_theme(): + """Kept for compatibility - always returns 'dark'""" + return 'dark' \ No newline at end of file diff --git a/intelsight-icon-transparent-dark.png b/intelsight-icon-transparent-dark.png new file mode 100644 index 0000000..b4fa184 Binary files /dev/null and b/intelsight-icon-transparent-dark.png differ diff --git a/logo_header.svg b/logo_header.svg new file mode 100644 index 0000000..4605707 --- /dev/null +++ b/logo_header.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..715892c --- /dev/null +++ b/main.py @@ -0,0 +1,54 @@ +""" +Claude Project Manager +Main entry point for the application +""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gui.main_window import MainWindow +from utils.logger import logger + +def main(): + """Main application entry point""" + logger.info("Starting Claude Project Manager") + try: + # Import and set theme first + import json + from gui.styles import set_theme + import customtkinter as ctk + + # Load theme preference before creating UI + settings_file = os.path.join(os.path.dirname(__file__), "data/settings.json") + theme = 'dark' # Default + + try: + if os.path.exists(settings_file): + with open(settings_file, 'r') as f: + settings = json.load(f) + if 'theme' in settings: + theme = settings['theme'] + except: + pass + + # Set theme before UI creation + ctk.set_appearance_mode(theme) + set_theme(theme) + + # Create and run the application + app = MainWindow() + logger.info("Application window created, starting main loop") + app.run() + + except Exception as e: + logger.error(f"Error starting application: {e}") + print(f"Error starting application: {e}") + import traceback + traceback.print_exc() + input("Press Enter to exit...") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/manage_refactoring.py b/manage_refactoring.py new file mode 100644 index 0000000..b3b3c2e --- /dev/null +++ b/manage_refactoring.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Refactoring Management Tool +Manage feature flags for the MainWindow refactoring +""" + +import argparse +import json +from pathlib import Path +from gui.config import refactoring_config + +def show_status(): + """Show current refactoring status""" + status = refactoring_config.get_status() + print("\n=== Refactoring Configuration Status ===") + print(f"Config file: {status['config_file']}") + print("\nFeature Flags:") + for flag, value in status['flags'].items(): + status_str = "✅ ENABLED" if value else "❌ DISABLED" + print(f" {flag}: {status_str}") + print() + +def enable_handler(handler_name): + """Enable a specific handler""" + refactoring_config.enable_handler(handler_name) + refactoring_config.save_config() + print(f"✅ Enabled {handler_name} handler") + # Reload config to show updated status + from gui.config import RefactoringConfig + updated_config = RefactoringConfig() + status = updated_config.get_status() + print("\n=== Refactoring Configuration Status ===") + print(f"Config file: {status['config_file']}") + print("\nFeature Flags:") + for flag, value in status['flags'].items(): + status_str = "✅ ENABLED" if value else "❌ DISABLED" + print(f" {flag}: {status_str}") + print() + +def disable_all(): + """Disable all refactoring features""" + refactoring_config.disable_all() + refactoring_config.save_config() + print("❌ All refactoring features disabled") + show_status() + +def set_flag(flag_name, value): + """Set a specific flag""" + refactoring_config.set(flag_name, value) + refactoring_config.save_config() + status_str = "enabled" if value else "disabled" + print(f"✅ Flag {flag_name} {status_str}") + show_status() + +def create_test_config(): + """Create a test configuration with gradual enablement""" + print("\n=== Creating Test Configuration ===") + + # Phase 1: Enable UI helpers only (safest) + print("\nPhase 1: UI Helpers only") + refactoring_config.disable_all() + refactoring_config.set('USE_UI_HELPERS', True) + refactoring_config.save_config() + + print("\nTest with: python main.py") + print("If stable, proceed to Phase 2") + +def main(): + parser = argparse.ArgumentParser(description='Manage MainWindow refactoring') + subparsers = parser.add_subparsers(dest='command', help='Commands') + + # Status command + subparsers.add_parser('status', help='Show current configuration') + + # Enable command + enable_parser = subparsers.add_parser('enable', help='Enable a handler') + enable_parser.add_argument('handler', choices=['gitea', 'process', 'project', 'ui'], + help='Handler to enable') + + # Disable all command + subparsers.add_parser('disable-all', help='Disable all refactoring features') + + # Set flag command + set_parser = subparsers.add_parser('set', help='Set a specific flag') + set_parser.add_argument('flag', help='Flag name') + set_parser.add_argument('value', choices=['true', 'false'], help='Flag value') + + # Test config command + subparsers.add_parser('test-config', help='Create test configuration') + + args = parser.parse_args() + + if args.command == 'status' or not args.command: + show_status() + elif args.command == 'enable': + enable_handler(args.handler) + elif args.command == 'disable-all': + disable_all() + elif args.command == 'set': + value = args.value.lower() == 'true' + set_flag(args.flag, value) + elif args.command == 'test-config': + create_test_config() + else: + parser.print_help() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/process_manager.py b/process_manager.py new file mode 100644 index 0000000..ae27b52 --- /dev/null +++ b/process_manager.py @@ -0,0 +1,145 @@ +""" +Process Manager Module +Tracks and manages running Claude processes +""" + +import subprocess +import os +import json +from typing import Dict, Optional +from datetime import datetime +from utils.logger import logger + +class ProcessManager: + def __init__(self): + self.processes: Dict[str, subprocess.Popen] = {} + self.process_data_file = os.path.join(os.path.dirname(__file__), "data/running_processes.json") + logger.info("Initializing ProcessManager") + self.load_process_data() + + def load_process_data(self): + """Load process data from file""" + try: + if os.path.exists(self.process_data_file): + with open(self.process_data_file, 'r') as f: + data = json.load(f) + # Clean up old data (processes that are no longer running) + self.clean_process_data(data) + except Exception as e: + logger.error(f"Error loading process data: {e}") + print(f"Error loading process data: {e}") + + def clean_process_data(self, data: dict): + """Clean up process data for processes that are no longer running""" + # For now, we start fresh each time the app starts + # In a real app, we might check if processes are still running + self.save_process_data({}) + + def save_process_data(self, data: dict = None): + """Save process data to file""" + if data is None: + data = { + project_id: { + "pid": proc.pid if proc else None, + "start_time": datetime.now().isoformat(), + "status": "running" if proc and proc.poll() is None else "stopped" + } + for project_id, proc in self.processes.items() + } + + os.makedirs(os.path.dirname(self.process_data_file), exist_ok=True) + try: + with open(self.process_data_file, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"Error saving process data: {e}") + + def start_process(self, project_id: str, command: list, shell: bool = False) -> subprocess.Popen: + """Start a new process for a project""" + logger.info(f"Starting process for project: {project_id}") + logger.debug(f"Command: {' '.join(command if isinstance(command, list) else [command])}") + + # Stop existing process if any + if project_id in self.processes: + logger.warning(f"Stopping existing process for project: {project_id}") + self.stop_process(project_id) + + # Start new process + process = subprocess.Popen(command, shell=shell) + self.processes[project_id] = process + logger.info(f"Process started with PID: {process.pid}") + self.save_process_data() + return process + + def stop_process(self, project_id: str, project_name: str = None) -> bool: + """Stop a process for a project""" + logger.info(f"Stopping process for project: {project_id} (name: {project_name})") + try: + if project_id == "vps-permanent": + # Kill VPS windows + cmd_kill_cmd = 'taskkill /fi "WINDOWTITLE eq Claude VPS Server" /f >nul 2>&1' + logger.debug(f"Executing VPS kill command: {cmd_kill_cmd}") + os.system(cmd_kill_cmd) + else: + # Kill project-specific window + if project_name: + window_title = f"Claude - {project_name}" + # Use powershell for more accurate window title matching + ps_cmd = f'powershell -Command "Get-Process | Where-Object {{$_.MainWindowTitle -eq \'{window_title}\'}} | Stop-Process -Force"' + logger.debug(f"Executing PowerShell kill command: {ps_cmd}") + os.system(ps_cmd) + + # Also try taskkill as fallback + cmd_kill_cmd = f'taskkill /fi "WINDOWTITLE eq {window_title}" /f >nul 2>&1' + logger.debug(f"Executing taskkill command: {cmd_kill_cmd}") + os.system(cmd_kill_cmd) + + # Clean up tracked process if exists + if project_id in self.processes: + process = self.processes[project_id] + if process and process.poll() is None: + try: + process.terminate() + process.wait(timeout=2) + except: + try: + process.kill() + except: + pass + + del self.processes[project_id] + + self.save_process_data() + logger.info(f"Process stopped successfully for project: {project_id}") + return True + + except Exception as e: + logger.error(f"Error stopping process: {e}") + print(f"Error stopping process: {e}") + return False + + def check_window_exists(self, window_title: str) -> bool: + """Check if a window with the given title exists""" + # This method is not used anymore - checking is done in ProjectProcessTracker + return False + + def is_running(self, project_id: str) -> bool: + """Check if a process is running for a project""" + # Don't rely on the launcher process, check for actual window + return False # Will be overridden by is_project_running + + def stop_all_processes(self): + """Stop all running processes""" + project_ids = list(self.processes.keys()) + for project_id in project_ids: + self.stop_process(project_id) + + def get_process_info(self, project_id: str) -> Optional[dict]: + """Get process information for a project""" + if project_id in self.processes: + process = self.processes[project_id] + return { + "pid": process.pid if process else None, + "running": self.is_running(project_id) + } + return None \ No newline at end of file diff --git a/project_manager.py b/project_manager.py new file mode 100644 index 0000000..d181dcc --- /dev/null +++ b/project_manager.py @@ -0,0 +1,209 @@ +""" +Project Manager Module +Handles project storage, loading, and management +""" + +import json +import os +from datetime import datetime +from typing import List, Dict, Optional +import uuid +from utils.logger import logger + +class Project: + def __init__(self, name: str, path: str, project_id: str = None): + self.id = project_id or str(uuid.uuid4()) + self.name = name + self.path = path + self.created_at = datetime.now().isoformat() + self.last_accessed = datetime.now().isoformat() + self.readme_path = os.path.join(path, "CLAUDE_PROJECT_README.md") + self.description = "" + self.tags = [] + self.gitea_repo = None # Format: "owner/repo_name" or None if not linked + + def to_dict(self) -> Dict: + """Convert project to dictionary for JSON storage""" + return { + 'id': self.id, + 'name': self.name, + 'path': self.path, + 'created_at': self.created_at, + 'last_accessed': self.last_accessed, + 'readme_path': self.readme_path, + 'description': self.description, + 'tags': self.tags, + 'gitea_repo': self.gitea_repo + } + + @classmethod + def from_dict(cls, data: Dict) -> 'Project': + """Create project from dictionary""" + project = cls(data['name'], data['path'], data['id']) + project.created_at = data.get('created_at', datetime.now().isoformat()) + project.last_accessed = data.get('last_accessed', datetime.now().isoformat()) + project.readme_path = data.get('readme_path', os.path.join(data['path'], "CLAUDE_PROJECT_README.md")) + project.description = data.get('description', '') + project.tags = data.get('tags', []) + project.gitea_repo = data.get('gitea_repo', None) + return project + + def update_last_accessed(self): + """Update last accessed timestamp""" + self.last_accessed = datetime.now().isoformat() + +class ProjectManager: + def __init__(self, data_file: str = "data/projects.json"): + self.data_file = data_file + self.projects: Dict[str, Project] = {} + self.vps_project = None + self.admin_panel_project = None + self._ensure_data_dir() + self.load_projects() + self._initialize_vps_project() + self._initialize_admin_panel_project() + + def _ensure_data_dir(self): + """Ensure data directory exists""" + data_dir = os.path.dirname(self.data_file) + if data_dir and not os.path.exists(data_dir): + os.makedirs(data_dir) + + def _initialize_vps_project(self): + """Initialize the permanent VPS project""" + vps_id = "vps-permanent" + if vps_id not in self.projects: + self.vps_project = Project( + name="VPS Server", + path="claude-dev@91.99.192.14", + project_id=vps_id + ) + self.vps_project.description = "Remote VPS Server with Claude" + self.vps_project.tags = ["vps", "remote", "server"] + self.projects[vps_id] = self.vps_project + self.save_projects() + else: + self.vps_project = self.projects[vps_id] + + def _initialize_admin_panel_project(self): + """Initialize the permanent Admin Panel project""" + admin_id = "admin-panel-permanent" + if admin_id not in self.projects: + self.admin_panel_project = Project( + name="Admin Panel", + path="/opt/v2-Docker", + project_id=admin_id + ) + self.admin_panel_project.description = "V2 Docker Admin Panel" + self.admin_panel_project.tags = ["admin", "docker", "v2"] + self.projects[admin_id] = self.admin_panel_project + self.save_projects() + else: + self.admin_panel_project = self.projects[admin_id] + + def load_projects(self): + """Load projects from JSON file""" + logger.info("Loading projects from file") + if os.path.exists(self.data_file): + try: + with open(self.data_file, 'r', encoding='utf-8') as f: + data = json.load(f) + for proj_data in data.get('projects', []): + project = Project.from_dict(proj_data) + self.projects[project.id] = project + except Exception as e: + print(f"Error loading projects: {e}") + self.projects = {} + + def save_projects(self): + """Save projects to JSON file""" + logger.debug("Saving projects to file") + try: + data = { + 'projects': [proj.to_dict() for proj in self.projects.values()], + 'last_updated': datetime.now().isoformat() + } + with open(self.data_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"Error saving projects: {e}") + + def add_project(self, name: str, path: str) -> Project: + """Add new project""" + logger.info(f"Adding project: {name} at {path}") + # Check if project with same path already exists + for project in self.projects.values(): + if project.path == path and project.id != "vps-permanent": + # Update existing project + project.name = name + project.update_last_accessed() + self.save_projects() + return project + + # Create new project + project = Project(name, path) + self.projects[project.id] = project + self.save_projects() + return project + + def remove_project(self, project_id: str) -> bool: + """Remove project by ID""" + logger.info(f"Removing project with ID: {project_id}") + if project_id in self.projects and project_id not in ["vps-permanent", "admin-panel-permanent"]: + del self.projects[project_id] + self.save_projects() + return True + return False + + def get_project(self, project_id: str) -> Optional[Project]: + """Get project by ID""" + return self.projects.get(project_id) + + def get_all_projects(self) -> List[Project]: + """Get all projects sorted alphabetically by name""" + projects = list(self.projects.values()) + # Sort alphabetically, but keep VPS and Admin Panel first + vps = [p for p in projects if p.id == "vps-permanent"] + admin = [p for p in projects if p.id == "admin-panel-permanent"] + others = [p for p in projects if p.id not in ["vps-permanent", "admin-panel-permanent"]] + others.sort(key=lambda p: p.name.lower()) # Sort alphabetically by name (case-insensitive) + return vps + admin + others + + def update_project(self, project_id: str, **kwargs): + """Update project properties""" + project = self.get_project(project_id) + if project: + for key, value in kwargs.items(): + if hasattr(project, key): + setattr(project, key, value) + self.save_projects() + + def search_projects(self, query: str) -> List[Project]: + """Search projects by name, path, or tags""" + query = query.lower() + results = [] + for project in self.projects.values(): + if (query in project.name.lower() or + query in project.path.lower() or + any(query in tag.lower() for tag in project.tags)): + results.append(project) + return results + +# Test the module +if __name__ == "__main__": + # Create manager + manager = ProjectManager("test_projects.json") + + # Add test projects + proj1 = manager.add_project("Test Project 1", "C:\\Users\\test\\project1") + proj2 = manager.add_project("Test Project 2", "C:\\Users\\test\\project2") + + # List all projects + print("All projects:") + for project in manager.get_all_projects(): + print(f"- {project.name} ({project.path})") + + # Clean up test file + import os + if os.path.exists("test_projects.json"): + os.remove("test_projects.json") \ No newline at end of file diff --git a/project_process_tracker.py b/project_process_tracker.py new file mode 100644 index 0000000..6c075dc --- /dev/null +++ b/project_process_tracker.py @@ -0,0 +1,65 @@ +""" +Project Process Tracker +Tracks running state of projects in memory +""" +import subprocess + +class ProjectProcessTracker: + """Simple in-memory tracker for project states""" + def __init__(self): + self.running_projects = set() + self.project_manager = None # Will be set by MainWindow + + def set_project_manager(self, project_manager): + """Set reference to project manager""" + self.project_manager = project_manager + + def set_running(self, project_id: str): + """Mark a project as running""" + self.running_projects.add(project_id) + + def set_stopped(self, project_id: str): + """Mark a project as stopped""" + self.running_projects.discard(project_id) + + def check_window_exists(self, window_title: str) -> bool: + """Check if a window with the given title exists""" + try: + # Use PowerShell for faster window checking + ps_command = f'powershell -Command "(Get-Process | Where-Object {{$_.MainWindowTitle -eq \'{window_title}\'}}).Count -gt 0"' + + # 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(ps_command, capture_output=True, text=True, shell=True, + timeout=1, startupinfo=startupinfo) + return result.stdout.strip().lower() == 'true' + except Exception as e: + # Fallback to simple check + return False + + def is_running(self, project_id: str) -> bool: + """Check if a project is running by checking window title""" + if project_id == "vps-permanent": + # Check for VPS window + return self.check_window_exists("Claude VPS Server") + else: + # Check for project window by name + if self.project_manager: + project = self.project_manager.get_project(project_id) + if project: + window_title = f"Claude - {project.name}" + return self.check_window_exists(window_title) + return False + + def stop_all(self): + """Clear all running states""" + self.running_projects.clear() + + def get_running_projects(self) -> set: + """Get all running project IDs""" + return self.running_projects.copy() \ No newline at end of file diff --git a/readme_generator.py b/readme_generator.py new file mode 100644 index 0000000..b7f111d --- /dev/null +++ b/readme_generator.py @@ -0,0 +1,295 @@ +""" +README Generator Module +Automatically generates and updates README files for projects +""" + +import os +import json +from datetime import datetime +from typing import Dict, List, Set +import mimetypes + +class ReadmeGenerator: + def __init__(self): + self.language_extensions = { + '.py': 'Python', + '.js': 'JavaScript', + '.ts': 'TypeScript', + '.jsx': 'React', + '.tsx': 'React TypeScript', + '.java': 'Java', + '.cpp': 'C++', + '.c': 'C', + '.cs': 'C#', + '.php': 'PHP', + '.rb': 'Ruby', + '.go': 'Go', + '.rs': 'Rust', + '.swift': 'Swift', + '.kt': 'Kotlin', + '.dart': 'Dart', + '.r': 'R', + '.scala': 'Scala', + '.lua': 'Lua', + '.pl': 'Perl', + '.sh': 'Shell', + '.bat': 'Batch', + '.ps1': 'PowerShell', + } + + self.framework_indicators = { + 'React': ['package.json', 'react'], + 'Angular': ['angular.json', '@angular'], + 'Vue': ['vue.config.js', 'vue'], + 'Django': ['manage.py', 'django'], + 'Flask': ['app.py', 'flask'], + 'Express': ['express', 'node_modules'], + 'Spring': ['pom.xml', 'spring'], + '.NET': ['.csproj', 'dotnet'], + 'Laravel': ['artisan', 'laravel'], + 'Rails': ['Gemfile', 'rails'], + } + + def analyze_project(self, project_path: str) -> Dict: + """Analyze project structure and content""" + analysis = { + 'path': project_path, + 'name': os.path.basename(project_path), + 'languages': set(), + 'frameworks': set(), + 'file_count': 0, + 'total_size': 0, + 'structure': {}, + 'key_files': [], + 'last_modified': None, + } + + # Walk through directory + for root, dirs, files in os.walk(project_path): + # Skip hidden and system directories + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv', 'env']] + + rel_path = os.path.relpath(root, project_path) + level = rel_path.count(os.sep) + + # Limit depth for structure + if level < 3: + current_dict = analysis['structure'] + if rel_path != '.': + for part in rel_path.split(os.sep): + if part not in current_dict: + current_dict[part] = {} + current_dict = current_dict[part] + + # Add files to structure + for file in files[:10]: # Limit files shown + if not file.startswith('.'): + current_dict[file] = 'file' + + # Analyze files + for file in files: + if file.startswith('.'): + continue + + file_path = os.path.join(root, file) + analysis['file_count'] += 1 + + try: + # Get file size + size = os.path.getsize(file_path) + analysis['total_size'] += size + + # Get modification time + mtime = os.path.getmtime(file_path) + if not analysis['last_modified'] or mtime > analysis['last_modified']: + analysis['last_modified'] = mtime + + # Detect language + ext = os.path.splitext(file)[1].lower() + if ext in self.language_extensions: + analysis['languages'].add(self.language_extensions[ext]) + + # Key files + if file in ['README.md', 'package.json', 'requirements.txt', 'Dockerfile', + '.gitignore', 'Makefile', 'setup.py', 'pom.xml']: + analysis['key_files'].append(file) + + except: + pass + + # Detect frameworks + for framework, indicators in self.framework_indicators.items(): + for indicator in indicators: + if any(indicator in str(f) for f in analysis['key_files']): + analysis['frameworks'].add(framework) + + # Convert sets to lists for JSON serialization + analysis['languages'] = list(analysis['languages']) + analysis['frameworks'] = list(analysis['frameworks']) + + return analysis + + def generate_readme(self, project_path: str, project_name: str = None) -> str: + """Generate README content for a project""" + analysis = self.analyze_project(project_path) + + if project_name: + analysis['name'] = project_name + + # Format file size + size_mb = analysis['total_size'] / (1024 * 1024) + size_str = f"{size_mb:.1f} MB" if size_mb > 1 else f"{analysis['total_size'] / 1024:.1f} KB" + + # Format last modified + if analysis['last_modified']: + last_mod = datetime.fromtimestamp(analysis['last_modified']).strftime('%Y-%m-%d %H:%M') + else: + last_mod = "Unknown" + + # Build README content + content = f"""# {analysis['name']} + +*This README was automatically generated by Claude Project Manager* + +## Project Overview + +- **Path**: `{analysis['path']}` +- **Files**: {analysis['file_count']} files +- **Size**: {size_str} +- **Last Modified**: {last_mod} + +## Technology Stack + +""" + + if analysis['languages']: + content += "### Languages\n" + for lang in sorted(analysis['languages']): + content += f"- {lang}\n" + content += "\n" + + if analysis['frameworks']: + content += "### Frameworks & Libraries\n" + for framework in sorted(analysis['frameworks']): + content += f"- {framework}\n" + content += "\n" + + # Add structure + content += "## Project Structure\n\n```\n" + content += self._format_structure(analysis['structure']) + content += "```\n\n" + + # Add key files section + if analysis['key_files']: + content += "## Key Files\n\n" + for file in analysis['key_files']: + content += f"- `{file}`\n" + content += "\n" + + # Add Claude usage section + content += """## Claude Integration + +This project is managed with Claude Project Manager. To work with this project: + +1. Open Claude Project Manager +2. Click on this project's tile +3. Claude will open in the project directory + +## Notes + +*Add your project-specific notes here* + +--- + +## Development Log + +""" + + # Add timestamp + content += f"- README generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + + return content + + def _format_structure(self, structure: Dict, prefix: str = "", is_last: bool = True) -> str: + """Format directory structure for display""" + output = "" + items = list(structure.items()) + + for i, (name, value) in enumerate(items): + is_last_item = i == len(items) - 1 + + # Add tree characters + if prefix: + output += prefix + output += "└── " if is_last_item else "├── " + + output += name + + if isinstance(value, dict) and value: # It's a directory with contents + output += "/\n" + extension = " " if is_last_item else "│ " + output += self._format_structure(value, prefix + extension, is_last_item) + else: + output += "\n" + + return output + + def update_readme(self, readme_path: str, new_content: str): + """Update README file, preserving user notes""" + if os.path.exists(readme_path): + try: + with open(readme_path, 'r', encoding='utf-8') as f: + old_content = f.read() + + # Try to preserve user notes section + notes_marker = "## Notes" + if notes_marker in old_content: + notes_start = old_content.find(notes_marker) + notes_end = old_content.find("\n---", notes_start) + if notes_end > notes_start: + user_notes = old_content[notes_start:notes_end] + # Replace empty notes section with user's notes + new_content = new_content.replace( + "## Notes\n\n*Add your project-specific notes here*\n", + user_notes + ) + + # Append to development log + if "## Development Log" in old_content: + log_start = old_content.find("## Development Log") + old_log = old_content[log_start:] + # Remove the new log header and just append entries + new_log_entry = f"- README updated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + new_content = new_content[:new_content.find("## Development Log")] + old_log + new_log_entry + + except: + pass + + # Write the README + with open(readme_path, 'w', encoding='utf-8') as f: + f.write(new_content) + + def generate_and_save_readme(self, project_path: str, project_name: str = None) -> str: + """Generate and save README for a project""" + readme_path = os.path.join(project_path, "CLAUDE_PROJECT_README.md") + content = self.generate_readme(project_path, project_name) + self.update_readme(readme_path, content) + return readme_path + +# Test the module +if __name__ == "__main__": + generator = ReadmeGenerator() + + # Test with current directory + test_path = os.path.dirname(os.path.abspath(__file__)) + analysis = generator.analyze_project(test_path) + + print("Project Analysis:") + print(f"Languages: {analysis['languages']}") + print(f"Files: {analysis['file_count']}") + print(f"Key files: {analysis['key_files']}") + + # Generate README + readme = generator.generate_readme(test_path, "Claude Project Manager") + print("\nGenerated README Preview:") + print(readme[:500] + "...") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a5e3c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +customtkinter>=5.2.0 +pillow>=10.0.0 +requests>=2.31.0 +python-socketio[client]>=5.10.0 +websocket-client>=1.6.0 \ No newline at end of file diff --git a/scripts/check_lfs_status.bat b/scripts/check_lfs_status.bat new file mode 100644 index 0000000..c2444fe --- /dev/null +++ b/scripts/check_lfs_status.bat @@ -0,0 +1,30 @@ +@echo off +echo Checking Git LFS Status... +echo ============================= +echo. + +echo 1. LFS Version: +git lfs version +echo. + +echo 2. LFS Environment: +git lfs env +echo. + +echo 3. Tracked LFS patterns: +git lfs track +echo. + +echo 4. LFS files in repository: +git lfs ls-files +echo. + +echo 5. Verifying LFS remote: +git lfs fsck +echo. + +echo 6. Current remotes: +git remote -v +echo. + +pause \ No newline at end of file diff --git a/scripts/fix_large_files.bat b/scripts/fix_large_files.bat new file mode 100644 index 0000000..aff8af2 --- /dev/null +++ b/scripts/fix_large_files.bat @@ -0,0 +1,19 @@ +@echo off +echo Finding large files in repository... +echo. + +REM Find files larger than 100MB +echo Files larger than 100MB: +git ls-files -z | xargs -0 -n1 -I{} -- du -h {} | grep -E "^[0-9]+(M|G)" | sort -hr + +echo. +echo To add large files to Git LFS: +echo 1. git lfs track "*.zip" +echo 2. git lfs track "*.mp4" +echo 3. git lfs track "path/to/largefile" +echo. +echo Then: git add .gitattributes +echo git add [large files] +echo git commit -m "Add LFS tracking" +echo. +pause \ No newline at end of file diff --git a/scripts/fix_remote_organization.bat b/scripts/fix_remote_organization.bat new file mode 100644 index 0000000..6187ab3 --- /dev/null +++ b/scripts/fix_remote_organization.bat @@ -0,0 +1,24 @@ +@echo off +echo Fixing Git Remote for IntelSight Organization +echo ============================================= +echo. + +echo Current remote: +git remote -v +echo. + +echo Removing old remote... +git remote remove origin + +echo Adding new remote for IntelSight organization... +git remote add origin https://gitea-undso.intelsight.de/IntelSight/website-main.git + +echo. +echo New remote: +git remote -v +echo. + +echo Now push to the new remote: +echo git push -u origin master +echo. +pause \ No newline at end of file diff --git a/scripts/fix_website_auth.bat b/scripts/fix_website_auth.bat new file mode 100644 index 0000000..8ef4cbd --- /dev/null +++ b/scripts/fix_website_auth.bat @@ -0,0 +1,31 @@ +@echo off +echo Fixing Authentication for website-main +echo ===================================== +echo. + +cd C:\Users\hendr\Desktop\IntelSight\website-main + +echo Current remote: +git remote -v +echo. + +echo Removing old remote... +git remote remove origin + +echo Adding authenticated remote... +echo NOTE: Replace USERNAME and TOKEN with your actual Gitea credentials! +echo. +echo Example: +echo git remote add origin https://USERNAME:TOKEN@gitea-undso.intelsight.de/IntelSight/website-main.git +echo. +echo Your current credentials from ClaudeProjectManager: +echo Username: StuXn3t +echo Token: 327caf5e04a43c35e67e0fb00e0a9cd269889db9 +echo. +echo Run this command: +echo git remote add origin https://StuXn3t:327caf5e04a43c35e67e0fb00e0a9cd269889db9@gitea-undso.intelsight.de/IntelSight/website-main.git +echo. +echo Then: +echo git push -u origin master +echo. +pause \ No newline at end of file diff --git a/scripts/fix_website_final.bat b/scripts/fix_website_final.bat new file mode 100644 index 0000000..9221227 --- /dev/null +++ b/scripts/fix_website_final.bat @@ -0,0 +1,54 @@ +@echo off +echo Final fix for website-main... +echo. + +cd /d "C:\Users\hendr\Desktop\IntelSight\website-main" + +echo Step 1: Removing Git LFS completely... +git lfs uninstall + +echo. +echo Step 2: Checking current status... +git status + +echo. +echo Step 3: The video file is missing! Let's check... +if not exist "assets\videos\background.mp4" ( + echo ERROR: background.mp4 was removed by LFS! + echo You need to restore it from backup or remove it from the repository. + echo. + echo Option A: Remove from repository + echo Run: git rm assets/videos/background.mp4 + echo git commit -m "Remove video file" + echo. + echo Option B: Add to .gitignore + echo Add this line to .gitignore: assets/videos/background.mp4 + echo. +) else ( + echo File exists. Adding it back... + git add assets/videos/background.mp4 + git commit -m "Re-add video file without LFS" +) + +echo. +echo Step 4: Removing LFS hooks... +if exist .git\hooks\pre-push ( + del .git\hooks\pre-push + echo Removed pre-push hook +) + +echo. +echo Step 5: Try push with --no-verify... +git push --no-verify -u origin main + +echo. +echo If still failing, the video file might be too large (>100MB). +echo In that case, add it to .gitignore: +echo. +echo echo assets/videos/background.mp4 >> .gitignore +echo git rm --cached assets/videos/background.mp4 +echo git add .gitignore +echo git commit -m "Remove large video file" +echo git push -u origin main +echo. +pause \ No newline at end of file diff --git a/scripts/fix_website_lfs_and_branch.bat b/scripts/fix_website_lfs_and_branch.bat new file mode 100644 index 0000000..ecbee45 --- /dev/null +++ b/scripts/fix_website_lfs_and_branch.bat @@ -0,0 +1,46 @@ +@echo off +echo Fixing website-main LFS and branch issues... +echo. + +cd /d "C:\Users\hendr\Desktop\IntelSight\website-main" + +echo Step 1: Checking for LFS files... +git lfs ls-files + +echo. +echo Step 2: Removing LFS tracking (if any)... +if exist .gitattributes ( + echo Backing up .gitattributes... + copy .gitattributes .gitattributes.backup + + echo Removing LFS entries... + powershell -Command "(Get-Content .gitattributes) | Where-Object {$_ -notmatch 'filter=lfs'} | Set-Content .gitattributes.temp" + move /y .gitattributes.temp .gitattributes + + git add .gitattributes + git commit -m "Remove LFS tracking" 2>nul +) + +echo. +echo Step 3: Checking which files are tracked by LFS... +git lfs ls-files +for /f "tokens=3" %%i in ('git lfs ls-files') do ( + echo Removing LFS tracking for: %%i + git rm --cached %%i + git add %%i +) + +echo. +echo Step 4: Renaming branch from master to main... +git branch -m master main + +echo. +echo Step 5: Pushing to main branch... +git push -u origin main --force + +echo. +echo Done! If push still fails, try: +echo 1. Delete .git/hooks/pre-push (if exists) +echo 2. Run: git push --no-verify -u origin main +echo. +pause \ No newline at end of file diff --git a/scripts/fix_website_repo.bat b/scripts/fix_website_repo.bat new file mode 100644 index 0000000..4182e1f --- /dev/null +++ b/scripts/fix_website_repo.bat @@ -0,0 +1,37 @@ +@echo off +echo Fixing website-main repository... +echo. + +cd /d "C:\Users\hendr\Desktop\IntelSight\website-main" + +echo Step 1: Removing old remote... +git remote remove origin 2>nul + +echo Step 2: Checking/removing LFS configuration... +if exist .gitattributes ( + findstr /C:"filter=lfs" .gitattributes >nul + if %errorlevel%==0 ( + echo Found LFS configuration, creating backup... + copy .gitattributes .gitattributes.backup + + echo Removing LFS entries... + powershell -Command "(Get-Content .gitattributes) | Where-Object {$_ -notmatch 'filter=lfs'} | Set-Content .gitattributes" + + echo Unstaging LFS files... + git rm --cached .gitattributes 2>nul + git add .gitattributes + ) +) + +echo Step 3: Adding correct remote... +git remote add origin https://IntelSight_Admin:3b4a6ba1ade3f34640f3c85d2333b4a3a0627471@gitea-undso.intelsight.de/IntelSight/website-main.git + +echo Step 4: Verifying remote... +git remote -v + +echo. +echo Ready! Now try: +echo 1. Create the repository on Gitea first (in IntelSight organization) +echo 2. Then run: git push -u origin master:main +echo. +pause \ No newline at end of file diff --git a/scripts/quick_fix_website.bat b/scripts/quick_fix_website.bat new file mode 100644 index 0000000..4308a62 --- /dev/null +++ b/scripts/quick_fix_website.bat @@ -0,0 +1,21 @@ +@echo off +echo Quick fix for website-main repository... +echo. + +cd /d "C:\Users\hendr\Desktop\IntelSight\website-main" + +echo Current remote: +git remote -v +echo. + +echo Fixing remote URL with correct token... +git remote set-url origin https://IntelSight_Admin:3b4a6ba1ade3f34640f3c85d2333b4a3a0627471@gitea-undso.intelsight.de/IntelSight/website-main.git + +echo. +echo New remote: +git remote -v +echo. + +echo Done! Now you can push normally. +echo. +pause \ No newline at end of file diff --git a/scripts/website_gitignore_template.txt b/scripts/website_gitignore_template.txt new file mode 100644 index 0000000..71b236d --- /dev/null +++ b/scripts/website_gitignore_template.txt @@ -0,0 +1,30 @@ +# Large files +*.zip +*.rar +*.7z +*.tar +*.gz + +# Media files over 100MB +*.mp4 +*.avi +*.mov +*.mkv +*.wmv + +# Database dumps +*.sql +*.dump + +# Backup files +*.bak +backup/ +backups/ + +# Build artifacts +dist/ +build/ +node_modules/ + +# Add specific large files here +# largefile.ext \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..c66a0b2 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# Services package \ No newline at end of file diff --git a/services/activity_sync.py b/services/activity_sync.py new file mode 100644 index 0000000..6d1f4a1 --- /dev/null +++ b/services/activity_sync.py @@ -0,0 +1,209 @@ +""" +Activity Sync Service for CPM +Handles WebSocket connection to the Activity Server +""" + +import socketio +import requests +import threading +import json +from typing import Optional, Callable, List, Dict +from pathlib import Path +from utils.logger import logger + + +class ActivitySyncService: + def __init__(self, server_url: str = None, api_key: str = None, user_id: str = None, user_name: str = None): + self.server_url = server_url or "http://localhost:3001" + self.api_key = api_key + self.user_id = user_id + self.user_name = user_name + self.sio = None + self.connected = False + self.on_activities_update = None + self.activities = [] + self.current_activity = None + + # Load settings + self.load_settings() + + def load_settings(self): + """Load activity sync settings from file""" + settings_file = Path.home() / ".claude_project_manager" / "activity_settings.json" + try: + if settings_file.exists(): + with open(settings_file, 'r') as f: + settings = json.load(f) + self.server_url = settings.get("server_url", self.server_url) + self.api_key = settings.get("api_key", self.api_key) + self.user_id = settings.get("user_id", self.user_id) + self.user_name = settings.get("user_name", self.user_name) + logger.info(f"Loaded activity sync settings from {settings_file}") + except Exception as e: + logger.error(f"Failed to load activity settings: {e}") + + def save_settings(self): + """Save activity sync settings to file""" + settings_file = Path.home() / ".claude_project_manager" / "activity_settings.json" + try: + settings_file.parent.mkdir(parents=True, exist_ok=True) + settings = { + "server_url": self.server_url, + "api_key": self.api_key, + "user_id": self.user_id, + "user_name": self.user_name + } + with open(settings_file, 'w') as f: + json.dump(settings, f, indent=2) + logger.info(f"Saved activity sync settings to {settings_file}") + except Exception as e: + logger.error(f"Failed to save activity settings: {e}") + + def connect(self): + """Connect to the activity server""" + if not all([self.server_url, self.api_key, self.user_id, self.user_name]): + logger.warning("Activity sync not configured properly") + return False + + try: + # Create Socket.IO client + self.sio = socketio.Client() + + # Register event handlers + @self.sio.event + def connect(): + logger.info("Connected to activity server") + self.connected = True + + @self.sio.event + def disconnect(): + logger.info("Disconnected from activity server") + self.connected = False + + @self.sio.event + def activities_update(data): + """Handle activities update from server""" + self.activities = data + logger.debug(f"Received activities update: {len(data)} activities") + if self.on_activities_update: + self.on_activities_update(data) + + @self.sio.event + def connect_error(data): + logger.error(f"Connection error: {data}") + self.connected = False + + # Connect with authentication + logger.info(f"Attempting to connect to activity server: {self.server_url}") + + # Try to force polling transport if WebSockets are blocked + import os + os.environ['SOCKETIO_TRANSPORT'] = 'polling' + + self.sio.connect( + self.server_url, + auth={ + 'token': self.api_key, + 'userId': self.user_id, + 'userName': self.user_name + } + ) + + return True + + except Exception as e: + logger.error(f"Failed to connect to activity server: {e}") + self.connected = False + return False + + def disconnect(self): + """Disconnect from the activity server""" + if self.sio: + try: + self.sio.disconnect() + self.sio = None + self.connected = False + logger.info("Disconnected from activity server") + except Exception as e: + logger.error(f"Error disconnecting: {e}") + + def start_activity(self, project_name: str, project_path: str, description: str = ""): + """Start a new activity""" + if not self.connected or not self.sio: + logger.warning("Not connected to activity server") + return False + + try: + self.sio.emit('activity-start', { + 'projectName': project_name, + 'projectPath': project_path, + 'description': description + }) + self.current_activity = { + 'projectName': project_name, + 'projectPath': project_path + } + logger.info(f"Started activity for project: {project_name}") + return True + except Exception as e: + logger.error(f"Failed to start activity: {e}") + return False + + def stop_activity(self): + """Stop the current activity""" + if not self.connected or not self.sio: + logger.warning("Not connected to activity server") + return False + + try: + self.sio.emit('activity-stop') + self.current_activity = None + logger.info("Stopped current activity") + return True + except Exception as e: + logger.error(f"Failed to stop activity: {e}") + return False + + def get_activities(self) -> List[Dict]: + """Get current activities via REST API""" + if not self.api_key: + return [] + + try: + response = requests.get( + f"{self.server_url}/api/activities", + headers={"X-API-Key": self.api_key}, + timeout=5 + ) + if response.status_code == 200: + data = response.json() + return data.get('activities', []) + else: + logger.error(f"Failed to fetch activities: {response.status_code}") + return [] + except Exception as e: + logger.error(f"Failed to fetch activities: {e}") + return [] + + def is_configured(self) -> bool: + """Check if the service is properly configured""" + return all([self.server_url, self.api_key, self.user_id, self.user_name]) + + def get_active_users_count(self) -> int: + """Get count of active users""" + return len(self.activities) + + def is_project_active(self, project_name: str) -> Optional[Dict]: + """Check if a project has active users""" + for activity in self.activities: + if activity.get('projectName') == project_name and activity.get('isActive'): + return activity + return None + + def get_current_activity(self) -> Optional[Dict]: + """Get current user's activity""" + return self.current_activity + + +# Global instance +activity_service = ActivitySyncService() \ No newline at end of file diff --git a/src/gitea/__init__.py b/src/gitea/__init__.py new file mode 100644 index 0000000..b551024 --- /dev/null +++ b/src/gitea/__init__.py @@ -0,0 +1,14 @@ +from .gitea_client import GiteaClient, GiteaConfig +from .git_operations import GitOperationsManager +from .repository_manager import RepositoryManager +from .issue_pr_manager import IssueManager, PullRequestManager, IssuePRManager + +__all__ = [ + 'GiteaClient', + 'GiteaConfig', + 'GitOperationsManager', + 'RepositoryManager', + 'IssueManager', + 'PullRequestManager', + 'IssuePRManager' +] \ No newline at end of file diff --git a/src/gitea/git_operations.py b/src/gitea/git_operations.py new file mode 100644 index 0000000..df03dcc --- /dev/null +++ b/src/gitea/git_operations.py @@ -0,0 +1,603 @@ +import os +import subprocess +import logging +from typing import Optional, List, Tuple +from pathlib import Path +from dataclasses import dataclass +import tkinter as tk +from tkinter import filedialog +from datetime import datetime +from utils.logger import logger + +@dataclass +class GitCredentials: + username: str + token: str + + def get_remote_url(self, base_url: str, repo_path: str) -> str: + from urllib.parse import urlparse + parsed = urlparse(base_url) + url = f"https://{self.username}:{self.token}@{parsed.netloc}/{repo_path}.git" + logger.info(f"Generated remote URL: https://{self.username}:****@{parsed.netloc}/{repo_path}.git") + return url + +class GitOperationsManager: + def __init__(self, base_url: str, token: str, username: str = "claude-project-manager"): + self.base_url = base_url + self.credentials = GitCredentials(username=username, token=token) + self.default_clone_dir = Path.home() / "GiteaRepos" + + def select_directory(self, title: str = "Select Clone Directory") -> Optional[Path]: + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + + directory = filedialog.askdirectory( + title=title, + initialdir=str(self.default_clone_dir), + mustexist=False + ) + + root.destroy() + + if directory: + path = Path(directory) + path.mkdir(parents=True, exist_ok=True) + return path + return None + + def _run_git_command(self, cmd: List[str], cwd: Optional[Path] = None) -> Tuple[bool, str, str]: + try: + # Log the command being executed + cmd_str = ' '.join(cmd) + logger.info(f"Executing git command: {cmd_str}") + if cwd: + logger.debug(f"Working directory: {cwd}") + + # Hide console window on Windows + startupinfo = None + if os.name == 'nt': # Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=60, + startupinfo=startupinfo + ) + + success = result.returncode == 0 + stdout = result.stdout.strip() + stderr = result.stderr.strip() + + if success: + logger.debug(f"Git command successful: {cmd_str}") + if stdout: + logger.debug(f"Output: {stdout[:500]}...") # Log first 500 chars + else: + logger.error(f"Git command failed: {cmd_str}") + logger.error(f"Error: {stderr}") + + return success, stdout, stderr + + except subprocess.TimeoutExpired: + logger.error(f"Git command timed out: {' '.join(cmd)}") + return False, "", "Command timed out" + except Exception as e: + logger.error(f"Git command error: {e}") + return False, "", str(e) + + def clone_repository(self, owner: str, repo: str, clone_dir: Optional[Path] = None) -> Tuple[bool, Path]: + if clone_dir is None: + # Use default clone directory if none specified + clone_dir = self.default_clone_dir + clone_dir.mkdir(parents=True, exist_ok=True) + + repo_path = f"{owner}/{repo}" + remote_url = self.credentials.get_remote_url(self.base_url, repo_path) + local_path = clone_dir / repo + + if local_path.exists(): + logger.warning(f"Repository already exists at {local_path}") + return True, local_path # Return success if already exists + + cmd = ["git", "clone", remote_url, str(local_path)] + success, stdout, stderr = self._run_git_command(cmd) + + if success: + logger.info(f"Successfully cloned {repo} to {local_path}") + self._configure_git_user(local_path) + else: + logger.error(f"Clone failed: {stderr}") + + return success, local_path + + def _configure_git_user(self, repo_path: Path) -> None: + self._run_git_command(["git", "config", "user.name", "Claude Project Manager"], cwd=repo_path) + self._run_git_command(["git", "config", "user.email", "claude@intelsight.de"], cwd=repo_path) + + def fetch(self, repo_path: Path, remote: str = "origin") -> Tuple[bool, str]: + cmd = ["git", "fetch", remote] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def pull(self, repo_path: Path, remote: str = "origin", branch: Optional[str] = None) -> Tuple[bool, str]: + cmd = ["git", "pull", remote] + if branch: + cmd.append(branch) + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def add(self, repo_path: Path, files: List[str] = None) -> Tuple[bool, str]: + if files is None: + files = ["."] + + cmd = ["git", "add"] + files + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def commit(self, repo_path: Path, message: str) -> Tuple[bool, str]: + cmd = ["git", "commit", "-m", message] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + if not success and "nothing to commit" in stderr: + return True, "Nothing to commit, working tree clean" + + return success, stdout or stderr + + def split_large_file(self, file_path: Path, chunk_size_mb: int = 45) -> List[Path]: + """Split a large file into smaller chunks""" + chunk_size = chunk_size_mb * 1024 * 1024 # Convert to bytes + chunks = [] + + try: + file_size = file_path.stat().st_size + with open(file_path, 'rb') as f: + chunk_num = 0 + while True: + chunk_data = f.read(chunk_size) + if not chunk_data: + break + + chunk_path = file_path.parent / f"{file_path.stem}.part{chunk_num:03d}{file_path.suffix}" + with open(chunk_path, 'wb') as chunk_file: + chunk_file.write(chunk_data) + chunks.append(chunk_path) + chunk_num += 1 + + logger.info(f"Split {file_path.name} into {len(chunks)} chunks") + return chunks + except Exception as e: + logger.error(f"Error splitting file: {e}") + return [] + + def create_join_script(self, original_file: Path, chunks: List[Path]) -> Path: + """Create a script to rejoin file chunks""" + script_path = original_file.parent / f"join_{original_file.stem}.bat" + + with open(script_path, 'w') as f: + f.write("@echo off\n") + f.write(f"echo Joining {original_file.name}...\n") + f.write(f"copy /b ") + + # Add all chunks + for i, chunk in enumerate(chunks): + if i > 0: + f.write("+") + f.write(f'"{chunk.name}"') + + f.write(f' "{original_file.name}"\n') + f.write("echo Done!\n") + f.write("pause\n") + + # Also create a shell script for Unix + sh_script_path = original_file.parent / f"join_{original_file.stem}.sh" + with open(sh_script_path, 'w') as f: + f.write("#!/bin/bash\n") + f.write(f"echo 'Joining {original_file.name}...'\n") + f.write("cat ") + for chunk in chunks: + f.write(f'"{chunk.name}" ') + f.write(f'> "{original_file.name}"\n') + f.write("echo 'Done!'\n") + + return script_path + + def check_large_files(self, repo_path: Path, size_limit_mb: int = 100) -> List[Tuple[str, int]]: + """Check for files larger than size_limit_mb in the repository""" + large_files = [] + try: + # Get list of all tracked files + cmd = ["git", "ls-files", "-z"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + if success and stdout: + files = stdout.split('\0') + for file in files: + if file: + file_path = repo_path / file + if file_path.exists(): + size = file_path.stat().st_size + if size > size_limit_mb * 1024 * 1024: + large_files.append((file, size)) + except Exception as e: + logger.error(f"Error checking large files: {e}") + + return large_files + + def push(self, repo_path: Path, remote: str = "origin", branch: Optional[str] = None) -> Tuple[bool, str]: + # First try regular push + cmd = ["git", "push", remote] + if branch: + cmd.append(branch) + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + # If push failed due to no upstream, try with --set-upstream + if not success and ("has no upstream branch" in stderr or "The current branch" in stderr or "--set-upstream" in stderr): + # Get current branch + branch_cmd = ["git", "branch", "--show-current"] + branch_success, current_branch, _ = self._run_git_command(branch_cmd, cwd=repo_path) + + if branch_success and current_branch.strip(): + # Retry with --set-upstream + cmd = ["git", "push", "--set-upstream", remote, current_branch.strip()] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + # Check for specific errors + if not success: + if "HTTP 413" in stderr or "Request Entity Too Large" in stderr: + # Extract file info from error if possible + import re + file_match = re.search(r'/([^/]+)/(\d+)$', stderr) + file_size = None + if file_match: + file_hash = file_match.group(1) + file_size = int(file_match.group(2)) + + # Check for large files + large_files = self.check_large_files(repo_path, 50) # Check for files > 50MB + if large_files: + error_msg = "Push fehlgeschlagen: Dateien zu groß für Gitea!\n\n" + error_msg += "Folgende Dateien überschreiten das Limit:\n" + for file, size in large_files[:5]: # Show first 5 + size_mb = size / (1024 * 1024) + error_msg += f" - {file} ({size_mb:.1f} MB)\n" + if len(large_files) > 5: + error_msg += f" ... und {len(large_files) - 5} weitere\n" + + if file_size: + error_msg += f"\nGitea versucht eine {file_size / (1024*1024):.1f} MB große Datei hochzuladen!\n" + + error_msg += "\nLösungen:\n" + error_msg += "1. Große Dateien zur .gitignore hinzufügen\n" + error_msg += "2. Git LFS verwenden (empfohlen für große Dateien)\n" + error_msg += "3. Dateien komprimieren oder entfernen\n\n" + error_msg += "HINWEIS: Git LFS muss auf dem Server aktiviert sein!" + return False, error_msg + else: + return False, f"Push fehlgeschlagen (HTTP 413): Repository oder einzelne Commits zu groß" + elif "Locking support detected" in stderr and "HTTP 413" not in stderr: + # Just LFS warning, not an error + return True, "Push erfolgreich (mit LFS-Warnung)" + + # Check if push was successful even with warnings + if success or "Everything up-to-date" in (stdout + stderr): + return True, "Push erfolgreich" + + return success, stdout or stderr + + def status(self, repo_path: Path) -> Tuple[bool, str]: + cmd = ["git", "status", "--porcelain"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def diff(self, repo_path: Path, staged: bool = False) -> Tuple[bool, str]: + cmd = ["git", "diff"] + if staged: + cmd.append("--staged") + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def branch(self, repo_path: Path, list_all: bool = False) -> Tuple[bool, str]: + cmd = ["git", "branch"] + if list_all: + cmd.append("-a") + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def checkout(self, repo_path: Path, branch: str, create: bool = False) -> Tuple[bool, str]: + cmd = ["git", "checkout"] + if create: + cmd.append("-b") + cmd.append(branch) + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def merge(self, repo_path: Path, branch: str) -> Tuple[bool, str]: + cmd = ["git", "merge", branch] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def log(self, repo_path: Path, n: int = 10, oneline: bool = True) -> Tuple[bool, str]: + cmd = ["git", "log", f"-{n}"] + if oneline: + cmd.append("--oneline") + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def remote_add(self, repo_path: Path, name: str, url: str) -> Tuple[bool, str]: + cmd = ["git", "remote", "add", name, url] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def remote_remove(self, repo_path: Path, name: str) -> Tuple[bool, str]: + cmd = ["git", "remote", "remove", name] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def remote_list(self, repo_path: Path) -> Tuple[bool, str]: + cmd = ["git", "remote", "-v"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def stash(self, repo_path: Path, message: Optional[str] = None) -> Tuple[bool, str]: + cmd = ["git", "stash", "push"] + if message: + cmd.extend(["-m", message]) + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def stash_pop(self, repo_path: Path) -> Tuple[bool, str]: + cmd = ["git", "stash", "pop"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def reset(self, repo_path: Path, hard: bool = False, commit: Optional[str] = None) -> Tuple[bool, str]: + cmd = ["git", "reset"] + if hard: + cmd.append("--hard") + if commit: + cmd.append(commit) + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + return success, stdout or stderr + + def init_repository(self, repo_path: Path) -> Tuple[bool, str]: + """Initialize a new git repository""" + cmd = ["git", "init"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + if success: + self._configure_git_user(repo_path) + # Check if .gitattributes exists with LFS entries + gitattributes = repo_path / ".gitattributes" + if gitattributes.exists(): + with open(gitattributes, 'r') as f: + content = f.read() + if 'filter=lfs' in content: + logger.info("Git LFS entries found in .gitattributes") + return success, stdout or stderr + + def disable_lfs_for_push(self, repo_path: Path) -> Tuple[bool, str]: + """Temporarily disable LFS for push""" + # Backup .gitattributes if it exists + gitattributes = repo_path / ".gitattributes" + backup_path = repo_path / ".gitattributes.backup" + + if gitattributes.exists(): + import shutil + shutil.copy2(gitattributes, backup_path) + + # Remove LFS entries from .gitattributes + with open(gitattributes, 'r') as f: + lines = f.readlines() + + with open(gitattributes, 'w') as f: + for line in lines: + if 'filter=lfs' not in line: + f.write(line) + + return True, "LFS temporarily disabled" + return True, "No LFS configuration found" + + def restore_lfs_config(self, repo_path: Path) -> None: + """Restore LFS configuration""" + backup_path = repo_path / ".gitattributes.backup" + gitattributes = repo_path / ".gitattributes" + + if backup_path.exists(): + import shutil + shutil.move(backup_path, gitattributes) + + def add_remote_to_existing_repo(self, repo_path: Path, owner: str, repo_name: str, + remote_name: str = "origin") -> Tuple[bool, str]: + """Add Gitea remote to an existing local repository""" + repo_path_str = f"{owner}/{repo_name}" + remote_url = self.credentials.get_remote_url(self.base_url, repo_path_str) + + # First check if remote already exists + success, remotes, _ = self._run_git_command(["git", "remote"], cwd=repo_path) + if success and remote_name in remotes.split('\n'): + # Remove existing remote + self.remote_remove(repo_path, remote_name) + + # Add new remote + return self.remote_add(repo_path, remote_name, remote_url) + + def push_existing_repo_to_gitea(self, repo_path: Path, owner: str, repo_name: str, + branch: str = "main", force: bool = False) -> Tuple[bool, str]: + """Push an existing local repository to Gitea""" + logger.info(f"=== Starting push_existing_repo_to_gitea ===") + logger.info(f"Repo path: {repo_path}") + logger.info(f"Owner: {owner}") + logger.info(f"Repo name: {repo_name}") + logger.info(f"Target branch: {branch}") + + # Create debug info file + debug_file = repo_path / "gitea_push_debug.txt" + debug_info = [] + debug_info.append(f"Push Debug Info - {datetime.now()}") + debug_info.append(f"Repository: {repo_name}") + debug_info.append(f"Owner: {owner}") + debug_info.append(f"Path: {repo_path}") + + # First check current branch + success, current_branch_output, _ = self._run_git_command( + ["git", "branch", "--show-current"], cwd=repo_path + ) + + current_branch = current_branch_output.strip() if success else "" + debug_info.append(f"Current branch: {current_branch}") + + # If no current branch (empty repo), use the specified branch + if not current_branch: + current_branch = branch + + # Add the remote + success, msg = self.add_remote_to_existing_repo(repo_path, owner, repo_name) + if not success: + error_msg = f"Failed to add remote: {msg}" + debug_info.append(f"ERROR: {error_msg}") + with open(debug_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(debug_info)) + return False, error_msg + + # Log the remote URL for debugging + success, remotes, _ = self._run_git_command(["git", "remote", "-v"], cwd=repo_path) + if success: + logger.info(f"Git remotes after adding: {remotes}") + debug_info.append(f"Git remotes:\n{remotes}") + + # Check git status before push + success, status, _ = self._run_git_command(["git", "status", "--porcelain"], cwd=repo_path) + debug_info.append(f"Git status before push:\n{status if status else 'Clean'}") + + # Push the current branch with upstream setting + cmd = ["git", "push", "--set-upstream", "origin", f"{current_branch}:{branch}", "-v"] + if force: + cmd.insert(2, "--force") + + logger.info(f"Executing push command: {' '.join(cmd)}") + debug_info.append(f"Push command: {' '.join(cmd)}") + + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + logger.info(f"Push command result - Success: {success}") + logger.info(f"Push stdout: {stdout}") + logger.info(f"Push stderr: {stderr}") + + debug_info.append(f"Push result: {'Success' if success else 'Failed'}") + debug_info.append(f"Push stdout:\n{stdout}") + debug_info.append(f"Push stderr:\n{stderr}") + + # Save debug info + with open(debug_file, 'w', encoding='utf-8') as f: + f.write('\n'.join(debug_info)) + + # Check if push was successful even with warnings + if success or "Locking support detected" in stderr: + # Also check if objects were written + if "objects" in stderr or "objects" in stdout or success: + # Verify the push by checking the remote + verify_cmd = ["git", "ls-remote", "origin", branch] + verify_success, verify_out, _ = self._run_git_command(verify_cmd, cwd=repo_path) + if verify_success and verify_out.strip(): + logger.info(f"Push verified - remote has branch {branch}") + return True, f"Successfully pushed {current_branch} to {branch}\n\nDebug-Datei: {debug_file}" + else: + logger.warning(f"Push reported success but could not verify remote branch") + return True, f"Push completed (verification pending)\n\nDebug-Datei: {debug_file}" + + # Check for common push success indicators + if any(indicator in (stdout + stderr).lower() for indicator in ["everything up-to-date", "branch", "->", "create mode", "writing objects"]): + return True, f"Successfully pushed {current_branch} to {branch}\n\nDebug-Datei: {debug_file}" + + return False, f"{stderr or stdout}\n\nDebug-Datei: {debug_file}" + + def setup_lfs(self, repo_path: Path) -> Tuple[bool, str]: + """Setup Git LFS for the repository""" + # First check if git-lfs is installed + cmd = ["git", "lfs", "version"] + success, stdout, stderr = self._run_git_command(cmd) + + if not success: + return False, "Git LFS ist nicht installiert. Bitte installieren Sie es von: https://git-lfs.github.com/" + + # Install LFS hooks in the repository + cmd = ["git", "lfs", "install"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + if not success: + return False, f"Git LFS Installation fehlgeschlagen: {stderr}" + + # Configure LFS for the remote + cmd = ["git", "config", "lfs.https://gitea-undso.intelsight.de/.locksverify", "true"] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + return True, "Git LFS wurde erfolgreich eingerichtet" + + def track_with_lfs(self, repo_path: Path, patterns: List[str]) -> Tuple[bool, str]: + """Track file patterns with Git LFS""" + results = [] + + for pattern in patterns: + cmd = ["git", "lfs", "track", pattern] + success, stdout, stderr = self._run_git_command(cmd, cwd=repo_path) + + if success: + results.append(f"✓ {pattern}") + else: + results.append(f"✗ {pattern}: {stderr}") + + # Add .gitattributes to git + cmd = ["git", "add", ".gitattributes"] + self._run_git_command(cmd, cwd=repo_path) + + return True, "LFS Tracking konfiguriert:\n" + "\n".join(results) + + def migrate_to_lfs(self, repo_path: Path, file_paths: List[str]) -> Tuple[bool, str]: + """Migrate existing large files to Git LFS""" + # First setup LFS + success, msg = self.setup_lfs(repo_path) + if not success: + return False, msg + + # Track the files + patterns = [] + for file_path in file_paths: + # Create pattern from file path + if "/" in file_path: + # Track specific file + patterns.append(file_path) + else: + # Track by extension + ext = Path(file_path).suffix + if ext: + patterns.append(f"*{ext}") + + if patterns: + success, msg = self.track_with_lfs(repo_path, patterns) + if not success: + return False, msg + + # Remove files from git cache and re-add them + for file_path in file_paths: + # Remove from cache + cmd = ["git", "rm", "--cached", file_path] + self._run_git_command(cmd, cwd=repo_path) + + # Re-add (will now use LFS) + cmd = ["git", "add", file_path] + self._run_git_command(cmd, cwd=repo_path) + + return True, "Dateien wurden zu Git LFS migriert. Bitte committen Sie die Änderungen." \ No newline at end of file diff --git a/src/gitea/gitea_client.py b/src/gitea/gitea_client.py new file mode 100644 index 0000000..0183f7a --- /dev/null +++ b/src/gitea/gitea_client.py @@ -0,0 +1,207 @@ +import requests +import json +from typing import Dict, List, Optional, Any +from dataclasses import dataclass +from datetime import datetime +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@dataclass +class GiteaConfig: + base_url: str = "https://gitea-undso.intelsight.de" + api_token: str = "3b4a6ba1ade3f34640f3c85d2333b4a3a0627471" + api_version: str = "v1" + username: str = "StuXn3t" + + @property + def api_url(self) -> str: + return f"{self.base_url}/api/{self.api_version}" + +class GiteaClient: + def __init__(self, config: Optional[GiteaConfig] = None): + self.config = config or GiteaConfig() + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"token {self.config.api_token}", + "Content-Type": "application/json" + }) + + def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + url = f"{self.config.api_url}/{endpoint}" + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + return response.json() if response.content else {} + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {e}") + raise + + def get_user_info(self) -> Dict[str, Any]: + return self._request("GET", "user") + + def list_user_organizations(self) -> List[Dict[str, Any]]: + """List all organizations the user is a member of""" + return self._request("GET", "user/orgs") + + def get_user_teams_in_org(self, org_name: str) -> List[Dict[str, Any]]: + """Get user's teams in a specific organization""" + try: + return self._request("GET", f"user/teams", params={"org": org_name}) + except Exception as e: + logger.error(f"Failed to get teams for org {org_name}: {e}") + return [] + + def list_repositories(self, page: int = 1, limit: int = 50) -> List[Dict[str, Any]]: + params = {"page": page, "limit": limit} + return self._request("GET", "user/repos", params=params) + + def create_repository(self, name: str, description: str = "", private: bool = False, + auto_init: bool = True, gitignores: str = "", license: str = "") -> Dict[str, Any]: + data = { + "name": name, + "description": description, + "private": private, + "auto_init": auto_init, + "gitignores": gitignores, + "license": license, + "default_branch": "main" # Use main instead of master + } + return self._request("POST", "user/repos", json=data) + + def delete_repository(self, owner: str, repo: str) -> None: + self._request("DELETE", f"repos/{owner}/{repo}") + + def get_repository(self, owner: str, repo: str) -> Dict[str, Any]: + return self._request("GET", f"repos/{owner}/{repo}") + + def fork_repository(self, owner: str, repo: str, organization: Optional[str] = None) -> Dict[str, Any]: + data = {"organization": organization} if organization else {} + return self._request("POST", f"repos/{owner}/{repo}/forks", json=data) + + def list_issues(self, owner: str, repo: str, state: str = "open", + labels: Optional[List[str]] = None, page: int = 1, limit: int = 50) -> List[Dict[str, Any]]: + params = { + "state": state, + "page": page, + "limit": limit + } + if labels: + params["labels"] = ",".join(labels) + return self._request("GET", f"repos/{owner}/{repo}/issues", params=params) + + def create_issue(self, owner: str, repo: str, title: str, body: str = "", + assignees: Optional[List[str]] = None, labels: Optional[List[int]] = None) -> Dict[str, Any]: + data = { + "title": title, + "body": body, + "assignees": assignees or [], + "labels": labels or [] + } + return self._request("POST", f"repos/{owner}/{repo}/issues", json=data) + + def update_issue(self, owner: str, repo: str, index: int, **kwargs) -> Dict[str, Any]: + return self._request("PATCH", f"repos/{owner}/{repo}/issues/{index}", json=kwargs) + + def close_issue(self, owner: str, repo: str, index: int) -> Dict[str, Any]: + return self.update_issue(owner, repo, index, state="closed") + + def list_pull_requests(self, owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 50) -> List[Dict[str, Any]]: + params = { + "state": state, + "page": page, + "limit": limit + } + return self._request("GET", f"repos/{owner}/{repo}/pulls", params=params) + + def create_pull_request(self, owner: str, repo: str, title: str, head: str, base: str, + body: str = "", assignees: Optional[List[str]] = None) -> Dict[str, Any]: + data = { + "title": title, + "head": head, + "base": base, + "body": body, + "assignees": assignees or [] + } + return self._request("POST", f"repos/{owner}/{repo}/pulls", json=data) + + def merge_pull_request(self, owner: str, repo: str, index: int, + merge_style: str = "merge") -> Dict[str, Any]: + data = {"Do": merge_style} + return self._request("POST", f"repos/{owner}/{repo}/pulls/{index}/merge", json=data) + + def list_branches(self, owner: str, repo: str) -> List[Dict[str, Any]]: + return self._request("GET", f"repos/{owner}/{repo}/branches") + + def create_branch(self, owner: str, repo: str, branch_name: str, + old_branch_name: str = "main") -> Dict[str, Any]: + data = { + "new_branch_name": branch_name, + "old_branch_name": old_branch_name + } + return self._request("POST", f"repos/{owner}/{repo}/branches", json=data) + + def delete_branch(self, owner: str, repo: str, branch_name: str) -> None: + self._request("DELETE", f"repos/{owner}/{repo}/branches/{branch_name}") + + def list_releases(self, owner: str, repo: str, page: int = 1, limit: int = 50) -> List[Dict[str, Any]]: + params = {"page": page, "limit": limit} + return self._request("GET", f"repos/{owner}/{repo}/releases", params=params) + + def create_release(self, owner: str, repo: str, tag_name: str, name: str, + body: str = "", target: str = "main", draft: bool = False, + prerelease: bool = False) -> Dict[str, Any]: + data = { + "tag_name": tag_name, + "name": name, + "body": body, + "target_commitish": target, + "draft": draft, + "prerelease": prerelease + } + return self._request("POST", f"repos/{owner}/{repo}/releases", json=data) + + def list_webhooks(self, owner: str, repo: str) -> List[Dict[str, Any]]: + return self._request("GET", f"repos/{owner}/{repo}/hooks") + + def create_webhook(self, owner: str, repo: str, url: str, events: List[str], + active: bool = True) -> Dict[str, Any]: + data = { + "type": "gitea", + "config": { + "url": url, + "content_type": "json" + }, + "events": events, + "active": active + } + return self._request("POST", f"repos/{owner}/{repo}/hooks", json=data) + + def get_repository_contents(self, owner: str, repo: str, filepath: str = "", + ref: str = "main") -> Dict[str, Any]: + params = {"ref": ref} + return self._request("GET", f"repos/{owner}/{repo}/contents/{filepath}", params=params) + + def create_or_update_file(self, owner: str, repo: str, filepath: str, content: str, + message: str, branch: str = "main", sha: Optional[str] = None) -> Dict[str, Any]: + import base64 + data = { + "content": base64.b64encode(content.encode()).decode(), + "message": message, + "branch": branch + } + if sha: + data["sha"] = sha + + return self._request("PUT", f"repos/{owner}/{repo}/contents/{filepath}", json=data) + + def delete_file(self, owner: str, repo: str, filepath: str, message: str, + sha: str, branch: str = "main") -> Dict[str, Any]: + data = { + "message": message, + "sha": sha, + "branch": branch + } + return self._request("DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json=data) \ No newline at end of file diff --git a/src/gitea/gitea_ui.py b/src/gitea/gitea_ui.py new file mode 100644 index 0000000..d56cd8b --- /dev/null +++ b/src/gitea/gitea_ui.py @@ -0,0 +1,509 @@ +import tkinter as tk +from tkinter import ttk, messagebox, scrolledtext +import threading +from pathlib import Path +from typing import Optional, Dict, Any, List +from .repository_manager import RepositoryManager +from .issue_pr_manager import IssuePRManager + +class GiteaIntegrationUI: + def __init__(self, parent_frame: tk.Frame): + self.parent = parent_frame + self.repo_manager = RepositoryManager() + self.issue_pr_manager = IssuePRManager(self.repo_manager.client) + self.selected_repo = None + self.selected_repo_path = None + + self.setup_ui() + self.refresh_repositories() + + def setup_ui(self): + main_paned = ttk.PanedWindow(self.parent, orient=tk.HORIZONTAL) + main_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + left_frame = ttk.Frame(main_paned) + self.setup_left_panel(left_frame) + main_paned.add(left_frame, weight=1) + + right_frame = ttk.Frame(main_paned) + self.setup_right_panel(right_frame) + main_paned.add(right_frame, weight=2) + + def setup_left_panel(self, parent): + ttk.Label(parent, text="Repositories", font=("Arial", 12, "bold")).pack(pady=5) + + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, padx=5) + + ttk.Button(button_frame, text="🔄 Refresh", command=self.refresh_repositories).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="➕ New", command=self.create_repository_dialog).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="🗑️ Delete", command=self.delete_repository).pack(side=tk.LEFT, padx=2) + + list_frame = ttk.Frame(parent) + list_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + scrollbar = ttk.Scrollbar(list_frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + self.repo_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set) + self.repo_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.repo_listbox.bind('<>', self.on_repo_select) + + scrollbar.config(command=self.repo_listbox.yview) + + def setup_right_panel(self, parent): + self.notebook = ttk.Notebook(parent) + self.notebook.pack(fill=tk.BOTH, expand=True) + + self.git_tab = ttk.Frame(self.notebook) + self.setup_git_operations_tab(self.git_tab) + self.notebook.add(self.git_tab, text="Git Operations") + + self.issues_tab = ttk.Frame(self.notebook) + self.setup_issues_tab(self.issues_tab) + self.notebook.add(self.issues_tab, text="Issues") + + self.pr_tab = ttk.Frame(self.notebook) + self.setup_pr_tab(self.pr_tab) + self.notebook.add(self.pr_tab, text="Pull Requests") + + self.info_tab = ttk.Frame(self.notebook) + self.setup_info_tab(self.info_tab) + self.notebook.add(self.info_tab, text="Repository Info") + + def setup_git_operations_tab(self, parent): + button_frame = ttk.LabelFrame(parent, text="Repository Actions", padding=10) + button_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Button(button_frame, text="📥 Clone", command=self.clone_repository).grid(row=0, column=0, padx=5, pady=2) + ttk.Button(button_frame, text="🔄 Fetch", command=self.fetch_repository).grid(row=0, column=1, padx=5, pady=2) + ttk.Button(button_frame, text="⬇️ Pull", command=self.pull_repository).grid(row=0, column=2, padx=5, pady=2) + ttk.Button(button_frame, text="📊 Status", command=self.show_status).grid(row=0, column=3, padx=5, pady=2) + + ttk.Button(button_frame, text="➕ Add All", command=self.add_all_files).grid(row=1, column=0, padx=5, pady=2) + ttk.Button(button_frame, text="💾 Commit", command=self.commit_dialog).grid(row=1, column=1, padx=5, pady=2) + ttk.Button(button_frame, text="⬆️ Push", command=self.push_repository).grid(row=1, column=2, padx=5, pady=2) + ttk.Button(button_frame, text="🌿 Branches", command=self.show_branches).grid(row=1, column=3, padx=5, pady=2) + + output_frame = ttk.LabelFrame(parent, text="Output", padding=10) + output_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + self.git_output = scrolledtext.ScrolledText(output_frame, height=20, wrap=tk.WORD) + self.git_output.pack(fill=tk.BOTH, expand=True) + + def setup_issues_tab(self, parent): + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Button(button_frame, text="🔄 Refresh", command=self.refresh_issues).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="➕ New Issue", command=self.create_issue_dialog).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="✏️ Edit", command=self.edit_issue).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="✅ Close", command=self.close_issue).pack(side=tk.LEFT, padx=2) + + columns = ('ID', 'Title', 'State', 'Author', 'Created') + self.issues_tree = ttk.Treeview(parent, columns=columns, show='tree headings') + + for col in columns: + self.issues_tree.heading(col, text=col) + self.issues_tree.column(col, width=100) + + self.issues_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + scrollbar = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.issues_tree.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.issues_tree.configure(yscrollcommand=scrollbar.set) + + def setup_pr_tab(self, parent): + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Button(button_frame, text="🔄 Refresh", command=self.refresh_prs).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="➕ New PR", command=self.create_pr_dialog).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="🔀 Merge", command=self.merge_pr).pack(side=tk.LEFT, padx=2) + ttk.Button(button_frame, text="❌ Close", command=self.close_pr).pack(side=tk.LEFT, padx=2) + + columns = ('ID', 'Title', 'State', 'Author', 'Head', 'Base') + self.pr_tree = ttk.Treeview(parent, columns=columns, show='tree headings') + + for col in columns: + self.pr_tree.heading(col, text=col) + self.pr_tree.column(col, width=100) + + self.pr_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + scrollbar = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.pr_tree.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.pr_tree.configure(yscrollcommand=scrollbar.set) + + def setup_info_tab(self, parent): + self.info_text = scrolledtext.ScrolledText(parent, height=20, wrap=tk.WORD) + self.info_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + def refresh_repositories(self): + def fetch(): + try: + repos = self.repo_manager.list_all_repositories() + self.parent.after(0, lambda: self.update_repo_list(repos)) + except Exception as e: + self.parent.after(0, lambda: messagebox.showerror("Error", f"Failed to fetch repositories: {e}")) + + threading.Thread(target=fetch, daemon=True).start() + + def update_repo_list(self, repos): + self.repo_listbox.delete(0, tk.END) + for repo in repos: + display_name = f"{repo['name']} {'🔒' if repo['private'] else '🌐'}" + self.repo_listbox.insert(tk.END, display_name) + self.repositories = repos + + def on_repo_select(self, event): + selection = self.repo_listbox.curselection() + if selection: + index = selection[0] + self.selected_repo = self.repositories[index] + self.update_repo_info() + self.refresh_issues() + self.refresh_prs() + + def update_repo_info(self): + if not self.selected_repo: + return + + info = f"Repository: {self.selected_repo['name']}\n" + info += f"Description: {self.selected_repo.get('description', 'No description')}\n" + info += f"Private: {'Yes' if self.selected_repo['private'] else 'No'}\n" + info += f"Default Branch: {self.selected_repo.get('default_branch', 'main')}\n" + info += f"Clone URL: {self.selected_repo['clone_url']}\n" + info += f"Created: {self.selected_repo['created_at']}\n" + info += f"Updated: {self.selected_repo['updated_at']}\n" + + self.info_text.delete(1.0, tk.END) + self.info_text.insert(1.0, info) + + def clone_repository(self): + if not self.selected_repo: + messagebox.showwarning("Warning", "Please select a repository") + return + + def clone(): + try: + success, path = self.repo_manager.clone_repository(self.selected_repo['name']) + if success: + self.selected_repo_path = path + self.parent.after(0, lambda: self.git_output.insert(tk.END, f"Repository cloned to: {path}\n")) + else: + self.parent.after(0, lambda: self.git_output.insert(tk.END, f"Failed to clone repository\n")) + except Exception as e: + self.parent.after(0, lambda: messagebox.showerror("Error", f"Clone failed: {e}")) + + threading.Thread(target=clone, daemon=True).start() + + def fetch_repository(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.fetch(self.selected_repo_path) + self.git_output.insert(tk.END, f"Fetch: {result}\n") + + def pull_repository(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.pull(self.selected_repo_path) + self.git_output.insert(tk.END, f"Pull: {result}\n") + + def push_repository(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.push(self.selected_repo_path) + self.git_output.insert(tk.END, f"Push: {result}\n") + + def show_status(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + status = self.repo_manager.get_repository_status(self.selected_repo_path) + self.git_output.insert(tk.END, f"\n--- Repository Status ---\n") + self.git_output.insert(tk.END, f"Current Branch: {status['current_branch']}\n") + self.git_output.insert(tk.END, f"Has Changes: {status['has_changes']}\n") + self.git_output.insert(tk.END, f"Status:\n{status['status']}\n") + self.git_output.insert(tk.END, f"Remotes:\n{status['remotes']}\n") + + def add_all_files(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.add(self.selected_repo_path) + self.git_output.insert(tk.END, f"Add all: {result}\n") + + def commit_dialog(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + dialog = tk.Toplevel(self.parent) + dialog.title("Commit Changes") + dialog.geometry("400x200") + + ttk.Label(dialog, text="Commit Message:").pack(pady=5) + + message_text = tk.Text(dialog, height=5, width=50) + message_text.pack(padx=10, pady=5) + + def do_commit(): + message = message_text.get(1.0, tk.END).strip() + if not message: + messagebox.showwarning("Warning", "Please enter a commit message") + return + + success, result = self.repo_manager.git_ops.commit(self.selected_repo_path, message) + self.git_output.insert(tk.END, f"Commit: {result}\n") + dialog.destroy() + + ttk.Button(dialog, text="Commit", command=do_commit).pack(pady=10) + + def show_branches(self): + if not self.selected_repo_path: + messagebox.showwarning("Warning", "Please clone the repository first") + return + + success, branches = self.repo_manager.git_ops.branch(self.selected_repo_path, list_all=True) + self.git_output.insert(tk.END, f"\n--- Branches ---\n{branches}\n") + + def create_repository_dialog(self): + dialog = tk.Toplevel(self.parent) + dialog.title("Create New Repository") + dialog.geometry("400x300") + + ttk.Label(dialog, text="Repository Name:").grid(row=0, column=0, sticky=tk.W, padx=10, pady=5) + name_entry = ttk.Entry(dialog, width=30) + name_entry.grid(row=0, column=1, padx=10, pady=5) + + ttk.Label(dialog, text="Description:").grid(row=1, column=0, sticky=tk.W, padx=10, pady=5) + desc_text = tk.Text(dialog, height=3, width=30) + desc_text.grid(row=1, column=1, padx=10, pady=5) + + private_var = tk.BooleanVar() + ttk.Checkbutton(dialog, text="Private Repository", variable=private_var).grid(row=2, column=1, sticky=tk.W, padx=10, pady=5) + + auto_init_var = tk.BooleanVar(value=True) + ttk.Checkbutton(dialog, text="Initialize with README", variable=auto_init_var).grid(row=3, column=1, sticky=tk.W, padx=10, pady=5) + + def create(): + name = name_entry.get().strip() + desc = desc_text.get(1.0, tk.END).strip() + + if not name: + messagebox.showwarning("Warning", "Please enter a repository name") + return + + try: + self.repo_manager.create_repository( + name, desc, private_var.get(), auto_init_var.get() + ) + messagebox.showinfo("Success", f"Repository '{name}' created successfully") + dialog.destroy() + self.refresh_repositories() + except Exception as e: + messagebox.showerror("Error", f"Failed to create repository: {e}") + + ttk.Button(dialog, text="Create", command=create).grid(row=4, column=1, pady=20) + + def delete_repository(self): + if not self.selected_repo: + messagebox.showwarning("Warning", "Please select a repository") + return + + if messagebox.askyesno("Confirm", f"Are you sure you want to delete '{self.selected_repo['name']}'?"): + if self.repo_manager.delete_repository(self.selected_repo['name']): + messagebox.showinfo("Success", "Repository deleted successfully") + self.refresh_repositories() + + def refresh_issues(self): + if not self.selected_repo: + return + + self.issues_tree.delete(*self.issues_tree.get_children()) + + try: + manager = self.issue_pr_manager.get_issue_manager(self.selected_repo['name']) + issues = manager.list_issues() + + for issue in issues: + self.issues_tree.insert('', tk.END, values=( + issue['number'], + issue['title'], + issue['state'], + issue['user']['username'], + issue['created_at'][:10] + )) + except Exception as e: + print(f"Error fetching issues: {e}") + + def refresh_prs(self): + if not self.selected_repo: + return + + self.pr_tree.delete(*self.pr_tree.get_children()) + + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + prs = manager.list_pull_requests() + + for pr in prs: + self.pr_tree.insert('', tk.END, values=( + pr['number'], + pr['title'], + pr['state'], + pr['user']['username'], + pr['head']['ref'], + pr['base']['ref'] + )) + except Exception as e: + print(f"Error fetching PRs: {e}") + + def create_issue_dialog(self): + if not self.selected_repo: + messagebox.showwarning("Warning", "Please select a repository") + return + + dialog = tk.Toplevel(self.parent) + dialog.title("Create New Issue") + dialog.geometry("500x400") + + ttk.Label(dialog, text="Title:").grid(row=0, column=0, sticky=tk.W, padx=10, pady=5) + title_entry = ttk.Entry(dialog, width=50) + title_entry.grid(row=0, column=1, padx=10, pady=5) + + ttk.Label(dialog, text="Description:").grid(row=1, column=0, sticky=tk.NW, padx=10, pady=5) + desc_text = tk.Text(dialog, height=10, width=50) + desc_text.grid(row=1, column=1, padx=10, pady=5) + + def create(): + title = title_entry.get().strip() + body = desc_text.get(1.0, tk.END).strip() + + if not title: + messagebox.showwarning("Warning", "Please enter a title") + return + + try: + manager = self.issue_pr_manager.get_issue_manager(self.selected_repo['name']) + manager.create_issue(title, body) + messagebox.showinfo("Success", "Issue created successfully") + dialog.destroy() + self.refresh_issues() + except Exception as e: + messagebox.showerror("Error", f"Failed to create issue: {e}") + + ttk.Button(dialog, text="Create", command=create).grid(row=2, column=1, pady=20) + + def edit_issue(self): + messagebox.showinfo("Info", "Edit issue functionality coming soon!") + + def close_issue(self): + selection = self.issues_tree.selection() + if not selection: + messagebox.showwarning("Warning", "Please select an issue") + return + + item = self.issues_tree.item(selection[0]) + issue_id = item['values'][0] + + if messagebox.askyesno("Confirm", f"Close issue #{issue_id}?"): + try: + manager = self.issue_pr_manager.get_issue_manager(self.selected_repo['name']) + manager.close_issue(issue_id) + messagebox.showinfo("Success", "Issue closed successfully") + self.refresh_issues() + except Exception as e: + messagebox.showerror("Error", f"Failed to close issue: {e}") + + def create_pr_dialog(self): + if not self.selected_repo: + messagebox.showwarning("Warning", "Please select a repository") + return + + dialog = tk.Toplevel(self.parent) + dialog.title("Create Pull Request") + dialog.geometry("500x500") + + ttk.Label(dialog, text="Title:").grid(row=0, column=0, sticky=tk.W, padx=10, pady=5) + title_entry = ttk.Entry(dialog, width=50) + title_entry.grid(row=0, column=1, padx=10, pady=5) + + ttk.Label(dialog, text="Head Branch:").grid(row=1, column=0, sticky=tk.W, padx=10, pady=5) + head_entry = ttk.Entry(dialog, width=50) + head_entry.grid(row=1, column=1, padx=10, pady=5) + + ttk.Label(dialog, text="Base Branch:").grid(row=2, column=0, sticky=tk.W, padx=10, pady=5) + base_entry = ttk.Entry(dialog, width=50, ) + base_entry.insert(0, "main") + base_entry.grid(row=2, column=1, padx=10, pady=5) + + ttk.Label(dialog, text="Description:").grid(row=3, column=0, sticky=tk.NW, padx=10, pady=5) + desc_text = tk.Text(dialog, height=10, width=50) + desc_text.grid(row=3, column=1, padx=10, pady=5) + + def create(): + title = title_entry.get().strip() + head = head_entry.get().strip() + base = base_entry.get().strip() + body = desc_text.get(1.0, tk.END).strip() + + if not all([title, head, base]): + messagebox.showwarning("Warning", "Please fill all required fields") + return + + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + manager.create_pull_request(title, head, base, body) + messagebox.showinfo("Success", "Pull request created successfully") + dialog.destroy() + self.refresh_prs() + except Exception as e: + messagebox.showerror("Error", f"Failed to create PR: {e}") + + ttk.Button(dialog, text="Create", command=create).grid(row=4, column=1, pady=20) + + def merge_pr(self): + selection = self.pr_tree.selection() + if not selection: + messagebox.showwarning("Warning", "Please select a pull request") + return + + item = self.pr_tree.item(selection[0]) + pr_id = item['values'][0] + + if messagebox.askyesno("Confirm", f"Merge pull request #{pr_id}?"): + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + manager.merge_pull_request(pr_id) + messagebox.showinfo("Success", "Pull request merged successfully") + self.refresh_prs() + except Exception as e: + messagebox.showerror("Error", f"Failed to merge PR: {e}") + + def close_pr(self): + selection = self.pr_tree.selection() + if not selection: + messagebox.showwarning("Warning", "Please select a pull request") + return + + item = self.pr_tree.item(selection[0]) + pr_id = item['values'][0] + + if messagebox.askyesno("Confirm", f"Close pull request #{pr_id}?"): + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + manager.close_pull_request(pr_id) + messagebox.showinfo("Success", "Pull request closed successfully") + self.refresh_prs() + except Exception as e: + messagebox.showerror("Error", f"Failed to close PR: {e}") \ No newline at end of file diff --git a/src/gitea/gitea_ui_ctk.py b/src/gitea/gitea_ui_ctk.py new file mode 100644 index 0000000..910b351 --- /dev/null +++ b/src/gitea/gitea_ui_ctk.py @@ -0,0 +1,984 @@ +import customtkinter as ctk +from tkinter import messagebox, filedialog +import threading +from pathlib import Path +from typing import Optional, Dict, Any, List +from .repository_manager import RepositoryManager +from .issue_pr_manager import IssuePRManager + +class GiteaIntegrationUI: + def __init__(self, parent_window): + self.window = parent_window + self.repo_manager = RepositoryManager() + self.issue_pr_manager = IssuePRManager(self.repo_manager.client) + self.selected_repo = None + self.selected_repo_path = None + + # Import styles from main app + from gui.styles import COLORS, FONTS + self.COLORS = COLORS + self.FONTS = FONTS + + self.setup_ui() + self.refresh_repositories() + + def setup_ui(self): + # Main container + main_frame = ctk.CTkFrame(self.window, fg_color=self.COLORS['bg_primary']) + main_frame.pack(fill="both", expand=True, padx=10, pady=10) + + # Left panel - Repository list + left_frame = ctk.CTkFrame(main_frame, width=300, fg_color=self.COLORS['bg_secondary']) + left_frame.pack(side="left", fill="y", padx=(0, 10)) + left_frame.pack_propagate(False) + + # Repository list header + header_frame = ctk.CTkFrame(left_frame, fg_color="transparent", height=50) + header_frame.pack(fill="x", padx=10, pady=(10, 0)) + header_frame.pack_propagate(False) + + ctk.CTkLabel( + header_frame, + text="Repositories", + font=self.FONTS['title'], + text_color=self.COLORS['text_primary'] + ).pack(side="left", pady=10) + + # Buttons + button_frame = ctk.CTkFrame(left_frame, fg_color="transparent") + button_frame.pack(fill="x", padx=10, pady=5) + + ctk.CTkButton( + button_frame, + text="🔄 Refresh", + command=self.refresh_repositories, + width=100, + height=30, + fg_color=self.COLORS['accent_primary'], + hover_color=self.COLORS['accent_hover'] + ).pack(side="left", padx=2) + + ctk.CTkButton( + button_frame, + text="➕ New Repo", + command=self.create_repository_dialog, + width=100, + height=30, + fg_color=self.COLORS['accent_success'], + hover_color=self.COLORS['accent_hover'] + ).pack(side="left", padx=2) + + ctk.CTkButton( + button_frame, + text="🗑️ Delete", + command=self.delete_repository, + width=100, + height=30, + fg_color=self.COLORS['accent_error'], + hover_color=self.COLORS['accent_hover'] + ).pack(side="left", padx=2) + + ctk.CTkButton( + button_frame, + text="📤 Push Local", + command=self.push_local_repo_dialog, + width=100, + height=30, + fg_color=self.COLORS['accent_warning'], + hover_color=self.COLORS['accent_hover'] + ).pack(side="left", padx=2) + + # Repository list + self.repo_list_frame = ctk.CTkScrollableFrame( + left_frame, + fg_color=self.COLORS['bg_primary'] + ) + self.repo_list_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + # Right panel - Tab view + right_frame = ctk.CTkFrame(main_frame, fg_color=self.COLORS['bg_secondary']) + right_frame.pack(side="right", fill="both", expand=True) + + self.tabview = ctk.CTkTabview(right_frame, fg_color=self.COLORS['bg_secondary']) + self.tabview.pack(fill="both", expand=True, padx=10, pady=10) + + # Create tabs + self.tabview.add("Git Operations") + self.tabview.add("Issues") + self.tabview.add("Pull Requests") + self.tabview.add("Repository Info") + + self.setup_git_operations_tab() + self.setup_issues_tab() + self.setup_pr_tab() + self.setup_info_tab() + + def setup_git_operations_tab(self): + tab = self.tabview.tab("Git Operations") + + # Operations buttons + operations_frame = ctk.CTkFrame(tab, fg_color=self.COLORS['bg_tile']) + operations_frame.pack(fill="x", padx=10, pady=10) + + # Row 1 + row1 = ctk.CTkFrame(operations_frame, fg_color="transparent") + row1.pack(fill="x", padx=10, pady=5) + + ctk.CTkButton( + row1, text="📥 Clone", command=self.clone_repository, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row1, text="🔄 Fetch", command=self.fetch_repository, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row1, text="⬇️ Pull", command=self.pull_repository, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row1, text="📊 Status", command=self.show_status, + fg_color=self.COLORS['accent_secondary'], width=100 + ).pack(side="left", padx=5) + + # Row 2 + row2 = ctk.CTkFrame(operations_frame, fg_color="transparent") + row2.pack(fill="x", padx=10, pady=5) + + ctk.CTkButton( + row2, text="➕ Add All", command=self.add_all_files, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row2, text="💾 Commit", command=self.commit_dialog, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row2, text="⬆️ Push", command=self.push_repository, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + row2, text="🌿 Branches", command=self.show_branches, + fg_color=self.COLORS['accent_secondary'], width=100 + ).pack(side="left", padx=5) + + # Output area + output_label = ctk.CTkLabel( + tab, text="Output:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ) + output_label.pack(anchor="w", padx=10, pady=(10, 5)) + + self.git_output = ctk.CTkTextbox( + tab, + fg_color=self.COLORS['bg_primary'], + text_color=self.COLORS['text_primary'], + font=self.FONTS['code'] + ) + self.git_output.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + def setup_issues_tab(self): + tab = self.tabview.tab("Issues") + + # Issue buttons + button_frame = ctk.CTkFrame(tab, fg_color="transparent") + button_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkButton( + button_frame, text="🔄 Refresh", command=self.refresh_issues, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + button_frame, text="➕ New Issue", command=self.create_issue_dialog, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + button_frame, text="✅ Close", command=self.close_issue, + fg_color=self.COLORS['accent_error'], width=100 + ).pack(side="left", padx=5) + + # Issues list + self.issues_frame = ctk.CTkScrollableFrame( + tab, + fg_color=self.COLORS['bg_primary'] + ) + self.issues_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + self.issue_widgets = [] + + def setup_pr_tab(self): + tab = self.tabview.tab("Pull Requests") + + # PR buttons + button_frame = ctk.CTkFrame(tab, fg_color="transparent") + button_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkButton( + button_frame, text="🔄 Refresh", command=self.refresh_prs, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + button_frame, text="➕ New PR", command=self.create_pr_dialog, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + button_frame, text="🔀 Merge", command=self.merge_pr, + fg_color=self.COLORS['accent_success'], width=100 + ).pack(side="left", padx=5) + + ctk.CTkButton( + button_frame, text="❌ Close", command=self.close_pr, + fg_color=self.COLORS['accent_error'], width=100 + ).pack(side="left", padx=5) + + # PR list + self.pr_frame = ctk.CTkScrollableFrame( + tab, + fg_color=self.COLORS['bg_primary'] + ) + self.pr_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + + self.pr_widgets = [] + + def setup_info_tab(self): + tab = self.tabview.tab("Repository Info") + + self.info_text = ctk.CTkTextbox( + tab, + fg_color=self.COLORS['bg_primary'], + text_color=self.COLORS['text_primary'], + font=self.FONTS['body'] + ) + self.info_text.pack(fill="both", expand=True, padx=10, pady=10) + + def refresh_repositories(self): + def fetch(): + try: + repos = self.repo_manager.list_all_repositories() + self.window.after(0, lambda: self.update_repo_list(repos)) + except Exception as e: + self.window.after(0, lambda: self.show_error(f"Failed to fetch repositories: {e}")) + + threading.Thread(target=fetch, daemon=True).start() + + def update_repo_list(self, repos): + # Clear existing + for widget in self.repo_list_frame.winfo_children(): + widget.destroy() + + self.repo_buttons = {} + self.repositories = repos + + for repo in repos: + btn = ctk.CTkButton( + self.repo_list_frame, + text=f"{'🔒' if repo['private'] else '🌐'} {repo['name']}", + command=lambda r=repo: self.select_repository(r), + fg_color=self.COLORS['bg_tile'], + hover_color=self.COLORS['bg_tile_hover'], + text_color=self.COLORS['text_primary'], + anchor="w" + ) + btn.pack(fill="x", pady=2) + self.repo_buttons[repo['id']] = btn + + def select_repository(self, repo): + self.selected_repo = repo + + # Update button colors + for repo_id, btn in self.repo_buttons.items(): + if repo_id == repo['id']: + btn.configure(fg_color=self.COLORS['accent_primary']) + else: + btn.configure(fg_color=self.COLORS['bg_tile']) + + self.update_repo_info() + self.refresh_issues() + self.refresh_prs() + + def update_repo_info(self): + if not self.selected_repo: + return + + info = f"Repository: {self.selected_repo['name']}\n" + info += f"Description: {self.selected_repo.get('description', 'No description')}\n" + info += f"Private: {'Yes' if self.selected_repo['private'] else 'No'}\n" + info += f"Default Branch: {self.selected_repo.get('default_branch', 'main')}\n" + info += f"Clone URL: {self.selected_repo['clone_url']}\n" + info += f"Created: {self.selected_repo['created_at']}\n" + info += f"Updated: {self.selected_repo['updated_at']}\n" + + self.info_text.delete("0.0", "end") + self.info_text.insert("0.0", info) + + def clone_repository(self): + if not self.selected_repo: + self.show_warning("Please select a repository") + return + + def clone(): + try: + success, path = self.repo_manager.clone_repository(self.selected_repo['name']) + if success: + self.selected_repo_path = path + self.window.after(0, lambda: self.append_output(f"Repository cloned to: {path}\n")) + else: + self.window.after(0, lambda: self.append_output(f"Failed to clone repository\n")) + except Exception as e: + self.window.after(0, lambda: self.show_error(f"Clone failed: {e}")) + + threading.Thread(target=clone, daemon=True).start() + + def fetch_repository(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.fetch(self.selected_repo_path) + self.append_output(f"Fetch: {result}\n") + + def pull_repository(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.pull(self.selected_repo_path) + self.append_output(f"Pull: {result}\n") + + def push_repository(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.push(self.selected_repo_path) + self.append_output(f"Push: {result}\n") + + def show_status(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + status = self.repo_manager.get_repository_status(self.selected_repo_path) + self.append_output(f"\n--- Repository Status ---\n") + self.append_output(f"Current Branch: {status['current_branch']}\n") + self.append_output(f"Has Changes: {status['has_changes']}\n") + self.append_output(f"Status:\n{status['status']}\n") + self.append_output(f"Remotes:\n{status['remotes']}\n") + + def add_all_files(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + success, result = self.repo_manager.git_ops.add(self.selected_repo_path) + self.append_output(f"Add all: {result}\n") + + def commit_dialog(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + dialog = ctk.CTkToplevel(self.window) + dialog.title("Commit Changes") + dialog.geometry("500x300") + dialog.configure(fg_color=self.COLORS['bg_primary']) + + # Center dialog + dialog.transient(self.window) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 500) // 2 + y = (dialog.winfo_screenheight() - 300) // 2 + dialog.geometry(f"500x300+{x}+{y}") + + ctk.CTkLabel( + dialog, + text="Commit Message:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 10)) + + message_text = ctk.CTkTextbox( + dialog, + height=150, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + message_text.pack(padx=20, pady=10, fill="both", expand=True) + + def do_commit(): + message = message_text.get("0.0", "end").strip() + if not message: + self.show_warning("Please enter a commit message") + return + + success, result = self.repo_manager.git_ops.commit(self.selected_repo_path, message) + self.append_output(f"Commit: {result}\n") + dialog.destroy() + + ctk.CTkButton( + dialog, + text="Commit", + command=do_commit, + fg_color=self.COLORS['accent_success'] + ).pack(pady=20) + + def show_branches(self): + if not self.selected_repo_path: + self.show_warning("Please clone the repository first") + return + + success, branches = self.repo_manager.git_ops.branch(self.selected_repo_path, list_all=True) + self.append_output(f"\n--- Branches ---\n{branches}\n") + + def create_repository_dialog(self): + dialog = ctk.CTkToplevel(self.window) + dialog.title("Create New Repository") + dialog.geometry("500x400") + dialog.configure(fg_color=self.COLORS['bg_primary']) + + # Center dialog + dialog.transient(self.window) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 500) // 2 + y = (dialog.winfo_screenheight() - 400) // 2 + dialog.geometry(f"500x400+{x}+{y}") + + # Name + ctk.CTkLabel( + dialog, text="Repository Name:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + name_entry = ctk.CTkEntry( + dialog, width=400, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + name_entry.pack(pady=5) + + # Description + ctk.CTkLabel( + dialog, text="Description:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + desc_text = ctk.CTkTextbox( + dialog, height=100, width=400, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + desc_text.pack(pady=5) + + # Options + private_var = ctk.BooleanVar() + ctk.CTkCheckBox( + dialog, text="Private Repository", + variable=private_var, + text_color=self.COLORS['text_primary'] + ).pack(pady=10) + + auto_init_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + dialog, text="Initialize with README", + variable=auto_init_var, + text_color=self.COLORS['text_primary'] + ).pack(pady=5) + + def create(): + name = name_entry.get().strip() + desc = desc_text.get("0.0", "end").strip() + + if not name: + self.show_warning("Please enter a repository name") + return + + try: + self.repo_manager.create_repository( + name, desc, private_var.get(), auto_init_var.get() + ) + self.show_info(f"Repository '{name}' created successfully") + dialog.destroy() + self.refresh_repositories() + except Exception as e: + self.show_error(f"Failed to create repository: {e}") + + ctk.CTkButton( + dialog, text="Create", command=create, + fg_color=self.COLORS['accent_success'] + ).pack(pady=20) + + def delete_repository(self): + if not self.selected_repo: + self.show_warning("Please select a repository") + return + + if messagebox.askyesno("Confirm", f"Are you sure you want to delete '{self.selected_repo['name']}'?"): + if self.repo_manager.delete_repository(self.selected_repo['name']): + self.show_info("Repository deleted successfully") + self.refresh_repositories() + + def refresh_issues(self): + if not self.selected_repo: + return + + # Clear existing + for widget in self.issue_widgets: + widget.destroy() + self.issue_widgets.clear() + + try: + manager = self.issue_pr_manager.get_issue_manager(self.selected_repo['name']) + issues = manager.list_issues() + + for issue in issues: + frame = ctk.CTkFrame(self.issues_frame, fg_color=self.COLORS['bg_tile']) + frame.pack(fill="x", pady=5) + + # Issue info + info_frame = ctk.CTkFrame(frame, fg_color="transparent") + info_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkLabel( + info_frame, + text=f"#{issue['number']}: {issue['title']}", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(anchor="w") + + ctk.CTkLabel( + info_frame, + text=f"State: {issue['state']} | Author: {issue['user']['username']} | Created: {issue['created_at'][:10]}", + font=self.FONTS['small'], + text_color=self.COLORS['text_secondary'] + ).pack(anchor="w") + + self.issue_widgets.append(frame) + + except Exception as e: + print(f"Error fetching issues: {e}") + + def refresh_prs(self): + if not self.selected_repo: + return + + # Clear existing + for widget in self.pr_widgets: + widget.destroy() + self.pr_widgets.clear() + + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + prs = manager.list_pull_requests() + + for pr in prs: + frame = ctk.CTkFrame(self.pr_frame, fg_color=self.COLORS['bg_tile']) + frame.pack(fill="x", pady=5) + + # PR info + info_frame = ctk.CTkFrame(frame, fg_color="transparent") + info_frame.pack(fill="x", padx=10, pady=10) + + ctk.CTkLabel( + info_frame, + text=f"#{pr['number']}: {pr['title']}", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(anchor="w") + + ctk.CTkLabel( + info_frame, + text=f"State: {pr['state']} | {pr['head']['ref']} → {pr['base']['ref']} | Author: {pr['user']['username']}", + font=self.FONTS['small'], + text_color=self.COLORS['text_secondary'] + ).pack(anchor="w") + + self.pr_widgets.append(frame) + + except Exception as e: + print(f"Error fetching PRs: {e}") + + def create_issue_dialog(self): + if not self.selected_repo: + self.show_warning("Please select a repository") + return + + dialog = ctk.CTkToplevel(self.window) + dialog.title("Create New Issue") + dialog.geometry("600x500") + dialog.configure(fg_color=self.COLORS['bg_primary']) + + # Center dialog + dialog.transient(self.window) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 500) // 2 + dialog.geometry(f"600x500+{x}+{y}") + + # Title + ctk.CTkLabel( + dialog, text="Title:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + title_entry = ctk.CTkEntry( + dialog, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + title_entry.pack(pady=5) + + # Description + ctk.CTkLabel( + dialog, text="Description:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + desc_text = ctk.CTkTextbox( + dialog, height=250, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + desc_text.pack(pady=5) + + def create(): + title = title_entry.get().strip() + body = desc_text.get("0.0", "end").strip() + + if not title: + self.show_warning("Please enter a title") + return + + try: + manager = self.issue_pr_manager.get_issue_manager(self.selected_repo['name']) + manager.create_issue(title, body) + self.show_info("Issue created successfully") + dialog.destroy() + self.refresh_issues() + except Exception as e: + self.show_error(f"Failed to create issue: {e}") + + ctk.CTkButton( + dialog, text="Create", command=create, + fg_color=self.COLORS['accent_success'] + ).pack(pady=20) + + def close_issue(self): + # TODO: Need to implement issue selection first + self.show_info("Please select an issue from the list to close") + + def create_pr_dialog(self): + if not self.selected_repo: + self.show_warning("Please select a repository") + return + + dialog = ctk.CTkToplevel(self.window) + dialog.title("Create Pull Request") + dialog.geometry("600x600") + dialog.configure(fg_color=self.COLORS['bg_primary']) + + # Center dialog + dialog.transient(self.window) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 600) // 2 + dialog.geometry(f"600x600+{x}+{y}") + + # Title + ctk.CTkLabel( + dialog, text="Title:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + title_entry = ctk.CTkEntry( + dialog, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + title_entry.pack(pady=5) + + # Branches + ctk.CTkLabel( + dialog, text="Head Branch:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + head_entry = ctk.CTkEntry( + dialog, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + head_entry.pack(pady=5) + + ctk.CTkLabel( + dialog, text="Base Branch:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + base_entry = ctk.CTkEntry( + dialog, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + base_entry.insert(0, "main") + base_entry.pack(pady=5) + + # Description + ctk.CTkLabel( + dialog, text="Description:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + desc_text = ctk.CTkTextbox( + dialog, height=200, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + desc_text.pack(pady=5) + + def create(): + title = title_entry.get().strip() + head = head_entry.get().strip() + base = base_entry.get().strip() + body = desc_text.get("0.0", "end").strip() + + if not all([title, head, base]): + self.show_warning("Please fill all required fields") + return + + try: + manager = self.issue_pr_manager.get_pr_manager(self.selected_repo['name']) + manager.create_pull_request(title, head, base, body) + self.show_info("Pull request created successfully") + dialog.destroy() + self.refresh_prs() + except Exception as e: + self.show_error(f"Failed to create PR: {e}") + + ctk.CTkButton( + dialog, text="Create", command=create, + fg_color=self.COLORS['accent_success'] + ).pack(pady=20) + + def merge_pr(self): + # TODO: Need to implement PR selection first + self.show_info("Please select a pull request from the list to merge") + + def close_pr(self): + # TODO: Need to implement PR selection first + self.show_info("Please select a pull request from the list to close") + + # Helper methods + def append_output(self, text): + self.git_output.insert("end", text) + self.git_output.see("end") + + def show_error(self, message): + messagebox.showerror("Error", message) + + def show_warning(self, message): + messagebox.showwarning("Warning", message) + + def show_info(self, message): + messagebox.showinfo("Info", message) + + def push_local_repo_dialog(self, project_name=None, project_path=None): + """Dialog to push an existing local repository to Gitea""" + dialog = ctk.CTkToplevel(self.window) + dialog.title("Push Local Repository to Gitea") + dialog.geometry("600x600") + dialog.configure(fg_color=self.COLORS['bg_primary']) + + # Center dialog + dialog.transient(self.window) + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 600) // 2 + y = (dialog.winfo_screenheight() - 600) // 2 + dialog.geometry(f"600x600+{x}+{y}") + + # Add description label + desc_label = ctk.CTkLabel( + dialog, + text="Push an existing local Git repository to your Gitea server", + font=self.FONTS['body'], + text_color=self.COLORS['text_secondary'] + ) + desc_label.pack(pady=(10, 5)) + + # Local repository path + ctk.CTkLabel( + dialog, text="Local Repository Path:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + path_frame = ctk.CTkFrame(dialog, fg_color="transparent") + path_frame.pack(pady=5, padx=20, fill="x") + + path_var = ctk.StringVar() + path_entry = ctk.CTkEntry( + path_frame, width=400, + textvariable=path_var, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + path_entry.pack(side="left", padx=(0, 10)) + + def browse_folder(): + from tkinter import filedialog + folder = filedialog.askdirectory() + if folder: + path_var.set(folder) + # Update repo name field with folder name if empty + if not name_entry.get().strip(): + import os + folder_name = os.path.basename(folder) + name_entry.delete(0, 'end') + name_entry.insert(0, folder_name) + + # Set initial values if provided + if project_path: + path_var.set(project_path) + + ctk.CTkButton( + path_frame, text="Browse", command=browse_folder, + fg_color=self.COLORS['accent_primary'], width=100 + ).pack(side="left") + + # Repository name + ctk.CTkLabel( + dialog, text="Repository Name:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + name_entry = ctk.CTkEntry( + dialog, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + name_entry.pack(pady=5, padx=20) + + # Set initial name if provided + if project_name: + name_entry.insert(0, project_name) + + # Description + ctk.CTkLabel( + dialog, text="Description:", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(20, 5)) + + desc_text = ctk.CTkTextbox( + dialog, height=100, width=500, + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + desc_text.pack(pady=5, padx=20) + + # Options + private_var = ctk.BooleanVar() + ctk.CTkCheckBox( + dialog, text="Private Repository", + variable=private_var, + text_color=self.COLORS['text_primary'] + ).pack(pady=10) + + # Branch name + ctk.CTkLabel( + dialog, text="Branch to push (default: main):", + font=self.FONTS['subtitle'], + text_color=self.COLORS['text_primary'] + ).pack(pady=(10, 5)) + + branch_entry = ctk.CTkEntry( + dialog, width=200, + placeholder_text="main", + fg_color=self.COLORS['bg_secondary'], + text_color=self.COLORS['text_primary'] + ) + branch_entry.pack(pady=5) + + # Status label for feedback + status_label = ctk.CTkLabel( + dialog, text="", + font=self.FONTS['body'], + text_color=self.COLORS['text_secondary'] + ) + status_label.pack(pady=10) + + def push_repo(): + local_path = path_var.get().strip() + repo_name = name_entry.get().strip() + desc = desc_text.get("0.0", "end").strip() + branch = branch_entry.get().strip() or "main" + + if not local_path: + self.show_warning("Please select a local repository path") + return + + if not repo_name: + self.show_warning("Please enter a repository name") + return + + # Check if it's a valid git repository + from pathlib import Path + repo_path = Path(local_path) + if not (repo_path / ".git").exists(): + self.show_warning("Selected path is not a git repository") + return + + def do_push(): + try: + # Update status + self.window.after(0, lambda: status_label.configure( + text="Creating repository on Gitea...", + text_color=self.COLORS['accent_primary'] + )) + + success, message = self.repo_manager.push_local_repo_to_gitea( + repo_path, repo_name, desc, private_var.get(), branch + ) + + if success: + self.window.after(0, lambda: self.show_info(message)) + self.window.after(0, dialog.destroy) + self.window.after(0, self.refresh_repositories) + else: + self.window.after(0, lambda: status_label.configure( + text=f"Error: {message}", + text_color=self.COLORS['accent_error'] + )) + except Exception as e: + self.window.after(0, lambda: status_label.configure( + text=f"Error: {str(e)}", + text_color=self.COLORS['accent_error'] + )) + + # Run in thread to avoid blocking UI + import threading + threading.Thread(target=do_push, daemon=True).start() + + ctk.CTkButton( + dialog, text="Push to Gitea", command=push_repo, + fg_color=self.COLORS['accent_success'] + ).pack(pady=20) \ No newline at end of file diff --git a/src/gitea/issue_pr_manager.py b/src/gitea/issue_pr_manager.py new file mode 100644 index 0000000..eb8e0c6 --- /dev/null +++ b/src/gitea/issue_pr_manager.py @@ -0,0 +1,299 @@ +import logging +from typing import List, Dict, Any, Optional +from datetime import datetime +from .gitea_client import GiteaClient + +logger = logging.getLogger(__name__) + +class IssueManager: + def __init__(self, gitea_client: GiteaClient, owner: str, repo: str): + self.client = gitea_client + self.owner = owner + self.repo = repo + + def list_issues(self, state: str = "open", labels: Optional[List[str]] = None) -> List[Dict[str, Any]]: + all_issues = [] + page = 1 + + while True: + issues = self.client.list_issues( + self.owner, self.repo, state=state, labels=labels, page=page + ) + if not issues: + break + all_issues.extend(issues) + page += 1 + + return all_issues + + def create_issue(self, title: str, body: str = "", + assignees: Optional[List[str]] = None, + labels: Optional[List[int]] = None) -> Optional[Dict[str, Any]]: + try: + issue = self.client.create_issue( + self.owner, self.repo, title, body, assignees, labels + ) + logger.info(f"Issue '{title}' created successfully") + return issue + except Exception as e: + logger.error(f"Failed to create issue: {e}") + return None + + def update_issue(self, index: int, **kwargs) -> Optional[Dict[str, Any]]: + try: + issue = self.client.update_issue(self.owner, self.repo, index, **kwargs) + logger.info(f"Issue #{index} updated successfully") + return issue + except Exception as e: + logger.error(f"Failed to update issue #{index}: {e}") + return None + + def close_issue(self, index: int) -> bool: + try: + self.client.close_issue(self.owner, self.repo, index) + logger.info(f"Issue #{index} closed successfully") + return True + except Exception as e: + logger.error(f"Failed to close issue #{index}: {e}") + return False + + def add_comment(self, index: int, comment: str) -> Optional[Dict[str, Any]]: + try: + data = {"body": comment} + comment_data = self.client._request( + "POST", + f"repos/{self.owner}/{self.repo}/issues/{index}/comments", + json=data + ) + logger.info(f"Comment added to issue #{index}") + return comment_data + except Exception as e: + logger.error(f"Failed to add comment to issue #{index}: {e}") + return None + + def get_issue_comments(self, index: int) -> List[Dict[str, Any]]: + try: + return self.client._request( + "GET", + f"repos/{self.owner}/{self.repo}/issues/{index}/comments" + ) + except Exception as e: + logger.error(f"Failed to get comments for issue #{index}: {e}") + return [] + + def search_issues(self, query: str) -> List[Dict[str, Any]]: + all_issues = self.list_issues(state="all") + query_lower = query.lower() + + return [ + issue for issue in all_issues + if query_lower in issue["title"].lower() or + query_lower in issue.get("body", "").lower() + ] + +class PullRequestManager: + def __init__(self, gitea_client: GiteaClient, owner: str, repo: str): + self.client = gitea_client + self.owner = owner + self.repo = repo + + def list_pull_requests(self, state: str = "open") -> List[Dict[str, Any]]: + all_prs = [] + page = 1 + + while True: + prs = self.client.list_pull_requests( + self.owner, self.repo, state=state, page=page + ) + if not prs: + break + all_prs.extend(prs) + page += 1 + + return all_prs + + def create_pull_request(self, title: str, head: str, base: str, + body: str = "", assignees: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: + try: + pr = self.client.create_pull_request( + self.owner, self.repo, title, head, base, body, assignees + ) + logger.info(f"Pull request '{title}' created successfully") + return pr + except Exception as e: + logger.error(f"Failed to create pull request: {e}") + return None + + def update_pull_request(self, index: int, **kwargs) -> Optional[Dict[str, Any]]: + try: + pr = self.client._request( + "PATCH", + f"repos/{self.owner}/{self.repo}/pulls/{index}", + json=kwargs + ) + logger.info(f"Pull request #{index} updated successfully") + return pr + except Exception as e: + logger.error(f"Failed to update pull request #{index}: {e}") + return None + + def merge_pull_request(self, index: int, merge_style: str = "merge") -> bool: + try: + self.client.merge_pull_request(self.owner, self.repo, index, merge_style) + logger.info(f"Pull request #{index} merged successfully") + return True + except Exception as e: + logger.error(f"Failed to merge pull request #{index}: {e}") + return False + + def close_pull_request(self, index: int) -> bool: + return self.update_pull_request(index, state="closed") is not None + + def get_pull_request_diff(self, index: int) -> Optional[str]: + try: + response = self.client.session.get( + f"{self.client.config.api_url}/repos/{self.owner}/{self.repo}/pulls/{index}.diff", + headers={"Accept": "text/plain"} + ) + response.raise_for_status() + return response.text + except Exception as e: + logger.error(f"Failed to get diff for PR #{index}: {e}") + return None + + def get_pull_request_commits(self, index: int) -> List[Dict[str, Any]]: + try: + return self.client._request( + "GET", + f"repos/{self.owner}/{self.repo}/pulls/{index}/commits" + ) + except Exception as e: + logger.error(f"Failed to get commits for PR #{index}: {e}") + return [] + + def add_comment(self, index: int, comment: str) -> Optional[Dict[str, Any]]: + try: + data = {"body": comment} + comment_data = self.client._request( + "POST", + f"repos/{self.owner}/{self.repo}/pulls/{index}/reviews", + json=data + ) + logger.info(f"Comment added to pull request #{index}") + return comment_data + except Exception as e: + logger.error(f"Failed to add comment to PR #{index}: {e}") + return None + + def get_pull_request_reviews(self, index: int) -> List[Dict[str, Any]]: + try: + return self.client._request( + "GET", + f"repos/{self.owner}/{self.repo}/pulls/{index}/reviews" + ) + except Exception as e: + logger.error(f"Failed to get reviews for PR #{index}: {e}") + return [] + + def approve_pull_request(self, index: int, comment: str = "") -> Optional[Dict[str, Any]]: + try: + data = { + "body": comment, + "event": "APPROVE" + } + review = self.client._request( + "POST", + f"repos/{self.owner}/{self.repo}/pulls/{index}/reviews", + json=data + ) + logger.info(f"Pull request #{index} approved") + return review + except Exception as e: + logger.error(f"Failed to approve PR #{index}: {e}") + return None + + def request_changes(self, index: int, comment: str) -> Optional[Dict[str, Any]]: + try: + data = { + "body": comment, + "event": "REQUEST_CHANGES" + } + review = self.client._request( + "POST", + f"repos/{self.owner}/{self.repo}/pulls/{index}/reviews", + json=data + ) + logger.info(f"Changes requested for PR #{index}") + return review + except Exception as e: + logger.error(f"Failed to request changes for PR #{index}: {e}") + return None + +class IssuePRManager: + def __init__(self, gitea_client: Optional[GiteaClient] = None): + self.client = gitea_client or GiteaClient() + self._current_user = None + self._issue_managers = {} + self._pr_managers = {} + + @property + def current_user(self) -> Dict[str, Any]: + if self._current_user is None: + self._current_user = self.client.get_user_info() + return self._current_user + + def get_issue_manager(self, repo_name: str) -> IssueManager: + if repo_name not in self._issue_managers: + owner = self.current_user["username"] + self._issue_managers[repo_name] = IssueManager(self.client, owner, repo_name) + return self._issue_managers[repo_name] + + def get_pr_manager(self, repo_name: str) -> PullRequestManager: + if repo_name not in self._pr_managers: + owner = self.current_user["username"] + self._pr_managers[repo_name] = PullRequestManager(self.client, owner, repo_name) + return self._pr_managers[repo_name] + + def list_all_issues(self, state: str = "open") -> Dict[str, List[Dict[str, Any]]]: + repos = self.client.list_repositories() + all_issues = {} + + for repo in repos: + repo_name = repo["name"] + manager = self.get_issue_manager(repo_name) + issues = manager.list_issues(state=state) + if issues: + all_issues[repo_name] = issues + + return all_issues + + def list_all_pull_requests(self, state: str = "open") -> Dict[str, List[Dict[str, Any]]]: + repos = self.client.list_repositories() + all_prs = {} + + for repo in repos: + repo_name = repo["name"] + manager = self.get_pr_manager(repo_name) + prs = manager.list_pull_requests(state=state) + if prs: + all_prs[repo_name] = prs + + return all_prs + + def get_activity_summary(self) -> Dict[str, Any]: + open_issues = self.list_all_issues("open") + open_prs = self.list_all_pull_requests("open") + + total_issues = sum(len(issues) for issues in open_issues.values()) + total_prs = sum(len(prs) for prs in open_prs.values()) + + return { + "open_issues": total_issues, + "open_pull_requests": total_prs, + "repositories_with_issues": len(open_issues), + "repositories_with_prs": len(open_prs), + "details": { + "issues_by_repo": {repo: len(issues) for repo, issues in open_issues.items()}, + "prs_by_repo": {repo: len(prs) for repo, prs in open_prs.items()} + } + } \ No newline at end of file diff --git a/src/gitea/repository_manager.py b/src/gitea/repository_manager.py new file mode 100644 index 0000000..1382db0 --- /dev/null +++ b/src/gitea/repository_manager.py @@ -0,0 +1,274 @@ +import logging +from typing import List, Dict, Any, Optional, Tuple +from pathlib import Path +from .gitea_client import GiteaClient, GiteaConfig +from .git_operations import GitOperationsManager + +logger = logging.getLogger(__name__) + +class RepositoryManager: + def __init__(self, gitea_client: Optional[GiteaClient] = None): + self.client = gitea_client or GiteaClient() + self._current_user = None + + try: + # Get the actual username from Gitea + user_info = self.client.get_user_info() + actual_username = user_info.get('username', self.client.config.username) + self._current_user = user_info + except Exception as e: + logger.warning(f"Failed to get user info from Gitea: {e}") + # Fall back to config username + actual_username = self.client.config.username + + self.git_ops = GitOperationsManager( + base_url=self.client.config.base_url, + token=self.client.config.api_token, + username=actual_username + ) + + @property + def current_user(self) -> Dict[str, Any]: + if self._current_user is None: + self._current_user = self.client.get_user_info() + return self._current_user + + def list_all_repositories(self) -> List[Dict[str, Any]]: + all_repos = [] + page = 1 + + while True: + repos = self.client.list_repositories(page=page) + if not repos: + break + all_repos.extend(repos) + page += 1 + + return all_repos + + def list_organization_repositories(self, org_name: str, page: int = None, per_page: int = None) -> List[Dict[str, Any]]: + """List all repositories for an organization""" + if page is not None and per_page is not None: + # Single page request + try: + repos = self.client._request("GET", f"orgs/{org_name}/repos", params={"page": page, "limit": per_page}) + return repos if repos else [] + except Exception as e: + logger.error(f"Failed to list org repositories: {e}") + return [] + else: + # Get all pages + all_repos = [] + current_page = 1 + + while True: + try: + repos = self.client._request("GET", f"orgs/{org_name}/repos", params={"page": current_page, "limit": 50}) + if not repos: + break + all_repos.extend(repos) + current_page += 1 + except Exception as e: + logger.error(f"Failed to list org repositories: {e}") + break + + return all_repos + + def create_repository(self, name: str, description: str = "", private: bool = False, + auto_init: bool = True, gitignore: str = "", license: str = "", + organization: str = None) -> Dict[str, Any]: + try: + # Try to create in organization first + if organization: + try: + data = { + "name": name, + "description": description, + "private": private, + "auto_init": auto_init, + "gitignores": gitignore, + "license": license, + "default_branch": "main" # Use main instead of master + } + repo = self.client._request("POST", f"orgs/{organization}/repos", json=data) + logger.info(f"Repository '{name}' created successfully in organization '{organization}'") + return repo + except Exception as org_error: + logger.error(f"Failed to create in organization {organization}: {org_error}") + # Don't fall back - raise the error so user knows what happened + raise Exception(f"Konnte Repository nicht in Organisation '{organization}' erstellen: {str(org_error)}") + + # Create as user repository + repo = self.client.create_repository( + name=name, + description=description, + private=private, + auto_init=auto_init, + gitignores=gitignore, + license=license + ) + logger.info(f"Repository '{name}' created successfully as user repository") + return repo + except Exception as e: + logger.error(f"Failed to create repository '{name}': {e}") + raise + + def delete_repository(self, repo_name: str) -> bool: + try: + owner = self.current_user["username"] + self.client.delete_repository(owner, repo_name) + logger.info(f"Repository '{repo_name}' deleted successfully") + return True + except Exception as e: + logger.error(f"Failed to delete repository '{repo_name}': {e}") + return False + + def clone_repository(self, repo_name: str, clone_dir: Optional[Path] = None) -> Tuple[bool, Path]: + owner = self.current_user["username"] + return self.git_ops.clone_repository(owner, repo_name, clone_dir) + + def get_repository_info(self, repo_name: str) -> Optional[Dict[str, Any]]: + try: + owner = self.current_user["username"] + return self.client.get_repository(owner, repo_name) + except Exception as e: + logger.error(f"Failed to get repository info for '{repo_name}': {e}") + return None + + def fork_repository(self, owner: str, repo_name: str) -> Optional[Dict[str, Any]]: + try: + fork = self.client.fork_repository(owner, repo_name) + logger.info(f"Repository '{owner}/{repo_name}' forked successfully") + return fork + except Exception as e: + logger.error(f"Failed to fork repository '{owner}/{repo_name}': {e}") + return None + + def search_repositories(self, query: str) -> List[Dict[str, Any]]: + all_repos = self.list_all_repositories() + query_lower = query.lower() + + return [ + repo for repo in all_repos + if query_lower in repo["name"].lower() or + query_lower in repo.get("description", "").lower() + ] + + def sync_repository(self, repo_path: Path) -> Tuple[bool, str]: + success, fetch_result = self.git_ops.fetch(repo_path) + if not success: + return False, f"Fetch failed: {fetch_result}" + + success, pull_result = self.git_ops.pull(repo_path) + if not success: + return False, f"Pull failed: {pull_result}" + + return True, "Repository synchronized successfully" + + def commit_and_push(self, repo_path: Path, message: str, + files: Optional[List[str]] = None) -> Tuple[bool, str]: + success, add_result = self.git_ops.add(repo_path, files) + if not success: + return False, f"Add failed: {add_result}" + + success, commit_result = self.git_ops.commit(repo_path, message) + if not success: + return False, f"Commit failed: {commit_result}" + + success, push_result = self.git_ops.push(repo_path) + if not success: + return False, f"Push failed: {push_result}" + + return True, "Changes committed and pushed successfully" + + def get_repository_status(self, repo_path: Path) -> Dict[str, Any]: + success, status = self.git_ops.status(repo_path) + success_branch, branches = self.git_ops.branch(repo_path) + success_remote, remotes = self.git_ops.remote_list(repo_path) + + current_branch = None + if success_branch: + for line in branches.split('\n'): + if line.startswith('*'): + current_branch = line[2:].strip() + break + + return { + "has_changes": bool(status.strip()) if success else None, + "status": status if success else "Unable to get status", + "current_branch": current_branch, + "remotes": remotes if success_remote else "Unable to get remotes" + } + + def create_branch(self, repo_path: Path, branch_name: str) -> Tuple[bool, str]: + return self.git_ops.checkout(repo_path, branch_name, create=True) + + def switch_branch(self, repo_path: Path, branch_name: str) -> Tuple[bool, str]: + return self.git_ops.checkout(repo_path, branch_name) + + def list_branches(self, repo_name: str) -> List[Dict[str, Any]]: + try: + owner = self.current_user["username"] + return self.client.list_branches(owner, repo_name) + except Exception as e: + logger.error(f"Failed to list branches for '{repo_name}': {e}") + return [] + + def create_remote_branch(self, repo_name: str, branch_name: str, + base_branch: str = "main") -> Optional[Dict[str, Any]]: + try: + owner = self.current_user["username"] + return self.client.create_branch(owner, repo_name, branch_name, base_branch) + except Exception as e: + logger.error(f"Failed to create branch '{branch_name}' in '{repo_name}': {e}") + return None + + def delete_remote_branch(self, repo_name: str, branch_name: str) -> bool: + try: + owner = self.current_user["username"] + self.client.delete_branch(owner, repo_name, branch_name) + logger.info(f"Branch '{branch_name}' deleted from '{repo_name}'") + return True + except Exception as e: + logger.error(f"Failed to delete branch '{branch_name}' from '{repo_name}': {e}") + return False + + def push_local_repo_to_gitea(self, local_repo_path: Path, repo_name: str, + description: str = "", private: bool = False, + branch: str = "main", organization: str = None) -> Tuple[bool, str]: + """Create a new repo on Gitea and push an existing local repository to it""" + try: + # First create the repository on Gitea + repo = self.create_repository( + name=repo_name, + description=description, + private=private, + auto_init=False, # Important: don't initialize since we're pushing existing code + organization=organization + ) + + # Determine the correct owner + if organization: + owner = organization + elif 'owner' in repo and repo['owner']: + owner = repo['owner']['username'] if 'username' in repo['owner'] else repo['owner']['login'] + else: + owner = self.current_user["username"] + + logger.info(f"Repository created, owner determined as: {owner}") + + # Then push the local repository + success, result = self.git_ops.push_existing_repo_to_gitea( + local_repo_path, owner, repo_name, branch + ) + + if success: + logger.info(f"Successfully pushed local repository to '{repo_name}'") + return True, f"Repository '{repo_name}' created and pushed successfully" + else: + logger.error(f"Failed to push to '{repo_name}': {result}") + return False, f"Repository created but push failed: {result}" + + except Exception as e: + logger.error(f"Failed to push local repository: {e}") + return False, str(e) \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..04b3b4a --- /dev/null +++ b/start.bat @@ -0,0 +1,32 @@ +@echo off +title Claude Project Manager +echo Starting Claude Project Manager... +echo. + +:: Check if Python is installed +python --version >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python is not installed or not in PATH + echo Please install Python 3.8 or higher + pause + exit /b 1 +) + +:: Check if requirements are installed +python -c "import customtkinter" >nul 2>&1 +if errorlevel 1 ( + echo Installing required packages... + pip install -r requirements.txt + echo. +) + +:: Start the application +echo Launching application... +python main.py + +:: If app crashes, keep window open +if errorlevel 1 ( + echo. + echo Application crashed! + pause +) \ No newline at end of file diff --git a/terminal_launcher.py b/terminal_launcher.py new file mode 100644 index 0000000..d10f22e --- /dev/null +++ b/terminal_launcher.py @@ -0,0 +1,264 @@ +""" +Terminal Launcher Module +Handles launching Claude in WSL terminal with specified directory +""" + +import subprocess +import os +import tempfile +from datetime import datetime +from typing import Optional, Union +from utils.logger import logger + +class TerminalLauncher: + def __init__(self): + self.claude_path = self.find_claude_path() + + def find_claude_path(self) -> str: + """Find Claude path dynamically in WSL""" + # Try to find claude using 'which' command in WSL with login shell + 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 + + # Use login shell to ensure all PATH and aliases are loaded + command = ["wsl", "bash", "-l", "-c", "which claude 2>/dev/null || command -v claude 2>/dev/null || type -p claude 2>/dev/null"] + logger.debug(f"Executing command to find Claude: {' '.join(command)}") + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=5, + startupinfo=startupinfo + ) + + if result.returncode == 0 and result.stdout.strip(): + claude_path = result.stdout.strip() + # Clean up any extra output from 'type' command + if " is " in claude_path: + claude_path = claude_path.split(" is ")[-1].strip() + logger.info(f"Found Claude at: {claude_path}") + print(f"Found Claude at: {claude_path}") + return claude_path + except Exception as e: + logger.error(f"Error finding Claude path: {e}") + print(f"Error finding Claude path: {e}") + + # Fallback paths to try + fallback_paths = [ + "claude", # Try just 'claude' - let WSL PATH handle it + "/usr/local/bin/claude", + "/usr/bin/claude", + "~/.local/bin/claude", + "/home/$USER/.nvm/versions/node/*/bin/claude" # Wildcard for any node version + ] + + # Test each fallback path + for path in fallback_paths: + 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( + ["wsl", "bash", "-c", f"test -f {path} && echo 'exists'"], + capture_output=True, + text=True, + timeout=2, + startupinfo=startupinfo + ) + if result.stdout.strip() == 'exists': + print(f"Found Claude at fallback: {path}") + return path + except: + continue + + # If nothing found, default to 'claude' and hope it's in PATH + print("Claude path not found, using 'claude' (hoping it's in PATH)") + return "claude" + + def convert_to_wsl_path(self, windows_path: str) -> str: + """Convert Windows path to WSL format""" + # Handle different path formats + windows_path = windows_path.replace('/', '\\') + + # Extract drive letter and path + if ':' in windows_path: + drive = windows_path[0].lower() + path = windows_path[2:].replace('\\', '/') + return f'/mnt/{drive}{path}' + return windows_path + + def launch_claude_wsl(self, project_path: str, project_name: str = "Claude Session") -> Union[subprocess.Popen, None]: + """Launch Claude in WSL terminal for specified directory""" + try: + wsl_path = self.convert_to_wsl_path(project_path) + logger.info(f"Launching Claude for project: {project_name} at path: {project_path}") + + # Check if Windows Terminal is available + if os.path.exists(r"C:\Windows\System32\wt.exe"): + # Use Windows Terminal + cmd = [ + "wt.exe", + "-w", "0", + "new-tab", + "--title", f"Claude - {project_name}", + "--suppressApplicationTitle", + "--", + "wsl", "bash", "-l", "-i", "-c", + f"cd '{wsl_path}' && echo 'Working directory: {wsl_path}' && echo '' && claude || (echo 'Error: Claude not found. Please install Claude in WSL.' && exec bash)" + ] + + logger.info(f"Executing Windows Terminal command: {' '.join(cmd)}") + process = subprocess.Popen(cmd, shell=False) + logger.info(f"Successfully launched Claude in Windows Terminal for: {project_path}") + print(f"Launched Claude in Windows Terminal for: {project_path}") + return process + + else: + # Fallback to cmd window + batch_content = f'''@echo off +title Claude - {project_name} +echo Starting Claude for project: {project_name} +echo Working directory: {project_path} +echo. +wsl bash -l -i -c "cd '{wsl_path}' && claude || (echo 'Error: Claude not found. Please install Claude in WSL.' && exec bash)" +pause +''' + + # Create temporary batch file + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(batch_content) + batch_path = f.name + + # Launch in new cmd window + cmd = ['cmd', '/c', 'start', '', batch_path] + logger.info(f"Executing CMD command: {' '.join(cmd)}") + logger.debug(f"Batch file content:\n{batch_content}") + process = subprocess.Popen(cmd, shell=False) + logger.info(f"Successfully launched Claude in CMD for: {project_path}") + print(f"Launched Claude in CMD for: {project_path}") + return process + + except Exception as e: + logger.error(f"Error launching Claude: {str(e)}", extra={"project_path": project_path, "project_name": project_name}) + print(f"Error launching Claude: {str(e)}") + return None + + def launch_claude_vps(self, server: str, username: str, password: str) -> bool: + """Launch Claude on VPS via SSH""" + try: + # Create SSH command with password + ssh_command = f'ssh {username}@{server}' + + # Create batch file for SSH connection + batch_content = f'''@echo off +title Claude - VPS Server +echo Connecting to VPS Server... +echo Server: {server} +echo Username: {username} +echo. +echo Please enter password when prompted: {password} +echo. +echo After connection, type 'claude' to start Claude +echo. +{ssh_command} +pause +''' + + # Create temporary batch file + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(batch_content) + batch_path = f.name + + # Launch in new window + if os.path.exists(r"C:\Windows\System32\wt.exe"): + # Windows Terminal + cmd = [ + "wt.exe", + "-w", "0", + "new-tab", + "--title", "Claude - VPS", + "--", + "cmd", "/c", batch_path + ] + subprocess.Popen(cmd) + else: + # CMD fallback + subprocess.Popen(['cmd', '/c', 'start', '', batch_path]) + + print(f"Launched VPS connection to: {server}") + return True + + except Exception as e: + print(f"Error launching VPS connection: {str(e)}") + return False + + def launch_claude_admin_panel(self, project_name: str = "Admin Panel") -> Union[subprocess.Popen, None]: + """Launch Claude for Admin Panel with directory change to /opt/v2-Docker""" + try: + # Check if Windows Terminal is available + if os.path.exists(r"C:\Windows\System32\wt.exe"): + # Use Windows Terminal + cmd = [ + "wt.exe", + "-w", "0", + "new-tab", + "--title", f"Claude - {project_name}", + "--suppressApplicationTitle", + "--", + "wsl", "bash", "-l", "-i", "-c", + f"cd /opt/v2-Docker && echo 'Working directory: /opt/v2-Docker' && echo '' && claude || (echo 'Error: Claude not found. Please install Claude in WSL.' && exec bash)" + ] + + process = subprocess.Popen(cmd, shell=False) + print(f"Launched Claude in Windows Terminal for Admin Panel") + return process + + else: + # Fallback to cmd window + batch_content = f'''@echo off +title Claude - {project_name} +echo Starting Claude for: {project_name} +echo Working directory: /opt/v2-Docker +echo. +wsl bash -l -i -c "cd /opt/v2-Docker && claude || (echo 'Error: Claude not found. Please install Claude in WSL.' && exec bash)" +pause +''' + + # Create temporary batch file + with tempfile.NamedTemporaryFile(mode='w', suffix='.bat', delete=False) as f: + f.write(batch_content) + batch_path = f.name + + # Launch in new cmd window + process = subprocess.Popen(['cmd', '/c', 'start', '', batch_path], shell=False) + print(f"Launched Claude in CMD for Admin Panel") + return process + + except Exception as e: + print(f"Error launching Claude for Admin Panel: {str(e)}") + return None + +# Test functions +if __name__ == "__main__": + launcher = TerminalLauncher() + + # Test WSL path conversion + test_paths = [ + "C:\\Users\\hendr\\Desktop\\test", + "D:/Projects/MyApp", + "C:\\Program Files\\test" + ] + + print("Testing WSL path conversion:") + for path in test_paths: + wsl_path = launcher.convert_to_wsl_path(path) + print(f"{path} -> {wsl_path}") \ No newline at end of file diff --git a/test_activity_connection.py b/test_activity_connection.py new file mode 100644 index 0000000..a8eff03 --- /dev/null +++ b/test_activity_connection.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Test-Skript für Activity Server Verbindung +""" + +import requests +import socket +import time +from urllib.parse import urlparse + +def test_connection(server_url="http://91.99.192.14:3001"): + print(f"Teste Verbindung zu: {server_url}") + print("-" * 50) + + # Parse URL + parsed = urlparse(server_url) + host = parsed.hostname + port = parsed.port or 80 + + # 1. DNS/IP Test + print(f"1. Teste DNS-Auflösung für {host}...") + try: + ip = socket.gethostbyname(host) + print(f" ✓ IP-Adresse: {ip}") + except socket.gaierror as e: + print(f" ✗ DNS-Fehler: {e}") + return + + # 2. Port Test + print(f"\n2. Teste TCP-Verbindung zu {host}:{port}...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + try: + result = sock.connect_ex((host, port)) + if result == 0: + print(f" ✓ Port {port} ist erreichbar") + else: + print(f" ✗ Port {port} ist nicht erreichbar (Fehlercode: {result})") + print(" Mögliche Ursachen:") + print(" - Firewall blockiert die Verbindung") + print(" - Server läuft nicht auf diesem Port") + print(" - Netzwerk-Routing-Problem") + return + except Exception as e: + print(f" ✗ Verbindungsfehler: {e}") + return + finally: + sock.close() + + # 3. HTTP Test + print(f"\n3. Teste HTTP-Verbindung...") + try: + response = requests.get(f"{server_url}/health", timeout=5) + print(f" ✓ HTTP Status: {response.status_code}") + if response.status_code == 200: + print(" ✓ Server antwortet korrekt") + print(f" Response: {response.text[:100]}...") + else: + print(f" ⚠ Server antwortet mit Status {response.status_code}") + except requests.exceptions.ConnectionError as e: + print(f" ✗ Verbindungsfehler: {e}") + print(" Mögliche Ursachen:") + print(" - Server ist nicht erreichbar") + print(" - Firewall blockiert HTTP/HTTPS") + except requests.exceptions.Timeout: + print(" ✗ Timeout - Server antwortet nicht rechtzeitig") + except Exception as e: + print(f" ✗ Unerwarteter Fehler: {e}") + + # 4. Socket.IO Test + print(f"\n4. Teste Socket.IO Verbindung...") + try: + # Socket.IO initial handshake + response = requests.get( + f"{server_url}/socket.io/?transport=polling&EIO=4", + timeout=5 + ) + print(f" Status: {response.status_code}") + if response.status_code == 200: + print(" ✓ Socket.IO Endpoint erreichbar") + else: + print(f" ✗ Socket.IO antwortet mit Status {response.status_code}") + except Exception as e: + print(f" ✗ Socket.IO Fehler: {e}") + + # 5. Traceroute simulation (simplified) + print(f"\n5. Netzwerk-Route (vereinfacht)...") + import subprocess + try: + if socket.gethostname().lower().startswith("win"): + cmd = ["tracert", "-h", "10", host] + else: + cmd = ["traceroute", "-m", "10", host] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + print(result.stdout[:500]) + except: + print(" Traceroute nicht verfügbar") + +if __name__ == "__main__": + # Test mit dem Activity Server + test_connection("http://91.99.192.14:3001") + + print("\n" + "=" * 50) + print("DIAGNOSE:") + print("=" * 50) + print("\nWenn Port erreichbar aber HTTP nicht funktioniert:") + print("- Prüfen Sie, ob der Activity Server korrekt läuft") + print("- Prüfen Sie die Server-Logs") + print("- Stellen Sie sicher, dass es ein HTTP-Server ist (nicht HTTPS)") + print("\nWenn Port nicht erreichbar:") + print("- Windows Firewall prüfen") + print("- Router/Netzwerk-Firewall prüfen") + print("- VPN-Verbindung prüfen (falls erforderlich)") + print("- Prüfen ob Sie im richtigen Netzwerk sind") \ No newline at end of file diff --git a/test_git_detection.py b/test_git_detection.py new file mode 100644 index 0000000..1075db0 --- /dev/null +++ b/test_git_detection.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Test script to check if git repository detection is working""" + +import os +import json +from pathlib import Path + +# Load projects +with open('data/projects.json', 'r') as f: + data = json.load(f) + projects = data.get('projects', []) + +print("Testing Git repository detection:\n") + +for project in projects: + if project['id'] not in ["vps-permanent", "admin-panel-permanent", "vps-docker-permanent"]: + project_path = Path(project['path']) + git_path = project_path / ".git" + + print(f"Project: {project['name']}") + print(f" Path: {project_path}") + print(f" Git repo exists: {git_path.exists()}") + + if git_path.exists(): + # Check remote + import subprocess + try: + result = subprocess.run( + ["git", "remote", "-v"], + cwd=project_path, + capture_output=True, + text=True, + check=False + ) + if result.returncode == 0: + print(f" Remotes: {result.stdout.strip()}") + else: + print(f" Error checking remotes: {result.stderr}") + except Exception as e: + print(f" Error: {e}") + print() \ No newline at end of file diff --git a/test_refactoring.py b/test_refactoring.py new file mode 100644 index 0000000..5e533a9 --- /dev/null +++ b/test_refactoring.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Test script for refactoring verification +Tests that refactored methods work correctly +""" + +import sys +import os +from unittest.mock import MagicMock + +# Mock GUI dependencies +sys.modules['customtkinter'] = MagicMock() +sys.modules['tkinter'] = MagicMock() +sys.modules['PIL'] = MagicMock() +sys.modules['PIL.Image'] = MagicMock() + +# Import after mocking +from gui.config import refactoring_config +from gui.main_window import MainWindow + +def test_ui_helpers(): + """Test UI helpers functionality""" + print("\n=== Testing UI Helpers ===") + + # Check current configuration + status = refactoring_config.get_status() + print(f"USE_UI_HELPERS: {status['flags']['USE_UI_HELPERS']}") + + # Mock required components + with MagicMock() as mock_project_manager: + mock_project_manager.get_projects.return_value = [] + + # Test load_and_apply_theme + print("\nTesting load_and_apply_theme...") + try: + # Create a minimal MainWindow instance + # This will test if the refactored method is called + import customtkinter as ctk + ctk.CTk = MagicMock(return_value=MagicMock()) + + # The method should be called during __init__ + # Since we can't fully instantiate MainWindow, test the handler directly + from gui.handlers.ui_helpers import UIHelpersHandler + + mock_main_window = MagicMock() + mock_main_window.root = MagicMock() + mock_main_window.main_container = MagicMock() + + handler = UIHelpersHandler(mock_main_window) + handler.load_and_apply_theme() + print("✅ load_and_apply_theme executed successfully") + + # Test _show_scrollable_info + print("\nTesting _show_scrollable_info...") + handler._show_scrollable_info("Test Title", "Test Content") + print("✅ _show_scrollable_info executed successfully") + + # Test create_header + print("\nTesting create_header...") + handler.create_header() + print("✅ create_header executed successfully") + + # Test create_status_bar + print("\nTesting create_status_bar...") + handler.create_status_bar() + print("✅ create_status_bar executed successfully") + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + traceback.print_exc() + +def test_project_handler(): + """Test project handler functionality""" + print("\n=== Testing Project Handler ===") + + from gui.handlers.project_manager import ProjectManagerHandler + from project_manager import Project + + mock_main_window = MagicMock() + mock_main_window.project_manager = MagicMock() + mock_main_window.refresh_projects = MagicMock() + mock_main_window.update_status = MagicMock() + + handler = ProjectManagerHandler(mock_main_window) + + # Test delete_project + print("\nTesting delete_project...") + try: + mock_project = MagicMock() + mock_project.name = "Test Project" + mock_project.id = "test-id" + + # Mock the dialog to return False (no delete) + with MagicMock() as mock_messagebox: + mock_messagebox.askyesno.return_value = False + handler.delete_project(mock_project) + print("✅ delete_project executed (no delete)") + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + traceback.print_exc() + +def test_process_handler(): + """Test process handler functionality""" + print("\n=== Testing Process Handler ===") + + from gui.handlers.process_manager import ProcessManagerHandler + + mock_main_window = MagicMock() + mock_main_window.status_label = MagicMock() + + handler = ProcessManagerHandler(mock_main_window) + + # Test update_status + print("\nTesting update_status...") + try: + handler.update_status("Test message", error=False) + print("✅ update_status executed successfully") + + handler.update_status("Error message", error=True) + print("✅ update_status with error executed successfully") + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + traceback.print_exc() + + # Note: download_log requires file dialog interaction, harder to test + +def test_gitea_handler(): + """Test Gitea handler functionality""" + print("\n=== Testing Gitea Handler ===") + + from gui.handlers.gitea_operations import GiteaOperationsHandler + + mock_main_window = MagicMock() + mock_main_window._show_scrollable_info = MagicMock() + mock_repo_manager = MagicMock() + mock_repo_manager.client = MagicMock() + mock_repo_manager.git_ops = MagicMock() + + mock_main_window.repo_manager = mock_repo_manager + + handler = GiteaOperationsHandler(mock_main_window) + + # Test consolidated test_gitea_connection + print("\nTesting test_gitea_connection (consolidated)...") + try: + # Mock the API calls + mock_repo_manager.client.get_user_info.return_value = {'username': 'testuser'} + mock_repo_manager.client.get_user_orgs.return_value = [] + mock_repo_manager.client.get_user_teams.return_value = [] + mock_repo_manager.client.get_user_repos.return_value = [] + + handler.test_gitea_connection() + print("✅ test_gitea_connection executed successfully (without project)") + + # Test with project + mock_project = MagicMock() + mock_project.name = "Test Project" + mock_project.path = "/test/path" + + handler.test_gitea_connection(mock_project) + print("✅ test_gitea_connection executed successfully (with project)") + + except Exception as e: + print(f"❌ Error: {e}") + import traceback + traceback.print_exc() + +def main(): + """Run all tests""" + print("=== Refactoring Test Suite ===") + + # Show current configuration + print("\nCurrent Configuration:") + status = refactoring_config.get_status() + for flag, value in status['flags'].items(): + if flag.startswith('USE_'): + status_str = "✅ ENABLED" if value else "❌ DISABLED" + print(f" {flag}: {status_str}") + + # Run tests based on enabled features + if status['flags']['USE_UI_HELPERS']: + test_ui_helpers() + else: + print("\n⚠️ UI Helpers not enabled. Enable with:") + print(" python3 manage_refactoring.py enable ui") + + if status['flags']['USE_PROJECT_HANDLER']: + test_project_handler() + else: + print("\n⚠️ Project Handler not enabled. Enable with:") + print(" python3 manage_refactoring.py enable project") + + if status['flags']['USE_PROCESS_HANDLER']: + test_process_handler() + else: + print("\n⚠️ Process Handler not enabled. Enable with:") + print(" python3 manage_refactoring.py enable process") + + if status['flags']['USE_GITEA_HANDLER']: + test_gitea_handler() + else: + print("\n⚠️ Gitea Handler not enabled. Enable with:") + print(" python3 manage_refactoring.py enable gitea") + + print("\n✅ Test completed") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/test_main_window_api.py b/tests/test_main_window_api.py new file mode 100644 index 0000000..ea5a5d1 --- /dev/null +++ b/tests/test_main_window_api.py @@ -0,0 +1,150 @@ +""" +API Compatibility Tests for MainWindow Refactoring +Dokumentiert alle öffentlichen Methoden der MainWindow Klasse +um sicherzustellen, dass keine Breaking Changes auftreten. +""" + +import unittest +from unittest.mock import Mock, patch, MagicMock +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Mock all GUI dependencies before import +sys.modules['customtkinter'] = MagicMock() +sys.modules['tkinter'] = MagicMock() +sys.modules['PIL'] = MagicMock() +sys.modules['PIL.Image'] = MagicMock() + +class TestMainWindowAPI(unittest.TestCase): + """Test to ensure all public methods exist after refactoring""" + + # Liste aller öffentlichen Methoden der MainWindow Klasse + # Generiert aus der Analyse vom 2025-07-05 + REQUIRED_PUBLIC_METHODS = [ + '__init__', + 'setup_ui', + 'create_header', + 'create_content_area', + 'create_status_bar', + 'refresh_projects', + 'create_project_tile', + 'create_add_tile', + 'create_project_tile_flow', + 'create_add_tile_flow', + 'add_new_project', + 'open_project', + 'open_vps_connection', + 'open_admin_panel', + 'open_vps_docker', + 'open_readme', + 'delete_project', + 'rename_project', + 'generate_readme_background', + 'update_status', + 'download_log', + 'refresh_ui', + 'load_and_apply_theme', + 'on_window_resize', + 'stop_project', + 'setup_interaction_tracking', + 'monitor_process', + 'check_process_status', + 'open_gitea_window', + 'on_gitea_repo_select', + 'clear_project_selection', + 'on_project_select', + 'show_git_status', + 'commit_changes', + 'push_to_gitea', + 'pull_from_gitea', + 'fetch_from_gitea', + 'manage_branches', + 'link_to_gitea', + 'clone_repository', + 'create_project_from_repo', + 'on_closing', + 'gitea_operation', + 'init_and_push_to_gitea', + 'init_git_repo', + 'test_gitea_connection', + 'verify_repository_on_gitea', + 'fix_repository_issues', + 'manage_large_files', + 'setup_git_lfs', + 'run' + ] + + # Private/Protected Methoden die NICHT Teil der öffentlichen API sind + PRIVATE_METHODS = [ + '_differential_update', + '_update_project_tiles_colors', + '_on_click_start', + '_on_click_end', + '_on_dropdown_select', + '_on_focus_in', + '_on_focus_out', + '_check_pending_updates', + '_handle_process_ended', + '_create_project_after_clone', + '_show_scrollable_info' + ] + + @patch('gui.main_window.logger') + @patch('gui.main_window.ProjectProcessTracker') + @patch('gui.main_window.ProcessManager') + @patch('gui.main_window.VPSConnection') + @patch('gui.main_window.ReadmeGenerator') + @patch('gui.main_window.TerminalLauncher') + @patch('gui.main_window.ProjectManager') + @patch('gui.main_window.RepositoryManager') + def test_all_public_methods_exist(self, *mocks): + """Testet dass alle öffentlichen Methoden existieren""" + # Import after all mocks are set up + from gui.main_window import MainWindow + + # Check that all required methods exist + missing_methods = [] + for method_name in self.REQUIRED_PUBLIC_METHODS: + if not hasattr(MainWindow, method_name): + missing_methods.append(method_name) + + self.assertEqual( + missing_methods, + [], + f"Following public methods are missing: {missing_methods}" + ) + + def test_no_unexpected_public_methods(self): + """Warnt vor neuen öffentlichen Methoden die dokumentiert werden sollten""" + # This test will be implemented after import works + pass + + +class TestMainWindowBehavior(unittest.TestCase): + """Behavioral tests for critical MainWindow operations""" + + def setUp(self): + """Set up test fixtures""" + self.window = None + + def test_project_lifecycle(self): + """Test basic project creation, opening and deletion""" + # TODO: Implement after mocking is complete + pass + + def test_gitea_operations(self): + """Test Gitea integration operations""" + # TODO: Test init_and_push_to_gitea, push_to_gitea, etc. + pass + + def test_duplicate_methods_behavior(self): + """Ensure duplicate methods have consistent behavior""" + # TODO: Compare behavior of duplicate methods + pass + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tools/download_winscp.txt b/tools/download_winscp.txt new file mode 100644 index 0000000..de4230c --- /dev/null +++ b/tools/download_winscp.txt @@ -0,0 +1,23 @@ +WinSCP Portable Download Instructions +===================================== + +1. Download WinSCP Portable from: + https://winscp.net/download/WinSCP-6.3.6-Portable.zip + + Alternative direct link: + https://sourceforge.net/projects/winscp/files/WinSCP/6.3.6/WinSCP-6.3.6-Portable.zip/download + +2. Extract the ZIP file to this folder: + tools/WinSCP/ + +3. The structure should be: + tools/ + └── WinSCP/ + ├── WinSCP.exe + ├── WinSCP.com + └── (other files) + +4. After extraction, the WinSCP button in Claude Project Manager will work automatically. + +Note: WinSCP Portable is about 10 MB and doesn't require installation. + It's a trusted SFTP/FTP client for Windows. \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..172569e --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,210 @@ +import logging +import datetime +import os +from pathlib import Path +from typing import Optional, Any +import json +from threading import Lock +import traceback +import inspect + +class ApplicationLogger: + _instance = None + _lock = Lock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not hasattr(self, 'initialized'): + self.initialized = True + self.log_entries = [] + self.max_entries = 50000 # Increased for more detailed logging + self.interaction_count = 0 + + # Create logs directory if it doesn't exist + self.log_dir = Path("logs") + self.log_dir.mkdir(exist_ok=True) + + # Setup file logging + self.log_file = self.log_dir / f"cpm_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.log" + + # Configure logger + self.logger = logging.getLogger('CPM') + self.logger.setLevel(logging.DEBUG) + + # File handler + file_handler = logging.FileHandler(self.log_file, encoding='utf-8') + file_handler.setLevel(logging.DEBUG) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # Detailed Formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S.%f'[:-3] # Include milliseconds + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + # Add handlers + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + # Custom handler to store in memory + self.memory_handler = MemoryHandler(self) + self.memory_handler.setFormatter(formatter) + self.logger.addHandler(self.memory_handler) + + self.info("Claude Project Manager - Logger initialized") + + def debug(self, message: str, extra: Optional[dict] = None): + self._log('DEBUG', message, extra) + + def info(self, message: str, extra: Optional[dict] = None): + self._log('INFO', message, extra) + + def warning(self, message: str, extra: Optional[dict] = None): + self._log('WARNING', message, extra) + + def error(self, message: str, extra: Optional[dict] = None, exc_info=None): + self._log('ERROR', message, extra, exc_info) + + def critical(self, message: str, extra: Optional[dict] = None, exc_info=None): + self._log('CRITICAL', message, extra, exc_info) + + def _log(self, level: str, message: str, extra: Optional[dict] = None, exc_info=None): + # Get caller information + frame = inspect.currentframe() + if frame and frame.f_back and frame.f_back.f_back: + caller = frame.f_back.f_back + caller_info = f"{caller.f_code.co_filename}:{caller.f_lineno}" + else: + caller_info = "unknown" + + log_method = getattr(self.logger, level.lower()) + if extra: + message = f"{message} | Extra: {json.dumps(extra, default=str)}" + + if exc_info: + log_method(message, exc_info=exc_info) + else: + log_method(message) + + def add_entry(self, timestamp: str, level: str, message: str): + """Add log entry to memory buffer""" + with self._lock: + self.log_entries.append({ + 'timestamp': timestamp, + 'level': level, + 'message': message + }) + + # Keep only the latest entries + if len(self.log_entries) > self.max_entries: + self.log_entries = self.log_entries[-self.max_entries:] + + def get_log_content(self) -> str: + """Get all log entries as formatted string""" + with self._lock: + lines = [] + for entry in self.log_entries: + lines.append(f"{entry['timestamp']} - {entry['level']} - {entry['message']}") + return '\n'.join(lines) + + def export_logs(self, filepath: str, include_system_info: bool = True): + """Export logs to a file with optional system information""" + content = [] + + if include_system_info: + content.append("=" * 80) + content.append("CLAUDE PROJECT MANAGER - LOG EXPORT") + content.append("=" * 80) + content.append(f"Export Date: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + content.append(f"Log File: {self.log_file}") + content.append(f"Total Interactions: {self.interaction_count}") + content.append(f"Total Log Entries: {len(self.log_entries)}") + content.append("=" * 80) + content.append("") + + content.append(self.get_log_content()) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write('\n'.join(content) if isinstance(content, list) else content) + + def clear_logs(self): + """Clear in-memory log entries""" + with self._lock: + self.log_entries.clear() + self.info("Log entries cleared") + +class MemoryHandler(logging.Handler): + """Custom handler to store logs in memory""" + def __init__(self, logger_instance): + super().__init__() + self.logger_instance = logger_instance + + def emit(self, record): + try: + msg = self.format(record) + # Extract timestamp, level and message + parts = msg.split(' - ', 3) + if len(parts) >= 4: + timestamp = parts[0] + level = parts[2] + message = parts[3] + self.logger_instance.add_entry(timestamp, level, message) + except Exception: + self.handleError(record) + + def log_interaction(self, component: str, action: str, details: Optional[dict] = None): + """Log UI interactions""" + self.interaction_count += 1 + interaction_msg = f"UI_INTERACTION #{self.interaction_count} - Component: {component}, Action: {action}" + if details: + interaction_msg += f", Details: {json.dumps(details, default=str)}" + self.info(interaction_msg) + + def log_git_operation(self, operation: str, status: str, details: Optional[dict] = None): + """Log Git operations with details""" + git_msg = f"GIT_OPERATION - Operation: {operation}, Status: {status}" + if details: + git_msg += f", Details: {json.dumps(details, default=str)}" + self.info(git_msg) + + def log_exception(self, exception: Exception, context: str = ""): + """Log exceptions with full traceback""" + tb = traceback.format_exc() + error_msg = f"EXCEPTION in {context}: {type(exception).__name__}: {str(exception)}\nTraceback:\n{tb}" + self.error(error_msg) + + def log_method_call(self, method_name: str, args: tuple = None, kwargs: dict = None): + """Log method calls with arguments""" + call_msg = f"METHOD_CALL - {method_name}" + if args: + call_msg += f", Args: {args}" + if kwargs: + call_msg += f", Kwargs: {kwargs}" + self.debug(call_msg) + + def log_state_change(self, component: str, old_state: Any, new_state: Any): + """Log state changes in the application""" + state_msg = f"STATE_CHANGE - Component: {component}, Old: {old_state}, New: {new_state}" + self.info(state_msg) + + def get_full_log_path(self) -> str: + """Get the full path to the current log file""" + return str(self.log_file.absolute()) + + def get_all_log_files(self) -> list: + """Get all log files in the log directory""" + return sorted([f for f in self.log_dir.glob('*.log')], reverse=True) + +# Global logger instance +logger = ApplicationLogger() \ No newline at end of file diff --git a/vps_connection.py b/vps_connection.py new file mode 100644 index 0000000..e5637b1 --- /dev/null +++ b/vps_connection.py @@ -0,0 +1,335 @@ +""" +VPS Connection Module +Handles SSH connection to VPS server +""" + +import os +import tempfile +import subprocess +from utils.logger import logger + +class VPSConnection: + def __init__(self): + self.server = "91.99.192.14" + self.username = "claude-dev" + self.password = "z0E1Al}q2H?Yqd!O" + logger.info("VPSConnection initialized") + + def create_ssh_script(self) -> str: + """Create a script for SSH connection with automatic password and Claude start""" + import tempfile + import os + logger.info(f"Creating SSH script for VPS connection to {self.server}") + + # Create a simple bash script for SSH connection + bash_script = f'''#!/bin/bash +echo "================================================================================" +echo " Claude VPS Server Connection" +echo "================================================================================" +echo "" +echo "Server: {self.server}" +echo "Username: {self.username}" +echo "" + +# Try to use sshpass if available +if command -v sshpass >/dev/null 2>&1; then + echo "Connecting to VPS server and starting Claude automatically..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "claude || bash" +else + # Try to install sshpass + echo "Installing sshpass for automatic login..." + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -qq 2>/dev/null + sudo apt-get install -y sshpass -qq 2>/dev/null + fi + + # Check again if sshpass is now available + if command -v sshpass >/dev/null 2>&1; then + echo "sshpass installed successfully!" + echo "Connecting to VPS server and starting Claude automatically..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "claude || bash" + else + echo "Manual password entry required." + echo "" + echo "Password: {self.password}" + echo "(Right-click to paste)" + echo "" + echo "After login, type: claude" + echo "" + ssh -o StrictHostKeyChecking=no {self.username}@{self.server} + fi +fi + +echo "" +echo "Connection closed." +echo "Press Enter to exit..." +read +''' + + # Save bash script with Unix line endings + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False, newline='\n') as f: + f.write(bash_script) + bash_path = f.name + + # Convert to WSL path + bash_wsl_path = bash_path.replace('\\', '/').replace('C:', '/mnt/c') + + script_content = f"""@echo off +cls +REM Convert line endings and launch WSL with the bash script +wsl dos2unix {bash_wsl_path} 2>nul || wsl sed -i 's/\r$//' {bash_wsl_path} +wsl chmod +x {bash_wsl_path} +wsl bash {bash_wsl_path} + +REM Clean up temp file +del "{bash_path}" 2>nul +""" + return script_content + + def create_readme_content(self) -> str: + """Generate README content for VPS project""" + from datetime import datetime + + content = f"""# Claude VPS Server + +*This README was automatically generated by Claude Project Manager* + +## Server Information + +- **Server IP**: {self.server} +- **Username**: {self.username} +- **Connection**: SSH (Port 22) + +## How to Connect + +### Using Claude Project Manager +1. Click on the VPS Server tile +2. Terminal will open with connection instructions +3. Enter the password when prompted +4. Type `claude` after successful login + +### Manual Connection +```bash +ssh {self.username}@{self.server} +``` + +## Available Commands + +After connecting to the VPS: +- `claude` - Start Claude CLI +- `ls` - List files +- `cd ` - Change directory +- `exit` - Close SSH connection + +## Security Notes + +- Keep your password secure +- Don't share SSH credentials +- Always logout when finished (`exit` command) + +## Features + +This VPS server provides: +- Remote access to Claude +- Persistent environment +- Isolated workspace +- Full Linux environment + +## Troubleshooting + +### Connection Issues +1. Check internet connection +2. Verify server is online +3. Ensure SSH port (22) is not blocked +4. Try manual SSH command + +### Authentication Failed +- Verify password is correct +- Check username spelling +- Ensure caps lock is off + +--- + +## Connection Log + +- README generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + return content + + def generate_vps_readme(self, readme_path: str): + """Generate and save VPS README""" + logger.info(f"Generating VPS README at: {readme_path}") + content = self.create_readme_content() + + # Create directory if needed + os.makedirs(os.path.dirname(readme_path), exist_ok=True) + + # Write README + with open(readme_path, 'w', encoding='utf-8') as f: + f.write(content) + + return readme_path + + def create_admin_panel_script(self) -> str: + """Create a script for SSH connection to Admin Panel with directory change""" + import tempfile + import os + logger.info("Creating Admin Panel SSH script") + + # Create a simple bash script for SSH connection + bash_script = f'''#!/bin/bash +echo "================================================================================" +echo " Claude Admin Panel Connection" +echo "================================================================================" +echo "" +echo "Server: {self.server}" +echo "Username: {self.username}" +echo "Target Directory: /opt/v2-Docker" +echo "" + +# Try to use sshpass if available +if command -v sshpass >/dev/null 2>&1; then + echo "Connecting to VPS server and starting Claude in Admin Panel..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "cd /opt/v2-Docker && echo 'Changed to /opt/v2-Docker' && claude || bash" +else + # Try to install sshpass + echo "Installing sshpass for automatic login..." + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -qq 2>/dev/null + sudo apt-get install -y sshpass -qq 2>/dev/null + fi + + # Check again if sshpass is now available + if command -v sshpass >/dev/null 2>&1; then + echo "sshpass installed successfully!" + echo "Connecting to VPS server and starting Claude in Admin Panel..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "cd /opt/v2-Docker && echo 'Changed to /opt/v2-Docker' && claude || bash" + else + echo "Manual password entry required." + echo "" + echo "Password: {self.password}" + echo "(Right-click to paste)" + echo "" + echo "After login:" + echo "1. Type: cd /opt/v2-Docker" + echo "2. Type: claude" + echo "" + ssh -o StrictHostKeyChecking=no {self.username}@{self.server} + fi +fi + +echo "" +echo "Connection closed." +echo "Press Enter to exit..." +read +''' + + # Save bash script with Unix line endings + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False, newline='\n') as f: + f.write(bash_script) + bash_path = f.name + + # Convert to WSL path + bash_wsl_path = bash_path.replace('\\', '/').replace('C:', '/mnt/c') + + script_content = f"""@echo off +cls +REM Convert line endings and launch WSL with the bash script +wsl dos2unix {bash_wsl_path} 2>nul || wsl sed -i 's/\r$//' {bash_wsl_path} +wsl chmod +x {bash_wsl_path} +wsl bash {bash_wsl_path} + +REM Clean up temp file +del "{bash_path}" 2>nul +""" + return script_content + + def create_vps_docker_script(self) -> str: + """Create a script for SSH connection to VPS Docker with docker compose commands""" + import tempfile + import os + logger.info("Creating VPS Docker restart script") + + # Create a bash script for SSH connection with docker commands + bash_script = f'''#!/bin/bash +echo "================================================================================" +echo " VPS Docker Admin Panel Restart" +echo "================================================================================" +echo "" +echo "Server: {self.server}" +echo "Username: {self.username}" +echo "Target Directory: /opt/v2-Docker/v2" +echo "" + +# Try to use sshpass if available +if command -v sshpass >/dev/null 2>&1; then + echo "Connecting to VPS server and restarting Admin Panel..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "cd /opt/v2-Docker/v2 && echo -e '\\n[Restarting Admin Panel Docker containers...]\\n' && docker compose down && echo -e '\\n[Building containers with no cache...]\\n' && docker compose build --no-cache && echo -e '\\n[Starting containers in detached mode...]\\n' && docker compose up -d && echo -e '\\n[SUCCESS] Admin Panel restart completed successfully!\\n' || echo -e '\\n[ERROR] Error during restart process!\\n'" +else + # Try to install sshpass + echo "Installing sshpass for automatic login..." + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -qq 2>/dev/null + sudo apt-get install -y sshpass -qq 2>/dev/null + fi + + # Check again if sshpass is now available + if command -v sshpass >/dev/null 2>&1; then + echo "sshpass installed successfully!" + echo "Connecting to VPS server and restarting Admin Panel..." + echo "" + sshpass -p "{self.password}" ssh -o StrictHostKeyChecking=no -t {self.username}@{self.server} "cd /opt/v2-Docker/v2 && echo -e '\\n[Restarting Admin Panel Docker containers...]\\n' && docker compose down && echo -e '\\n[Building containers with no cache...]\\n' && docker compose build --no-cache && echo -e '\\n[Starting containers in detached mode...]\\n' && docker compose up -d && echo -e '\\n[SUCCESS] Admin Panel restart completed successfully!\\n' || echo -e '\\n[ERROR] Error during restart process!\\n'" + else + echo "Manual password entry required." + echo "" + echo "Password: {self.password}" + echo "(Right-click to paste)" + echo "" + echo "After login, the following commands will be executed:" + echo "1. cd /opt/v2-Docker/v2" + echo "2. docker compose down" + echo "3. docker compose build --no-cache" + echo "4. docker compose up -d" + echo "" + ssh -o StrictHostKeyChecking=no {self.username}@{self.server} + fi +fi + +echo "" +echo "Press Enter to close..." +read +''' + + # Save bash script with Unix line endings + with tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False, newline='\n') as f: + f.write(bash_script) + bash_path = f.name + + # Convert to WSL path + bash_wsl_path = bash_path.replace('\\', '/').replace('C:', '/mnt/c') + + script_content = f"""@echo off +cls +REM Convert line endings and launch WSL with the bash script +wsl dos2unix {bash_wsl_path} 2>nul || wsl sed -i 's/\r$//' {bash_wsl_path} +wsl chmod +x {bash_wsl_path} +wsl bash {bash_wsl_path} + +REM Clean up temp file +del "{bash_path}" 2>nul +""" + return script_content + +# Test module +if __name__ == "__main__": + vps = VPSConnection() + print("VPS Connection Module") + print(f"Server: {vps.server}") + print(f"Username: {vps.username}") + print("\nSSH Script Preview:") + print(vps.create_ssh_script()[:300] + "...") \ No newline at end of file