pycodex-ws 0.4.5__tar.gz → 0.4.7__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.
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/PKG-INFO +21 -7
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/README.md +19 -5
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/index.html +199 -34
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/server.py +38 -3
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pyproject.toml +2 -2
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/.gitignore +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/LICENSE +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/client.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/app.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/touch_selection.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/owned_process.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/workspace.html +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/workspace_views.py +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/workspaces.html +0 -0
- {pycodex_ws-0.4.5 → pycodex_ws-0.4.7}/workspace_server/workspaces.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pycodex-ws
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Browser workspace and optional Feishu integration for python-codex
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.7
|
|
7
7
|
Requires-Dist: pillow>=9.0.0
|
|
8
|
-
Requires-Dist: python-codex==0.4.
|
|
8
|
+
Requires-Dist: python-codex==0.4.7
|
|
9
9
|
Requires-Dist: websockets<16,>=11
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
|
|
@@ -96,12 +96,26 @@ Creating a workspace through the management page or API without a prefix
|
|
|
96
96
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
97
97
|
|
|
98
98
|
The router directory collects unread workspace shortcuts and their device names
|
|
99
|
-
in a distinct Unread panel at the top. Read workspaces leave that panel
|
|
100
|
-
|
|
99
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
100
|
+
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
101
|
+
order. The focused handle also supports the up and down arrow keys. Escape or
|
|
102
|
+
dropping outside the list cancels a drag. On touch screens, drag the handle;
|
|
103
|
+
the rest of the card still scrolls normally. List updates wait until the drag
|
|
104
|
+
and save finish.
|
|
105
|
+
The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
|
|
106
|
+
shared by all browsers and retained across router restarts. Its device entry
|
|
107
|
+
order is the display order; an empty alias means to show the original name.
|
|
108
|
+
Existing alias files remain readable. Offline device positions are
|
|
109
|
+
remembered, newly registered devices go last, and each device's workspaces keep
|
|
110
|
+
their configuration order. Reordering requires router login; if connected
|
|
111
|
+
devices change during a drag, refresh and try again.
|
|
101
112
|
Device groups start collapsed, retain their expanded state during updates, and
|
|
102
|
-
show Manage and the number of working workspaces in their headings. Click
|
|
103
|
-
device name to
|
|
104
|
-
|
|
113
|
+
show Manage and the number of working workspaces in their headings. Click a
|
|
114
|
+
device heading or its name to expand or collapse the group. To rename a device,
|
|
115
|
+
use its pencil button in the Online devices list, then press Enter or Save;
|
|
116
|
+
Escape or Cancel discards the edit. Leaving the field does not save it.
|
|
117
|
+
Aliases must be nonblank and contain 1 to 128 characters.
|
|
118
|
+
Device headings and the Online devices list share an unread dot
|
|
105
119
|
whenever any of that device's workspaces is unread. Final replies and failed
|
|
106
120
|
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
107
121
|
|
|
@@ -85,12 +85,26 @@ Creating a workspace through the management page or API without a prefix
|
|
|
85
85
|
generates a `/tmp/pcws-*` prefix and saves it in the configuration.
|
|
86
86
|
|
|
87
87
|
The router directory collects unread workspace shortcuts and their device names
|
|
88
|
-
in a distinct Unread panel at the top. Read workspaces leave that panel
|
|
89
|
-
|
|
88
|
+
in a distinct Unread panel at the top. Read workspaces leave that panel.
|
|
89
|
+
Drag a handle in Online devices to reorder devices; both lists share the saved
|
|
90
|
+
order. The focused handle also supports the up and down arrow keys. Escape or
|
|
91
|
+
dropping outside the list cancels a drag. On touch screens, drag the handle;
|
|
92
|
+
the rest of the card still scrolls normally. List updates wait until the drag
|
|
93
|
+
and save finish.
|
|
94
|
+
The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
|
|
95
|
+
shared by all browsers and retained across router restarts. Its device entry
|
|
96
|
+
order is the display order; an empty alias means to show the original name.
|
|
97
|
+
Existing alias files remain readable. Offline device positions are
|
|
98
|
+
remembered, newly registered devices go last, and each device's workspaces keep
|
|
99
|
+
their configuration order. Reordering requires router login; if connected
|
|
100
|
+
devices change during a drag, refresh and try again.
|
|
90
101
|
Device groups start collapsed, retain their expanded state during updates, and
|
|
91
|
-
show Manage and the number of working workspaces in their headings. Click
|
|
92
|
-
device name to
|
|
93
|
-
|
|
102
|
+
show Manage and the number of working workspaces in their headings. Click a
|
|
103
|
+
device heading or its name to expand or collapse the group. To rename a device,
|
|
104
|
+
use its pencil button in the Online devices list, then press Enter or Save;
|
|
105
|
+
Escape or Cancel discards the edit. Leaving the field does not save it.
|
|
106
|
+
Aliases must be nonblank and contain 1 to 128 characters.
|
|
107
|
+
Device headings and the Online devices list share an unread dot
|
|
94
108
|
whenever any of that device's workspaces is unread. Final replies and failed
|
|
95
109
|
turns both trigger shared unread dots; viewing the reply or error marks it as read.
|
|
96
110
|
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* { box-sizing: border-box; }
|
|
41
41
|
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
|
|
42
42
|
a { color: inherit; text-decoration: none; }
|
|
43
|
-
a:focus-visible,
|
|
43
|
+
a:focus-visible, button:focus-visible, .workspace-group-heading:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
|
|
44
44
|
svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
45
45
|
h1, h2, h3, h4, p { margin: 0; }
|
|
46
46
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
.registration-content { display: grid; gap: 8px; margin-top: 12px; }
|
|
65
65
|
.registration input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
66
66
|
.registration p { color: var(--muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
|
67
|
-
#alias-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
|
|
68
|
-
#alias-status:empty { display: none; }
|
|
67
|
+
#alias-status, #order-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
|
|
68
|
+
#alias-status:empty, #order-status:empty { display: none; }
|
|
69
69
|
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
|
|
70
70
|
.layout > * { min-width: 0; }
|
|
71
71
|
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
|
@@ -120,9 +120,19 @@
|
|
|
120
120
|
.device-heading { display: flex; align-items: start; gap: 9px; }
|
|
121
121
|
.device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
|
|
122
122
|
.device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
|
|
123
|
-
.device-label {
|
|
124
|
-
.device-
|
|
123
|
+
.device-label { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
124
|
+
.device-rename, .device-drag, .device-alias-actions button { display: inline-flex; align-items: center; justify-content: center; min-height: 28px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font: inherit; cursor: pointer; }
|
|
125
|
+
.device-rename:hover, .device-drag:hover, .device-alias-actions button:hover { background: var(--soft); color: var(--ink); }
|
|
126
|
+
.device-rename, .device-drag { flex: 0 0 28px; padding: 4px; border-color: transparent; }
|
|
127
|
+
.device-rename[hidden] { display: none; }
|
|
128
|
+
.device-rename svg, .device-drag svg { width: 14px; height: 14px; margin: 0; color: inherit; }
|
|
129
|
+
.device-drag { touch-action: none; user-select: none; cursor: grab; }
|
|
130
|
+
.device-drag:disabled { opacity: .35; cursor: default; }
|
|
131
|
+
.device[data-dragging] { background: var(--soft); opacity: .65; }
|
|
132
|
+
.device-drop-before { box-shadow: 0 -2px var(--accent); }
|
|
133
|
+
.nodes.device-drop-end { box-shadow: inset 0 -2px var(--accent); }
|
|
125
134
|
.device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
|
|
135
|
+
.device-alias-actions { display: flex; gap: 6px; margin-top: 6px; }
|
|
126
136
|
.online-dot { margin-top: 7px; }
|
|
127
137
|
.device-heading > .unread-dot { margin-top: 7px; }
|
|
128
138
|
.device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
|
|
@@ -146,6 +156,8 @@
|
|
|
146
156
|
.workspace-paths { grid-column: 1 / -1; grid-row: 2; }
|
|
147
157
|
.workspace-status { grid-column: 2; grid-row: 1; }
|
|
148
158
|
.open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
|
|
159
|
+
.device-rename, .device-drag, .device-alias-actions button { min-height: 36px; }
|
|
160
|
+
.device-rename, .device-drag { flex-basis: 36px; }
|
|
149
161
|
}
|
|
150
162
|
</style>
|
|
151
163
|
</head>
|
|
@@ -177,6 +189,7 @@
|
|
|
177
189
|
</div>
|
|
178
190
|
</details>
|
|
179
191
|
<p id="alias-status" role="alert"></p>
|
|
192
|
+
<p id="order-status" role="alert"></p>
|
|
180
193
|
<div class="layout">
|
|
181
194
|
<section aria-labelledby="workspace-heading">
|
|
182
195
|
<div class="section-heading">
|
|
@@ -211,7 +224,7 @@
|
|
|
211
224
|
<summary class="workspace-group-heading">
|
|
212
225
|
<svg class="disclosure-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
|
|
213
226
|
<h3 class="workspace-group-name">
|
|
214
|
-
<
|
|
227
|
+
<span class="device-label"></span>
|
|
215
228
|
<span class="activity-badge device-working" data-state="working" hidden></span>
|
|
216
229
|
</h3>
|
|
217
230
|
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
@@ -244,8 +257,13 @@
|
|
|
244
257
|
<template id="device-template">
|
|
245
258
|
<article class="device">
|
|
246
259
|
<div class="device-heading">
|
|
247
|
-
<
|
|
248
|
-
|
|
260
|
+
<button type="button" class="device-drag" title="Drag to reorder, or use the up and down arrow keys">
|
|
261
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" stroke-width="3"/></svg>
|
|
262
|
+
</button>
|
|
263
|
+
<h3 class="device-name"><span class="device-label"></span></h3>
|
|
264
|
+
<button type="button" class="device-rename" title="Rename device">
|
|
265
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m16 4 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16l-1 4Z"/></svg>
|
|
266
|
+
</button>
|
|
249
267
|
<span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
|
|
250
268
|
<span class="online-dot" aria-label="Online"></span>
|
|
251
269
|
</div>
|
|
@@ -255,6 +273,15 @@
|
|
|
255
273
|
</div>
|
|
256
274
|
</article>
|
|
257
275
|
</template>
|
|
276
|
+
<template id="device-alias-template">
|
|
277
|
+
<span class="device-alias-editor">
|
|
278
|
+
<input type="text" class="device-alias-input" minlength="1" maxlength="128" required aria-label="Device alias" title="Use 1 to 128 characters.">
|
|
279
|
+
<span class="device-alias-actions">
|
|
280
|
+
<button type="button" class="device-alias-save">Save</button>
|
|
281
|
+
<button type="button" class="device-alias-cancel">Cancel</button>
|
|
282
|
+
</span>
|
|
283
|
+
</span>
|
|
284
|
+
</template>
|
|
258
285
|
<template id="empty-template">
|
|
259
286
|
<div class="empty">
|
|
260
287
|
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
|
|
@@ -273,15 +300,19 @@
|
|
|
273
300
|
const workspaceGroupTemplate = document.getElementById("workspace-group-template");
|
|
274
301
|
const workspaceTemplate = document.getElementById("workspace-template");
|
|
275
302
|
const deviceTemplate = document.getElementById("device-template");
|
|
303
|
+
const deviceAliasTemplate = document.getElementById("device-alias-template");
|
|
276
304
|
const emptyTemplate = document.getElementById("empty-template");
|
|
277
305
|
const deviceConnection = document.getElementById("device-connection");
|
|
278
306
|
const registrationKey = document.getElementById("registration-key");
|
|
279
307
|
const registrationStatus = document.getElementById("registration-status");
|
|
280
308
|
const aliasStatus = document.getElementById("alias-status");
|
|
309
|
+
const orderStatus = document.getElementById("order-status");
|
|
281
310
|
let refreshing = false;
|
|
282
311
|
let signature = "";
|
|
283
312
|
let editingDevice = null;
|
|
284
|
-
let
|
|
313
|
+
let inventoryRevision = 0;
|
|
314
|
+
let reordering = false;
|
|
315
|
+
let drag = null;
|
|
285
316
|
|
|
286
317
|
registrationKey.addEventListener("focus", () => registrationKey.select());
|
|
287
318
|
deviceConnection.addEventListener("toggle", async () => {
|
|
@@ -318,36 +349,30 @@
|
|
|
318
349
|
layout.dataset.stale = String(state === "error");
|
|
319
350
|
}
|
|
320
351
|
|
|
321
|
-
function
|
|
352
|
+
function setDeviceLabel(label, node) {
|
|
322
353
|
label.textContent = node.alias || node.name;
|
|
323
|
-
label.title =
|
|
324
|
-
label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
325
|
-
label.addEventListener("click", () => editDeviceAlias(label, node, data));
|
|
354
|
+
label.title = node.name;
|
|
326
355
|
}
|
|
327
356
|
|
|
328
|
-
function editDeviceAlias(label, node, data) {
|
|
329
|
-
if (editingDevice !== null) return;
|
|
357
|
+
function editDeviceAlias(label, button, node, data) {
|
|
358
|
+
if (editingDevice !== null || reordering) return;
|
|
330
359
|
editingDevice = node.id;
|
|
331
360
|
aliasStatus.textContent = "";
|
|
332
|
-
const
|
|
333
|
-
input
|
|
334
|
-
input.className = "device-alias-input";
|
|
335
|
-
input.minLength = 1;
|
|
336
|
-
input.maxLength = 128;
|
|
337
|
-
input.required = true;
|
|
338
|
-
input.setAttribute("aria-label", "Device alias");
|
|
339
|
-
input.title = "Use 1 to 128 characters.";
|
|
361
|
+
const editor = deviceAliasTemplate.content.firstElementChild.cloneNode(true);
|
|
362
|
+
const input = editor.querySelector("input");
|
|
340
363
|
input.value = node.alias || "";
|
|
341
364
|
input.placeholder = node.name;
|
|
342
365
|
label.hidden = true;
|
|
343
|
-
|
|
366
|
+
button.hidden = true;
|
|
367
|
+
label.before(editor);
|
|
344
368
|
let finished = false;
|
|
345
369
|
const finish = async save => {
|
|
346
370
|
if (finished) return;
|
|
347
371
|
finished = true;
|
|
348
372
|
const alias = input.value.trim();
|
|
349
|
-
|
|
373
|
+
editor.remove();
|
|
350
374
|
label.hidden = false;
|
|
375
|
+
button.hidden = false;
|
|
351
376
|
try {
|
|
352
377
|
if (save && !alias) throw new Error("Device alias must contain at least one character.");
|
|
353
378
|
if (save && alias !== (node.alias || "")) {
|
|
@@ -359,7 +384,7 @@
|
|
|
359
384
|
if (!response.ok) throw new Error(await response.text());
|
|
360
385
|
const result = await response.json();
|
|
361
386
|
node.alias = result.alias;
|
|
362
|
-
|
|
387
|
+
inventoryRevision += 1;
|
|
363
388
|
render(data);
|
|
364
389
|
signature = JSON.stringify(data);
|
|
365
390
|
}
|
|
@@ -367,23 +392,153 @@
|
|
|
367
392
|
aliasStatus.textContent = `Could not rename device: ${error.message}`;
|
|
368
393
|
} finally {
|
|
369
394
|
editingDevice = null;
|
|
395
|
+
nodes.querySelector(`.device[data-node-id="${CSS.escape(node.id)}"] .device-rename`).focus();
|
|
370
396
|
refresh();
|
|
371
397
|
}
|
|
372
398
|
};
|
|
373
|
-
|
|
399
|
+
editor.addEventListener("keydown", event => {
|
|
374
400
|
if (event.isComposing) return;
|
|
375
|
-
if (event.key === "
|
|
401
|
+
if (event.key === "Escape" || (event.key === "Enter" && event.target === input)) {
|
|
376
402
|
event.preventDefault();
|
|
377
403
|
finish(event.key === "Enter");
|
|
378
|
-
label.focus();
|
|
379
404
|
}
|
|
380
405
|
});
|
|
381
|
-
|
|
406
|
+
editor.querySelector(".device-alias-save").addEventListener("click", () => finish(true));
|
|
407
|
+
editor.querySelector(".device-alias-cancel").addEventListener("click", () => finish(false));
|
|
382
408
|
input.focus();
|
|
383
409
|
input.select();
|
|
384
410
|
}
|
|
385
411
|
|
|
412
|
+
async function saveDeviceOrder(data, order, nodeId) {
|
|
413
|
+
reordering = true;
|
|
414
|
+
orderStatus.textContent = "";
|
|
415
|
+
try {
|
|
416
|
+
if (order.some((id, index) => id !== data.nodes[index].id)) {
|
|
417
|
+
const response = await fetch("api/nodes/order", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers: {"Content-Type": "application/json"},
|
|
420
|
+
body: JSON.stringify({order}),
|
|
421
|
+
});
|
|
422
|
+
if (!response.ok) throw new Error(await response.text());
|
|
423
|
+
const result = await response.json();
|
|
424
|
+
const byId = new Map(data.nodes.map(node => [node.id, node]));
|
|
425
|
+
data.nodes = result.order.map(id => byId.get(id));
|
|
426
|
+
data.workspaces = result.order.flatMap(
|
|
427
|
+
id => data.workspaces.filter(workspace => workspace.node_id === id));
|
|
428
|
+
inventoryRevision += 1;
|
|
429
|
+
render(data);
|
|
430
|
+
signature = JSON.stringify(data);
|
|
431
|
+
}
|
|
432
|
+
} catch (error) {
|
|
433
|
+
orderStatus.textContent = `Could not reorder devices: ${error.message}`;
|
|
434
|
+
} finally {
|
|
435
|
+
reordering = false;
|
|
436
|
+
nodes.querySelector(`.device[data-node-id="${CSS.escape(nodeId)}"] .device-drag`).focus({preventScroll: true});
|
|
437
|
+
refresh();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function clearDropMarker() {
|
|
442
|
+
nodes.classList.remove("device-drop-end");
|
|
443
|
+
for (const card of nodes.querySelectorAll(".device-drop-before")) {
|
|
444
|
+
card.classList.remove("device-drop-before");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function markDropTarget() {
|
|
449
|
+
clearDropMarker();
|
|
450
|
+
drag.before = Array.from(nodes.children).find(card => {
|
|
451
|
+
const rect = card.getBoundingClientRect();
|
|
452
|
+
return card !== drag.card && drag.y < (rect.top + rect.bottom) / 2;
|
|
453
|
+
});
|
|
454
|
+
(drag.before || nodes).classList.add(drag.before ? "device-drop-before" : "device-drop-end");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function scrollDrag() {
|
|
458
|
+
const step = drag.y < 56 ? -12 : drag.y > innerHeight - 56 ? 12 : 0;
|
|
459
|
+
if (step) {
|
|
460
|
+
window.scrollBy(0, step);
|
|
461
|
+
markDropTarget();
|
|
462
|
+
}
|
|
463
|
+
drag.frame = requestAnimationFrame(scrollDrag);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function finishDrag(commit, event) {
|
|
467
|
+
const current = drag;
|
|
468
|
+
if (!current) return;
|
|
469
|
+
drag = null;
|
|
470
|
+
cancelAnimationFrame(current.frame);
|
|
471
|
+
current.card.removeAttribute("data-dragging");
|
|
472
|
+
clearDropMarker();
|
|
473
|
+
if (nodes.hasPointerCapture(current.pointerId)) {
|
|
474
|
+
nodes.releasePointerCapture(current.pointerId);
|
|
475
|
+
}
|
|
476
|
+
const rect = nodes.getBoundingClientRect();
|
|
477
|
+
if (commit && current.moved && event.clientX >= rect.left
|
|
478
|
+
&& event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) {
|
|
479
|
+
const cards = Array.from(nodes.children).filter(card => card !== current.card);
|
|
480
|
+
const index = current.before ? cards.indexOf(current.before) : cards.length;
|
|
481
|
+
cards.splice(index, 0, current.card);
|
|
482
|
+
saveDeviceOrder(current.data, cards.map(card => card.dataset.nodeId), current.card.dataset.nodeId);
|
|
483
|
+
} else {
|
|
484
|
+
reordering = false;
|
|
485
|
+
refresh();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
nodes.addEventListener("pointermove", event => {
|
|
490
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
491
|
+
drag.y = event.clientY;
|
|
492
|
+
if (!drag.moved) {
|
|
493
|
+
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.startY) < 5) return;
|
|
494
|
+
drag.moved = true;
|
|
495
|
+
drag.card.setAttribute("data-dragging", "");
|
|
496
|
+
drag.frame = requestAnimationFrame(scrollDrag);
|
|
497
|
+
}
|
|
498
|
+
markDropTarget();
|
|
499
|
+
});
|
|
500
|
+
nodes.addEventListener("pointerup", event => {
|
|
501
|
+
if (drag && event.pointerId === drag.pointerId) finishDrag(true, event);
|
|
502
|
+
});
|
|
503
|
+
nodes.addEventListener("pointercancel", () => finishDrag(false));
|
|
504
|
+
nodes.addEventListener("lostpointercapture", () => finishDrag(false));
|
|
505
|
+
nodes.addEventListener("keydown", event => {
|
|
506
|
+
if (drag && event.key === "Escape") {
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
finishDrag(false);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
function bindDeviceDrag(card, node, data) {
|
|
513
|
+
const handle = card.querySelector(".device-drag");
|
|
514
|
+
handle.disabled = data.nodes.length < 2;
|
|
515
|
+
handle.setAttribute("aria-label", `Reorder device ${node.alias || node.name}`);
|
|
516
|
+
handle.addEventListener("pointerdown", event => {
|
|
517
|
+
if (event.button !== 0 || reordering || editingDevice !== null) return;
|
|
518
|
+
event.preventDefault();
|
|
519
|
+
handle.focus({preventScroll: true});
|
|
520
|
+
reordering = true;
|
|
521
|
+
orderStatus.textContent = "";
|
|
522
|
+
drag = {card, data, pointerId: event.pointerId, x: event.clientX,
|
|
523
|
+
startY: event.clientY, y: event.clientY, moved: false, frame: null, before: null};
|
|
524
|
+
nodes.setPointerCapture(event.pointerId);
|
|
525
|
+
});
|
|
526
|
+
handle.addEventListener("keydown", event => {
|
|
527
|
+
if (reordering || editingDevice !== null || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
const order = data.nodes.map(item => item.id);
|
|
530
|
+
const index = order.indexOf(node.id);
|
|
531
|
+
const target = index + (event.key === "ArrowUp" ? -1 : 1);
|
|
532
|
+
if (target < 0 || target >= order.length) return;
|
|
533
|
+
[order[index], order[target]] = [order[target], order[index]];
|
|
534
|
+
saveDeviceOrder(data, order, node.id);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
386
538
|
function render(data) {
|
|
539
|
+
const focusedHandle = nodes.contains(document.activeElement)
|
|
540
|
+
&& document.activeElement.matches(".device-drag")
|
|
541
|
+
? document.activeElement.closest(".device").dataset.nodeId : null;
|
|
387
542
|
const expanded = new Set(Array.from(
|
|
388
543
|
workspaces.querySelectorAll(".workspace-group[open]"), group => group.dataset.nodeId));
|
|
389
544
|
const workspaceList = document.createDocumentFragment();
|
|
@@ -395,7 +550,7 @@
|
|
|
395
550
|
group.dataset.nodeId = node.id;
|
|
396
551
|
group.open = expanded.has(node.id);
|
|
397
552
|
group.setAttribute("aria-label", node.alias || node.name);
|
|
398
|
-
|
|
553
|
+
setDeviceLabel(group.querySelector(".device-label"), node);
|
|
399
554
|
const link = group.querySelector(".manage-link");
|
|
400
555
|
link.href = node.url;
|
|
401
556
|
link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
|
|
@@ -454,7 +609,13 @@
|
|
|
454
609
|
const nodeList = document.createDocumentFragment();
|
|
455
610
|
for (const node of data.nodes) {
|
|
456
611
|
const card = deviceTemplate.content.firstElementChild.cloneNode(true);
|
|
457
|
-
|
|
612
|
+
card.dataset.nodeId = node.id;
|
|
613
|
+
bindDeviceDrag(card, node, data);
|
|
614
|
+
const label = card.querySelector(".device-label");
|
|
615
|
+
setDeviceLabel(label, node);
|
|
616
|
+
const rename = card.querySelector(".device-rename");
|
|
617
|
+
rename.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
|
|
618
|
+
rename.addEventListener("click", () => editDeviceAlias(label, rename, node, data));
|
|
458
619
|
const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
|
|
459
620
|
card.querySelector(".device-unread").hidden = !owned.some(workspace => workspace.unread);
|
|
460
621
|
const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
|
|
@@ -472,6 +633,10 @@
|
|
|
472
633
|
}
|
|
473
634
|
workspaces.replaceChildren(workspaceList);
|
|
474
635
|
nodes.replaceChildren(nodeList);
|
|
636
|
+
if (focusedHandle) {
|
|
637
|
+
const handle = nodes.querySelector(`.device[data-node-id="${CSS.escape(focusedHandle)}"] .device-drag`);
|
|
638
|
+
if (handle) handle.focus({preventScroll: true});
|
|
639
|
+
}
|
|
475
640
|
document.getElementById("workspace-count").textContent = data.workspaces.length;
|
|
476
641
|
document.getElementById("device-count").textContent = data.nodes.length;
|
|
477
642
|
const running = data.workspaces.filter(workspace => workspace.running).length;
|
|
@@ -481,7 +646,7 @@
|
|
|
481
646
|
async function refresh() {
|
|
482
647
|
if (refreshing) return;
|
|
483
648
|
refreshing = true;
|
|
484
|
-
const revision =
|
|
649
|
+
const revision = inventoryRevision;
|
|
485
650
|
try {
|
|
486
651
|
const response = await fetch("api/workspaces", {cache: "no-store"});
|
|
487
652
|
if (response.status === 401) {
|
|
@@ -491,7 +656,7 @@
|
|
|
491
656
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
492
657
|
const data = await response.json();
|
|
493
658
|
const next = JSON.stringify(data);
|
|
494
|
-
if (editingDevice === null && revision ===
|
|
659
|
+
if (editingDevice === null && !reordering && revision === inventoryRevision && next !== signature) {
|
|
495
660
|
render(data);
|
|
496
661
|
signature = next;
|
|
497
662
|
}
|
|
@@ -176,8 +176,8 @@ class Router:
|
|
|
176
176
|
aliases = json.loads(self.aliases_path.read_text(encoding="utf-8"))
|
|
177
177
|
if not isinstance(aliases, dict) or any(
|
|
178
178
|
not re.fullmatch("[a-z2-7]{52}", node_id)
|
|
179
|
-
or not alias
|
|
180
|
-
or normalize_alias(alias) != alias
|
|
179
|
+
or not isinstance(alias, str)
|
|
180
|
+
or (alias and normalize_alias(alias) != alias)
|
|
181
181
|
for node_id, alias in aliases.items()
|
|
182
182
|
):
|
|
183
183
|
raise ValueError("invalid router aliases file")
|
|
@@ -201,6 +201,33 @@ class Router:
|
|
|
201
201
|
temporary_path.unlink()
|
|
202
202
|
self.aliases = aliases
|
|
203
203
|
|
|
204
|
+
async def reorder_devices(self, request):
|
|
205
|
+
if not self.browser_token or not _auth_cookie_matches(
|
|
206
|
+
self.browser_token, request.cookies.get("pycodex_router_auth")
|
|
207
|
+
):
|
|
208
|
+
raise HTTPException(401, "Authentication required.")
|
|
209
|
+
payload = await _json_object_body(request, max_bytes=64 * 1024)
|
|
210
|
+
order = payload.get("order")
|
|
211
|
+
if (
|
|
212
|
+
not isinstance(order, list)
|
|
213
|
+
or any(
|
|
214
|
+
not isinstance(node_id, str)
|
|
215
|
+
or not re.fullmatch("[a-z2-7]{52}", node_id)
|
|
216
|
+
for node_id in order
|
|
217
|
+
)
|
|
218
|
+
or len(set(order)) != len(order)
|
|
219
|
+
):
|
|
220
|
+
raise HTTPException(422, "Device order must be a list of unique device IDs.")
|
|
221
|
+
if set(order) != set(self.nodes):
|
|
222
|
+
raise HTTPException(409, "Connected devices changed. Refresh and try again.")
|
|
223
|
+
online_order = iter(order)
|
|
224
|
+
saved_order = [
|
|
225
|
+
next(online_order) if node_id in self.nodes else node_id
|
|
226
|
+
for node_id in self.aliases
|
|
227
|
+
]
|
|
228
|
+
self._save_aliases({node_id: self.aliases[node_id] for node_id in saved_order})
|
|
229
|
+
return JSONResponse({"order": order})
|
|
230
|
+
|
|
204
231
|
async def rename_device(self, request):
|
|
205
232
|
if not self.browser_token or not _auth_cookie_matches(
|
|
206
233
|
self.browser_token, request.cookies.get("pycodex_router_auth")
|
|
@@ -263,6 +290,10 @@ class Router:
|
|
|
263
290
|
name,
|
|
264
291
|
validate_inventory(message["workspaces"]),
|
|
265
292
|
)
|
|
293
|
+
if node_id not in self.aliases:
|
|
294
|
+
aliases = dict(self.aliases)
|
|
295
|
+
aliases[node_id] = ""
|
|
296
|
+
self._save_aliases(aliases)
|
|
266
297
|
self.nodes[node_id] = node
|
|
267
298
|
await websocket.send_text(encode({"type": "registered", "id": node_id}))
|
|
268
299
|
while True:
|
|
@@ -423,7 +454,10 @@ class Router:
|
|
|
423
454
|
async def workspaces(self, request):
|
|
424
455
|
nodes = []
|
|
425
456
|
workspaces = []
|
|
426
|
-
for
|
|
457
|
+
for node_id in self.aliases:
|
|
458
|
+
if node_id not in self.nodes:
|
|
459
|
+
continue
|
|
460
|
+
node = self.nodes[node_id]
|
|
427
461
|
base = request.scope.get("root_path", "") + "/n/" + node.id + "/"
|
|
428
462
|
nodes.append({
|
|
429
463
|
"id": node.id,
|
|
@@ -478,6 +512,7 @@ def create_app(
|
|
|
478
512
|
Route("/", index),
|
|
479
513
|
Route("/api/workspaces", router.workspaces),
|
|
480
514
|
Route("/api/registration-key", registration_key_info),
|
|
515
|
+
Route("/api/nodes/order", router.reorder_devices, methods=["POST"]),
|
|
481
516
|
Route("/api/nodes/{node_id}/alias", router.rename_device, methods=["POST"]),
|
|
482
517
|
]
|
|
483
518
|
)
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycodex-ws"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.7"
|
|
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.
|
|
12
|
+
"python-codex==0.4.7",
|
|
13
13
|
"pillow>=9.0.0",
|
|
14
14
|
"websockets>=11,<16",
|
|
15
15
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|