portacode 1.3.32__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +119 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
  5. portacode/connection/handlers/__init__.py +10 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +307 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +140 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +51 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/top_level.txt +0 -0
@@ -9,6 +9,7 @@ import asyncio
9
9
  import hashlib
10
10
  import logging
11
11
  import os
12
+ import threading
12
13
  import time
13
14
  from pathlib import Path
14
15
  from typing import Any, Callable, Dict, List, Optional, Tuple, Union
@@ -36,30 +37,25 @@ except ImportError:
36
37
  DIFF_MATCH_PATCH_AVAILABLE = False
37
38
  diff_match_patch = None
38
39
 
39
- # Import Pygments with fallback
40
- try:
41
- from pygments import highlight
42
- from pygments.lexers import get_lexer_for_filename, get_lexer_by_name
43
- from pygments.formatters import HtmlFormatter
44
- from pygments.util import ClassNotFound
45
- PYGMENTS_AVAILABLE = True
46
- except ImportError:
47
- PYGMENTS_AVAILABLE = False
48
- highlight = None
49
- get_lexer_for_filename = None
50
- get_lexer_by_name = None
51
- HtmlFormatter = None
52
- ClassNotFound = Exception
40
+ from portacode.utils import diff_renderer
41
+
42
+
43
+ _GIT_PROCESS_REF_COUNTS: Dict[int, int] = {}
44
+ _GIT_PROCESS_LOCK = threading.Lock()
53
45
 
54
46
 
55
47
  class GitManager:
56
48
  """Manages Git operations for project state."""
57
49
 
58
- def __init__(self, project_path: str, change_callback: Optional[Callable] = None):
50
+ def __init__(self, project_path: str, change_callback: Optional[Callable] = None, owner_session_id: Optional[str] = None):
59
51
  self.project_path = project_path
60
52
  self.repo: Optional[Repo] = None
61
53
  self.is_git_repo = False
62
54
  self._change_callback = change_callback
55
+ self.owner_session_id = owner_session_id
56
+
57
+ # Track git processes spawned by this specific GitManager instance
58
+ self._tracked_git_processes = set()
63
59
 
64
60
  # Periodic monitoring attributes
65
61
  self._monitoring_task: Optional[asyncio.Task] = None
@@ -84,9 +80,33 @@ class GitManager:
84
80
  self.repo = Repo(self.project_path)
85
81
  self.is_git_repo = True
86
82
  logger.info("Initialized Git repo for project: %s", self.project_path)
83
+
84
+ # Track initial git processes after repo creation
85
+ self._track_current_git_processes()
86
+
87
87
  except (InvalidGitRepositoryError, Exception) as e:
88
88
  logger.debug("Not a Git repository or Git error: %s", e)
89
89
 
90
+ def _track_current_git_processes(self):
91
+ """Track currently running git cat-file processes for this repo."""
92
+ try:
93
+ import psutil
94
+
95
+ for proc in psutil.process_iter(['pid', 'cmdline', 'cwd']):
96
+ try:
97
+ cmdline = proc.info['cmdline']
98
+ if (cmdline and len(cmdline) >= 2 and
99
+ 'git' in cmdline[0] and 'cat-file' in cmdline[1] and
100
+ proc.info['cwd'] == self.project_path):
101
+ pid = proc.pid
102
+ self._tracked_git_processes.add(pid)
103
+ with _GIT_PROCESS_LOCK:
104
+ _GIT_PROCESS_REF_COUNTS[pid] = _GIT_PROCESS_REF_COUNTS.get(pid, 0) + 1
105
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
106
+ continue
107
+ except Exception as e:
108
+ logger.debug("Error tracking git processes: %s", e)
109
+
90
110
  def reinitialize(self):
91
111
  """Reinitialize git repo detection (useful when .git directory is created after initialization)."""
92
112
  logger.info("Reinitializing git repo detection for: %s", self.project_path)
@@ -634,543 +654,9 @@ class GitManager:
634
654
  logger.error("Error computing diff details: %s", e)
635
655
  return None
636
656
 
