cct-cli 0.7.9.0__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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""CAT browser/ — PreviewController: the one owner of preview lifecycle.
|
|
2
|
+
|
|
3
|
+
▷ click ─► start_for_file(index.html)
|
|
4
|
+
├─ LiveServer.start() (127.0.0.1:auto port)
|
|
5
|
+
├─ BrowserEngine.start() (real Chromium)
|
|
6
|
+
├─ navigate(entry url)
|
|
7
|
+
└─ PreviewFileWatcher.start() (debounced live reload)
|
|
8
|
+
|
|
9
|
+
⏻ click ─► stop_preview() server/browser/watcher torn down,
|
|
10
|
+
editor session untouched
|
|
11
|
+
CAT exit ─► shutdown() idempotent, atexit-registered
|
|
12
|
+
|
|
13
|
+
State model lives here (spec section 22): server_state + preview_state
|
|
14
|
+
progress STOPPED → STARTING → RUNNING/ERROR, and every transition is
|
|
15
|
+
reported through `on_event(kind, **info)` so the UI can render progress
|
|
16
|
+
without any of this code touching widgets.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import atexit
|
|
22
|
+
import os
|
|
23
|
+
import threading
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
from .engine import BrowserEngine, PLAYWRIGHT_AVAILABLE
|
|
27
|
+
from .navigation import NavigationHistory
|
|
28
|
+
from .preview_entry import find_entry_file, relative_url_for
|
|
29
|
+
from .project_detector import (detect_project, dependencies_installed,
|
|
30
|
+
STATIC)
|
|
31
|
+
from .devserver import DevServerProcess
|
|
32
|
+
from .server import LiveServer
|
|
33
|
+
from .state import PreviewState, ServerState
|
|
34
|
+
from .watcher import PreviewFileWatcher, is_web_file
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PreviewController:
|
|
38
|
+
"""Owns Server + Engine + Watcher + Navigation for one workspace."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, root: str, engine: Optional[BrowserEngine] = None,
|
|
41
|
+
on_event: Optional[Callable] = None,
|
|
42
|
+
devserver_factory=None):
|
|
43
|
+
self.root = os.path.abspath(os.path.expanduser(root))
|
|
44
|
+
self.on_event = on_event or (lambda *a, **k: None)
|
|
45
|
+
self.server = LiveServer(self.root)
|
|
46
|
+
self.engine = engine or BrowserEngine()
|
|
47
|
+
self.history = NavigationHistory()
|
|
48
|
+
self.watcher: Optional[PreviewFileWatcher] = None
|
|
49
|
+
# v0.7.10 spec section 19-20: framework workspaces (Vite/Next/
|
|
50
|
+
# CRA) reuse THEIR OWN dev server; this factory builds that
|
|
51
|
+
# process handle (injectable for tests).
|
|
52
|
+
self._devserver_factory = devserver_factory or DevServerProcess
|
|
53
|
+
self.devserver = None
|
|
54
|
+
self.detection = detect_project(self.root)
|
|
55
|
+
self.server_state = ServerState.STOPPED
|
|
56
|
+
self.preview_state = PreviewState.STOPPED
|
|
57
|
+
self.last_error = ""
|
|
58
|
+
self.entry_url = ""
|
|
59
|
+
self._lock = threading.RLock()
|
|
60
|
+
self._hot_css_enabled = True
|
|
61
|
+
self._pending_ai_paths = set()
|
|
62
|
+
self._ai_debouncer = None
|
|
63
|
+
_register_instance(self)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------ events --
|
|
66
|
+
def _emit(self, kind: str, **info):
|
|
67
|
+
try:
|
|
68
|
+
self.on_event(kind, **info)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def _set_server_state(self, state: ServerState, error=""):
|
|
73
|
+
self.server_state = state
|
|
74
|
+
if error:
|
|
75
|
+
self.last_error = error
|
|
76
|
+
self._emit("server", state=state, error=error)
|
|
77
|
+
|
|
78
|
+
def _set_preview_state(self, state: PreviewState, error=""):
|
|
79
|
+
self.preview_state = state
|
|
80
|
+
if error:
|
|
81
|
+
self.last_error = error
|
|
82
|
+
self._emit("preview", state=state, error=error)
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------- props --
|
|
85
|
+
@property
|
|
86
|
+
def running(self) -> bool:
|
|
87
|
+
if self.preview_state is not PreviewState.RUNNING:
|
|
88
|
+
return False
|
|
89
|
+
if self.devserver is not None:
|
|
90
|
+
# External (framework) mode: the project's own server is
|
|
91
|
+
# the source of truth, CAT's static one stays idle.
|
|
92
|
+
return self.devserver.running or bool(self.history.current)
|
|
93
|
+
return self.server.running
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def base_url(self) -> str:
|
|
97
|
+
"""The URL everything is served from right now."""
|
|
98
|
+
if self.devserver is not None and self.devserver.url:
|
|
99
|
+
return self.devserver.url
|
|
100
|
+
return self.server.url
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def url(self) -> str:
|
|
104
|
+
return self.history.current or ""
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------- start --
|
|
107
|
+
def _start_static_server(self) -> bool:
|
|
108
|
+
"""CAT's own 127.0.0.1 static LiveServer (the default path)."""
|
|
109
|
+
self._emit("activity", text="● Starting local server…")
|
|
110
|
+
self._set_server_state(ServerState.STARTING)
|
|
111
|
+
if not self.server.running:
|
|
112
|
+
if not self.server.start():
|
|
113
|
+
self._set_server_state(
|
|
114
|
+
ServerState.ERROR,
|
|
115
|
+
"Failed to start development server "
|
|
116
|
+
"(port bind refused). CAT will retry next time.")
|
|
117
|
+
self._set_preview_state(PreviewState.ERROR,
|
|
118
|
+
"Server failed to start.")
|
|
119
|
+
return False
|
|
120
|
+
if not self.server.wait_ready():
|
|
121
|
+
self._set_server_state(ServerState.ERROR,
|
|
122
|
+
"Server did not answer.")
|
|
123
|
+
self._set_preview_state(PreviewState.ERROR,
|
|
124
|
+
"Server did not answer.")
|
|
125
|
+
return False
|
|
126
|
+
self._set_server_state(ServerState.RUNNING)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def _start_framework_devserver(self, detection) -> bool:
|
|
130
|
+
"""Spec section 19-20: the project already HAS a dev server
|
|
131
|
+
(Vite/Next/CRA) — start IT and reuse it; never stack a second
|
|
132
|
+
server on top of a running one."""
|
|
133
|
+
self._emit("activity", text=f"● {detection.label} detected — "
|
|
134
|
+
f"starting its dev server…")
|
|
135
|
+
self._set_server_state(ServerState.STARTING)
|
|
136
|
+
ds = self._devserver_factory(
|
|
137
|
+
detection.root, detection.command,
|
|
138
|
+
on_activity=lambda t: self._emit("activity", text=t))
|
|
139
|
+
if not ds.start():
|
|
140
|
+
tail = getattr(ds, "last_output", "")
|
|
141
|
+
self._set_server_state(
|
|
142
|
+
ServerState.ERROR,
|
|
143
|
+
f"{detection.label} dev server failed to start."
|
|
144
|
+
+ (f"\n{tail}" if tail else ""))
|
|
145
|
+
self._set_preview_state(PreviewState.ERROR,
|
|
146
|
+
"Dev server failed to start.")
|
|
147
|
+
return False
|
|
148
|
+
self.devserver = ds
|
|
149
|
+
self._set_server_state(ServerState.RUNNING)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
def start_for_file(self, path: str) -> bool:
|
|
153
|
+
"""▷ — entry point from an open editor tab (or any html file).
|
|
154
|
+
Detects the project type FIRST (spec section 20): framework
|
|
155
|
+
projects get their own dev server + HMR; everything else is
|
|
156
|
+
served by CAT's static LiveServer."""
|
|
157
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
158
|
+
entry, web_root = find_entry_file(path, self.root)
|
|
159
|
+
|
|
160
|
+
# Re-detect per start: the workspace may have gained/lost its
|
|
161
|
+
# package.json since this controller was created.
|
|
162
|
+
self.detection = detect_project(self.root)
|
|
163
|
+
detection = self.detection
|
|
164
|
+
use_external = (detection.is_framework
|
|
165
|
+
and dependencies_installed(detection))
|
|
166
|
+
if not entry and not use_external:
|
|
167
|
+
self._set_preview_state(
|
|
168
|
+
PreviewState.ERROR,
|
|
169
|
+
"No HTML entry point found in this workspace.")
|
|
170
|
+
self._emit("error", reason="no-entry")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
with self._lock:
|
|
174
|
+
if use_external:
|
|
175
|
+
# A Vite index.html at the project root is NOT a plain
|
|
176
|
+
# static page — only the real dev server can render it.
|
|
177
|
+
if not self._start_framework_devserver(detection):
|
|
178
|
+
return False
|
|
179
|
+
url = self.devserver.url
|
|
180
|
+
else:
|
|
181
|
+
if not self._start_static_server():
|
|
182
|
+
return False
|
|
183
|
+
rel = relative_url_for(entry, web_root) if entry else ""
|
|
184
|
+
url = self.base_url.rstrip("/") + "/" + rel.lstrip("/")
|
|
185
|
+
|
|
186
|
+
self.entry_url = url
|
|
187
|
+
self.watcher = self._ensure_watcher()
|
|
188
|
+
self._emit("activity", text="● Starting browser engine…")
|
|
189
|
+
self._set_preview_state(PreviewState.STARTING)
|
|
190
|
+
try:
|
|
191
|
+
self.engine.start()
|
|
192
|
+
snap = self.navigate(url, push=True)
|
|
193
|
+
if snap is not None and snap.ok:
|
|
194
|
+
self._emit("activity", text="✓ Preview synchronized")
|
|
195
|
+
except Exception as e:
|
|
196
|
+
# Headless engine failed or Playwright missing — local server is still running
|
|
197
|
+
# and fully functional for real-time viewing in CAT Browser app
|
|
198
|
+
self._emit("activity", text=f"● Live server ready for CAT Browser: {url}")
|
|
199
|
+
|
|
200
|
+
self._set_preview_state(PreviewState.RUNNING)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
def navigate_current_workspace(self) -> bool:
|
|
204
|
+
"""Preview the workspace root itself (no specific file)."""
|
|
205
|
+
entry, _web_root = find_entry_file(None, self.root)
|
|
206
|
+
if not entry:
|
|
207
|
+
self._set_preview_state(PreviewState.ERROR,
|
|
208
|
+
"No HTML entry point in this workspace.")
|
|
209
|
+
return False
|
|
210
|
+
return self.start_for_file(entry)
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------- navigate --
|
|
213
|
+
def navigate(self, url: str, push: bool = True):
|
|
214
|
+
"""Load a URL in the running preview; records history."""
|
|
215
|
+
if not self.engine.available:
|
|
216
|
+
self._set_preview_state(PreviewState.ERROR, "Browser not running.")
|
|
217
|
+
return None
|
|
218
|
+
self._emit("activity", text=f"● Loading {url}")
|
|
219
|
+
try:
|
|
220
|
+
snap = self.engine.navigate(url)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
self._set_preview_state(PreviewState.ERROR, str(e))
|
|
223
|
+
return None
|
|
224
|
+
if push and url != self.history.current:
|
|
225
|
+
self.history.push(url)
|
|
226
|
+
else:
|
|
227
|
+
self.history.replace_current(url)
|
|
228
|
+
if snap.ok:
|
|
229
|
+
self._set_preview_state(PreviewState.RUNNING)
|
|
230
|
+
else:
|
|
231
|
+
# A single broken page must not kill the whole stack.
|
|
232
|
+
self._set_preview_state(PreviewState.ERROR, snap.error)
|
|
233
|
+
self._emit("snapshot", snapshot=snap)
|
|
234
|
+
return snap
|
|
235
|
+
|
|
236
|
+
def reload(self):
|
|
237
|
+
if not self.running and not self.engine.available:
|
|
238
|
+
return None
|
|
239
|
+
self._emit("activity", text="● Reloading preview…")
|
|
240
|
+
try:
|
|
241
|
+
snap = self.engine.reload()
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self._set_preview_state(PreviewState.ERROR, str(e))
|
|
244
|
+
return None
|
|
245
|
+
if snap.ok:
|
|
246
|
+
self._set_preview_state(PreviewState.RUNNING)
|
|
247
|
+
self._emit("activity", text="✓ Preview synchronized")
|
|
248
|
+
else:
|
|
249
|
+
self._set_preview_state(PreviewState.ERROR, snap.error)
|
|
250
|
+
self._emit("snapshot", snapshot=snap)
|
|
251
|
+
return snap
|
|
252
|
+
|
|
253
|
+
def back(self):
|
|
254
|
+
target = self.history.back()
|
|
255
|
+
if target is None:
|
|
256
|
+
return None
|
|
257
|
+
return self.navigate(target, push=False)
|
|
258
|
+
|
|
259
|
+
def forward(self):
|
|
260
|
+
target = self.history.forward()
|
|
261
|
+
if target is None:
|
|
262
|
+
return None
|
|
263
|
+
return self.navigate(target, push=False)
|
|
264
|
+
|
|
265
|
+
def open_path(self, path: str):
|
|
266
|
+
"""Navigate the RUNNING preview to a workspace file (URL bar /
|
|
267
|
+
explorer integration). No-op when stopped."""
|
|
268
|
+
if not self.running:
|
|
269
|
+
return None
|
|
270
|
+
rel = relative_url_for(os.path.abspath(path), self.root)
|
|
271
|
+
if rel is None:
|
|
272
|
+
return None
|
|
273
|
+
return self.navigate(self.base_url.rstrip("/") + "/" + rel)
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------ hot path --
|
|
276
|
+
def on_files_changed(self, changed_paths) -> None:
|
|
277
|
+
"""Watcher/AI-write hook. Debounced upstream; decides between
|
|
278
|
+
CSS-only hot update and full reload. Never raises.
|
|
279
|
+
|
|
280
|
+
Framework mode (Vite/Next): their OWN HMR pipeline reacts to
|
|
281
|
+
file changes — reloading again from CAT would double-fire — so
|
|
282
|
+
we surface the activity lines (spec section 18) and leave the
|
|
283
|
+
updating to the project's dev server."""
|
|
284
|
+
try:
|
|
285
|
+
paths = [p for p in (changed_paths or []) if is_web_file(p)]
|
|
286
|
+
if not paths:
|
|
287
|
+
return
|
|
288
|
+
if self.devserver is not None and self.devserver.running:
|
|
289
|
+
for p in sorted(paths)[:6]:
|
|
290
|
+
self._emit("activity",
|
|
291
|
+
text=f"● Writing {os.path.basename(p)}")
|
|
292
|
+
self._emit("activity", text="✓ Framework HMR applied")
|
|
293
|
+
return
|
|
294
|
+
css_only = all(p.lower().endswith(".css") for p in paths)
|
|
295
|
+
if css_only and self._hot_css_enabled:
|
|
296
|
+
hrefs = [os.path.relpath(p, self.root).replace(os.sep, "/")
|
|
297
|
+
for p in paths]
|
|
298
|
+
reached = self.server.notify_hot_css(hrefs)
|
|
299
|
+
done = False
|
|
300
|
+
if reached == 0 and getattr(self.engine, "available", False):
|
|
301
|
+
done = bool(self.engine.hot_css(hrefs))
|
|
302
|
+
else:
|
|
303
|
+
done = True
|
|
304
|
+
if done:
|
|
305
|
+
self._emit("activity",
|
|
306
|
+
text=f"✓ CSS updated ({len(paths)} file(s))")
|
|
307
|
+
return
|
|
308
|
+
for p in sorted(paths)[:6]:
|
|
309
|
+
self._emit("activity",
|
|
310
|
+
text=f"● Writing {os.path.basename(p)}")
|
|
311
|
+
self.server.notify_reload()
|
|
312
|
+
if getattr(self.engine, "available", False):
|
|
313
|
+
self.reload()
|
|
314
|
+
except Exception as e:
|
|
315
|
+
self._emit("error", reason="hot-reload", detail=str(e))
|
|
316
|
+
|
|
317
|
+
def notify_ai_wrote(self, path: str):
|
|
318
|
+
"""Explicit fast-path notification from CAT's own write tools —
|
|
319
|
+
bypasses waiting for the OS watcher but STILL debounced (spec
|
|
320
|
+
section 13): a burst of AI writes coalesces into ONE preview
|
|
321
|
+
update ~150 ms after the last one lands."""
|
|
322
|
+
try:
|
|
323
|
+
if not is_web_file(path) or not self.engine.available:
|
|
324
|
+
return
|
|
325
|
+
with self._lock:
|
|
326
|
+
self._pending_ai_paths.add(os.path.abspath(path))
|
|
327
|
+
if self._ai_debouncer is None:
|
|
328
|
+
from .watcher import Debouncer
|
|
329
|
+
self._ai_debouncer = Debouncer()
|
|
330
|
+
|
|
331
|
+
def flush():
|
|
332
|
+
with self._lock:
|
|
333
|
+
batch = set(self._pending_ai_paths)
|
|
334
|
+
self._pending_ai_paths.clear()
|
|
335
|
+
if batch:
|
|
336
|
+
self.on_files_changed(batch)
|
|
337
|
+
|
|
338
|
+
self._ai_debouncer.schedule(flush)
|
|
339
|
+
except Exception as e:
|
|
340
|
+
self._emit("error", reason="ai-write", detail=str(e))
|
|
341
|
+
|
|
342
|
+
# ----------------------------------------------------------- watcher --
|
|
343
|
+
def _ensure_watcher(self) -> Optional[PreviewFileWatcher]:
|
|
344
|
+
if self.watcher is not None and self.watcher.active \
|
|
345
|
+
and self.watcher.root == self.root:
|
|
346
|
+
return self.watcher
|
|
347
|
+
try:
|
|
348
|
+
watcher = PreviewFileWatcher(
|
|
349
|
+
lambda batch: self.on_files_changed(batch),
|
|
350
|
+
on_activity=lambda p: self._emit(
|
|
351
|
+
"activity",
|
|
352
|
+
text=f"● Writing {os.path.basename(p)}"))
|
|
353
|
+
watcher.start(self.root)
|
|
354
|
+
self.watcher = watcher
|
|
355
|
+
return watcher
|
|
356
|
+
except Exception:
|
|
357
|
+
self.watcher = None
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
# -------------------------------------------------------------- stop --
|
|
361
|
+
def stop_preview(self):
|
|
362
|
+
"""⏻ — tear the preview down; the workspace/editor are untouched.
|
|
363
|
+
Idempotent."""
|
|
364
|
+
with self._lock:
|
|
365
|
+
watcher, self.watcher = self.watcher, None
|
|
366
|
+
if watcher is not None:
|
|
367
|
+
try:
|
|
368
|
+
watcher.stop()
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
try:
|
|
372
|
+
self.engine.close()
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
try:
|
|
376
|
+
self.server.stop()
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
devserver, self.devserver = self.devserver, None
|
|
380
|
+
if devserver is not None:
|
|
381
|
+
# Kill the framework's npm/node process TREE (§21: no
|
|
382
|
+
# orphans survive CAT, crashes included — atexit calls
|
|
383
|
+
# this same path).
|
|
384
|
+
try:
|
|
385
|
+
devserver.stop()
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
self.history.clear()
|
|
389
|
+
self._set_preview_state(PreviewState.STOPPED)
|
|
390
|
+
self._set_server_state(ServerState.STOPPED)
|
|
391
|
+
|
|
392
|
+
def restart_engine(self):
|
|
393
|
+
"""Recover from a wedged browser without touching the server."""
|
|
394
|
+
try:
|
|
395
|
+
self.engine.close()
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
try:
|
|
399
|
+
self.engine.start()
|
|
400
|
+
except Exception as e:
|
|
401
|
+
self._set_preview_state(PreviewState.ERROR, str(e))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
# process-wide lifecycle: every controller ever created is stopped at exit
|
|
406
|
+
# so no orphan Chromium/server can survive CAT.
|
|
407
|
+
_CONTROLLERS = []
|
|
408
|
+
_atexit_installed = False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _register_instance(ctrl: PreviewController):
|
|
412
|
+
global _atexit_installed
|
|
413
|
+
_CONTROLLERS.append(ctrl)
|
|
414
|
+
del _CONTROLLERS[:-8:] # keep the list bounded
|
|
415
|
+
if not _atexit_installed:
|
|
416
|
+
atexit.register(_shutdown_all)
|
|
417
|
+
_atexit_installed = True
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _shutdown_all():
|
|
421
|
+
for ctrl in list(_CONTROLLERS):
|
|
422
|
+
try:
|
|
423
|
+
ctrl.stop_preview()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def playwright_available() -> bool:
|
|
429
|
+
return PLAYWRIGHT_AVAILABLE
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""CAT browser/ — entry-point detection + workspace-relative URLs.
|
|
2
|
+
|
|
3
|
+
Given an open file (or nothing), decide WHAT to preview and WHERE the
|
|
4
|
+
web root is:
|
|
5
|
+
|
|
6
|
+
index.html anywhere in the tree → its folder becomes the web root
|
|
7
|
+
docs/site/index.html → web root = docs/site
|
|
8
|
+
no html at all → (None, root) → honest error
|
|
9
|
+
|
|
10
|
+
Keeps the controller free of path heuristics and is unit-testable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Optional, Tuple
|
|
15
|
+
|
|
16
|
+
ENTRY_NAMES = ("index.html", "index.htm", "default.html")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_html(path: str) -> bool:
|
|
20
|
+
return os.path.splitext(path)[1].lower() in (".html", ".htm")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_entry_file(open_path: Optional[str],
|
|
24
|
+
workspace_root: str) -> Tuple[Optional[str], str]:
|
|
25
|
+
"""Return (entry_html_path, web_root).
|
|
26
|
+
|
|
27
|
+
Order:
|
|
28
|
+
1. the open file itself, when it's HTML;
|
|
29
|
+
2. an index.html next to the open file, walking UP to the root;
|
|
30
|
+
3. workspace_root/index.html;
|
|
31
|
+
4. shallowest index.html anywhere under the root (bounded walk).
|
|
32
|
+
"""
|
|
33
|
+
root = os.path.abspath(workspace_root)
|
|
34
|
+
if open_path and _is_html(open_path) and os.path.isfile(open_path):
|
|
35
|
+
# prefer a sibling index if the open file sits inside a folder
|
|
36
|
+
# that has one (e.g. user opened src/about.html but src/index.html
|
|
37
|
+
# exists → still treat src/ as the site)
|
|
38
|
+
folder = os.path.dirname(open_path)
|
|
39
|
+
for name in ENTRY_NAMES:
|
|
40
|
+
candidate = os.path.join(folder, name)
|
|
41
|
+
if os.path.isfile(candidate):
|
|
42
|
+
return candidate, folder
|
|
43
|
+
return open_path, folder
|
|
44
|
+
if open_path and os.path.isdir(open_path):
|
|
45
|
+
for name in ENTRY_NAMES:
|
|
46
|
+
candidate = os.path.join(open_path, name)
|
|
47
|
+
if os.path.isfile(candidate):
|
|
48
|
+
return candidate, open_path
|
|
49
|
+
# direct root entries
|
|
50
|
+
for name in ENTRY_NAMES:
|
|
51
|
+
candidate = os.path.join(root, name)
|
|
52
|
+
if os.path.isfile(candidate):
|
|
53
|
+
return candidate, root
|
|
54
|
+
# bounded search (depth ≤ 3) for the shallowest entry page
|
|
55
|
+
best = None
|
|
56
|
+
best_depth = 99
|
|
57
|
+
base_depth = root.rstrip(os.sep).count(os.sep)
|
|
58
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
59
|
+
depth = dirpath.rstrip(os.sep).count(os.sep) - base_depth
|
|
60
|
+
if depth > 3:
|
|
61
|
+
dirnames[:] = []
|
|
62
|
+
continue
|
|
63
|
+
dirnames[:] = [d for d in dirnames
|
|
64
|
+
if d not in ("node_modules", ".git", "__pycache__",
|
|
65
|
+
".venv", "venv")]
|
|
66
|
+
for name in ENTRY_NAMES:
|
|
67
|
+
if name in filenames:
|
|
68
|
+
if depth < best_depth:
|
|
69
|
+
best = os.path.join(dirpath, name)
|
|
70
|
+
best_depth = depth
|
|
71
|
+
break
|
|
72
|
+
if best is not None:
|
|
73
|
+
return best, os.path.dirname(best)
|
|
74
|
+
return None, root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def relative_url_for(path: str, web_root: str) -> Optional[str]:
|
|
78
|
+
"""Filesystem path → URL path relative to the served root."""
|
|
79
|
+
path = os.path.abspath(path)
|
|
80
|
+
web_root = os.path.abspath(web_root).rstrip(os.sep) + os.sep
|
|
81
|
+
try:
|
|
82
|
+
rel = os.path.relpath(path, web_root)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return None
|
|
85
|
+
if rel.startswith(".."):
|
|
86
|
+
return None
|
|
87
|
+
return rel.replace(os.sep, "/")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def url_for_file(path: str, server_url: str,
|
|
91
|
+
web_root: str) -> Optional[str]:
|
|
92
|
+
rel = relative_url_for(path, web_root)
|
|
93
|
+
if rel is None:
|
|
94
|
+
return None
|
|
95
|
+
return server_url.rstrip("/") + "/" + rel
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CAT browser/ — project detection (spec section 20).
|
|
2
|
+
|
|
3
|
+
Decide WHAT KIND of web project the workspace is before starting any
|
|
4
|
+
server, so the preview can REUSE the project's own dev server (Vite /
|
|
5
|
+
Next / CRA) instead of blindly stacking a second static one on top:
|
|
6
|
+
|
|
7
|
+
index.html → STATIC (CAT's LiveServer)
|
|
8
|
+
package.json + vite.config.* → VITE (npm run dev)
|
|
9
|
+
package.json + next.config.* → NEXT (npm run dev)
|
|
10
|
+
package.json + react-scripts → CRA (npm start)
|
|
11
|
+
package.json + <other> → NODE (best-effort npm run dev)
|
|
12
|
+
nothing recognizable → STATIC
|
|
13
|
+
|
|
14
|
+
Detection is pure filesystem reading — no subprocesses, no network —
|
|
15
|
+
so it is safe to call from the UI thread and trivially unit-testable.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Recognized project kinds -------------------------------------------------
|
|
25
|
+
STATIC = "static"
|
|
26
|
+
VITE = "vite"
|
|
27
|
+
NEXT = "next"
|
|
28
|
+
CRA = "cra"
|
|
29
|
+
NODE = "node"
|
|
30
|
+
|
|
31
|
+
_VITE_CONFIGS = ("vite.config.js", "vite.config.ts", "vite.config.mjs",
|
|
32
|
+
"vite.config.mts", "vite.config.cjs")
|
|
33
|
+
_NEXT_CONFIGS = ("next.config.js", "next.config.ts", "next.config.mjs",
|
|
34
|
+
"next.config.cjs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Detection:
|
|
39
|
+
"""One immutable detection result for a workspace root."""
|
|
40
|
+
kind: str = STATIC
|
|
41
|
+
root: str = ""
|
|
42
|
+
# Shell-ish command tokens to spawn (argv list, no shell).
|
|
43
|
+
command: tuple = ()
|
|
44
|
+
# Human explanation surfaced in the preview activity strip.
|
|
45
|
+
detail: str = ""
|
|
46
|
+
# Files that identified the project (for honest status lines).
|
|
47
|
+
evidence: tuple = field(default_factory=tuple)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_framework(self) -> bool:
|
|
51
|
+
return self.kind in (VITE, NEXT, CRA, NODE)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def label(self) -> str:
|
|
55
|
+
return {"static": "Static site", VITE: "Vite", NEXT: "Next.js",
|
|
56
|
+
CRA: "Create React App", NODE: "Node"}.get(
|
|
57
|
+
self.kind, self.kind)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_scripts(pkg_path: str) -> dict:
|
|
61
|
+
try:
|
|
62
|
+
with open(pkg_path, "r", encoding="utf-8", errors="replace") as f:
|
|
63
|
+
data = json.load(f)
|
|
64
|
+
return data.get("scripts") or {}
|
|
65
|
+
except Exception:
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_project(workspace_root: str) -> Detection:
|
|
70
|
+
"""Detect the project type of `workspace_root`. Never raises."""
|
|
71
|
+
root = os.path.abspath(os.path.expanduser(workspace_root or ""))
|
|
72
|
+
if not root or not os.path.isdir(root):
|
|
73
|
+
return Detection(kind=STATIC, root=root,
|
|
74
|
+
detail="Not a folder — serving statically.")
|
|
75
|
+
|
|
76
|
+
pkg_path = os.path.join(root, "package.json")
|
|
77
|
+
has_pkg = os.path.isfile(pkg_path)
|
|
78
|
+
|
|
79
|
+
if has_pkg:
|
|
80
|
+
scripts = _read_scripts(pkg_path)
|
|
81
|
+
evidence = ["package.json"]
|
|
82
|
+
|
|
83
|
+
vite_cfg = next((c for c in _VITE_CONFIGS
|
|
84
|
+
if os.path.isfile(os.path.join(root, c))), None)
|
|
85
|
+
dep_vite = False
|
|
86
|
+
try:
|
|
87
|
+
with open(pkg_path, "r", encoding="utf-8",
|
|
88
|
+
errors="replace") as f:
|
|
89
|
+
deps = json.load(f)
|
|
90
|
+
all_deps = {}
|
|
91
|
+
all_deps.update(deps.get("dependencies") or {})
|
|
92
|
+
all_deps.update(deps.get("devDependencies") or {})
|
|
93
|
+
dep_vite = "vite" in all_deps
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
if vite_cfg or (dep_vite and "dev" in scripts):
|
|
97
|
+
if vite_cfg:
|
|
98
|
+
evidence.append(vite_cfg)
|
|
99
|
+
cmd = scripts.get("dev") or scripts.get("serve") or "vite"
|
|
100
|
+
return Detection(kind=VITE, root=root,
|
|
101
|
+
command=("npm", "run", "dev"),
|
|
102
|
+
detail=f"Vite project (`npm run {cmd}`) — "
|
|
103
|
+
f"reusing its own HMR dev server.",
|
|
104
|
+
evidence=tuple(evidence))
|
|
105
|
+
|
|
106
|
+
next_cfg = next((c for c in _NEXT_CONFIGS
|
|
107
|
+
if os.path.isfile(os.path.join(root, c))), None)
|
|
108
|
+
if next_cfg or "next" in (scripts.get("dev") or ""):
|
|
109
|
+
if next_cfg:
|
|
110
|
+
evidence.append(next_cfg)
|
|
111
|
+
return Detection(kind=NEXT, root=root,
|
|
112
|
+
command=("npm", "run", "dev"),
|
|
113
|
+
detail="Next.js project (`npm run dev`) — "
|
|
114
|
+
"reusing its own dev server.",
|
|
115
|
+
evidence=tuple(evidence))
|
|
116
|
+
|
|
117
|
+
start_script = scripts.get("start", "")
|
|
118
|
+
if "react-scripts" in start_script:
|
|
119
|
+
return Detection(kind=CRA, root=root,
|
|
120
|
+
command=("npm", "start"),
|
|
121
|
+
detail="Create React App (`npm start`) — "
|
|
122
|
+
"reusing its own dev server.",
|
|
123
|
+
evidence=tuple(evidence))
|
|
124
|
+
|
|
125
|
+
if scripts.get("dev"):
|
|
126
|
+
return Detection(kind=NODE, root=root,
|
|
127
|
+
command=("npm", "run", "dev"),
|
|
128
|
+
detail="Node project (`npm run dev`).",
|
|
129
|
+
evidence=tuple(evidence))
|
|
130
|
+
# package.json but no runnable dev script → static fallback.
|
|
131
|
+
return Detection(kind=STATIC, root=root,
|
|
132
|
+
detail="package.json without a dev script — "
|
|
133
|
+
"serving statically.")
|
|
134
|
+
|
|
135
|
+
return Detection(kind=STATIC, root=root,
|
|
136
|
+
detail="Static HTML project.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def dependencies_installed(detection: Detection) -> bool:
|
|
140
|
+
"""Whether the framework project can actually be started right now
|
|
141
|
+
(its node_modules are present). Static projects are always ready."""
|
|
142
|
+
if not detection.is_framework:
|
|
143
|
+
return True
|
|
144
|
+
return os.path.isdir(os.path.join(detection.root, "node_modules"))
|