feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369) (#1447)

* feat(web): reorderable ServerCards — drag-and-drop + keyboard (#1369)

Let users control the order of servers on the Servers screen instead of
hand-editing mcp.json. Each ServerCard gets a grip handle (in the header,
left of the name) that drives an @dnd-kit sortable; pointer and keyboard
sensors both work, with live-region announcements for screen readers.

- Backend: PUT /api/servers/order rewrites mcp.json in the supplied order
  inside the write lock, reusing the atomic-write + watcher-notify path.
  Registered before /:id so "order" isn't captured as a server id. Rejects
  with 409 when the id set no longer matches disk and 400 on duplicate /
  malformed bodies, so a reorder racing an external edit can't drop or
  duplicate an entry.
- Hook: useServers.reorderServers(orderedIds) — optimistic local reorder,
  PUT, refresh-to-disk-truth on failure.
- UI: SortableServerCard wrapper owns the dnd-kit concerns; ServerListScreen
  wraps the grid in DndContext + SortableContext (rect strategy) with
  pointer + keyboard sensors and announcements. Reorder math and a11y copy
  extracted to reorderIds.ts / serverReorder.ts for direct unit testing.
  Renders plain cards (no grip) when no onReorder is wired.
- Wired onServerReorder through InspectorView -> App.tsx.

Tests: pure-helper units, hook (persist / optimistic / 409 revert), 9
backend integration tests (incl. id-set-mismatch rejection and the
route-registration guard), ServerCard/ServerListScreen units, and a
real-browser Storybook keyboard-reorder play (the dnd-kit keyboard sensor
needs layout rects that happy-dom doesn't provide).

Adds @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): notify on reorder failure; skip reorder glue when not reorderable

Address PR #1447 review:
- App.tsx: catch reorderServers rejection (409 from a racing external edit,
  or a network error) and show a red toast, matching the error-handling
  pattern used by every other mutation — previously the rejection was
  discarded with `void` and the drag silently bounced back.
- ServerListScreen: only build handleDragEnd / announcements when
  `reorderable`, so the four-closure announcements object isn't allocated
  every render when no onReorder is wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-09 14:15:58 -04:00 • committed by GitHub
commit d5f12b2a82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1514,6 +1514,67 @@ export function createRemoteApp(
}
});
// Reorder the on-disk `mcpServers` map. Registered before
// `PUT /api/servers/:id` so the literal `/order` segment isn't captured
// as an `:id` param ("order" would otherwise be a valid store id). The
// request body is `{ order: string[] }` — the complete set of server ids
// in the desired iteration order. We reject (409) unless that set matches
// the on-disk set exactly, so a reorder racing an external add/remove
// can't silently drop or duplicate an entry. Reordering touches no secret
// values, so we just permute the existing stripped entries and reuse the
// same atomic-write + watcher-notify path as the other mutators.
app.put("/api/servers/order", async (c) => {
let body: { order?: unknown };
try {
body = (await c.req.json()) as { order?: unknown };
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (
!Array.isArray(body.order) ||
!body.order.every((id): id is string => typeof id === "string")
) {
return c.json({ error: "order must be an array of strings" }, 400);
}
const order = body.order;
if (new Set(order).size !== order.length) {
return c.json({ error: "order contains duplicate ids" }, 400);
}
try {
return await withWriteLock(async () => {
const current = await readMcpConfig();
const currentIds = Object.keys(current.mcpServers);
// Exact-set match: same size and every requested id present on disk.
// The duplicate check above plus equal sizes guarantees this is a
// permutation, never a partial reorder that would drop an entry.
const currentSet = new Set(currentIds);
const sameSet =
currentIds.length === order.length &&
order.every((id) => currentSet.has(id));
if (!sameSet) {
return c.json(
{
error:
"order does not match the current server set (it may have changed on disk)",
},
409,
);
}
const next: MCPConfig = { mcpServers: {} };
for (const id of order) {
// Non-null: `sameSet` proves every `id` is a key of the map.
next.mcpServers[id] = current.mcpServers[id]!;
}
await writeMcpAndTrackMtime(serializeStore(next));
return c.json({ ok: true });
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return c.json({ error: `Failed to reorder servers: ${msg}` }, 500);
}
});
app.put("/api/servers/:id", async (c) => {
const originalId = c.req.param("id");
if (!originalId || !validateStoreId(originalId)) {