637
- def _get_pygments_lexer(self, file_path: str) -> Optional[object]:
638
- """Get Pygments lexer for a file path using built-in detection."""
639
- if not PYGMENTS_AVAILABLE:
640
- return None
641
-
642
- try:
643
- # Use Pygments' built-in filename detection
644
- lexer = get_lexer_for_filename(file_path)
645
- return lexer
646
- except ClassNotFound:
647
- # If no lexer found, return None (will fall back to plain text)
648
- logger.debug("No Pygments lexer found for file: %s", file_path)
649
- return None
650
- except Exception as e:
651
- logger.debug("Error getting Pygments lexer: %s", e)
652
- return None
653
-
654
657
  def _generate_html_diff(self, original_content: str, modified_content: str, file_path: str) -> Optional[Dict[str, str]]:
655
- """Generate unified HTML diff with intra-line highlighting. Returns both minimal and full context versions."""
656
- if not PYGMENTS_AVAILABLE:
657
- logger.debug("Pygments not available for HTML diff generation")
658
- return None
659
-
660
- # Add performance safeguards to prevent blocking
661
- max_content_size = 500000 # 500KB max per file (more reasonable)
662
- max_lines = 5000 # Max 5000 lines per file (more reasonable for real projects)
663
-
664
- original_line_count = original_content.count('\n')
665
- modified_line_count = modified_content.count('\n')
666
- max_line_count = max(original_line_count, modified_line_count)
667
-
668
- # Check if file is too large for full processing
669
- is_large_file = (len(original_content) > max_content_size or
670
- len(modified_content) > max_content_size or
671
- max_line_count > max_lines)
672
-
673
- if is_large_file:
674
- logger.warning(f"Large file detected for diff generation: {file_path} ({max_line_count} lines)")
675
- # Generate simplified diff without syntax highlighting for large files
676
- return self._generate_simple_diff_html(original_content, modified_content, file_path)
677
-
678
- try:
679
- import difflib
680
- import time
681
-
682
- start_time = time.time()
683
- timeout_seconds = 5 # 5 second timeout
684
-
685
- # Get line-based diff using Python's difflib (similar to git diff)
686
- original_lines = original_content.splitlines(keepends=True)
687
- modified_lines = modified_content.splitlines(keepends=True)
688
-
689
- # Check timeout
690
- if time.time() - start_time > timeout_seconds:
691
- logger.warning(f"Diff generation timeout for {file_path}")
692
- return None
693
-
694
- # Generate both minimal and full diff with performance safeguards
695
- minimal_diff_lines = list(difflib.unified_diff(
696
- original_lines,
697
- modified_lines,
698
- fromfile='a/' + os.path.basename(file_path),
699
- tofile='b/' + os.path.basename(file_path),
700
- lineterm='',
701
- n=3 # 3 lines of context (default)
702
- ))
703
-
704
- # Check timeout
705
- if time.time() - start_time > timeout_seconds:
706
- logger.warning(f"Diff generation timeout for {file_path}")
707
- return None
708
-
709
- # Generate full context diff only if file is small enough
710
- if len(original_lines) + len(modified_lines) < 2000: # Increased threshold for better UX
711
- full_diff_lines = list(difflib.unified_diff(
712
- original_lines,
713
- modified_lines,
714
- fromfile='a/' + os.path.basename(file_path),
715
- tofile='b/' + os.path.basename(file_path),
716
- lineterm='',
717
- n=len(original_lines) + len(modified_lines) # Show all lines
718
- ))
719
- else:
720
- full_diff_lines = minimal_diff_lines # Use minimal for large files
721
-
722
- # Parse diffs (simplified but restored)
723
- minimal_parsed = self._parse_unified_diff_simple(minimal_diff_lines)
724
- full_parsed = self._parse_unified_diff_simple(full_diff_lines)
725
-
726
- # Check timeout
727
- if time.time() - start_time > timeout_seconds:
728
- logger.warning(f"Diff generation timeout for {file_path}")
729
- return None
730
-
731
- # Generate HTML for both versions
732
- minimal_html = self._generate_diff_html(minimal_parsed, file_path, 'minimal')
733
- full_html = self._generate_diff_html(full_parsed, file_path, 'full')
734
-
735
- return {
736
- 'minimal': minimal_html,
737
- 'full': full_html
738
- }
739
-
740
- except Exception as e:
741
- logger.error("Error generating HTML diff: %s", e)
742
- return None
743
-
744
- def _generate_simple_diff_html(self, original_content: str, modified_content: str, file_path: str) -> Dict[str, str]:
745
- """Generate simplified diff HTML for large files without syntax highlighting."""
746
- try:
747
- import difflib
748
-
749
- # Get line-based diff using Python's difflib
750
- original_lines = original_content.splitlines(keepends=True)
751
- modified_lines = modified_content.splitlines(keepends=True)
752
-
753
- # Generate minimal diff only for large files
754
- diff_lines = list(difflib.unified_diff(
755
- original_lines,
756
- modified_lines,
757
- fromfile='a/' + os.path.basename(file_path),
758
- tofile='b/' + os.path.basename(file_path),
759
- lineterm='',
760
- n=3 # Keep minimal context
761
- ))
762
-
763
- # Parse with simple parser (no syntax highlighting)
764
- parsed = self._parse_unified_diff_simple(diff_lines)
765
-
766
- # Limit to reasonable size for large files
767
- max_simple_diff_lines = 500
768
- if len(parsed) > max_simple_diff_lines:
769
- parsed = parsed[:max_simple_diff_lines]
770
- logger.info(f"Truncated large diff to {max_simple_diff_lines} lines for {file_path}")
771
-
772
- # Generate HTML without syntax highlighting but with good UI
773
- html = self._generate_simple_diff_html_content(parsed, file_path)
774
-
775
- return {
776
- 'minimal': html,
777
- 'full': html # Same for both to keep UI consistent
778
- }
779
-
780
- except Exception as e:
781
- logger.error(f"Error generating simple diff HTML: {e}")
782
- return {
783
- 'minimal': self._generate_fallback_diff_html(file_path),
784
- 'full': self._generate_fallback_diff_html(file_path)
785
- }
786
-
787
- def _generate_simple_diff_html_content(self, parsed_diff: List[Dict], file_path: str) -> str:
788
- """Generate simple HTML diff content without syntax highlighting but with good UI."""
789
- html_parts = []
790
- html_parts.append('<div class="unified-diff-container" data-view-mode="minimal">')
791
-
792
- # Add stats header (no toggle for large files to keep it simple)
793
- line_additions = sum(1 for line in parsed_diff if line['type'] == 'add')
794
- line_deletions = sum(1 for line in parsed_diff if line['type'] == 'delete')
795
-
796
- html_parts.append(f'''
797
- <div class="diff-stats">
798
- <div class="diff-stats-left">
799
- <span class="additions">+{line_additions}</span>
800
- <span class="deletions">-{line_deletions}</span>
801
- <span class="file-path">{os.path.basename(file_path)} (Large file - simplified view)</span>
802
- </div>
803
- </div>
804
- ''')
805
-
806
- # Generate content without syntax highlighting
807
- html_parts.append('<div class="diff-content">')
808
- html_parts.append('<table class="diff-table">')
809
-
810
- for line_info in parsed_diff:
811
- if line_info['type'] == 'header':
812
- continue # Skip headers
813
-
814
- line_type = line_info['type']
815
- old_line_num = line_info.get('old_line_num', '')
816
- new_line_num = line_info.get('new_line_num', '')
817
- content = line_info['content']
818
-
819
- # Simple HTML escaping without syntax highlighting
820
- escaped_content = self._escape_html(content)
821
-
822
- row_class = f'diff-line diff-{line_type}'
823
- html_parts.append(f'''
824
- <tr class="{row_class}">
825
- <td class="line-num old-line-num">{old_line_num}</td>
826
- <td class="line-num new-line-num">{new_line_num}</td>
827
- <td class="line-content">{escaped_content}</td>
828
- </tr>
829
- ''')
830
-
831
- html_parts.append('</table>')
832
- html_parts.append('</div>')
833
- html_parts.append('</div>')
834
-
835
- return ''.join(html_parts)
836
-
837
- def _generate_fallback_diff_html(self, file_path: str) -> str:
838
- """Generate minimal fallback HTML when all else fails."""
839
- return f'''
840
- <div class="unified-diff-container" data-view-mode="minimal">
841
- <div class="diff-stats">
842
- <div class="diff-stats-left">
843
- <span class="file-path">{os.path.basename(file_path)} (Diff unavailable)</span>
844
- </div>
845
- </div>
846
- <div class="diff-content">
847
- <div style="padding: 2rem; text-align: center; color: var(--text-secondary);">
848
- <i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem;"></i>
849
- <p>Diff view unavailable for this file</p>
850
- <p style="font-size: 0.9rem;">File may be too large or binary</p>
851
- </div>
852
- </div>
853
- </div>
854
- '''
855
-
856
- def _parse_unified_diff_simple(self, diff_lines):
857
- """Simple unified diff parser without intra-line highlighting for better performance."""
858
- parsed = []
859
- old_line_num = 0
860
- new_line_num = 0
861
-
862
- for line in diff_lines:
863
- if line.startswith('@@'):
864
- # Parse hunk header to get line numbers
865
- import re
866
- match = re.match(r'@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@', line)
867
- if match:
868
- old_line_num = int(match.group(1)) - 1
869
- new_line_num = int(match.group(2)) - 1
870
-
871
- parsed.append({
872
- 'type': 'header',
873
- 'content': line,
874
- 'old_line_num': '',
875
- 'new_line_num': ''
876
- })
877
- elif line.startswith('---') or line.startswith('+++'):
878
- # Skip diff file headers (--- a/file, +++ b/file)
879
- parsed.append({
880
- 'type': 'header',
881
- 'content': line,
882
- 'old_line_num': '',
883
- 'new_line_num': ''
884
- })
885
- elif line.startswith('-'):
886
- old_line_num += 1
887
- parsed.append({
888
- 'type': 'delete',
889
- 'old_line_num': old_line_num,
890
- 'new_line_num': '',
891
- 'content': line
892
- })
893
- elif line.startswith('+'):
894
- new_line_num += 1
895
- parsed.append({
896
- 'type': 'add',
897
- 'old_line_num': '',
898
- 'new_line_num': new_line_num,
899
- 'content': line
900
- })
901
- elif line.startswith(' '):
902
- old_line_num += 1
903
- new_line_num += 1
904
- parsed.append({
905
- 'type': 'context',
906
- 'old_line_num': old_line_num,
907
- 'new_line_num': new_line_num,
908
- 'content': line
909
- })
910
-
911
- return parsed
912
-
913
- def _generate_diff_html(self, parsed_diff: List[Dict], file_path: str, view_mode: str) -> str:
914
- """Generate HTML for a parsed diff."""
915
- # Limit diff size to prevent performance issues
916
- max_diff_lines = 1000 # Increased limit for better UX
917
- if len(parsed_diff) > max_diff_lines:
918
- logger.warning(f"Diff too large, truncating: {file_path} ({len(parsed_diff)} lines)")
919
- parsed_diff = parsed_diff[:max_diff_lines]
920
-
921
- # Get Pygments lexer for syntax highlighting
922
- lexer = self._get_pygments_lexer(file_path)
923
-
924
- # Pre-highlight all unique lines for better context-aware syntax highlighting
925
- unique_lines = set()
926
- for line_info in parsed_diff:
927
- if line_info['type'] != 'header' and 'content' in line_info:
928
- content = line_info['content']
929
- if content and content[0] in '+- ':
930
- clean_line = content[1:].rstrip('\n')
931
- if clean_line.strip():
932
- unique_lines.add(clean_line)
933
-
934
- # Pre-highlight all unique lines as a batch for better context
935
- highlighted_cache = {}
936
- if lexer and unique_lines:
937
- try:
938
- # Combine all lines to give Pygments better context
939
- combined_content = '\n'.join(unique_lines)
940
- combined_highlighted = highlight(combined_content, lexer, HtmlFormatter(nowrap=True, noclasses=False, style='monokai'))
941
-
942
- # Split back into individual lines
943
- highlighted_lines = combined_highlighted.split('\n')
944
- unique_lines_list = list(unique_lines)
945
-
946
- for i, line in enumerate(unique_lines_list):
947
- if i < len(highlighted_lines):
948
- highlighted_cache[line] = highlighted_lines[i]
949
- except Exception as e:
950
- logger.debug(f"Error in batch syntax highlighting: {e}")
951
- highlighted_cache = {}
952
-
953
- # Build HTML
954
- html_parts = []
955
- html_parts.append(f'<div class="unified-diff-container" data-view-mode="{view_mode}">')
956
-
957
- # Add stats header with toggle
958
- line_additions = sum(1 for line in parsed_diff if line['type'] == 'add')
959
- line_deletions = sum(1 for line in parsed_diff if line['type'] == 'delete')
960
-
961
- html_parts.append(f'''
962
- <div class="diff-stats">
963
- <div class="diff-stats-left">
964
- <span class="additions">+{line_additions}</span>
965
- <span class="deletions">-{line_deletions}</span>
966
- <span class="file-path">{os.path.basename(file_path)}</span>
967
- </div>
968
- <div class="diff-stats-right">
969
- <button class="diff-toggle-btn" data-current-mode="{view_mode}">
970
- <i class="fas fa-eye"></i>
971
- <span class="toggle-text"></span>
972
- </button>
973
- </div>
974
- </div>
975
- ''')
976
-
977
- # Generate unified diff view
978
- html_parts.append('<div class="diff-content">')
979
- html_parts.append('<table class="diff-table">')
980
-
981
- for line_info in parsed_diff:
982
- if line_info['type'] == 'header':
983
- continue # Skip all diff headers including --- and +++ lines
984
-
985
- line_type = line_info['type']
986
- old_line_num = line_info.get('old_line_num', '')
987
- new_line_num = line_info.get('new_line_num', '')
988
- content = line_info['content']
989
-
990
- # Apply syntax highlighting using pre-highlighted cache for better accuracy
991
- if content and content[0] in '+- ':
992
- prefix = content[0] if content[0] in '+-' else ' '
993
- clean_content = content[1:].rstrip('\n')
994
-
995
- # Use pre-highlighted cache if available
996
- if clean_content.strip() and clean_content in highlighted_cache:
997
- final_content = prefix + highlighted_cache[clean_content]
998
- elif clean_content.strip():
999
- # Fallback to individual line highlighting
1000
- try:
1001
- highlighted = highlight(clean_content, lexer, HtmlFormatter(nowrap=True, noclasses=False, style='monokai'))
1002
- final_content = prefix + highlighted
1003
- except Exception as e:
1004
- logger.debug("Error applying syntax highlighting: %s", e)
1005
- final_content = self._escape_html(content)
1006
- else:
1007
- final_content = self._escape_html(content)
1008
- else:
1009
- final_content = self._escape_html(content)
1010
-
1011
- # CSS classes for different line types
1012
- row_class = f'diff-line diff-{line_type}'
1013
-
1014
- html_parts.append(f'''
1015
- <tr class="{row_class}">
1016
- <td class="line-num old-line-num">{old_line_num}</td>
1017
- <td class="line-num new-line-num">{new_line_num}</td>
1018
- <td class="line-content">{final_content}</td>
1019
- </tr>
1020
- ''')
1021
-
1022
- html_parts.append('</table>')
1023
- html_parts.append('</div>')
1024
- html_parts.append('</div>')
1025
-
1026
- return ''.join(html_parts)
1027
-
1028
- def _parse_unified_diff_with_intraline(self, diff_lines, original_lines, modified_lines):
1029
- """Parse unified diff and add intra-line character highlighting."""
1030
- parsed = []
1031
- old_line_num = 0
1032
- new_line_num = 0
1033
-
1034
- pending_deletes = []
1035
- pending_adds = []
1036
-
1037
- def flush_pending():
1038
- """Process pending delete/add pairs for intra-line highlighting."""
1039
- if pending_deletes and pending_adds:
1040
- # Apply intra-line highlighting to delete/add pairs
1041
- for i, (del_line, add_line) in enumerate(zip(pending_deletes, pending_adds)):
1042
- del_content = del_line['content'][1:] # Remove '-' prefix
1043
- add_content = add_line['content'][1:] # Remove '+' prefix
1044
-
1045
- del_highlighted, add_highlighted = self._generate_intraline_diff(del_content, add_content)
1046
-
1047
- # Update the parsed lines with intra-line highlighting
1048
- del_line['intraline_html'] = '-' + del_highlighted
1049
- add_line['intraline_html'] = '+' + add_highlighted
1050
-
1051
- parsed.append(del_line)
1052
- parsed.append(add_line)
1053
-
1054
- # Handle remaining unmatched deletes/adds
1055
- for del_line in pending_deletes[len(pending_adds):]:
1056
- parsed.append(del_line)
1057
- for add_line in pending_adds[len(pending_deletes):]:
1058
- parsed.append(add_line)
1059
- else:
1060
- # No pairs to highlight, just add them as-is
1061
- parsed.extend(pending_deletes)
1062
- parsed.extend(pending_adds)
1063
-
1064
- pending_deletes.clear()
1065
- pending_adds.clear()
1066
-
1067
- for line in diff_lines:
1068
- if line.startswith('@@'):
1069
- # Flush any pending changes before hunk header
1070
- flush_pending()
1071
-
1072
- # Parse hunk header to get line numbers
1073
- import re
1074
- match = re.match(r'@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@', line)
1075
- if match:
1076
- old_line_num = int(match.group(1)) - 1
1077
- new_line_num = int(match.group(2)) - 1
1078
-
1079
- parsed.append({
1080
- 'type': 'header',
1081
- 'content': line,
1082
- 'old_line_num': '',
1083
- 'new_line_num': ''
1084
- })
1085
- elif line.startswith('---') or line.startswith('+++'):
1086
- # Skip diff file headers (--- a/file, +++ b/file)
1087
- parsed.append({
1088
- 'type': 'header',
1089
- 'content': line,
1090
- 'old_line_num': '',
1091
- 'new_line_num': ''
1092
- })
1093
- elif line.startswith('-'):
1094
- pending_deletes.append({
1095
- 'type': 'delete',
1096
- 'old_line_num': old_line_num + 1,
1097
- 'new_line_num': '',
1098
- 'content': line
1099
- })
1100
- old_line_num += 1
1101
- elif line.startswith('+'):
1102
- pending_adds.append({
1103
- 'type': 'add',
1104
- 'old_line_num': '',
1105
- 'new_line_num': new_line_num + 1,
1106
- 'content': line
1107
- })
1108
- new_line_num += 1
1109
- elif line.startswith(' '):
1110
- # Flush pending changes before context line
1111
- flush_pending()
1112
-
1113
- old_line_num += 1
1114
- new_line_num += 1
1115
- parsed.append({
1116
- 'type': 'context',
1117
- 'old_line_num': old_line_num,
1118
- 'new_line_num': new_line_num,
1119
- 'content': line
1120
- })
1121
- elif line.startswith('---') or line.startswith('+++'):
1122
- parsed.append({
1123
- 'type': 'header',
1124
- 'content': line,
1125
- 'old_line_num': '',
1126
- 'new_line_num': ''
1127
- })
1128
-
1129
- # Flush any remaining pending changes
1130
- flush_pending()
1131
-
1132
- return parsed
1133
-
1134
- def _generate_intraline_diff(self, old_text: str, new_text: str) -> Tuple[str, str]:
1135
- """Generate intra-line character-level diff highlighting."""
1136
- # Temporarily disable intraline highlighting to fix performance issues
1137
- return self._escape_html(old_text), self._escape_html(new_text)
1138
-
1139
- if not DIFF_MATCH_PATCH_AVAILABLE:
1140
- return self._escape_html(old_text), self._escape_html(new_text)
1141
-
1142
- try:
1143
- dmp = diff_match_patch()
1144
- diffs = dmp.diff_main(old_text, new_text)
1145
- dmp.diff_cleanupSemantic(diffs)
1146
-
1147
- old_parts = []
1148
- new_parts = []
1149
-
1150
- for op, text in diffs:
1151
- escaped_text = self._escape_html(text)
1152
-
1153
- if op == 0: # EQUAL
1154
- old_parts.append(escaped_text)
1155
- new_parts.append(escaped_text)
1156
- elif op == -1: # DELETE
1157
- old_parts.append(f'<span class="intraline-delete">{escaped_text}</span>')
1158
- elif op == 1: # INSERT
1159
- new_parts.append(f'<span class="intraline-add">{escaped_text}</span>')
1160
-
1161
- return ''.join(old_parts), ''.join(new_parts)
1162
-
1163
- except Exception as e:
1164
- logger.debug("Error generating intra-line diff: %s", e)
1165
- return self._escape_html(old_text), self._escape_html(new_text)
1166
-
1167
- def _escape_html(self, text: str) -> str:
1168
- """Escape HTML special characters."""
1169
- return (text.replace('&', '&amp;')
1170
- .replace('<', '&lt;')
1171
- .replace('>', '&gt;')
1172
- .replace('"', '&quot;')
1173
- .replace("'", '&#x27;'))
658
+ """Proxy to the stateless diff renderer so tab views keep their HTML output."""
659
+ return diff_renderer.generate_html_diff(original_content, modified_content, file_path)
1174
660
 
