portacode 0.3.4.dev0__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 (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -1,17 +1,148 @@
1
1
  """System command handlers."""
2
2
 
3
+ import concurrent.futures
4
+ import getpass
5
+ import importlib.util
3
6
  import logging
4
7
  import os
5
8
  import platform
9
+ import shutil
10
+ import threading
6
11
  from pathlib import Path
7
12
  from typing import Any, Dict
8
13
 
14
+ from portacode import __version__
9
15
  import psutil
10
16
 
17
+ try:
18
+ from importlib import metadata as importlib_metadata
19
+ except ImportError: # pragma: no cover - py<3.8
20
+ import importlib_metadata
21
+
11
22
  from .base import SyncHandler
23
+ from .proxmox_infra import get_infra_snapshot
12
24
 
13
25
  logger = logging.getLogger(__name__)
14
26
 
27
+ # Global CPU monitoring
28
+ _cpu_percent = 0.0
29
+ _cpu_thread = None
30
+ _cpu_lock = threading.Lock()
31
+
32
+ def _cpu_monitor():
33
+ """Background thread to update CPU usage every 5 seconds."""
34
+ global _cpu_percent
35
+ while True:
36
+ _cpu_percent = psutil.cpu_percent(interval=5.0)
37
+
38
+ def _ensure_cpu_thread():
39
+ """Ensure CPU monitoring thread is running (singleton)."""
40
+ global _cpu_thread
41
+ with _cpu_lock:
42
+ if _cpu_thread is None or not _cpu_thread.is_alive():
43
+ _cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
44
+ _cpu_thread.start()
45
+
46
+
47
+ def _get_user_context() -> Dict[str, Any]:
48
+ """Gather current CLI user plus permission hints."""
49
+ context = {}
50
+ login_source = "os.getlogin"
51
+ try:
52
+ username = os.getlogin()
53
+ except Exception:
54
+ login_source = "getpass"
55
+ username = getpass.getuser()
56
+
57
+ context["username"] = username
58
+ context["username_source"] = login_source
59
+ context["home"] = str(Path.home())
60
+
61
+ uid = getattr(os, "getuid", None)
62
+ euid = getattr(os, "geteuid", None)
63
+ context["uid"] = uid() if uid else None
64
+ context["euid"] = euid() if euid else context["uid"]
65
+ if os.name == "nt":
66
+ try:
67
+ import ctypes
68
+
69
+ context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
70
+ except Exception:
71
+ context["is_root"] = None
72
+ else:
73
+ context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
74
+
75
+ context["has_sudo"] = shutil.which("sudo") is not None
76
+ context["sudo_user"] = os.environ.get("SUDO_USER")
77
+ context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
78
+ return context
79
+
80
+
81
+ def _get_playwright_info() -> Dict[str, Any]:
82
+ """Return Playwright presence, version, and browser binaries if available."""
83
+ result: Dict[str, Any] = {
84
+ "installed": False,
85
+ "version": None,
86
+ "browsers": {},
87
+ "error": None,
88
+ }
89
+
90
+ if importlib.util.find_spec("playwright") is None:
91
+ return result
92
+
93
+ result["installed"] = True
94
+ try:
95
+ result["version"] = importlib_metadata.version("playwright")
96
+ except Exception as exc:
97
+ logger.debug("Unable to read Playwright version metadata: %s", exc)
98
+
99
+ def _inspect_browsers() -> Dict[str, Any]:
100
+ from playwright.sync_api import sync_playwright
101
+
102
+ browsers_data: Dict[str, Any] = {}
103
+ with sync_playwright() as p:
104
+ for name in ("chromium", "firefox", "webkit"):
105
+ browser_type = getattr(p, name, None)
106
+ if browser_type is None:
107
+ continue
108
+ exec_path = getattr(browser_type, "executable_path", None)
109
+ browsers_data[name] = {
110
+ "available": bool(exec_path),
111
+ "executable_path": exec_path,
112
+ }
113
+ return browsers_data
114
+
115
+ try:
116
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
117
+ future = executor.submit(_inspect_browsers)
118
+ browsers = future.result(timeout=5)
119
+ result["browsers"] = browsers
120
+ except concurrent.futures.TimeoutError:
121
+ msg = "Playwright inspection timed out"
122
+ logger.warning(msg)
123
+ result["error"] = msg
124
+ except Exception as exc:
125
+ logger.warning("Playwright browser inspection failed: %s", exc)
126
+ result["error"] = str(exc)
127
+
128
+ return result
129
+
130
+
131
+ def _get_proxmox_info() -> Dict[str, Any]:
132
+ """Detect if the current host is a Proxmox node."""
133
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
134
+ release_file = Path("/etc/proxmox-release")
135
+ if release_file.exists():
136
+ info["is_proxmox_node"] = True
137
+ try:
138
+ info["version"] = release_file.read_text().strip()
139
+ except Exception:
140
+ info["version"] = None
141
+ elif Path("/etc/pve").exists():
142
+ info["is_proxmox_node"] = True
143
+ info["infra"] = get_infra_snapshot()
144
+ return info
145
+
15
146
 
16
147
  def _get_os_info() -> Dict[str, Any]:
17
148
  """Get operating system information with robust error handling."""
@@ -98,15 +229,13 @@ class SystemInfoHandler(SyncHandler):
98
229
  """Get system information including OS details."""
99
230
  logger.debug("Collecting system information...")
100
231
 
232
+ # Ensure CPU monitoring thread is running
233
+ _ensure_cpu_thread()
234
+
101
235
  # Collect basic system metrics
102
236
  info = {}
103
237
 
104
- try:
105
- info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
106
- logger.debug("CPU usage: %s%%", info["cpu_percent"])
107
- except Exception as e:
108
- logger.warning("Failed to get CPU info: %s", e)
109
- info["cpu_percent"] = 0.0
238
+ info["cpu_percent"] = _cpu_percent
110
239
 
111
240
  try:
112
241
  info["memory"] = psutil.virtual_memory()._asdict()
@@ -124,9 +253,14 @@ class SystemInfoHandler(SyncHandler):
124
253
 
125
254
  # Add OS information - this is critical for proper shell detection
126
255
  info["os_info"] = _get_os_info()
127
- logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
256
+ info["user_context"] = _get_user_context()
257
+ info["playwright"] = _get_playwright_info()
258
+ info["proxmox"] = _get_proxmox_info()
259
+ # logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
128
260
 
261
+ info["portacode_version"] = __version__
262
+
129
263
  return {
130
264
  "event": "system_info",
131
265
  "info": info,
132
- }
266
+ }
@@ -0,0 +1,389 @@
1
+ """Tab factory for creating TabInfo objects with appropriate content loading.
2
+
3
+ This module provides a centralized way to create tabs for different file types,
4
+ handling content loading, MIME type detection, and encoding appropriately.
5
+ """
6
+
7
+ import asyncio
8
+ import base64
9
+ import logging
10
+ import mimetypes
11
+ import os
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Optional, Dict, Any
15
+
16
+ from .project_state_handlers import TabInfo
17
+ from .project_state.utils import generate_content_hash
18
+ from .file_handlers import cache_content
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Maximum file size for text content loading (10MB)
23
+ MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
24
+
25
+ # Maximum file size for binary content loading (50MB)
26
+ MAX_BINARY_FILE_SIZE = 50 * 1024 * 1024
27
+
28
+ # Text file extensions that should be treated as code/text
29
+ TEXT_EXTENSIONS = {
30
+ # Programming languages
31
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.html', '.htm', '.css', '.scss', '.sass',
32
+ '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf',
33
+ '.java', '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.cs', '.php', '.rb',
34
+ '.go', '.rs', '.kt', '.swift', '.dart', '.scala', '.clj', '.hs', '.ml',
35
+ '.r', '.m', '.pl', '.lua', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat',
36
+ '.sql', '.graphql', '.proto', '.thrift',
37
+
38
+ # Markup and documentation
39
+ '.md', '.markdown', '.rst', '.txt', '.rtf', '.tex', '.latex',
40
+ '.adoc', '.asciidoc', '.org',
41
+
42
+ # Configuration and data
43
+ '.env', '.gitignore', '.gitattributes', '.dockerignore', '.editorconfig',
44
+ '.eslintrc', '.prettierrc', '.babelrc', '.tsconfig', '.package-lock',
45
+ '.requirements', '.pipfile', '.gemfile', '.makefile', '.cmake',
46
+
47
+ # Web technologies
48
+ '.vue', '.svelte', '.astro', '.ejs', '.hbs', '.handlebars', '.mustache',
49
+ '.pug', '.jade', '.haml', '.slim',
50
+
51
+ # Other text formats
52
+ '.log', '.diff', '.patch', '.csv', '.tsv', '.properties'
53
+ }
54
+
55
+ # Binary file extensions that should be treated as media
56
+ MEDIA_EXTENSIONS = {
57
+ # Images
58
+ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.svg',
59
+ '.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
60
+
61
+ # Audio
62
+ '.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
63
+
64
+ # Video
65
+ '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
66
+ '.ogv', '.ts', '.mts', '.m2ts'
67
+ }
68
+
69
+ # Extensions that should be ignored/not loaded
70
+ IGNORED_EXTENSIONS = {
71
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.cache', '.tmp', '.temp',
72
+ '.lock', '.pid', '.swp', '.swo', '.bak', '.orig', '.pyc', '.pyo', '.class',
73
+ '.o', '.obj', '.lib', '.a', '.jar', '.war', '.ear', '.zip', '.tar', '.gz',
74
+ '.7z', '.rar', '.deb', '.rpm', '.dmg', '.iso', '.img'
75
+ }
76
+
77
+
78
+ class TabFactory:
79
+ """Factory class for creating TabInfo objects with appropriate content."""
80
+
81
+ def __init__(self):
82
+ self.logger = logger.getChild(self.__class__.__name__)
83
+
84
+ async def create_file_tab(self, file_path: str, tab_id: Optional[str] = None) -> TabInfo:
85
+ """Create a file tab with content loaded based on file type.
86
+
87
+ Args:
88
+ file_path: Absolute path to the file
89
+ tab_id: Optional tab ID, will generate UUID if not provided
90
+
91
+ Returns:
92
+ TabInfo object with appropriate content loaded
93
+ """
94
+ if tab_id is None:
95
+ tab_id = str(uuid.uuid4())
96
+
97
+ file_path = Path(file_path)
98
+
99
+ # Basic tab info
100
+ tab_info = {
101
+ 'tab_id': tab_id,
102
+ 'tab_type': 'file',
103
+ 'title': file_path.name,
104
+ 'file_path': str(file_path),
105
+ 'content': None,
106
+ 'original_content': None,
107
+ 'modified_content': None,
108
+ 'is_dirty': False,
109
+ 'mime_type': None,
110
+ 'encoding': None,
111
+ 'metadata': {}
112
+ }
113
+
114
+ # Check if file exists
115
+ if not file_path.exists():
116
+ self.logger.warning(f"File does not exist: {file_path}")
117
+ tab_info['metadata']['error'] = 'File not found'
118
+ return TabInfo(**tab_info)
119
+
120
+ # Check if it's a file (not directory)
121
+ if not file_path.is_file():
122
+ self.logger.warning(f"Path is not a file: {file_path}")
123
+ tab_info['metadata']['error'] = 'Not a file'
124
+ return TabInfo(**tab_info)
125
+
126
+ # Get file info
127
+ try:
128
+ file_stat = file_path.stat()
129
+ file_size = file_stat.st_size
130
+ tab_info['metadata']['size'] = file_size
131
+ tab_info['metadata']['modified_time'] = file_stat.st_mtime
132
+ except OSError as e:
133
+ self.logger.error(f"Error getting file info for {file_path}: {e}")
134
+ tab_info['metadata']['error'] = f'Cannot access file: {e}'
135
+ return TabInfo(**tab_info)
136
+
137
+ # Determine file type and MIME type
138
+ extension = file_path.suffix.lower()
139
+ mime_type, _ = mimetypes.guess_type(str(file_path))
140
+ tab_info['mime_type'] = mime_type
141
+
142
+ # Determine how to handle the file
143
+ if extension in IGNORED_EXTENSIONS:
144
+ tab_info['metadata']['ignored'] = True
145
+ content = f"# Binary file not displayed\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
146
+ tab_info['content'] = content
147
+ content_hash = generate_content_hash(content)
148
+ tab_info['content_hash'] = content_hash
149
+ cache_content(content_hash, content)
150
+ return TabInfo(**tab_info)
151
+
152
+ # Handle different file types
153
+ if extension in TEXT_EXTENSIONS or self._is_text_file(file_path, mime_type):
154
+ await self._load_text_content(file_path, tab_info, file_size)
155
+ elif extension in MEDIA_EXTENSIONS or (mime_type and mime_type.startswith(('image/', 'audio/', 'video/'))):
156
+ await self._load_media_content(file_path, tab_info, file_size, mime_type)
157
+ else:
158
+ # Try to detect if it's a text file by sampling
159
+ if await self._detect_text_file(file_path):
160
+ await self._load_text_content(file_path, tab_info, file_size)
161
+ else:
162
+ await self._load_binary_content(file_path, tab_info, file_size)
163
+
164
+ return TabInfo(**tab_info)
165
+
166
+ async def create_diff_tab_with_title(self, file_path: str, original_content: str,
167
+ modified_content: str, title: str,
168
+ tab_id: Optional[str] = None,
169
+ diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
170
+ """Create a diff tab with a custom title for git timeline comparisons.
171
+
172
+ Args:
173
+ file_path: Path to the file being compared
174
+ original_content: Original version of the file
175
+ modified_content: Modified version of the file
176
+ title: Custom title for the diff tab
177
+ tab_id: Optional tab ID, will generate UUID if not provided
178
+ diff_details: Optional detailed diff information from diff-match-patch
179
+
180
+ Returns:
181
+ TabInfo object configured for diff viewing with custom title
182
+ """
183
+ if tab_id is None:
184
+ tab_id = str(uuid.uuid4())
185
+
186
+ metadata = {'diff_mode': True, 'timeline_diff': True}
187
+ if diff_details:
188
+ metadata['diff_details'] = diff_details
189
+
190
+ # Cache diff content
191
+ original_hash = generate_content_hash(original_content)
192
+ modified_hash = generate_content_hash(modified_content)
193
+ cache_content(original_hash, original_content)
194
+ cache_content(modified_hash, modified_content)
195
+
196
+ return TabInfo(
197
+ tab_id=tab_id,
198
+ tab_type='diff',
199
+ title=title,
200
+ file_path=str(file_path),
201
+ content=None, # Diff tabs don't use regular content
202
+ original_content=original_content,
203
+ modified_content=modified_content,
204
+ original_content_hash=original_hash,
205
+ modified_content_hash=modified_hash,
206
+ is_dirty=False,
207
+ mime_type=None,
208
+ encoding='utf-8',
209
+ metadata=metadata
210
+ )
211
+
212
+ async def create_untitled_tab(self, content: str = "", language: str = "plaintext",
213
+ tab_id: Optional[str] = None) -> TabInfo:
214
+ """Create an untitled tab for new content.
215
+
216
+ Args:
217
+ content: Initial content for the tab
218
+ language: Programming language for syntax highlighting
219
+ tab_id: Optional tab ID, will generate UUID if not provided
220
+
221
+ Returns:
222
+ TabInfo object for untitled content
223
+ """
224
+ if tab_id is None:
225
+ tab_id = str(uuid.uuid4())
226
+
227
+ # Cache untitled content
228
+ content_hash = generate_content_hash(content)
229
+ cache_content(content_hash, content)
230
+
231
+ return TabInfo(
232
+ tab_id=tab_id,
233
+ tab_type='untitled',
234
+ title="Untitled",
235
+ file_path=None,
236
+ content=content,
237
+ content_hash=content_hash,
238
+ original_content=None,
239
+ modified_content=None,
240
+ is_dirty=bool(content), # Dirty if has initial content
241
+ mime_type=None,
242
+ encoding='utf-8',
243
+ metadata={'language': language}
244
+ )
245
+
246
+ async def _load_text_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
247
+ """Load text content from file."""
248
+ if file_size > MAX_TEXT_FILE_SIZE:
249
+ content = f"# File too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Maximum size for text files: {self._format_file_size(MAX_TEXT_FILE_SIZE)}"
250
+ tab_info['content'] = content
251
+ content_hash = generate_content_hash(content)
252
+ tab_info['content_hash'] = content_hash
253
+ cache_content(content_hash, content)
254
+ tab_info['metadata']['truncated'] = True
255
+ return
256
+
257
+ try:
258
+ # Try different encodings
259
+ for encoding in ['utf-8', 'utf-16', 'latin-1', 'cp1252']:
260
+ try:
261
+ content = file_path.read_text(encoding=encoding)
262
+ tab_info['content'] = content
263
+ content_hash = generate_content_hash(content)
264
+ tab_info['content_hash'] = content_hash
265
+ cache_content(content_hash, content)
266
+ tab_info['encoding'] = encoding
267
+ self.logger.debug(f"Successfully loaded {file_path} with {encoding} encoding")
268
+ return
269
+ except UnicodeDecodeError:
270
+ continue
271
+
272
+ # If all encodings fail, treat as binary
273
+ self.logger.warning(f"Could not decode {file_path} as text, treating as binary")
274
+ await self._load_binary_content(file_path, tab_info, file_size)
275
+
276
+ except OSError as e:
277
+ self.logger.error(f"Error reading file {file_path}: {e}")
278
+ content = f"# Error reading file\n# {e}"
279
+ tab_info['content'] = content
280
+ content_hash = generate_content_hash(content)
281
+ tab_info['content_hash'] = content_hash
282
+ cache_content(content_hash, content)
283
+ tab_info['metadata']['error'] = str(e)
284
+
285
+ async def _load_media_content(self, file_path: Path, tab_info: Dict[str, Any],
286
+ file_size: int, mime_type: Optional[str]):
287
+ """Load media content as base64."""
288
+ if file_size > MAX_BINARY_FILE_SIZE:
289
+ content = f"# Media file too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
290
+ tab_info['content'] = content
291
+ content_hash = generate_content_hash(content)
292
+ tab_info['content_hash'] = content_hash
293
+ cache_content(content_hash, content)
294
+ tab_info['metadata']['too_large'] = True
295
+ return
296
+
297
+ try:
298
+ # Determine tab type based on MIME type
299
+ if mime_type:
300
+ if mime_type.startswith('image/'):
301
+ tab_info['tab_type'] = 'image'
302
+ elif mime_type.startswith('audio/'):
303
+ tab_info['tab_type'] = 'audio'
304
+ elif mime_type.startswith('video/'):
305
+ tab_info['tab_type'] = 'video'
306
+
307
+ # Read file as binary and encode as base64
308
+ binary_content = file_path.read_bytes()
309
+ base64_content = base64.b64encode(binary_content).decode('ascii')
310
+
311
+ tab_info['content'] = base64_content
312
+ content_hash = generate_content_hash(base64_content)
313
+ tab_info['content_hash'] = content_hash
314
+ cache_content(content_hash, base64_content)
315
+ tab_info['encoding'] = 'base64'
316
+ tab_info['metadata']['original_size'] = file_size
317
+
318
+ self.logger.debug(f"Loaded media file {file_path} as base64 ({file_size} bytes)")
319
+
320
+ except OSError as e:
321
+ self.logger.error(f"Error reading media file {file_path}: {e}")
322
+ content = f"# Error loading media file\n# {e}"
323
+ tab_info['content'] = content
324
+ content_hash = generate_content_hash(content)
325
+ tab_info['content_hash'] = content_hash
326
+ cache_content(content_hash, content)
327
+ tab_info['metadata']['error'] = str(e)
328
+
329
+ async def _load_binary_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
330
+ """Handle binary files that can't be displayed."""
331
+ content = f"# Binary file\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Type: {tab_info.get('mime_type', 'Unknown')}\n\n# This file contains binary data and cannot be displayed as text."
332
+ tab_info['content'] = content
333
+ content_hash = generate_content_hash(content)
334
+ tab_info['content_hash'] = content_hash
335
+ cache_content(content_hash, content)
336
+ tab_info['metadata']['binary'] = True
337
+ self.logger.debug(f"Marked {file_path} as binary file")
338
+
339
+ def _is_text_file(self, file_path: Path, mime_type: Optional[str]) -> bool:
340
+ """Check if a file should be treated as text based on MIME type."""
341
+ if not mime_type:
342
+ return False
343
+
344
+ return (mime_type.startswith('text/') or
345
+ mime_type in ['application/json', 'application/xml', 'application/javascript',
346
+ 'application/typescript', 'application/x-python', 'application/x-sh'])
347
+
348
+ async def _detect_text_file(self, file_path: Path) -> bool:
349
+ """Try to detect if a file is text by sampling the beginning."""
350
+ try:
351
+ # Read first 1024 bytes
352
+ with open(file_path, 'rb') as f:
353
+ sample = f.read(1024)
354
+
355
+ # Check for null bytes (strong indicator of binary)
356
+ if b'\x00' in sample:
357
+ return False
358
+
359
+ # Try to decode as UTF-8
360
+ try:
361
+ sample.decode('utf-8')
362
+ return True
363
+ except UnicodeDecodeError:
364
+ return False
365
+
366
+ except OSError:
367
+ return False
368
+
369
+ def _format_file_size(self, size_bytes: int) -> str:
370
+ """Format file size in human-readable format."""
371
+ if size_bytes < 1024:
372
+ return f"{size_bytes} B"
373
+ elif size_bytes < 1024 ** 2:
374
+ return f"{size_bytes / 1024:.1f} KB"
375
+ elif size_bytes < 1024 ** 3:
376
+ return f"{size_bytes / (1024 ** 2):.1f} MB"
377
+ else:
378
+ return f"{size_bytes / (1024 ** 3):.1f} GB"
379
+
380
+
381
+ # Global factory instance
382
+ _tab_factory = None
383
+
384
+ def get_tab_factory() -> TabFactory:
385
+ """Get the global tab factory instance."""
386
+ global _tab_factory
387
+ if _tab_factory is None:
388
+ _tab_factory = TabFactory()
389
+ return _tab_factory