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,449 @@
|
|
|
1
|
+
"""CAT browser/ — the local live development server.
|
|
2
|
+
|
|
3
|
+
A threaded HTTP server that serves the CURRENT WORKSPACE only:
|
|
4
|
+
|
|
5
|
+
http://127.0.0.1:<auto port>/
|
|
6
|
+
├── index.html (directory -> index resolution)
|
|
7
|
+
├── style.css / app.js (correct MIME types)
|
|
8
|
+
├── images / fonts / svg (static assets)
|
|
9
|
+
└── /__cat__/ws (WebSocket hot-reload channel)
|
|
10
|
+
|
|
11
|
+
Hard guarantees:
|
|
12
|
+
* binds 127.0.0.1 ONLY (never 0.0.0.0) — the workspace is not exposed
|
|
13
|
+
to the network unless a future feature explicitly opts in;
|
|
14
|
+
* the port is chosen by the OS at bind time (port 0), so collisions are
|
|
15
|
+
impossible by construction; `url` reports the real one;
|
|
16
|
+
* every served path is resolved and verified to live UNDER the workspace
|
|
17
|
+
root — no `..` traversal can turn this into a file-sharing server;
|
|
18
|
+
* responses carry no-cache headers so Chromium always revalidates against
|
|
19
|
+
the files CAT's AI is actively rewriting;
|
|
20
|
+
* shutdown() stops listener + threads cleanly (idempotent), so CAT exit
|
|
21
|
+
never leaves an orphan socket/process behind.
|
|
22
|
+
|
|
23
|
+
The WebSocket endpoint is a minimal RFC6455 server (text frames only)
|
|
24
|
+
implemented with the stdlib — no extra dependency. Browsers connect
|
|
25
|
+
through the injected hot-reload snippet (server.inject_hot_reload)
|
|
26
|
+
and receive JSON events: {"cmd": "reload"} or {"cmd": "hot_css",
|
|
27
|
+
"hrefs": [...]} for CSS-only updates.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import socket
|
|
37
|
+
import struct
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
import webbrowser # noqa: F401 (deliberately NOT used — terminal-first)
|
|
41
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------ mimes --
|
|
44
|
+
_MIME = {
|
|
45
|
+
".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8",
|
|
46
|
+
".css": "text/css; charset=utf-8",
|
|
47
|
+
".js": "application/javascript; charset=utf-8",
|
|
48
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
49
|
+
".json": "application/json; charset=utf-8",
|
|
50
|
+
".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
|
|
51
|
+
".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
|
|
52
|
+
".ico": "image/x-icon", ".bmp": "image/bmp", ".avif": "image/avif",
|
|
53
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf",
|
|
54
|
+
".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
|
|
55
|
+
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
|
56
|
+
".xml": "application/xml", ".pdf": "application/pdf",
|
|
57
|
+
".wasm": "application/wasm", ".map": "application/json",
|
|
58
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
|
|
59
|
+
".mp4": "video/mp4", ".webm": "video/webm",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
WEB_EXTENSIONS = {".html", ".htm", ".css", ".js", ".mjs"}
|
|
63
|
+
|
|
64
|
+
HOT_RELOAD_SNIPPET = """
|
|
65
|
+
<script>
|
|
66
|
+
(function () {
|
|
67
|
+
if (window.__CAT_HOT__) return; window.__CAT_HOT__ = true;
|
|
68
|
+
function connect() {
|
|
69
|
+
var ws;
|
|
70
|
+
try { ws = new WebSocket("ws://127.0.0.1:" + location.port + "/__cat__/ws"); }
|
|
71
|
+
catch (e) { return; }
|
|
72
|
+
ws.onmessage = function (ev) {
|
|
73
|
+
try {
|
|
74
|
+
var msg = JSON.parse(ev.data);
|
|
75
|
+
if (msg.cmd === "reload") { location.reload(); return; }
|
|
76
|
+
if (msg.cmd === "hot_css") {
|
|
77
|
+
(msg.hrefs || []).forEach(function (href) {
|
|
78
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
79
|
+
links.forEach(function (l) {
|
|
80
|
+
var key = l.getAttribute('href') || '';
|
|
81
|
+
if (!href || key.indexOf(href.split('?')[0]) !== -1 ||
|
|
82
|
+
href.indexOf(key.split('?')[0]) !== -1) {
|
|
83
|
+
var clone = l.cloneNode();
|
|
84
|
+
var bust = "href=" + l.href.replace(/[?&]__cat=\\d+/, '') +
|
|
85
|
+
(l.href.indexOf("?") === -1 ? "?" : "&") +
|
|
86
|
+
"__cat=" + Date.now();
|
|
87
|
+
clone.setAttribute("href", bust);
|
|
88
|
+
l.parentNode.replaceChild(clone, l);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {}
|
|
94
|
+
};
|
|
95
|
+
ws.onclose = function () { setTimeout(connect, 700); };
|
|
96
|
+
}
|
|
97
|
+
connect();
|
|
98
|
+
})();
|
|
99
|
+
</script>
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_free_port() -> int:
|
|
104
|
+
"""Ask the OS for a free localhost port (bind :0). The port stays
|
|
105
|
+
free only momentarily — LiveServer binds immediately after."""
|
|
106
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
107
|
+
s.bind(("127.0.0.1", 0))
|
|
108
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
109
|
+
return s.getsockname()[1]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _WSClient:
|
|
113
|
+
"""One connected preview browser (minimal RFC6455 text-frame peer)."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, sock):
|
|
116
|
+
self.sock = sock
|
|
117
|
+
self.alive = True
|
|
118
|
+
self._lock = threading.Lock()
|
|
119
|
+
|
|
120
|
+
def send_text(self, text: str) -> bool:
|
|
121
|
+
try:
|
|
122
|
+
payload = text.encode("utf-8")
|
|
123
|
+
header = bytearray([0x81]) # FIN + text opcode
|
|
124
|
+
n = len(payload)
|
|
125
|
+
if n < 126:
|
|
126
|
+
header.append(n)
|
|
127
|
+
elif n < 65536:
|
|
128
|
+
header.append(126)
|
|
129
|
+
header += struct.pack(">H", n)
|
|
130
|
+
else:
|
|
131
|
+
header.append(127)
|
|
132
|
+
header += struct.pack(">Q", n)
|
|
133
|
+
with self._lock:
|
|
134
|
+
self.sock.sendall(bytes(header) + payload)
|
|
135
|
+
return True
|
|
136
|
+
except Exception:
|
|
137
|
+
self.alive = False
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def close(self):
|
|
141
|
+
self.alive = False
|
|
142
|
+
try:
|
|
143
|
+
self.sock.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ws_accept(key: str) -> str:
|
|
149
|
+
guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
150
|
+
digest = hashlib.sha1((key + guid).encode("ascii")).digest()
|
|
151
|
+
return base64.b64encode(digest).decode("ascii")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
155
|
+
"""Request handler bound to its LiveServer via the server class."""
|
|
156
|
+
|
|
157
|
+
protocol_version = "HTTP/1.1"
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------ utils --
|
|
160
|
+
def log_message(self, fmt, *args): # silence stderr spam
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
def _no_cache(self):
|
|
164
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
165
|
+
self.send_header("Pragma", "no-cache")
|
|
166
|
+
self.send_header("Expires", "0")
|
|
167
|
+
|
|
168
|
+
def _safe_path(self, url_path: str):
|
|
169
|
+
"""Resolve a URL path to a real file under the root. Returns
|
|
170
|
+
(filesystem_path_or_None, status_code)."""
|
|
171
|
+
root = self.server.cat_root
|
|
172
|
+
clean = url_path.split("?", 1)[0].split("#", 1)[0]
|
|
173
|
+
clean = clean.replace("\\", "/").lstrip("/")
|
|
174
|
+
try:
|
|
175
|
+
candidate = os.path.normpath(os.path.join(root, clean))
|
|
176
|
+
# normcase both sides so Windows casing can't smuggle a path
|
|
177
|
+
# outside the root either.
|
|
178
|
+
if os.path.normcase(candidate).startswith(
|
|
179
|
+
os.path.normcase(root.rstrip(os.sep) + os.sep)) \
|
|
180
|
+
or os.path.normcase(candidate) == os.path.normcase(root):
|
|
181
|
+
if os.path.isdir(candidate):
|
|
182
|
+
for name in ("index.html", "index.htm"):
|
|
183
|
+
p = os.path.join(candidate, name)
|
|
184
|
+
if os.path.isfile(p):
|
|
185
|
+
return p, 200
|
|
186
|
+
return None, 404
|
|
187
|
+
if os.path.isfile(candidate):
|
|
188
|
+
return candidate, 200
|
|
189
|
+
return None, 404
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return None, 400
|
|
193
|
+
|
|
194
|
+
def _inject(self, body: bytes) -> bytes:
|
|
195
|
+
"""Append the hot-reload snippet to HTML responses."""
|
|
196
|
+
lower = bytes(body[-64:]).lower()
|
|
197
|
+
marker = b"</body>"
|
|
198
|
+
idx = bytes(body).lower().rfind(marker)
|
|
199
|
+
if idx == -1:
|
|
200
|
+
# plain append still works for sloppy HTML
|
|
201
|
+
return body + HOT_RELOAD_SNIPPET.encode("utf-8")
|
|
202
|
+
out = body[:idx] + HOT_RELOAD_SNIPPET.encode("utf-8") + body[idx:]
|
|
203
|
+
del lower
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
# ----------------------------------------------------------- verbs --
|
|
207
|
+
def do_GET(self):
|
|
208
|
+
# ---- WebSocket upgrade -----------------------------------------
|
|
209
|
+
if self.path.startswith("/__cat__/ws"):
|
|
210
|
+
key = self.headers.get("Sec-WebSocket-Key", "")
|
|
211
|
+
if key:
|
|
212
|
+
self.send_response(101, "Switching Protocols")
|
|
213
|
+
self.send_header("Upgrade", "websocket")
|
|
214
|
+
self.send_header("Connection", "Upgrade")
|
|
215
|
+
self.send_header("Sec-WebSocket-Accept", _ws_accept(key))
|
|
216
|
+
self.end_headers()
|
|
217
|
+
self.close_connection = True
|
|
218
|
+
try:
|
|
219
|
+
self.connection.settimeout(600)
|
|
220
|
+
client = _WSClient(self.connection)
|
|
221
|
+
self.server.cat_ws_register(client)
|
|
222
|
+
self.server.cat_ws_reader(client)
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
else:
|
|
226
|
+
self.send_error(400)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
path, status = self._safe_path(self.path)
|
|
230
|
+
if status != 200 or path is None:
|
|
231
|
+
body = (f"<h1>404 — Not in workspace</h1>"
|
|
232
|
+
f"<p>{self.path} does not exist under "
|
|
233
|
+
f"{self.server.cat_root}</p>").encode("utf-8")
|
|
234
|
+
self.send_response(404)
|
|
235
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
236
|
+
self._no_cache()
|
|
237
|
+
self.send_header("Content-Length", str(len(body)))
|
|
238
|
+
self.end_headers()
|
|
239
|
+
self.wfile.write(body)
|
|
240
|
+
self.server.cat_note_hit(self.path, 404)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
ext = os.path.splitext(path)[1].lower()
|
|
244
|
+
ctype = _MIME.get(ext, "application/octet-stream")
|
|
245
|
+
try:
|
|
246
|
+
with open(path, "rb") as f:
|
|
247
|
+
body = f.read()
|
|
248
|
+
except OSError as e:
|
|
249
|
+
body = f"<h1>500</h1><p>{e}</p>".encode("utf-8")
|
|
250
|
+
self.send_response(500)
|
|
251
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
252
|
+
self._no_cache()
|
|
253
|
+
self.send_header("Content-Length", str(len(body)))
|
|
254
|
+
self.end_headers()
|
|
255
|
+
self.wfile.write(body)
|
|
256
|
+
return
|
|
257
|
+
if ext in (".html", ".htm"):
|
|
258
|
+
body = self._inject(body)
|
|
259
|
+
ctype = "text/html; charset=utf-8"
|
|
260
|
+
self.send_response(200)
|
|
261
|
+
self.send_header("Content-Type", ctype)
|
|
262
|
+
self._no_cache()
|
|
263
|
+
self.send_header("Content-Length", str(len(body)))
|
|
264
|
+
self.end_headers()
|
|
265
|
+
try:
|
|
266
|
+
self.wfile.write(body)
|
|
267
|
+
except (BrokenPipeError, ConnectionResetError,
|
|
268
|
+
ConnectionAbortedError, OSError):
|
|
269
|
+
pass # preview tab navigated away mid-response — harmless
|
|
270
|
+
self.server.cat_note_hit(self.path, 200)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class LiveServer:
|
|
274
|
+
"""One running dev server for ONE workspace root.
|
|
275
|
+
|
|
276
|
+
Usage:
|
|
277
|
+
srv = LiveServer(root)
|
|
278
|
+
srv.start() # binds 127.0.0.1:<auto>
|
|
279
|
+
srv.url # 'http://127.0.0.1:5173/'
|
|
280
|
+
srv.stop() # idempotent, joins threads
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, root: str, server_class=ThreadingHTTPServer,
|
|
284
|
+
handler_class=_Handler):
|
|
285
|
+
self.root = os.path.abspath(os.path.expanduser(root))
|
|
286
|
+
self._httpd = None
|
|
287
|
+
self._thread = None
|
|
288
|
+
self._state_lock = threading.Lock()
|
|
289
|
+
self.port = None
|
|
290
|
+
self.hits = [] # [(path, status)] recent
|
|
291
|
+
self.ws_clients = []
|
|
292
|
+
self._ws_lock = threading.Lock()
|
|
293
|
+
# wire the handler-facing attributes onto the server class
|
|
294
|
+
base = server_class
|
|
295
|
+
|
|
296
|
+
class _S(base):
|
|
297
|
+
cat_root = self.root
|
|
298
|
+
cat_server = self
|
|
299
|
+
|
|
300
|
+
def handle_error(srv, request, client_address):
|
|
301
|
+
# Preview tabs disconnect constantly (navigation, reload,
|
|
302
|
+
# shutdown) — a dropped connection is never an error worth
|
|
303
|
+
# printing into CAT's terminal.
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
def cat_note_hit(srv, path, status):
|
|
307
|
+
self._note(path, status)
|
|
308
|
+
|
|
309
|
+
def cat_ws_register(srv, client):
|
|
310
|
+
with self._ws_lock:
|
|
311
|
+
self.ws_clients = [c for c in self.ws_clients if c.alive]
|
|
312
|
+
self.ws_clients.append(client)
|
|
313
|
+
|
|
314
|
+
def cat_ws_reader(srv, client):
|
|
315
|
+
# drain frames (we never require client messages); any
|
|
316
|
+
# error just marks the client dead.
|
|
317
|
+
try:
|
|
318
|
+
while client.alive:
|
|
319
|
+
hdr = client.sock.recv(2)
|
|
320
|
+
if len(hdr) < 2:
|
|
321
|
+
break
|
|
322
|
+
opcode = hdr[0] & 0x0F
|
|
323
|
+
masked = hdr[1] & 0x80
|
|
324
|
+
length = hdr[1] & 0x7F
|
|
325
|
+
if length == 126:
|
|
326
|
+
ext = client.sock.recv(2)
|
|
327
|
+
length = struct.unpack(">H", ext)[0]
|
|
328
|
+
elif length == 127:
|
|
329
|
+
ext = client.sock.recv(8)
|
|
330
|
+
length = struct.unpack(">Q", ext)[0]
|
|
331
|
+
mask = client.sock.recv(4) if masked else b""
|
|
332
|
+
payload = client.sock.recv(length) if length else b""
|
|
333
|
+
if masked and mask:
|
|
334
|
+
payload = bytes(b ^ mask[i % 4]
|
|
335
|
+
for i, b in enumerate(payload))
|
|
336
|
+
if opcode == 0x8: # close
|
|
337
|
+
break
|
|
338
|
+
if opcode == 0x9: # ping → pong
|
|
339
|
+
try:
|
|
340
|
+
frame = bytearray([0x8A, len(payload)])
|
|
341
|
+
client.sock.sendall(bytes(frame) + payload)
|
|
342
|
+
except Exception:
|
|
343
|
+
break
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
finally:
|
|
347
|
+
client.alive = False
|
|
348
|
+
|
|
349
|
+
self._server_cls = _S
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------ props --
|
|
352
|
+
@property
|
|
353
|
+
def url(self) -> str:
|
|
354
|
+
return f"http://127.0.0.1:{self.port}/"
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def running(self) -> bool:
|
|
358
|
+
return self._httpd is not None
|
|
359
|
+
|
|
360
|
+
def _note(self, path, status):
|
|
361
|
+
self.hits.append((path, status))
|
|
362
|
+
del self.hits[:-200:]
|
|
363
|
+
|
|
364
|
+
# ----------------------------------------------------------- start --
|
|
365
|
+
def start(self) -> bool:
|
|
366
|
+
"""Bind 127.0.0.1 on an OS-assigned port and serve forever on a
|
|
367
|
+
daemon thread. Returns False (without raising) when binding
|
|
368
|
+
failed for any reason."""
|
|
369
|
+
if self._httpd is not None:
|
|
370
|
+
return True
|
|
371
|
+
try:
|
|
372
|
+
port = find_free_port()
|
|
373
|
+
self._httpd = self._server_cls(("127.0.0.1", port), _Handler)
|
|
374
|
+
self._httpd.daemon_threads = True
|
|
375
|
+
self.port = self._httpd.server_address[1]
|
|
376
|
+
except Exception:
|
|
377
|
+
self._httpd = None
|
|
378
|
+
return False
|
|
379
|
+
self._thread = threading.Thread(
|
|
380
|
+
target=self._serve_loop, name="cat-live-server", daemon=True)
|
|
381
|
+
self._thread.start()
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
def _serve_loop(self):
|
|
385
|
+
try:
|
|
386
|
+
self._httpd.serve_forever(poll_interval=0.25)
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
def wait_ready(self, timeout=3.0) -> bool:
|
|
391
|
+
"""Block until the socket answers (or timeout). Keeps start-up
|
|
392
|
+
ordering honest for the engine's first navigation."""
|
|
393
|
+
deadline = time.time() + timeout
|
|
394
|
+
while time.time() < deadline:
|
|
395
|
+
if self.port is not None:
|
|
396
|
+
try:
|
|
397
|
+
with socket.create_connection(
|
|
398
|
+
("127.0.0.1", self.port), timeout=0.4):
|
|
399
|
+
return True
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
time.sleep(0.03)
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------- broadcast API --
|
|
406
|
+
def broadcast(self, obj: dict) -> int:
|
|
407
|
+
"""Send one JSON event to every connected preview browser.
|
|
408
|
+
Returns the number of live clients it reached."""
|
|
409
|
+
data = json.dumps(obj)
|
|
410
|
+
with self._ws_lock:
|
|
411
|
+
clients = [c for c in self.ws_clients if c.alive]
|
|
412
|
+
sent = 0
|
|
413
|
+
for c in clients:
|
|
414
|
+
if c.send_text(data):
|
|
415
|
+
sent += 1
|
|
416
|
+
with self._ws_lock:
|
|
417
|
+
self.ws_clients = [c for c in self.ws_clients if c.alive]
|
|
418
|
+
return sent
|
|
419
|
+
|
|
420
|
+
def notify_reload(self):
|
|
421
|
+
# counted so tests/probes can verify debounce coalescing
|
|
422
|
+
self._reload_count = getattr(self, "_reload_count", 0) + 1
|
|
423
|
+
return self.broadcast({"cmd": "reload"})
|
|
424
|
+
|
|
425
|
+
def notify_hot_css(self, hrefs):
|
|
426
|
+
return self.broadcast({"cmd": "hot_css", "hrefs": list(hrefs or [])})
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------- stop --
|
|
429
|
+
def stop(self):
|
|
430
|
+
"""Idempotent full teardown: WebSocket peers, listener, thread."""
|
|
431
|
+
with self._ws_lock:
|
|
432
|
+
clients = list(self.ws_clients)
|
|
433
|
+
self.ws_clients = []
|
|
434
|
+
for c in clients:
|
|
435
|
+
c.close()
|
|
436
|
+
httpd, self._httpd = self._httpd, None
|
|
437
|
+
if httpd is not None:
|
|
438
|
+
try:
|
|
439
|
+
httpd.shutdown()
|
|
440
|
+
except Exception:
|
|
441
|
+
pass
|
|
442
|
+
try:
|
|
443
|
+
httpd.server_close()
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
thread, self._thread = self._thread, None
|
|
447
|
+
if thread is not None and thread.is_alive():
|
|
448
|
+
thread.join(timeout=2.0)
|
|
449
|
+
self.port = None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""CAT browser/ — the UI state model (spec section 22).
|
|
2
|
+
|
|
3
|
+
One place owns the vocabulary for the right pane. The UI derives its
|
|
4
|
+
layout from these states instead of scattered booleans:
|
|
5
|
+
|
|
6
|
+
WorkspaceMode CODE | PREVIEW what the right pane shows
|
|
7
|
+
PaneMode DEFAULT | FULLSCREEN right pane vs whole window
|
|
8
|
+
PreviewState STOPPED | STARTING | RUNNING | ERROR
|
|
9
|
+
ServerState STOPPED | STARTING | RUNNING | ERROR
|
|
10
|
+
EditorState EMPTY | HAS_TABS editor tab strip state
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkspaceMode(Enum):
|
|
19
|
+
CODE = "code"
|
|
20
|
+
PREVIEW = "preview"
|
|
21
|
+
SPLIT = "split"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PaneMode(Enum):
|
|
25
|
+
DEFAULT = "default"
|
|
26
|
+
FULLSCREEN = "fullscreen"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PreviewState(Enum):
|
|
30
|
+
STOPPED = "stopped"
|
|
31
|
+
STARTING = "starting"
|
|
32
|
+
RUNNING = "running"
|
|
33
|
+
ERROR = "error"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ServerState(Enum):
|
|
37
|
+
STOPPED = "stopped"
|
|
38
|
+
STARTING = "starting"
|
|
39
|
+
RUNNING = "running"
|
|
40
|
+
ERROR = "error"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EditorState(Enum):
|
|
44
|
+
EMPTY = "empty"
|
|
45
|
+
HAS_TABS = "has_tabs"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PreviewSnapshot:
|
|
50
|
+
"""One immutable view of a loaded page — everything the preview
|
|
51
|
+
panel renders. Produced by BrowserEngine after every navigation /
|
|
52
|
+
reload / hot update."""
|
|
53
|
+
url: str = ""
|
|
54
|
+
title: str = ""
|
|
55
|
+
ok: bool = True
|
|
56
|
+
error: str = ""
|
|
57
|
+
# Structured outline extracted from the LIVE DOM (headings, text,
|
|
58
|
+
# links, buttons...) so the terminal can show what Chromium sees.
|
|
59
|
+
outline_lines: Tuple[str, ...] = ()
|
|
60
|
+
console_tail: Tuple[str, ...] = () # recent browser console lines
|
|
61
|
+
js_errors: Tuple[str, ...] = ()
|
|
62
|
+
elements: int = 0
|
|
63
|
+
images_loaded: int = 0
|
|
64
|
+
images_broken: int = 0
|
|
65
|
+
status_code: Optional[int] = None
|
|
66
|
+
extra: dict = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def summary_line(self):
|
|
70
|
+
if not self.ok and self.error:
|
|
71
|
+
return f"✗ {self.error}"
|
|
72
|
+
bits = [f"{self.elements} elements"]
|
|
73
|
+
if self.images_loaded or self.images_broken:
|
|
74
|
+
bits.append(f"{self.images_loaded}✓/{self.images_broken}✗ images")
|
|
75
|
+
return " · ".join(bits)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CAT browser/ — debounced workspace watch for WEB files.
|
|
2
|
+
|
|
3
|
+
Reuses fs_watcher.WorkspaceWatcher's proven watchdog/poller machinery,
|
|
4
|
+
narrowed to what a website preview cares about:
|
|
5
|
+
|
|
6
|
+
.html .htm .css .js .mjs .json .svg (+ image/font assets)
|
|
7
|
+
|
|
8
|
+
and re-debounced to PREVIEW_DEBOUNCE (150 ms, spec section 13) so a
|
|
9
|
+
burst of AI writes coalesces into ONE preview update:
|
|
10
|
+
|
|
11
|
+
AI writes index.html ─┐
|
|
12
|
+
AI writes style.css ─┼─► quiet 150ms ─► callback({paths}) ─► reload
|
|
13
|
+
AI writes app.js ─┘
|
|
14
|
+
|
|
15
|
+
The callback always fires on a background thread (same contract as
|
|
16
|
+
fs_watcher) and receives absolute paths.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
|
|
22
|
+
from ..fs_watcher import WorkspaceWatcher
|
|
23
|
+
from .server import WEB_EXTENSIONS
|
|
24
|
+
|
|
25
|
+
PREVIEW_DEBOUNCE = 0.15 # spec: wait 100–200 ms after the last change
|
|
26
|
+
ASSET_EXTENSIONS = {
|
|
27
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".avif",
|
|
28
|
+
".woff", ".woff2", ".ttf", ".otf", ".svg",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_web_file(path: str) -> bool:
|
|
33
|
+
_, ext = os.path.splitext(str(path).lower())
|
|
34
|
+
return ext in WEB_EXTENSIONS or ext in ASSET_EXTENSIONS
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PreviewFileWatcher(WorkspaceWatcher):
|
|
38
|
+
"""WorkspaceWatcher tuned for live preview:
|
|
39
|
+
|
|
40
|
+
* DEBOUNCE = 150 ms (the Explorer's copy stays at 350 ms);
|
|
41
|
+
* only web-relevant paths reach the callback;
|
|
42
|
+
* `on_activity` fires per accepted change batch so the UI can show
|
|
43
|
+
'● Writing style.css' style build activity without polling.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
DEBOUNCE = PREVIEW_DEBOUNCE
|
|
47
|
+
|
|
48
|
+
def __init__(self, callback, on_activity=None):
|
|
49
|
+
self._user_callback = callback
|
|
50
|
+
self._activity = on_activity
|
|
51
|
+
super().__init__(self._filtered_flush)
|
|
52
|
+
|
|
53
|
+
def _filtered_flush(self, batch):
|
|
54
|
+
web = {p for p in batch if is_web_file(p)}
|
|
55
|
+
if not web:
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
if self._activity is not None:
|
|
59
|
+
for p in sorted(web)[:12]:
|
|
60
|
+
self._activity(p)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
self._user_callback(web)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Debouncer:
|
|
70
|
+
"""Generic trailing-edge debounce usable by tests and the controller:
|
|
71
|
+
schedule(fn) resets a 150 ms timer; fn runs once after quiet."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, delay=PREVIEW_DEBOUNCE):
|
|
74
|
+
self.delay = delay
|
|
75
|
+
self._timer = None
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def schedule(self, fn):
|
|
79
|
+
with self._lock:
|
|
80
|
+
if self._timer is not None:
|
|
81
|
+
self._timer.cancel()
|
|
82
|
+
timer = threading.Timer(self.delay, self._run, args=(fn,))
|
|
83
|
+
timer.daemon = True
|
|
84
|
+
self._timer = timer
|
|
85
|
+
timer.start()
|
|
86
|
+
|
|
87
|
+
def _run(self, fn):
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._timer = None
|
|
90
|
+
try:
|
|
91
|
+
fn()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def cancel(self):
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._timer is not None:
|
|
98
|
+
self._timer.cancel()
|
|
99
|
+
self._timer = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CAT Browser GUI package — real embedded browser (Chromium/WebView2)."""
|