1175
661
  def get_head_commit_hash(self) -> Optional[str]:
1176
662
  """Get the hash of the HEAD commit."""
@@ -1476,8 +962,8 @@ class GitManager:
1476
962
  # For submodules, use git add directly to stage only the submodule reference
1477
963
  self.repo.git.add(rel_path)
1478
964
  else:
1479
- # For regular files, use the index method
1480
- self.repo.index.add([rel_path])
965
+ # Use git add -A on the specific file to handle deletions as well
966
+ self.repo.git.add('-A', '--', rel_path)
1481
967
 
1482
968
  logger.info("Successfully staged file: %s", rel_path)
1483
969
  return True
@@ -1539,8 +1025,8 @@ class GitManager:
1539
1025
  has_head = False
1540
1026
 
1541
1027
  if has_head:
1542
- # Restore the file from HEAD - for repos with commits
1543
- self.repo.git.restore(rel_path)
1028
+ # Restore both index and working tree from HEAD
1029
+ self.repo.git.restore('--staged', '--worktree', '--', rel_path)
1544
1030
  logger.info("Successfully reverted file: %s", rel_path)
1545
1031
  else:
1546
1032
  # For repositories with no commits, we can't revert to HEAD
@@ -1584,10 +1070,10 @@ class GitManager:
1584
1070
  for submodule_path in submodule_paths:
