Spaces:
Running
Running
| /* catalog.js β one place that holds everything you have worked on. | |
| =========================================================================== | |
| Before this, your work was in three places that did not know about each | |
| other: constructs on Mission Control, a local "recent runs" trail beside | |
| them, and conversations behind a 320px dropdown in the rail. The dropdown | |
| could only OPEN a run β it could not rename one and could not delete one β | |
| so it silted up with rows all called "Design a more thermostable variant of | |
| this protβ¦" and there was no way to clear them. A reviewing scientist put it | |
| plainly: no catalog of conversations, and no way to delete constructs or | |
| conversations. | |
| This is that catalog. Both kinds of thing in one panel, searchable, with the | |
| verbs that were missing. And each conversation carries the fold of the | |
| protein it is about, drawn from the run's own saved bench snapshot β because | |
| a scientist finds "the one about the beta-lactamase" by recognising it, not | |
| by reading ten near-identical sentences. | |
| Constructs are deletable too, as far as the API allows β see CX_DELETE for | |
| which kinds have a route and which are still a server-side gap. | |
| Depends on: TDStructCard (thumbnails), TDCockpit (switchRun/newRun), | |
| TDBench (openConstruct). Degrades if any is missing rather than throwing. | |
| =========================================================================== */ | |
| (function () { | |
| "use strict"; | |
| var state = { | |
| root: null, open: false, runs: [], constructs: [], | |
| q: "", filter: "all", loading: false, busy: {}, sel: -1, prevFocus: null, | |
| }; | |
| function esc(s) { | |
| return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { | |
| return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; | |
| }); | |
| } | |
| function when(t) { | |
| if (!t) return ""; | |
| var ms = typeof t === "number" ? t : Date.parse(t); | |
| if (!isFinite(ms)) return ""; | |
| var s = Math.max(0, (Date.now() - ms) / 1000); | |
| if (s < 3600) return Math.max(1, Math.round(s / 60)) + "m ago"; | |
| if (s < 86400) return Math.round(s / 3600) + "h ago"; | |
| if (s < 7 * 86400) return Math.round(s / 86400) + "d ago"; | |
| try { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } | |
| catch (e) { return ""; } | |
| } | |
| function money(v) { | |
| var n = Number(v || 0); | |
| if (!n) return ""; | |
| return "$" + (n < 0.01 ? n.toFixed(4) : n.toFixed(2)); | |
| } | |
| /* ββ data βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function localRuns() { | |
| try { | |
| if (window.TDCockpit && window.TDCockpit.recentRuns) { | |
| return window.TDCockpit.recentRuns() || []; | |
| } | |
| } catch (e) {} | |
| return []; | |
| } | |
| function mergeRuns(server) { | |
| var seen = {}, out = []; | |
| (server || []).forEach(function (r) { | |
| if (!r || !r.run_id || seen[r.run_id]) return; | |
| seen[r.run_id] = 1; | |
| var ws = (r.meta && r.meta.workspace) || {}; | |
| out.push({ | |
| runId: r.run_id, title: r.title, at: r.updated_at || r.created_at, | |
| steps: r.steps, cost: r.cost_usd, status: r.status, | |
| construct: ws.construct || "", organism: ws.organism || "", | |
| uniprot: ws.uniprot || "", ready: ws.ready || [], server: true, | |
| }); | |
| }); | |
| // Local rows are the signed-out and not-yet-synced case. They carry no | |
| // bench snapshot, so they get a plate rather than a fold β never a | |
| // borrowed one from another run. | |
| localRuns().forEach(function (r) { | |
| if (!r || !r.runId || seen[r.runId]) return; | |
| seen[r.runId] = 1; | |
| out.push({ runId: r.runId, title: r.title, at: r.at, ready: [], server: false }); | |
| }); | |
| out.forEach(function (r) { | |
| r._t = typeof r.at === "number" ? r.at : Date.parse(r.at || "") || 0; | |
| }); | |
| out.sort(function (a, b) { return b._t - a._t; }); | |
| return out; | |
| } | |
| function load() { | |
| state.loading = true; | |
| paint(); | |
| var runs = fetch("/api/orchestrator/runs") | |
| .then(function (r) { return r.ok ? r.json() : null; }) | |
| .catch(function () { return null; }); | |
| var mission = fetch("/api/mission") | |
| .then(function (r) { return r.ok ? r.json() : null; }) | |
| .catch(function () { return null; }); | |
| return Promise.all([runs, mission]).then(function (res) { | |
| state.runs = mergeRuns((res[0] && res[0].runs) || []); | |
| state.constructs = (res[1] && res[1].constructs) || []; | |
| state.loading = false; | |
| paint(); | |
| }); | |
| } | |
| /* ββ shell ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function build() { | |
| if (state.root) return state.root; | |
| var root = document.createElement("div"); | |
| root.className = "cat"; | |
| root.id = "catalog"; | |
| root.hidden = true; | |
| root.setAttribute("role", "dialog"); | |
| root.setAttribute("aria-modal", "true"); | |
| root.setAttribute("aria-label", "Your work"); | |
| root.innerHTML = | |
| '<div class="cat-backdrop" data-cat-close></div>' + | |
| '<div class="cat-panel">' + | |
| '<header class="cat-head">' + | |
| '<h2 class="cat-title">Your work</h2>' + | |
| '<button type="button" class="cat-x" data-cat-close aria-label="Close">×</button>' + | |
| '</header>' + | |
| '<div class="cat-controls">' + | |
| '<input type="search" class="cat-q" id="catQ" placeholder="Search by name, protein or organismβ¦" ' + | |
| 'autocomplete="off" spellcheck="false" aria-label="Search your work">' + | |
| '<div class="cat-seg" role="tablist" aria-label="Filter">' + | |
| '<button type="button" class="cat-segb is-on" data-f="all" role="tab" aria-selected="true">All</button>' + | |
| '<button type="button" class="cat-segb" data-f="runs" role="tab" aria-selected="false">Conversations</button>' + | |
| '<button type="button" class="cat-segb" data-f="constructs" role="tab" aria-selected="false">Constructs</button>' + | |
| '</div>' + | |
| '</div>' + | |
| '<div class="cat-body" id="catBody" tabindex="-1"></div>' + | |
| "</div>"; | |
| document.body.appendChild(root); | |
| state.root = root; | |
| root.addEventListener("click", onClick); | |
| root.addEventListener("keydown", onKey); | |
| root.querySelector("#catQ").addEventListener("input", function (e) { | |
| state.q = e.target.value; state.sel = -1; paint(); | |
| }); | |
| return root; | |
| } | |
| function open() { | |
| build(); | |
| state.prevFocus = document.activeElement; | |
| state.open = true; | |
| state.root.hidden = false; | |
| document.body.classList.add("cat-open"); | |
| var q = state.root.querySelector("#catQ"); | |
| q.value = state.q; | |
| load().then(function () { q.focus(); }); | |
| paint(); | |
| } | |
| function close() { | |
| if (!state.open) return; | |
| state.open = false; | |
| if (state.root) state.root.hidden = true; | |
| document.body.classList.remove("cat-open"); | |
| if (state.prevFocus && state.prevFocus.focus) { | |
| try { state.prevFocus.focus(); } catch (e) {} | |
| } | |
| } | |
| /* ββ cards ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| var READY_LABEL = { | |
| design: "Library", structure: "Structure", crispr: "Guides", | |
| primers: "Primers", plasmid: "Map", | |
| }; | |
| /* Where a construct of each kind can be deleted, and what it is called | |
| while you are being asked to confirm it. | |
| The reviewer asked for two things β "no catalog of conversations/chats | |
| or way to delete 'constructs' and conversations". Conversations got both | |
| verbs. Constructs got neither, and half of that is a server gap rather | |
| than a UI one: plasmids and primer analyses have owner-checked DELETE | |
| routes, saved LIBRARIES and CRISPR DESIGNS have none, in server.py or in | |
| auth.py. A library is the primary artifact this product makes, so that | |
| is the bigger half of the hole. | |
| This table is the whole switch. A kind with no entry renders no Delete | |
| button, because the alternative is a button that 404s β and a delete | |
| control that silently does nothing is worse than an honest absence. Add | |
| `DELETE /api/library/<id>` and `DELETE /api/crispr/designs/<id>` plus | |
| their auth.py counterparts and the two commented lines below finish the | |
| feature; nothing else here changes. */ | |
| var CX_DELETE = { | |
| plasmid: { path: "/api/plasmid/library/", noun: "plasmid map" }, | |
| primer: { path: "/api/primers/analyses/", noun: "primer analysis" }, | |
| // library: { path: "/api/library/", noun: "variant library" }, | |
| // crispr: { path: "/api/crispr/designs/", noun: "CRISPR design" }, | |
| }; | |
| function thumbHtml(r) { | |
| if (r.uniprot) { | |
| return '<span class="cat-thumb" data-sc-host data-sc="idle">' + | |
| '<canvas class="cat-canvas" data-uniprot="' + esc(r.uniprot) + '" ' + | |
| 'role="img" aria-label="Predicted structure of ' + esc(r.uniprot) + '"></canvas>' + | |
| '<span class="cat-thumb-msg"></span>' + | |
| '<span class="cat-acc">' + esc(r.uniprot) + "</span></span>"; | |
| } | |
| // No accession on this run. Say so β a decorative squiggle here would | |
| // read as "here is the protein", which is the one thing it isn't. | |
| return '<span class="cat-thumb" data-sc="none">' + | |
| '<span class="cat-thumb-msg">No structure resolved</span></span>'; | |
| } | |
| function runCard(r, i) { | |
| var chips = []; | |
| if (r.construct) chips.push('<span class="cat-chip is-key">' + esc(r.construct) + "</span>"); | |
| if (r.organism) chips.push('<span class="cat-chip">' + esc(r.organism) + "</span>"); | |
| (r.ready || []).forEach(function (t) { | |
| if (READY_LABEL[t]) chips.push('<span class="cat-chip is-soft">' + esc(READY_LABEL[t]) + "</span>"); | |
| }); | |
| var meta = []; | |
| if (r.steps) meta.push(r.steps + " step" + (r.steps === 1 ? "" : "s")); | |
| var m = money(r.cost); if (m) meta.push(m); | |
| var w = when(r._t); if (w) meta.push(w); | |
| if (!r.server) meta.push("this browser only"); | |
| var current = window.TDCockpit && window.TDCockpit.currentRunId && | |
| window.TDCockpit.currentRunId() === r.runId; | |
| var busy = state.busy[r.runId]; | |
| return '<article class="cat-card' + (current ? " is-current" : "") + | |
| (busy ? " is-busy" : "") + '" data-i="' + i + '" data-run="' + esc(r.runId) + '">' + | |
| thumbHtml(r) + | |
| '<div class="cat-main">' + | |
| '<button type="button" class="cat-open" data-act="open" data-run="' + esc(r.runId) + '">' + | |
| '<span class="cat-name">' + esc(r.title || "Untitled run") + "</span>" + | |
| "</button>" + | |
| (r.status === "awaiting_input" | |
| ? '<span class="cat-flag">Waiting on you</span>' : "") + | |
| (chips.length ? '<div class="cat-chips">' + chips.join("") + "</div>" : "") + | |
| '<div class="cat-meta">' + esc(meta.join(" Β· ")) + "</div>" + | |
| (_notes[r.runId] ? '<p class="cat-note" role="status">' + esc(_notes[r.runId]) + "</p>" : "") + | |
| "</div>" + | |
| '<div class="cat-acts">' + | |
| (r.server ? '<button type="button" class="cat-act" data-act="rename" data-run="' + esc(r.runId) + | |
| '" aria-label="Rename this conversation" title="Rename">Rename</button>' : "") + | |
| '<button type="button" class="cat-act is-danger" data-act="delete" data-run="' + esc(r.runId) + | |
| '" aria-label="Delete this conversation" title="Delete">Delete</button>' + | |
| "</div>" + | |
| "</article>"; | |
| } | |
| /* One mark per kind of artifact. | |
| Every construct used to get the same glyph: two crossing curves meant as | |
| a DNA helix, drawn without its rungs, at 26px inside a 104x84 bordered | |
| tile. At that size against an empty frame it reads as a large X β the | |
| browser's broken-image placeholder, which is exactly what a reviewer | |
| took it for. Worse, it was the same X for a plasmid map, a primer set, a | |
| CRISPR design and a variant library, so the tile cost 104px of card and | |
| carried no information at all. | |
| These are the four things this product makes, and each mark says which: | |
| a ranked list, a circular map, a cut site, a converging pair. Stroke | |
| glyphs on a 24-unit grid, so they stay legible at the tile's size and | |
| inherit the card's ink. */ | |
| var CX_GLYPH = { | |
| library: '<path d="M4 6h16M4 12h11M4 18h6"/>', | |
| plasmid: '<circle cx="12" cy="12" r="8"/>' + | |
| '<path d="M12 4a8 8 0 0 1 6.93 4" stroke-width="2.4"/>', | |
| crispr: '<path d="M3 9h6M15 9h6M3 15h6M15 15h6"/>' + | |
| '<path d="M12 3v18" stroke-dasharray="2.5 2.5"/>', | |
| primer: '<path d="M3 12h18"/><path d="M6.5 8.2 10.5 12l-4 3.8"/>' + | |
| '<path d="M17.5 15.8 13.5 12l4-3.8"/>', | |
| }; | |
| var CX_GLYPH_FALLBACK = '<path d="M5 5.5c7 3.5 5.5 12.5 12.5 16M5 19.5c7-3.5 5.5-12.5 12.5-16"/>' + | |
| '<path d="M7.6 8.4h8M8.6 15.6h8"/>'; | |
| function constructCard(c, i) { | |
| var meta = []; | |
| if (c.kind) meta.push(String(c.kind)); | |
| var w = when(c.at); if (w) meta.push(w); | |
| var del = CX_DELETE[c.kind]; | |
| var busy = state.busy[c.id]; | |
| var glyph = CX_GLYPH[c.kind] || CX_GLYPH_FALLBACK; | |
| return '<article class="cat-card cat-card--cx' + (busy ? " is-busy" : "") + | |
| '" data-i="' + i + '" data-cx="' + esc(c.id) + '" data-kind="' + esc(c.kind || "") + '">' + | |
| '<span class="cat-thumb cat-thumb--cx" data-sc="cx" aria-hidden="true">' + | |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" ' + | |
| 'stroke-linecap="round" stroke-linejoin="round">' + glyph + "</svg></span>" + | |
| '<div class="cat-main">' + | |
| '<button type="button" class="cat-open" data-act="opencx" data-cx="' + esc(c.id) + | |
| '" data-name="' + esc(c.name) + '">' + | |
| '<span class="cat-name">' + esc(c.name) + "</span></button>" + | |
| '<div class="cat-meta">' + esc(meta.join(" Β· ")) + "</div>" + | |
| (_notes[c.id] ? '<p class="cat-note" role="status">' + esc(_notes[c.id]) + "</p>" : "") + | |
| "</div>" + | |
| (del | |
| ? '<div class="cat-acts">' + | |
| '<button type="button" class="cat-act is-danger" data-act="delcx" data-cx="' + esc(c.id) + | |
| '" aria-label="Delete this ' + esc(del.noun) + '" title="Delete">Delete</button>' + | |
| "</div>" | |
| : "") + | |
| "</article>"; | |
| } | |
| /* ββ render βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function matches(hay, q) { | |
| return String(hay || "").toLowerCase().indexOf(q) !== -1; | |
| } | |
| function paint() { | |
| if (!state.root) return; | |
| var body = state.root.querySelector("#catBody"); | |
| if (!body) return; | |
| state.root.querySelectorAll(".cat-segb").forEach(function (b) { | |
| var on = b.getAttribute("data-f") === state.filter; | |
| b.classList.toggle("is-on", on); | |
| b.setAttribute("aria-selected", on ? "true" : "false"); | |
| }); | |
| if (state.loading) { | |
| body.innerHTML = '<p class="cat-empty">Loading your workβ¦</p>'; | |
| return; | |
| } | |
| var q = state.q.trim().toLowerCase(); | |
| var runs = state.filter === "constructs" ? [] : state.runs.filter(function (r) { | |
| return !q || matches(r.title, q) || matches(r.construct, q) || | |
| matches(r.organism, q) || matches(r.uniprot, q); | |
| }); | |
| var cxs = state.filter === "runs" ? [] : state.constructs.filter(function (c) { | |
| return !q || matches(c.name, q) || matches(c.kind, q); | |
| }); | |
| if (!runs.length && !cxs.length) { | |
| body.innerHTML = '<p class="cat-empty">' + | |
| (state.q ? "Nothing matches that." | |
| : "Nothing here yet. Start a conversation and it will show up.") + | |
| "</p>"; | |
| return; | |
| } | |
| var html = "", i = 0; | |
| if (runs.length) { | |
| html += '<h3 class="cat-sec">Conversations <span>' + runs.length + "</span></h3>"; | |
| html += '<div class="cat-grid">' + runs.map(function (r) { return runCard(r, i++); }).join("") + "</div>"; | |
| } | |
| if (cxs.length) { | |
| html += '<h3 class="cat-sec">Constructs <span>' + cxs.length + "</span></h3>"; | |
| html += '<div class="cat-grid">' + cxs.map(function (c) { return constructCard(c, i++); }).join("") + "</div>"; | |
| } | |
| body.innerHTML = html; | |
| if (window.TDStructCard) window.TDStructCard.observe(body); | |
| } | |
| /* ββ actions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function onClick(e) { | |
| if (e.target.closest("[data-cat-close]")) { close(); return; } | |
| var btn = e.target.closest("[data-act]"); | |
| if (btn) { | |
| var act = btn.getAttribute("data-act"); | |
| if (act === "open") { openRun(btn.getAttribute("data-run")); return; } | |
| if (act === "opencx") { openConstruct(btn); return; } | |
| if (act === "rename") { startRename(btn.getAttribute("data-run")); return; } | |
| if (act === "delete") { confirmDeleteRun(btn.getAttribute("data-run")); return; } | |
| if (act === "delcx") { confirmDeleteConstruct(btn.getAttribute("data-cx")); return; } | |
| return; | |
| } | |
| var seg = e.target.closest(".cat-segb"); | |
| if (seg) { state.filter = seg.getAttribute("data-f"); state.sel = -1; paint(); } | |
| } | |
| function openRun(runId) { | |
| if (!runId) return; | |
| close(); | |
| if (window.TDCockpit && window.TDCockpit.switchRun) window.TDCockpit.switchRun(runId); | |
| } | |
| function openConstruct(btn) { | |
| close(); | |
| if (window.TDBench && window.TDBench.openConstruct) { | |
| window.TDBench.openConstruct({ name: btn.getAttribute("data-name") || "Construct", | |
| id: btn.getAttribute("data-cx") }); | |
| } | |
| } | |
| function cardBy(attr, id) { | |
| if (!state.root || !id) return null; | |
| // Ids are minted as uuid4().hex, so an attribute-value scan is exact | |
| // without needing CSS.escape (which older Safari lacks). | |
| var all = state.root.querySelectorAll(".cat-card"); | |
| for (var i = 0; i < all.length; i++) { | |
| if (all[i].getAttribute(attr) === id) return all[i]; | |
| } | |
| return null; | |
| } | |
| function cardFor(runId) { return cardBy("data-run", runId); } | |
| /* Rename in place. A prompt() would be two fewer lines and would also be a | |
| modal inside a modal that cannot be styled, cannot be cancelled with | |
| Escape reliably, and is blocked outright in some embedded browsers. */ | |
| function startRename(runId) { | |
| var card = cardFor(runId); | |
| var run = state.runs.filter(function (r) { return r.runId === runId; })[0]; | |
| if (!card || !run) return; | |
| var main = card.querySelector(".cat-main"); | |
| var opener = card.querySelector(".cat-open"); | |
| if (!main || card.querySelector(".cat-rename")) return; | |
| var form = document.createElement("form"); | |
| form.className = "cat-rename"; | |
| form.innerHTML = '<input type="text" class="cat-rename-i" aria-label="New name" maxlength="120">' + | |
| '<button type="submit" class="cat-act">Save</button>' + | |
| '<button type="button" class="cat-act" data-cancel>Cancel</button>'; | |
| // Directly under the name it replaces, wherever that sits. | |
| if (opener && opener.nextSibling) main.insertBefore(form, opener.nextSibling); | |
| else main.appendChild(form); | |
| var input = form.querySelector("input"); | |
| input.value = run.title || ""; | |
| input.focus(); | |
| input.select(); | |
| form.querySelector("[data-cancel]").addEventListener("click", function () { form.remove(); }); | |
| input.addEventListener("keydown", function (e) { | |
| if (e.key === "Escape") { e.stopPropagation(); form.remove(); } | |
| }); | |
| form.addEventListener("submit", function (e) { | |
| e.preventDefault(); | |
| var title = input.value.trim(); | |
| if (!title) { input.focus(); return; } | |
| form.remove(); | |
| doRename(runId, title); | |
| }); | |
| } | |
| function doRename(runId, title) { | |
| state.busy[runId] = 1; paint(); | |
| fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ title: title }), | |
| }).then(function (r) { return r.json().catch(function () { return null; }); }) | |
| .then(function (d) { | |
| delete state.busy[runId]; | |
| if (d && d.ok) { | |
| state.runs.forEach(function (r) { if (r.runId === runId) r.title = d.title || title; }); | |
| } else { | |
| note(runId, (d && d.error) || "Couldn't rename that."); | |
| } | |
| paint(); | |
| }).catch(function () { | |
| delete state.busy[runId]; | |
| note(runId, "Couldn't reach the server."); | |
| paint(); | |
| }); | |
| } | |
| /* Two-step delete, inline. There is no undo behind any of these, so the | |
| confirm names the thing that is about to go rather than asking "are you | |
| sure?" about an unnamed row. */ | |
| function confirmDelete(card, message, onYes) { | |
| if (!card || card.querySelector(".cat-confirm")) return; | |
| var bar = document.createElement("div"); | |
| bar.className = "cat-confirm"; | |
| bar.innerHTML = "<span>" + message + "</span>" + | |
| '<button type="button" class="cat-act is-danger" data-yes>Delete</button>' + | |
| '<button type="button" class="cat-act" data-no>Keep</button>'; | |
| card.appendChild(bar); | |
| bar.querySelector("[data-no]").addEventListener("click", function () { bar.remove(); }); | |
| bar.querySelector("[data-yes]").addEventListener("click", function () { | |
| bar.remove(); onYes(); | |
| }); | |
| bar.querySelector("[data-yes]").focus(); | |
| } | |
| function confirmDeleteRun(runId) { | |
| var run = state.runs.filter(function (r) { return r.runId === runId; })[0]; | |
| if (!run) return; | |
| confirmDelete(cardFor(runId), | |
| "Delete β" + esc(run.title || "Untitled run") + | |
| "β? Its transcript goes with it.", | |
| function () { doDelete(runId); }); | |
| } | |
| /* Constructs are saved WORK β a plasmid map, a primer analysis β not a | |
| conversation about it, so the confirm says which kind is going and does | |
| not promise anything about the run that produced it. */ | |
| function confirmDeleteConstruct(cxId) { | |
| var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0]; | |
| if (!cx) return; | |
| var del = CX_DELETE[cx.kind]; | |
| if (!del) return; | |
| confirmDelete(cardBy("data-cx", cxId), | |
| "Delete the " + esc(del.noun) + " β" + esc(cx.name || "Untitled") + | |
| "β? This removes the saved record.", | |
| function () { doDeleteConstruct(cxId); }); | |
| } | |
| function doDeleteConstruct(cxId) { | |
| var cx = state.constructs.filter(function (c) { return String(c.id) === String(cxId); })[0]; | |
| var del = cx && CX_DELETE[cx.kind]; | |
| if (!del) return; | |
| state.busy[cxId] = 1; paint(); | |
| fetch(del.path + encodeURIComponent(cxId), { method: "DELETE" }) | |
| .then(function (r) { | |
| return r.json().catch(function () { return { ok: r.ok }; }); | |
| }) | |
| .then(function (d) { | |
| delete state.busy[cxId]; | |
| if (d && d.ok) { | |
| state.constructs = state.constructs.filter(function (c) { | |
| return String(c.id) !== String(cxId); | |
| }); | |
| // Mission Control lists the same constructs from the same | |
| // endpoint. Leaving it showing a card whose record is gone | |
| // is the silted-up-dropdown problem in a second place. | |
| try { | |
| if (window.TDMission && window.TDMission.reload) window.TDMission.reload(); | |
| } catch (e) {} | |
| } else { | |
| note(cxId, (d && d.error) || "Couldn't delete that."); | |
| } | |
| paint(); | |
| }).catch(function () { | |
| delete state.busy[cxId]; | |
| note(cxId, "Couldn't reach the server."); | |
| paint(); | |
| }); | |
| } | |
| function doDelete(runId) { | |
| state.busy[runId] = 1; paint(); | |
| fetch("/api/orchestrator/runs/" + encodeURIComponent(runId), { method: "DELETE" }) | |
| .then(function (r) { return r.json().catch(function () { return null; }); }) | |
| .then(function (d) { | |
| delete state.busy[runId]; | |
| if (d && d.ok) { | |
| var wasCurrent = window.TDCockpit && window.TDCockpit.currentRunId && | |
| window.TDCockpit.currentRunId() === runId; | |
| state.runs = state.runs.filter(function (r) { return r.runId !== runId; }); | |
| // Deleting the conversation you are looking at has to move | |
| // you somewhere: leaving the rail showing a run that no | |
| // longer exists is worse than the dropdown ever was. | |
| if (wasCurrent && window.TDCockpit && window.TDCockpit.newRun) { | |
| window.TDCockpit.newRun(); | |
| } | |
| } else { | |
| note(runId, (d && d.error) || "Couldn't delete that."); | |
| } | |
| paint(); | |
| }).catch(function () { | |
| delete state.busy[runId]; | |
| note(runId, "Couldn't reach the server."); | |
| paint(); | |
| }); | |
| } | |
| var _notes = {}; | |
| function note(runId, msg) { | |
| _notes[runId] = msg; | |
| setTimeout(function () { delete _notes[runId]; paint(); }, 6000); | |
| } | |
| /* ββ keyboard βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function cards() { | |
| return state.root ? Array.prototype.slice.call(state.root.querySelectorAll(".cat-card")) : []; | |
| } | |
| function onKey(e) { | |
| if (e.key === "Escape") { e.preventDefault(); close(); return; } | |
| var list = cards(); | |
| if (!list.length) return; | |
| if (e.key === "ArrowDown" || e.key === "ArrowUp") { | |
| e.preventDefault(); | |
| state.sel += (e.key === "ArrowDown" ? 1 : -1); | |
| if (state.sel < 0) state.sel = list.length - 1; | |
| if (state.sel >= list.length) state.sel = 0; | |
| list.forEach(function (c, i) { c.classList.toggle("is-sel", i === state.sel); }); | |
| var pick = list[state.sel]; | |
| if (pick) { | |
| pick.scrollIntoView({ block: "nearest" }); | |
| var opener = pick.querySelector(".cat-open"); | |
| if (opener) opener.focus(); | |
| } | |
| return; | |
| } | |
| if (e.key === "Enter" && state.sel >= 0) { | |
| var card = list[state.sel]; | |
| if (!card) return; | |
| e.preventDefault(); | |
| if (card.getAttribute("data-run")) openRun(card.getAttribute("data-run")); | |
| else openConstruct(card.querySelector("[data-act='opencx']")); | |
| } | |
| } | |
| document.addEventListener("keydown", function (e) { | |
| if (!state.open) return; | |
| if (e.key === "Escape") { e.preventDefault(); close(); } | |
| }); | |
| window.TDCatalog = { open: open, close: close, reload: load, | |
| isOpen: function () { return state.open; } }; | |
| })(); | |