pycodex-ws 0.4.5__tar.gz → 0.4.6__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 (41) hide show
  1. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/PKG-INFO +21 -7
  2. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/README.md +19 -5
  3. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/index.html +199 -34
  4. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/server.py +38 -3
  5. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pyproject.toml +2 -2
  6. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/.gitignore +0 -0
  7. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/LICENSE +0 -0
  8. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/__init__.py +0 -0
  9. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/__main__.py +0 -0
  10. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/browser.py +0 -0
  11. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/client.py +0 -0
  12. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_router/protocol.py +0 -0
  13. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_ws/__init__.py +0 -0
  14. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_ws/feishu_card.py +0 -0
  15. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_ws/feishu_link.py +0 -0
  16. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/pycodex_ws/feishu_oauth.py +0 -0
  17. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/__init__.py +0 -0
  18. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/__main__.py +0 -0
  19. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/app.py +0 -0
  20. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/content_views.LICENSE.txt +0 -0
  21. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/content_views.css +0 -0
  22. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/content_views.mjs +0 -0
  23. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/editor.css +0 -0
  24. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/editor.mjs +0 -0
  25. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/.gitignore +0 -0
  26. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/build.mjs +0 -0
  27. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/composer.mjs +0 -0
  28. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.css +0 -0
  29. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.mjs +0 -0
  30. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.css +0 -0
  31. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.mjs +0 -0
  32. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/editor_context.mjs +0 -0
  33. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/package-lock.json +0 -0
  34. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/package.json +0 -0
  35. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/touch_selection.mjs +0 -0
  36. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/frontend/view_labels.mjs +0 -0
  37. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/owned_process.py +0 -0
  38. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/workspace.html +0 -0
  39. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/workspace_views.py +0 -0
  40. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/workspace_server/workspaces.html +0 -0
  41. {pycodex_ws-0.4.5 → pycodex_ws-0.4.6}/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.5
3
+ Version: 0.4.6
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.5
8
+ Requires-Dist: python-codex==0.4.6
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; the
100
- complete device groups keep their device id and workspace configuration order.
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 the
103
- device name to rename it; aliases must be nonblank and contain 1 to 128
104
- characters. Device headings and the Online devices list share an unread dot
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; the
89
- complete device groups keep their device id and workspace configuration order.
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 the
92
- device name to rename it; aliases must be nonblank and contain 1 to 128
93
- characters. Device headings and the Online devices list share an unread dot
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, .device-label:focus-visible, .workspace-group-heading:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
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 { display: inline-block; max-width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
124
- .device-label[hidden] { display: none; }
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
- <button type="button" class="device-label"></button>
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
- <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>
248
- <h3 class="device-name"><button type="button" class="device-label"></button></h3>
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 aliasRevision = 0;
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 bindDeviceLabel(label, node, data) {
352
+ function setDeviceLabel(label, node) {
322
353
  label.textContent = node.alias || node.name;
323
- label.title = `${node.name}\nClick to rename device`;
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 input = document.createElement("input");
333
- input.type = "text";
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
- label.before(input);
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
- input.remove();
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
- aliasRevision += 1;
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
- input.addEventListener("keydown", event => {
399
+ editor.addEventListener("keydown", event => {
374
400
  if (event.isComposing) return;
375
- if (event.key === "Enter" || event.key === "Escape") {
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
- input.addEventListener("blur", () => finish(true));
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
- bindDeviceLabel(group.querySelector(".device-label"), node, data);
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
- bindDeviceLabel(card.querySelector(".device-label"), node, data);
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 = aliasRevision;
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 === aliasRevision && next !== signature) {
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 node in sorted(self.nodes.values(), key=lambda item: item.id):
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.5"
7
+ version = "0.4.6"
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.5",
12
+ "python-codex==0.4.6",
13
13
  "pillow>=9.0.0",
14
14
  "websockets>=11,<16",
15
15
  ]
File without changes
File without changes