1585
1071
  self.repo.git.add(submodule_path)
1586
1072
 
1587
- # Stage regular files using index.add for efficiency
1073
+ # Stage regular files using git add -A to capture deletions
1588
1074
  if rel_paths:
1589
1075
  logger.info("Staging regular files: %s", rel_paths)
1590
- self.repo.index.add(rel_paths)
1076
+ self.repo.git.add('-A', '--', *rel_paths)
1591
1077
 
1592
1078
  logger.info("Successfully staged %d files (%d submodules, %d regular)",
1593
1079
  len(file_paths), len(submodule_paths), len(rel_paths))
@@ -1672,7 +1158,7 @@ class GitManager:
1672
1158
  regular_files.append(rel_paths[i])
1673
1159
 
1674
1160
  if regular_files:
1675
- self.repo.git.restore(*regular_files)
1161
+ self.repo.git.restore('--staged', '--worktree', '--', *regular_files)
1676
1162
  logger.info("Successfully reverted %d files", len(regular_files))
1677
1163
  else:
1678
1164
  # For repositories with no commits, remove files to "revert" them
@@ -1809,7 +1295,7 @@ class GitManager:
1809
1295
  logger.debug("Git monitoring already running for %s", self.project_path)
1810
1296
  return
1811
1297
 
1812
- logger.info("Starting periodic git monitoring for %s", self.project_path)
1298
+ logger.info("Starting periodic git monitoring for %s (session=%s)", self.project_path, self.owner_session_id)
1813
1299
  self._monitoring_enabled = True
