pycodex-ws 0.4.8__tar.gz → 0.5.0__tar.gz

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.
Files changed (46) hide show
  1. pycodex_ws-0.5.0/PKG-INFO +121 -0
  2. pycodex_ws-0.5.0/README.md +110 -0
  3. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/index.html +11 -1
  4. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pyproject.toml +2 -2
  5. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/app.py +46 -8
  6. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.mjs +1 -1
  7. pycodex_ws-0.5.0/workspace_server/customization.py +80 -0
  8. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.mjs +3 -1
  9. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspace.html +475 -571
  10. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspaces.html +11 -1
  11. pycodex_ws-0.4.8/PKG-INFO +0 -166
  12. pycodex_ws-0.4.8/README.md +0 -155
  13. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/.gitignore +0 -0
  14. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/LICENSE +0 -0
  15. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/__init__.py +0 -0
  16. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/__main__.py +0 -0
  17. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/browser.py +0 -0
  18. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/client.py +0 -0
  19. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/protocol.py +0 -0
  20. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_router/server.py +0 -0
  21. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/__init__.py +0 -0
  22. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_card.py +0 -0
  23. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_link.py +0 -0
  24. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/pycodex_ws/feishu_oauth.py +0 -0
  25. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/__init__.py +0 -0
  26. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/__main__.py +0 -0
  27. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/agent_access.py +0 -0
  28. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.LICENSE.txt +0 -0
  29. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/content_views.css +0 -0
  30. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/editor.css +0 -0
  31. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/editor.mjs +0 -0
  32. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/.gitignore +0 -0
  33. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/build.mjs +0 -0
  34. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/composer.mjs +0 -0
  35. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/content_views.css +0 -0
  36. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.css +0 -0
  37. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor.mjs +0 -0
  38. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/editor_context.mjs +0 -0
  39. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/package-lock.json +0 -0
  40. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/package.json +0 -0
  41. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/touch_selection.mjs +0 -0
  42. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/frontend/view_labels.mjs +0 -0
  43. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/owned_process.py +0 -0
  44. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/runtime_info.py +0 -0
  45. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspace_views.py +0 -0
  46. {pycodex_ws-0.4.8 → pycodex_ws-0.5.0}/workspace_server/workspaces.py +0 -0
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.1
2
+ Name: pycodex-ws
3
+ Version: 0.5.0
4
+ Summary: Browser workspace and optional Feishu integration for python-codex
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: pillow>=9.0.0
8
+ Requires-Dist: python-codex==0.5.0
9
+ Requires-Dist: websockets<16,>=11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pycodex-ws
13
+
14
+ Browser conversations, a live HTML Canvas, read-only file/diff viewers and
15
+ optional Feishu integration for
16
+ [`python-codex`](https://pypi.org/project/python-codex/).
17
+ Requires Python 3.7+ and installs the matching core version.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ python3 -m pip install pycodex-ws
23
+ ```
24
+
25
+ Create `workspaces.json`:
26
+
27
+ ```json
28
+ {
29
+ "workspaces": [
30
+ {"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
31
+ ]
32
+ }
33
+ ```
34
+
35
+ ```bash
36
+ pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
37
+ ```
38
+
39
+ Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
40
+ is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
41
+ Model configuration comes from `~/.codex/config.toml`.
42
+
43
+ ## Conversations and views
44
+
45
+ The session sidebar starts collapsed. Hover to preview it, click to keep it
46
+ open, and click the toggle or empty space again to close it. Expansion narrows
47
+ the conversation while keeping the board fixed. On phones, use the header
48
+ buttons to switch between chat and board.
49
+
50
+ Use the expanded sidebar to create, rename or close sessions. Status dots show
51
+ running sessions and unread replies. `/cd <directory>` changes the current
52
+ session's working directory while idle; `/model`, `/resume`, `/compact` and
53
+ other core session commands are also available.
54
+
55
+ Click Markdown links to open files and comparisons, for example
56
+ `[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
57
+ select text and choose **Insert into chat** to add editable context to the draft.
58
+ Click a context block to edit or remove it before sending.
59
+ See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
60
+
61
+ When using a router, its directory groups workspaces by device and lists unread
62
+ workspaces at the top. Device handles reorder the list; pencil buttons edit
63
+ device names.
64
+
65
+ The [VS Code extension](extensions/vscode/README.md) uses this backend and is
66
+ distributed separately as a VSIX.
67
+
68
+ ## Customize the interface
69
+
70
+ Click the gear button to start a customization conversation with the Agent.
71
+ Custom files live in the server user's `~/.pycodex/frontend/`:
72
+
73
+ - `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
74
+ - `workspace.html`: optional full-page override; otherwise the packaged page is used.
75
+ - Other files: available at workspace-relative `frontend/<path>` URLs.
76
+
77
+ Refresh to apply changes and keep the selected conversation. Open the workspace
78
+ with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
79
+ to return. Changes apply to all workspaces for that OS user.
80
+ See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
81
+
82
+ ## Storage
83
+
84
+ Paths in `workspaces.json` resolve from the configuration directory.
85
+ `state_path` is a unique filename prefix: the example saves sessions in
86
+ `demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
87
+ `demo.html`. Suffixes are appended to the complete prefix, including any dots.
88
+
89
+ Set `enable_board` to `false` to disable Canvas while retaining conversations
90
+ and file views. Session directories and history survive server restarts;
91
+ changing a session's directory does not move Canvas or workspace state files.
92
+ Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
93
+
94
+ ## Feishu
95
+
96
+ Install the optional SDK in the workspace's Python environment, then authorize:
97
+
98
+ ```bash
99
+ python3 -m pip install lark-oapi
100
+ python3 -m pycodex_ws.feishu_oauth
101
+ ```
102
+
103
+ Authorization reads app credentials from `~/.codex/.env` and stores the refresh
104
+ token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
105
+ `/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
106
+ Other workspace features work without the SDK.
107
+
108
+ ## Development
109
+
110
+ From the repository root:
111
+
112
+ ```bash
113
+ uv sync --dev
114
+ uv run pycodex-ws --help
115
+ uv run --dev pytest packages/pycodex-ws/tests
116
+ uv build packages/pycodex-ws
117
+ ```
118
+
119
+ Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
120
+ commands. See [runtime contracts](../../docs/RUNTIME.md) and
121
+ [test documentation](../../tests/TESTS.md).
@@ -0,0 +1,110 @@
1
+ # pycodex-ws
2
+
3
+ Browser conversations, a live HTML Canvas, read-only file/diff viewers and
4
+ optional Feishu integration for
5
+ [`python-codex`](https://pypi.org/project/python-codex/).
6
+ Requires Python 3.7+ and installs the matching core version.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ python3 -m pip install pycodex-ws
12
+ ```
13
+
14
+ Create `workspaces.json`:
15
+
16
+ ```json
17
+ {
18
+ "workspaces": [
19
+ {"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
20
+ ]
21
+ }
22
+ ```
23
+
24
+ ```bash
25
+ pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
26
+ ```
27
+
28
+ Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
29
+ is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
30
+ Model configuration comes from `~/.codex/config.toml`.
31
+
32
+ ## Conversations and views
33
+
34
+ The session sidebar starts collapsed. Hover to preview it, click to keep it
35
+ open, and click the toggle or empty space again to close it. Expansion narrows
36
+ the conversation while keeping the board fixed. On phones, use the header
37
+ buttons to switch between chat and board.
38
+
39
+ Use the expanded sidebar to create, rename or close sessions. Status dots show
40
+ running sessions and unread replies. `/cd <directory>` changes the current
41
+ session's working directory while idle; `/model`, `/resume`, `/compact` and
42
+ other core session commands are also available.
43
+
44
+ Click Markdown links to open files and comparisons, for example
45
+ `[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
46
+ select text and choose **Insert into chat** to add editable context to the draft.
47
+ Click a context block to edit or remove it before sending.
48
+ See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
49
+
50
+ When using a router, its directory groups workspaces by device and lists unread
51
+ workspaces at the top. Device handles reorder the list; pencil buttons edit
52
+ device names.
53
+
54
+ The [VS Code extension](extensions/vscode/README.md) uses this backend and is
55
+ distributed separately as a VSIX.
56
+
57
+ ## Customize the interface
58
+
59
+ Click the gear button to start a customization conversation with the Agent.
60
+ Custom files live in the server user's `~/.pycodex/frontend/`:
61
+
62
+ - `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
63
+ - `workspace.html`: optional full-page override; otherwise the packaged page is used.
64
+ - Other files: available at workspace-relative `frontend/<path>` URLs.
65
+
66
+ Refresh to apply changes and keep the selected conversation. Open the workspace
67
+ with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
68
+ to return. Changes apply to all workspaces for that OS user.
69
+ See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
70
+
71
+ ## Storage
72
+
73
+ Paths in `workspaces.json` resolve from the configuration directory.
74
+ `state_path` is a unique filename prefix: the example saves sessions in
75
+ `demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
76
+ `demo.html`. Suffixes are appended to the complete prefix, including any dots.
77
+
78
+ Set `enable_board` to `false` to disable Canvas while retaining conversations
79
+ and file views. Session directories and history survive server restarts;
80
+ changing a session's directory does not move Canvas or workspace state files.
81
+ Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
82
+
83
+ ## Feishu
84
+
85
+ Install the optional SDK in the workspace's Python environment, then authorize:
86
+
87
+ ```bash
88
+ python3 -m pip install lark-oapi
89
+ python3 -m pycodex_ws.feishu_oauth
90
+ ```
91
+
92
+ Authorization reads app credentials from `~/.codex/.env` and stores the refresh
93
+ token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
94
+ `/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
95
+ Other workspace features work without the SDK.
96
+
97
+ ## Development
98
+
99
+ From the repository root:
100
+
101
+ ```bash
102
+ uv sync --dev
103
+ uv run pycodex-ws --help
104
+ uv run --dev pytest packages/pycodex-ws/tests
105
+ uv build packages/pycodex-ws
106
+ ```
107
+
108
+ Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
109
+ commands. See [runtime contracts](../../docs/RUNTIME.md) and
110
+ [test documentation](../../tests/TESTS.md).
@@ -19,6 +19,8 @@
19
19
  --danger: #b54739;
20
20
  --unread-bg: #fff7f4;
21
21
  --unread-line: #ead0c8;
22
+ --breathing-duration: 1.2s;
23
+ --breathing-ease: cubic-bezier(0.4, 0, 0.2, 1);
22
24
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
25
  }
24
26
  @media (prefers-color-scheme: dark) {
@@ -97,7 +99,7 @@
97
99
  .activity-badge[hidden] { display: none; }
98
100
  .workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
99
101
  .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
100
- .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
102
+ .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse var(--breathing-duration) var(--breathing-ease) infinite; }
101
103
  @keyframes spinner-pulse {
102
104
  0%, 100% { opacity: .45; transform: scale(.82); }
103
105
  50% { opacity: 1; transform: scale(1.08); }
@@ -643,6 +645,14 @@
643
645
  document.getElementById("activity").textContent = running ? `${running} working` : "";
644
646
  }
645
647
 
648
+ document.addEventListener("animationstart", event => {
649
+ if (event.animationName !== "spinner-pulse") return;
650
+ // Include pseudo-elements and anchor new indicators to the page's breathing phase.
651
+ for (const animation of event.target.getAnimations({subtree: true})) {
652
+ if (animation.animationName === event.animationName) animation.startTime = 0;
653
+ }
654
+ });
655
+
646
656
  async function refresh() {
647
657
  if (refreshing) return;
648
658
  refreshing = true;
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodex-ws"
7
- version = "0.4.8"
7
+ version = "0.5.0"
8
8
  description = "Browser workspace and optional Feishu integration for python-codex"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
11
11
  dependencies = [
12
- "python-codex==0.4.8",
12
+ "python-codex==0.5.0",
13
13
  "pillow>=9.0.0",
14
14
  "websockets>=11,<16",
15
15
  ]
@@ -68,6 +68,7 @@ from pycodex.events import (
68
68
  )
69
69
  from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
70
70
  from pycodex.utils import uuid7_string
71
+ from pycodex.utils.compat import importlib_metadata
71
72
  from pycodex.utils.event_helpers import (
72
73
  completed_history,
73
74
  shorten_title,
@@ -76,6 +77,11 @@ from pycodex.utils.session_persist import load_display_history
76
77
  from pycodex_ws import build_runtime
77
78
  from pycodex_router.protocol import trusted_agent_request
78
79
 
80
+ from .customization import (
81
+ frontend_asset_response,
82
+ frontend_directory,
83
+ load_workspace_template,
84
+ )
79
85
  from .runtime_info import runtime_info_prompt
80
86
  from .workspace_views import WorkspaceViewOpener
81
87
  from .workspaces import (
@@ -950,6 +956,7 @@ def create_multi_workspace_app(
950
956
  entry.definition.board_path,
951
957
  title=entry.definition.workspace_id,
952
958
  work_dir=entry.definition.work_dir,
959
+ builtin=request.query_params.get("builtin") == "1",
953
960
  )
954
961
  )
955
962
 
@@ -996,7 +1003,11 @@ def create_multi_workspace_app(
996
1003
 
997
1004
  async def workspace_new_session(request: Request) -> JSONResponse:
998
1005
  entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
999
- return await _new_session_response(entry.manager)
1006
+ return await _new_session_response(entry.manager, request)
1007
+
1008
+ def workspace_frontend_asset(request: Request) -> Response:
1009
+ _workspace_entry_or_404(registry, request.path_params["workspace_id"])
1010
+ return frontend_asset_response(request.path_params["asset_path"])
1000
1011
 
1001
1012
  async def workspace_delete_session(request: Request) -> JSONResponse:
1002
1013
  entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
@@ -1082,6 +1093,10 @@ def create_multi_workspace_app(
1082
1093
  ),
1083
1094
  Route("/w/{workspace_id}/api/sessions", workspace_sessions, methods=["GET"]),
1084
1095
  Route("/w/{workspace_id}/api/sessions", workspace_new_session, methods=["POST"]),
1096
+ Route(
1097
+ "/w/{workspace_id}/frontend/{asset_path:path}",
1098
+ workspace_frontend_asset, methods=["GET", "HEAD"],
1099
+ ),
1085
1100
  Route(
1086
1101
  "/w/{workspace_id}/api/sessions/{session_id}",
1087
1102
  workspace_delete_session, methods=["DELETE"],
@@ -1368,7 +1383,10 @@ def _install_workspace_routes(
1368
1383
  view_asset_cache = {}
1369
1384
 
1370
1385
  async def index(request: Request) -> HTMLResponse:
1371
- return _html_response(_render_workspace_shell(board_path, work_dir=work_dir))
1386
+ return _html_response(_render_workspace_shell(
1387
+ board_path, work_dir=work_dir,
1388
+ builtin=request.query_params.get("builtin") == "1",
1389
+ ))
1372
1390
 
1373
1391
  async def favicon(request: Request) -> Response:
1374
1392
  return Response(status_code=204)
@@ -1404,7 +1422,10 @@ def _install_workspace_routes(
1404
1422
  return _sessions_response(manager)
1405
1423
 
1406
1424
  async def new_session(request: Request) -> JSONResponse:
1407
- return await _new_session_response(manager)
1425
+ return await _new_session_response(manager, request)
1426
+
1427
+ def frontend_asset(request: Request) -> Response:
1428
+ return frontend_asset_response(request.path_params["asset_path"])
1408
1429
 
1409
1430
  async def delete_session(request: Request) -> JSONResponse:
1410
1431
  return await _delete_session_response(manager, request.path_params["session_id"])
@@ -1449,6 +1470,7 @@ def _install_workspace_routes(
1449
1470
  Route("/ws/session", websocket_backend_hint, methods=["GET"]),
1450
1471
  Route("/api/sessions", sessions, methods=["GET"]),
1451
1472
  Route("/api/sessions", new_session, methods=["POST"]),
1473
+ Route("/frontend/{asset_path:path}", frontend_asset, methods=["GET", "HEAD"]),
1452
1474
  Route("/api/sessions/{session_id}", delete_session, methods=["DELETE"]),
1453
1475
  Route("/api/sessions/{session_id}/read", read_session, methods=["POST"]),
1454
1476
  Route("/api/session", session, methods=["GET"]),
@@ -1619,15 +1641,27 @@ def _read_session_response(
1619
1641
  return _sessions_response(manager)
1620
1642
 
1621
1643
 
1622
- async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1623
- session_id = await manager.create_session()
1644
+ async def _new_session_response(
1645
+ manager: "WorkspaceSessionManager", request: Request,
1646
+ ) -> JSONResponse:
1647
+ payload = await _json_object_body(request) if await request.body() else {}
1648
+ title = payload.get("title", "")
1649
+ prompt = payload.get("prompt", "")
1650
+ if not isinstance(title, str) or not isinstance(prompt, str):
1651
+ raise HTTPException(status_code=422, detail="title and prompt must be strings")
1652
+ session_id = await manager.create_session(title=title)
1653
+ result = (
1654
+ await manager.get(session_id).submit(prompt, sender="web")
1655
+ if prompt else {"ok": True}
1656
+ )
1624
1657
  return JSONResponse(
1625
1658
  {
1626
- "ok": True,
1659
+ **result,
1627
1660
  "session_id": session_id,
1628
1661
  "sessions": manager.list_sessions(),
1629
1662
  "snapshot": session_snapshot(manager.get(session_id)),
1630
- }
1663
+ },
1664
+ status_code=200 if result["ok"] else 400,
1631
1665
  )
1632
1666
 
1633
1667
 
@@ -2034,17 +2068,21 @@ def _render_workspace_shell(
2034
2068
  board_path: "typing.Union[Path, None]",
2035
2069
  title: "typing.Union[str, None]" = None,
2036
2070
  work_dir: "typing.Union[Path, None]" = None,
2071
+ builtin: bool = False,
2037
2072
  ) -> str:
2038
2073
  board_label = str(board_path) if board_path is not None else "No board"
2039
2074
  cwd_label = str(work_dir or Path.cwd())
2040
2075
  page_title = str(title or "pycodex workspace")
2041
- template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
2076
+ template = load_workspace_template(builtin)
2042
2077
  return (
2043
2078
  template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
2044
2079
  .replace("__BOARD_LABEL__", html.escape(board_label))
2045
2080
  .replace("__CWD_LABEL__", html.escape(cwd_label))
2046
2081
  .replace("__ENABLE_BOARD__", "true" if board_path is not None else "false")
2047
2082
  .replace("__BOARD_URL__", "board" if board_path is not None else "about:blank")
2083
+ .replace("__FRONTEND_DIRECTORY__", html.escape(str(frontend_directory())))
2084
+ .replace("__PACKAGE_DIRECTORY__", html.escape(str(Path(__file__).resolve().parent)))
2085
+ .replace("__PACKAGE_VERSION__", html.escape(importlib_metadata.version("pycodex-ws")))
2048
2086
  )
2049
2087
 
2050
2088
 
@@ -26,6 +26,6 @@ Click to edit context; Backspace / Delete to remove`,this.element.append(n,docum
26
26
  type: "workspace-preview-ready", generation,
27
27
  }, "*"));
28
28
  })();`,e.head.prepend(s);let n=e.doctype?new this.window.XMLSerializer().serializeToString(e.doctype)+`
29
- `:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},P=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,createHtmlPreview:n=(o,r)=>new O(o,r)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=n,this.activeId=null,this.editor=null,this.htmlPreview=null,this.generation=0,this.remaining=new Set,this.editorStates=new Map,this.previews=new Map,this.scrollPositions=new Map,this.sourceViews=new Set,this.pendingSelection=null,this.selectionRevision=0}loadEditorAssets(){if(!this.editorAssets){let t=this.document.createElement("link");t.rel="stylesheet",t.href=new URL("./editor.css",import.meta.url).href;let e=new Promise((i,s)=>{t.addEventListener("load",i,{once:!0}),t.addEventListener("error",()=>s(new Error("Editor stylesheet request failed.")),{once:!0})});this.document.head.appendChild(t),this.editorAssets=Promise.all([import("./editor.mjs"),e]).then(([i])=>i).catch(i=>{throw t.remove(),this.editorAssets=null,i})}return this.editorAssets}retain(t){this.remaining=new Set(t);for(let e of[this.editorStates,this.previews,this.scrollPositions,this.sourceViews])for(let i of e.keys())this.remaining.has(i)||e.delete(i)}clear(t=""){this.generation+=1,this.editor?(this.remaining.has(this.activeId)&&this.editorStates.set(this.activeId,this.editor.capture()),this.editor.destroy(),this.editor=null):this.htmlPreview?(this.remaining.has(this.activeId)&&this.scrollPositions.set(this.activeId,this.htmlPreview.capture()),this.htmlPreview.destroy(),this.htmlPreview=null):this.remaining.has(this.activeId)&&this.previews.has(this.activeId)&&this.scrollPositions.set(this.activeId,[this.content.scrollLeft,this.content.scrollTop]),this.activeId=null,this.content.classList.remove("code-view"),this.content.classList.remove("html-view"),this.content.textContent="",t&&this.content.appendChild(y(this.document,"div","content-placeholder",t))}async show(t,e,i=null,s=null){i&&e.revision<i.contentRevision&&(i=null),this.pendingSelection=i,s==="preview"?this.sourceViews.delete(t):(i||s==="source")&&this.sourceViews.add(t);let n=e.documents[0],o=e.kind==="file"&&!this.sourceViews.has(t),r=o&&/\.(md|markdown)$/i.test(n.label)&&this.renderMarkdown,d=o&&/\.html?$/i.test(n.label);if(this.activeId===t&&this.htmlPreview&&d)return this.htmlPreview.update(n.text);if(this.activeId===t&&this.editor&&!r&&!d){let m=this.generation;try{await this.editor.update(e),m===this.generation&&await this.applySelection(t,i)}catch(v){if(m===this.generation)throw v}return}this.clear(),this.activeId=t;let h=this.generation;if(e.kind==="diff"||!n.image_url&&!r&&!d){this.content.classList.add("code-view"),this.content.appendChild(y(this.document,"div","content-placeholder","Loading code viewer\u2026"));try{let m=await this.loadEditor();if(h!==this.generation)return;this.content.textContent="";let v=y(this.document,"div","workspace-editor");this.content.appendChild(v);let g=this.onInsertContext&&(E=>{h===this.generation&&this.activeId===t&&this.onInsertContext(E)}),S=await m.createSnapshotEditor(v,e,this.editorStates.get(t),g);if(h!==this.generation){S.destroy();return}this.editor=S}catch(m){if(h!==this.generation)return;this.content.textContent="",this.content.appendChild(y(this.document,"div","content-placeholder","Unable to load code viewer: "+m.message));return}await this.applySelection(t,i);return}if(d)return this.content.classList.add("html-view"),this.htmlPreview=this.createHtmlPreview(this.content,this.scrollPositions.get(t)),this.htmlPreview.update(n.text);if(!this.previews.has(t)){let m=y(this.document,"div","snapshot"),v=y(this.document,"div","snapshot-heading");if(v.appendChild(y(this.document,"span","snapshot-label",n.label)),v.appendChild(y(this.document,"span","snapshot-stamp","Read only")),m.appendChild(v),n.image_url){let g=y(this.document,"img","snapshot-image");g.alt=n.label,m.appendChild(g)}else m.appendChild(y(this.document,"div","snapshot-markdown markdown"));this.previews.set(t,m)}this.content.appendChild(this.previews.get(t));let u=this.content.querySelector(".snapshot-image");if(u&&u.src!==n.image_url&&(u.src=n.image_url),r){let m=this.content.querySelector(".snapshot-markdown");n.source&&m.setAttribute("data-src-path",n.source.path),this.renderMarkdown(m,n.text)}let[p,k]=this.scrollPositions.get(t)||[0,0];this.content.scrollLeft=p,this.content.scrollTop=k}async applySelection(t,e){if(!e||e.revision<=this.selectionRevision)return;let i=this.editor,s=()=>this.activeId===t&&this.editor===i&&this.pendingSelection===e&&e.revision>this.selectionRevision;s()&&await i.revealLine(e.document,e.line,s)&&(this.selectionRevision=e.revision)}};function F({tabs:c,frame:t,content:e,status:i,request:s,renderMarkdown:n,onInsertContext:o,enableBoard:r=!0}){let d=c.ownerDocument,h=new P({content:e,renderMarkdown:n,onInsertContext:o}),u,p=null,k=!1,m=null,v=null,g;function S(a){let C=d.getElementById("content-tab-"+a);C&&(C.focus({preventScroll:!0}),C.scrollIntoView({block:"nearest",inline:"nearest"}))}function E(a){let C=a.snapshot?a.snapshot.documents.map(l=>l.error):[];i.textContent=[a.error,...C].filter(Boolean).join("; ")||(a.openRequest?"Opening view\u2026":""),i.title=i.textContent,i.hidden=!i.textContent;let R=new Map(Array.from(c.children,l=>[l.dataset.viewId,l])),J=(r?[{id:"board",title:"Canvas",kind:"board"}]:[]).concat(a.views),T=U(a.views);T.set("board",{name:"Canvas",description:""}),J.forEach((l,I)=>{let f=R.get(l.id);if(!f){f=y(d,"div","tab content-tab"),f.dataset.viewId=l.id,f.id="content-tab-"+l.id,f.setAttribute("role","tab"),f.setAttribute("aria-controls",l.id==="board"?t.id:e.id);let D=y(d,"span","tab-label content-tab-label");if(D.appendChild(y(d,"span","content-tab-name")),D.appendChild(y(d,"span","content-tab-description")),f.appendChild(D),f.addEventListener("click",()=>g.select(l.id)),f.addEventListener("keydown",w=>{if(w.target!==f)return;let b=(r?["board"]:[]).concat(g.views.map($=>$.id)),j=b.indexOf(l.id),x=null;w.key==="ArrowRight"&&(x=b[(j+1)%b.length]),w.key==="ArrowLeft"&&(x=b[(j+b.length-1)%b.length]),w.key==="Home"&&(x=b[0]),w.key==="End"&&(x=b[b.length-1]),(w.key==="Enter"||w.key===" ")&&(x=l.id),w.key==="Delete"&&l.id!=="board"&&(w.preventDefault(),g.close(l.id)),x!==null&&(w.preventDefault(),g.select(x),d.getElementById("content-tab-"+x).focus())}),l.id!=="board"){let w=y(d,"button","tab-close content-tab-close");w.type="button",w.title="Close tab",w.setAttribute("aria-label","Close "+l.title),w.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',w.addEventListener("click",b=>{b.stopPropagation(),g.close(l.id)}),f.appendChild(w)}}R.delete(l.id);let L=T.get(l.id);f.querySelector(".content-tab-name").textContent=L.name;let V=f.querySelector(".content-tab-description");V.textContent=L.description,V.hidden=!L.description,f.className="tab content-tab"+(l.id===a.activeId?" active":""),f.tabIndex=l.id===a.activeId?0:-1,f.title=l.id==="board"?c.dataset.boardTitle:l.title+(l.live?`
29
+ `:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},P=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,createHtmlPreview:n=(o,r)=>new O(o,r)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=n,this.activeId=null,this.editor=null,this.htmlPreview=null,this.generation=0,this.remaining=new Set,this.editorStates=new Map,this.previews=new Map,this.scrollPositions=new Map,this.sourceViews=new Set,this.pendingSelection=null,this.selectionRevision=0}loadEditorAssets(){if(!this.editorAssets){let t=this.document.createElement("link");t.rel="stylesheet",t.href=new URL("./editor.css",import.meta.url).href;let e=new Promise((i,s)=>{t.addEventListener("load",i,{once:!0}),t.addEventListener("error",()=>s(new Error("Editor stylesheet request failed.")),{once:!0})});this.document.head.insertBefore(t,this.document.head.querySelector("[data-pycodex-custom-style]")),this.editorAssets=Promise.all([import("./editor.mjs"),e]).then(([i])=>i).catch(i=>{throw t.remove(),this.editorAssets=null,i})}return this.editorAssets}retain(t){this.remaining=new Set(t);for(let e of[this.editorStates,this.previews,this.scrollPositions,this.sourceViews])for(let i of e.keys())this.remaining.has(i)||e.delete(i)}clear(t=""){this.generation+=1,this.editor?(this.remaining.has(this.activeId)&&this.editorStates.set(this.activeId,this.editor.capture()),this.editor.destroy(),this.editor=null):this.htmlPreview?(this.remaining.has(this.activeId)&&this.scrollPositions.set(this.activeId,this.htmlPreview.capture()),this.htmlPreview.destroy(),this.htmlPreview=null):this.remaining.has(this.activeId)&&this.previews.has(this.activeId)&&this.scrollPositions.set(this.activeId,[this.content.scrollLeft,this.content.scrollTop]),this.activeId=null,this.content.classList.remove("code-view"),this.content.classList.remove("html-view"),this.content.textContent="",t&&this.content.appendChild(y(this.document,"div","content-placeholder",t))}async show(t,e,i=null,s=null){i&&e.revision<i.contentRevision&&(i=null),this.pendingSelection=i,s==="preview"?this.sourceViews.delete(t):(i||s==="source")&&this.sourceViews.add(t);let n=e.documents[0],o=e.kind==="file"&&!this.sourceViews.has(t),r=o&&/\.(md|markdown)$/i.test(n.label)&&this.renderMarkdown,d=o&&/\.html?$/i.test(n.label);if(this.activeId===t&&this.htmlPreview&&d)return this.htmlPreview.update(n.text);if(this.activeId===t&&this.editor&&!r&&!d){let m=this.generation;try{await this.editor.update(e),m===this.generation&&await this.applySelection(t,i)}catch(v){if(m===this.generation)throw v}return}this.clear(),this.activeId=t;let h=this.generation;if(e.kind==="diff"||!n.image_url&&!r&&!d){this.content.classList.add("code-view"),this.content.appendChild(y(this.document,"div","content-placeholder","Loading code viewer\u2026"));try{let m=await this.loadEditor();if(h!==this.generation)return;this.content.textContent="";let v=y(this.document,"div","workspace-editor");this.content.appendChild(v);let g=this.onInsertContext&&(E=>{h===this.generation&&this.activeId===t&&this.onInsertContext(E)}),S=await m.createSnapshotEditor(v,e,this.editorStates.get(t),g);if(h!==this.generation){S.destroy();return}this.editor=S}catch(m){if(h!==this.generation)return;this.content.textContent="",this.content.appendChild(y(this.document,"div","content-placeholder","Unable to load code viewer: "+m.message));return}await this.applySelection(t,i);return}if(d)return this.content.classList.add("html-view"),this.htmlPreview=this.createHtmlPreview(this.content,this.scrollPositions.get(t)),this.htmlPreview.update(n.text);if(!this.previews.has(t)){let m=y(this.document,"div","snapshot"),v=y(this.document,"div","snapshot-heading");if(v.appendChild(y(this.document,"span","snapshot-label",n.label)),v.appendChild(y(this.document,"span","snapshot-stamp","Read only")),m.appendChild(v),n.image_url){let g=y(this.document,"img","snapshot-image");g.alt=n.label,m.appendChild(g)}else m.appendChild(y(this.document,"div","snapshot-markdown markdown"));this.previews.set(t,m)}this.content.appendChild(this.previews.get(t));let u=this.content.querySelector(".snapshot-image");if(u&&u.src!==n.image_url&&(u.src=n.image_url),r){let m=this.content.querySelector(".snapshot-markdown");n.source&&m.setAttribute("data-src-path",n.source.path),this.renderMarkdown(m,n.text)}let[p,k]=this.scrollPositions.get(t)||[0,0];this.content.scrollLeft=p,this.content.scrollTop=k}async applySelection(t,e){if(!e||e.revision<=this.selectionRevision)return;let i=this.editor,s=()=>this.activeId===t&&this.editor===i&&this.pendingSelection===e&&e.revision>this.selectionRevision;s()&&await i.revealLine(e.document,e.line,s)&&(this.selectionRevision=e.revision)}};function F({tabs:c,frame:t,content:e,status:i,request:s,renderMarkdown:n,onInsertContext:o,enableBoard:r=!0}){let d=c.ownerDocument,h=new P({content:e,renderMarkdown:n,onInsertContext:o}),u,p=null,k=!1,m=null,v=null,g;function S(a){let C=d.getElementById("content-tab-"+a);C&&(C.focus({preventScroll:!0}),C.scrollIntoView({block:"nearest",inline:"nearest"}))}function E(a){let C=a.snapshot?a.snapshot.documents.map(l=>l.error):[];i.textContent=[a.error,...C].filter(Boolean).join("; ")||(a.openRequest?"Opening view\u2026":""),i.title=i.textContent,i.hidden=!i.textContent;let R=new Map(Array.from(c.children,l=>[l.dataset.viewId,l])),J=(r?[{id:"board",title:"Canvas",kind:"board"}]:[]).concat(a.views),T=U(a.views);T.set("board",{name:"Canvas",description:""}),J.forEach((l,I)=>{let f=R.get(l.id);if(!f){f=y(d,"div","tab content-tab"),f.dataset.viewId=l.id,f.id="content-tab-"+l.id,f.setAttribute("role","tab"),f.setAttribute("aria-controls",l.id==="board"?t.id:e.id);let D=y(d,"span","tab-label content-tab-label");if(D.appendChild(y(d,"span","content-tab-name")),D.appendChild(y(d,"span","content-tab-description")),f.appendChild(D),f.addEventListener("click",()=>g.select(l.id)),f.addEventListener("keydown",w=>{if(w.target!==f)return;let b=(r?["board"]:[]).concat(g.views.map($=>$.id)),j=b.indexOf(l.id),x=null;w.key==="ArrowRight"&&(x=b[(j+1)%b.length]),w.key==="ArrowLeft"&&(x=b[(j+b.length-1)%b.length]),w.key==="Home"&&(x=b[0]),w.key==="End"&&(x=b[b.length-1]),(w.key==="Enter"||w.key===" ")&&(x=l.id),w.key==="Delete"&&l.id!=="board"&&(w.preventDefault(),g.close(l.id)),x!==null&&(w.preventDefault(),g.select(x),d.getElementById("content-tab-"+x).focus())}),l.id!=="board"){let w=y(d,"button","tab-close content-tab-close");w.type="button",w.title="Close tab",w.setAttribute("aria-label","Close "+l.title),w.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',w.addEventListener("click",b=>{b.stopPropagation(),g.close(l.id)}),f.appendChild(w)}}R.delete(l.id);let L=T.get(l.id);f.querySelector(".content-tab-name").textContent=L.name;let V=f.querySelector(".content-tab-description");V.textContent=L.description,V.hidden=!L.description,f.className="tab content-tab"+(l.id===a.activeId?" active":""),f.tabIndex=l.id===a.activeId?0:-1,f.title=l.id==="board"?c.dataset.boardTitle:l.title+(l.live?`
30
30
  Read only \xB7 follows file changes \xB7 `:`
31
31
  Read-only snapshot \xB7 `)+new Date(l.created_at).toLocaleString(),f.setAttribute("aria-selected",String(l.id===a.activeId)),f.setAttribute("aria-label",l.title);let A=f.querySelector(".content-tab-close");A&&(A.disabled=a.closing.has(l.id),A.setAttribute("aria-label","Close "+l.title)),c.children[I]!==f&&c.insertBefore(f,c.children[I]||null)}),R.forEach(l=>l.remove()),h.retain(a.views.map(l=>l.id));let q=a.activeId==="board",z=u!==a.activeId,B=z||p!==a.snapshot||k!==a.loading||m!==a.selection||v!==a.displayMode;if(q&&B&&h.clear(),t.hidden=!q,e.hidden=q,!!B&&(u=a.activeId,p=a.snapshot,k=a.loading,m=a.selection,v=a.displayMode,a.activeId?e.setAttribute("aria-labelledby","content-tab-"+a.activeId):e.removeAttribute("aria-labelledby"),e.setAttribute("aria-busy",String(a.loading)),!q)){if(a.snapshot){let l=a.activeId,I=a.snapshot;h.show(l,I,a.selection,a.displayMode).catch(f=>{u!==l||p!==I||(p=null,a.error="Unable to update view: "+f.message,i.textContent=i.title=a.error,i.hidden=!1)})}else h.clear(a.loading?"Loading view\u2026":a.activeId?"View unavailable.":"Open a file link to view it here.");z&&S(a.activeId)}}return g=new N({request:s,enableBoard:r,onChange:E,onSelect:a=>{e.closest(".workspace").dataset.mobilePane="board",S(a)}}),E(g),{start(){g.start(),E(g)},stop(){g.stop(),h.clear(),u=null},poll(){return g.poll()},open(a,C,R){return g.open(a,C,R)}}}export{M as Composer,N as ContentTabs,P as SnapshotDisplay,X as isWorkspaceLink,F as mountContentViews};
@@ -0,0 +1,80 @@
1
+ """Workspace frontend template and static resource loading."""
2
+
3
+ import mimetypes
4
+ from html.parser import HTMLParser
5
+ from pathlib import Path
6
+
7
+ from starlette.exceptions import HTTPException
8
+ from starlette.responses import FileResponse
9
+
10
+
11
+ class _AssetInsertions(HTMLParser):
12
+ def __init__(self, template: str, additions: dict) -> None:
13
+ super().__init__()
14
+ self.lines = template.splitlines(keepends=True)
15
+ self.additions = additions
16
+ self.positions = []
17
+
18
+ def handle_endtag(self, tag: str) -> None:
19
+ markup = self.additions.pop(tag, None)
20
+ if markup is not None:
21
+ line, column = self.getpos()
22
+ offset = sum(len(row) for row in self.lines[:line - 1]) + column
23
+ self.positions.append((offset, markup))
24
+
25
+
26
+ def frontend_directory() -> Path:
27
+ return Path.home() / ".pycodex" / "frontend"
28
+
29
+
30
+ def frontend_file(name: str) -> Path:
31
+ directory = frontend_directory().resolve()
32
+ path = (directory / name).resolve()
33
+ try:
34
+ path.relative_to(directory)
35
+ except ValueError:
36
+ raise HTTPException(status_code=404, detail="frontend file not found")
37
+ return path
38
+
39
+
40
+ def load_workspace_template(builtin: bool = False) -> str:
41
+ packaged = Path(__file__).with_name("workspace.html")
42
+ if builtin:
43
+ return packaged.read_text(encoding="utf-8")
44
+ try:
45
+ template = frontend_file("workspace.html").read_text(
46
+ encoding="utf-8", errors="replace"
47
+ )
48
+ except FileNotFoundError:
49
+ template = packaged.read_text(encoding="utf-8")
50
+ additions = {}
51
+ for name, closing_tag, markup in [
52
+ ("custom.css", "head", '<link rel="stylesheet" href="frontend/custom.css" data-pycodex-custom-style>'),
53
+ ("custom.js", "body", '<script type="module" src="frontend/custom.js"></script>'),
54
+ ]:
55
+ try:
56
+ frontend_file(name).stat()
57
+ except FileNotFoundError:
58
+ continue
59
+ additions[closing_tag] = markup
60
+ if additions:
61
+ parser = _AssetInsertions(template, additions)
62
+ parser.feed(template)
63
+ for offset, markup in reversed(parser.positions):
64
+ template = template[:offset] + markup + "\n" + template[offset:]
65
+ return template
66
+
67
+
68
+ def frontend_asset_response(name: str) -> FileResponse:
69
+ path = frontend_file(name)
70
+ if not path.is_file():
71
+ raise HTTPException(status_code=404, detail="frontend file not found")
72
+ media_type = (
73
+ "text/javascript" if path.suffix.lower() in {".js", ".mjs"}
74
+ else mimetypes.guess_type(str(path))[0] or "application/octet-stream"
75
+ )
76
+ return FileResponse(
77
+ str(path),
78
+ media_type=media_type,
79
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
80
+ )
@@ -328,7 +328,9 @@ export class SnapshotDisplay {
328
328
  stylesheet.addEventListener("error",
329
329
  () => reject(new Error("Editor stylesheet request failed.")), {once: true});
330
330
  });
331
- this.document.head.appendChild(stylesheet);
331
+ this.document.head.insertBefore(
332
+ stylesheet, this.document.head.querySelector("[data-pycodex-custom-style]")
333
+ );
332
334
  this.editorAssets = Promise.all([import("./editor.mjs"), styles])
333
335
  .then(([module]) => module)
334
336
  .catch(error => {