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.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|