1814
1300
 
1815
1301
  # Initialize cached status
@@ -1822,10 +1308,23 @@ class GitManager:
1822
1308
  """Stop periodic monitoring of git status changes."""
1823
1309
  self._monitoring_enabled = False
1824
1310
 
1825
- if self._monitoring_task and not self._monitoring_task.done():
1311
+ task = self._monitoring_task
1312
+ if task and not task.done():
1826
1313
  logger.info("Stopping periodic git monitoring for %s", self.project_path)
1827
- self._monitoring_task.cancel()
1828
- self._monitoring_task = None
1314
+ task.cancel()
1315
+ try:
1316
+ loop = asyncio.get_event_loop()
1317
+ if loop.is_running():
1318
+ loop.create_task(self._await_monitor_stop(task))
1319
+ except Exception:
1320
+ pass
1321
+ self._monitoring_task = None
1322
+
1323
+ async def _await_monitor_stop(self, task):
1324
+ try:
1325
+ await task
1326
+ except asyncio.CancelledError:
1327
+ logger.debug("Git monitoring task cancelled for %s", self.project_path)
1829
1328
 
1830
1329
  def _update_cached_status(self):
1831
1330
  """Update cached git status for comparison."""
@@ -1844,16 +1343,17 @@ class GitManager:
1844
1343
  """Monitor git changes periodically and trigger callback when changes are detected."""
1845
1344
  try:
1846
1345
  while self._monitoring_enabled:
1847
- await asyncio.sleep(1.0) # Check every 1000ms
1848
-
1346
+ await asyncio.sleep(5.0) # Check every 5000ms
1347
+
1849
1348
  if not self._monitoring_enabled or not self.is_git_repo:
1850
1349
  break
1851
-
1350
+
1852
1351
  try:
1853
- # Get current status
1854
- current_status_summary = self.get_status_summary()
1855
- current_detailed_status = self.get_detailed_status()
1856
- current_branch = self.get_branch_name()
1352
+ # Get current status - run in executor to avoid blocking event loop
1353
+ loop = asyncio.get_event_loop()
1354
+ current_status_summary = await loop.run_in_executor(None, self.get_status_summary)
1355
+ current_detailed_status = await loop.run_in_executor(None, self.get_detailed_status)
1356
+ current_branch = await loop.run_in_executor(None, self.get_branch_name)
1857
1357
 
1858
1358
  # Compare with cached status
1859
1359
  status_changed = (
@@ -1862,8 +1362,10 @@ class GitManager:
1862
1362
  self._detailed_status_changed(current_detailed_status, self._cached_detailed_status)
1863
1363
  )
1864
1364
 
1365
+ if not self._monitoring_enabled:
1366
+ continue
1865
1367
  if status_changed:
1866
- logger.info("Git status change detected for %s", self.project_path)
1368
+ logger.info("Git status change detected for %s (session=%s)", self.project_path, self.owner_session_id)
1867
1369
  logger.debug("Status summary: %s -> %s", self._cached_status_summary, current_status_summary)
1868
1370
  logger.debug("Branch: %s -> %s", self._cached_branch, current_branch)
1869
1371
 
@@ -1931,5 +1433,70 @@ class GitManager:
1931
1433
 
1932
1434
  def cleanup(self):
1933
1435
  """Cleanup resources when GitManager is being destroyed."""
1934
- logger.info("Cleaning up GitManager for %s", self.project_path)
1935
- self.stop_periodic_monitoring()
1436
+ logger.info("Cleaning up GitManager for %s (session=%s)", self.project_path, self.owner_session_id)
1437
+ self.stop_periodic_monitoring()
1438
+
1439
+ # CRITICAL: Close GitPython repo to cleanup git cat-file processes
1440
+ if self.repo:
1441
+ try:
1442
+ # Force cleanup of git command processes
1443
+ if hasattr(self.repo.git, 'clear_cache'):
1444
+ self.repo.git.clear_cache()
1445
+ self.repo.close()
1446
+ logger.info("Successfully closed GitPython repo for %s", self.project_path)
1447
+ except Exception as e:
1448
+ logger.warning("Error during git repo cleanup for %s: %s", self.project_path, e)
1449
+ finally:
1450
+ self.repo = None
1451
+
1452
+ # Clean up only the git processes tracked by this specific GitManager instance
1453
+ try:
1454
+ import psutil
1455
+
1456
+ killed_count = 0
1457
+ for pid in list(self._tracked_git_processes):
1458
+ should_terminate = False
1459
+ with _GIT_PROCESS_LOCK:
1460
+ if pid in _GIT_PROCESS_REF_COUNTS:
1461
+ _GIT_PROCESS_REF_COUNTS[pid] -= 1
1462
+ if _GIT_PROCESS_REF_COUNTS[pid] <= 0:
1463
+ should_terminate = True
1464
+ _GIT_PROCESS_REF_COUNTS.pop(pid, None)
1465
+ else:
1466
+ should_terminate = True
1467
+
1468
+ if should_terminate:
1469
+ try:
1470
+ proc = psutil.Process(pid)
1471
+ if proc.is_running():
1472
+ proc.terminate()
1473
+ killed_count += 1
1474
+ logger.info("Terminated tracked git process %d", pid)
1475
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
1476
+ pass
1477
+
1478
+ self._tracked_git_processes.discard(pid)
1479
+
1480
+ if killed_count > 0:
1481
+ logger.info("Cleaned up %d tracked git processes for session", killed_count)
1482
+
1483
+ except Exception as e:
1484
+ logger.warning("Error cleaning up tracked git processes: %s", e)
1485
+
1486
+ # Clear the tracking set
1487
+ self._tracked_git_processes.clear()
1488
+
1489
+ def get_tracked_git_process_count(self) -> int:
1490
+ """Return how many git helper processes this manager is tracking."""
1491
+ return len(self._tracked_git_processes)
1492
+
1493
+ def get_diagnostics(self) -> Dict[str, Any]:
1494
+ """Expose lightweight stats for health monitoring."""
1495
+ return {
1496
+ "project_path": self.project_path,
1497
+ "is_git_repo": self.is_git_repo,
1498
+ "tracked_git_processes": self.get_tracked_git_process_count(),
1499
+ "monitoring_enabled": self._monitoring_enabled,
1500
+ "monitoring_task_active": bool(self._monitoring_task and not self._monitoring_task.done()),
1501
+ "session_id": self.owner_session_id,
1502
+ }