samagotchi 0.53.0 → 0.55.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
// The session lists: the strip of the latest sessions over the chat (with
|
|
2
|
+
// its All sessions tile and the hidden strip's pill), the all-sessions view
|
|
3
|
+
// (#allList, its search, count and select mode), their cards, and the
|
|
4
|
+
// family and chain popovers. Split out of app.js.
|
|
5
|
+
//
|
|
6
|
+
// The pure parts (a card's markup, the search match, the lists' orders and
|
|
7
|
+
// counts) are exported for node specs; createListsView draws them and binds
|
|
8
|
+
// the clicks. Everything that changes while the page runs is read through
|
|
9
|
+
// deps when a list draws, never captured at creation.
|
|
10
|
+
//
|
|
11
|
+
// deps:
|
|
12
|
+
// doc, win document and window
|
|
13
|
+
// topStripEl, allListEl the strip and the all-sessions list
|
|
14
|
+
// sessions() every session the page knows (archived ones too)
|
|
15
|
+
// selected() the open session's id, or null
|
|
16
|
+
// scope the page's scope (scope.js; projectName read live)
|
|
17
|
+
// scopeError() why the server refused ?dir, or null
|
|
18
|
+
// eventsLive() whether the hub's events stream is open
|
|
19
|
+
// refresh() fetch the list (no events stream)
|
|
20
|
+
// select(id) open a session
|
|
21
|
+
// confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
|
|
22
|
+
// updateInfoBar(), updateSessionTitle(), showToast(text, action)
|
|
23
|
+
// allViewOpen(), openAllView(), leaveAllView(hash)
|
|
24
|
+
// setStripHidden(hidden)
|
|
25
|
+
|
|
26
|
+
import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
|
|
27
|
+
import { htmlElement } from "./turn_html.js";
|
|
28
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
29
|
+
import { cardCtxText, ctxBarTip } from "./ctx.js";
|
|
30
|
+
import { allScopeHref, cardFolder } from "./scope.js";
|
|
31
|
+
import { sessionHash } from "./route.js";
|
|
32
|
+
import { stripColumns, stripShowParts } from "./strip.js";
|
|
33
|
+
import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
|
|
34
|
+
import { allViewChains, chainChipHtml, chainRowsHtml, chainTitle, foldChains } from "./chains.js";
|
|
35
|
+
import {
|
|
36
|
+
batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
|
|
37
|
+
listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
|
|
38
|
+
} from "./sessions_list.js";
|
|
39
|
+
|
|
40
|
+
// Whether a session matches the all view's search +q+ (lower case);
|
|
41
|
+
// +title+ is its card's title (cardTitle).
|
|
42
|
+
export function matchesFilter(s, q, title) {
|
|
43
|
+
return title.toLowerCase().includes(q) ||
|
|
44
|
+
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
45
|
+
s.short_id.toLowerCase().includes(q) ||
|
|
46
|
+
s.id.toLowerCase().includes(q) ||
|
|
47
|
+
s.status.toLowerCase().includes(q) ||
|
|
48
|
+
waitingSearchText(s).includes(q);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The strip's cards before the pointer's hold and the cut to what fits:
|
|
52
|
+
// families in the list's order (a family in its first-listed member's
|
|
53
|
+
// place, so a hub upsert doesn't move it), chains folded, waiting ones
|
|
54
|
+
// first. A family is one card. +shown+: the strip's sessions (no archived
|
|
55
|
+
// ones); +all+: every session (a chain's links).
|
|
56
|
+
export function stripOrder(shown, all) {
|
|
57
|
+
return waitingFirst(foldChains(families(shown, { order: "list" }), all));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The all view's families: +all+ (a search and the archived ones see every
|
|
61
|
+
// link as a card, allViewChains), +shown+ (the ones a search keeps, with
|
|
62
|
+
// their matchIds; every family without a search), waiting ones first, and
|
|
63
|
+
// how many delegates +all+ folds in.
|
|
64
|
+
// @param matches (session) → whether it matches the search
|
|
65
|
+
export function allViewFamilies(listed, every, { query = "", includeArchived = false, matches = () => true } = {}) {
|
|
66
|
+
const all = allViewChains(families(listed), every, { query, includeArchived });
|
|
67
|
+
const shown = query ? filterFamilies(all, matches) : all;
|
|
68
|
+
const delegates = all.reduce((n, f) => n + f.members.length, 0);
|
|
69
|
+
return { all, shown: waitingFirst(shown), delegates };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The all view's count: "14 (+7 delegates)", or "3 / 14" under a search.
|
|
73
|
+
export function allCountText(shown, total, delegates) {
|
|
74
|
+
return shown === total
|
|
75
|
+
? `${total}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
|
|
76
|
+
: `${shown} / ${total}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The All sessions tile's count: the strip's sessions, or "N archived"
|
|
80
|
+
// when every session is archived.
|
|
81
|
+
export function stripCountText(shown, total) {
|
|
82
|
+
return shown || total === shown
|
|
83
|
+
? `${shown} ${shown === 1 ? "session" : "sessions"}`
|
|
84
|
+
: `${total - shown} archived`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A card: the first message as its title, then status, how long ago, owner
|
|
88
|
+
// and memories. The id is in the tooltip (and the search) only. A session
|
|
89
|
+
// that waits on the user (a question, an approval, a card with actions)
|
|
90
|
+
// wears the warning colour and says what for.
|
|
91
|
+
// deletable / archivable: the all-sessions view's cards get a delete and an
|
|
92
|
+
// archive (unarchive, on an archived card) button. selectable: select mode's
|
|
93
|
+
// checkbox instead (picked: checked), and skipReason: why a batch skipped it.
|
|
94
|
+
// family: the family the session heads (sessions_list.js families): with
|
|
95
|
+
// members, its chip (which opens their list), and the card waits when any
|
|
96
|
+
// member does; with rows (all sessions) an open family lists its members
|
|
97
|
+
// inline, the strip's opens in a popover; matchIds: a search's matching
|
|
98
|
+
// members. A family in a session chain (chains.js foldChains) carries its
|
|
99
|
+
// `day N` chip, which opens the other links in a popover.
|
|
100
|
+
// +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
|
|
101
|
+
// open session), chainOpen (its chain's popover is open), parent (the
|
|
102
|
+
// delegating session, or null).
|
|
103
|
+
export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
|
|
104
|
+
const fam = family && family.members.length ? family : null;
|
|
105
|
+
const ts = s.updated_at || s.created_at || "";
|
|
106
|
+
const tip = `${s.id} · updated ${ts} · ${s.status}`;
|
|
107
|
+
const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
|
|
108
|
+
const ago = relativeTime(ts);
|
|
109
|
+
const time = ago ? html`<span class="time" title=${new Date(ts).toLocaleString()}>${ago}</span>` : nothing;
|
|
110
|
+
const folder = cardFolder(s.working_directory, projectName);
|
|
111
|
+
const folderT = folder ? html`<span class="folder" title=${s.working_directory}>${folder}</span>` : nothing;
|
|
112
|
+
const archived = s.archived ? html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : nothing;
|
|
113
|
+
const ctx = cardCtxText(s.ctx_pct);
|
|
114
|
+
const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct })}>${ctx}</span>` : nothing;
|
|
115
|
+
const waiting = waitingBadge(s);
|
|
116
|
+
const waitingT = waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing;
|
|
117
|
+
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
118
|
+
// the user (the waiting badge wins: the stop is behind the need).
|
|
119
|
+
const stopped = waiting ? null : stoppedBadge(s);
|
|
120
|
+
const stoppedT = stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing;
|
|
121
|
+
const dotWord = waiting ? waiting.title : s.status;
|
|
122
|
+
const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
|
|
123
|
+
const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
|
|
124
|
+
const familyWaiting = !!family && familyWaits(family);
|
|
125
|
+
// The family and chain chips and rows are families_view.js's and
|
|
126
|
+
// chains.js's string builders (not lit yet): one element each, as nodes.
|
|
127
|
+
const chain = family?.chain ? htmlElement(chainChipHtml(family, { open: chainOpen })) : nothing;
|
|
128
|
+
const kids = fam ? htmlElement(familyChipHtml(fam, { open })) : nothing;
|
|
129
|
+
const famRows = fam && rows && open ? htmlElement(familyRowsHtml(fam, { activeId, matchIds, archivable: !selectable })) : nothing;
|
|
130
|
+
// Its delegates and its chain's other links: the card is .contains-active while one is open.
|
|
131
|
+
const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
|
|
132
|
+
...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
|
|
133
|
+
const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}`;
|
|
134
|
+
const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
|
|
135
|
+
return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cardToolsTemplate(s, { deletable, archivable }) {
|
|
139
|
+
if (!deletable && !archivable) return nothing;
|
|
140
|
+
const short = s.short_id;
|
|
141
|
+
const arch = !archivable ? nothing : s.archived
|
|
142
|
+
? html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Unarchive: back to the lists"} aria-label=${`Unarchive session ${short}`}></button>`
|
|
143
|
+
: html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Archive: hide from the lists, keep for good"} aria-label=${`Archive session ${short}`}></button>`;
|
|
144
|
+
const del = deletable ? html`<button type="button" class="card-del" data-del=${s.id} title=${"Delete session"} aria-label=${`Delete session ${short}`}>\u2715</button>` : nothing;
|
|
145
|
+
// title is bound, as data-arch and aria-label are, so the attributes keep
|
|
146
|
+
// their order (lit sets bound ones after the static ones).
|
|
147
|
+
return html`<div class="card-tools">${arch}${del}</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
151
|
+
function parentChipTemplate(s, parent) {
|
|
152
|
+
const chip = delegatedBy(s.parent_id, parent);
|
|
153
|
+
return chip ? html`<span class="parent" title=${chip.title}>${chip.text}</span>` : nothing;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ownerBadgeTemplate(s) {
|
|
157
|
+
const badge = ownerBadge(s.owner, s.id);
|
|
158
|
+
return badge ? html`<span class=${`owner ${badge.kind}`} title=${badge.title}>${badge.text}</span>` : nothing;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The strip's end: the All sessions tile (with its count) and hide ▴.
|
|
162
|
+
export function stripTileTemplate(count) {
|
|
163
|
+
return html`<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
|
|
167
|
+
// (strip.js stripShowParts).
|
|
168
|
+
export function stripPillTemplate({ count, live, waiting }) {
|
|
169
|
+
const liveT = live ? html` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
|
|
170
|
+
const waitingT = waiting ? html` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
|
|
171
|
+
return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${count}${liveT}${waitingT}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
|
|
175
|
+
// node of the last draw is kept (the cards' clicks are bound per draw, and
|
|
176
|
+
// a card's checked box or marks never carry over to another session's).
|
|
177
|
+
function draw(el, template) {
|
|
178
|
+
const frag = el.ownerDocument.createDocumentFragment();
|
|
179
|
+
render(template, frag);
|
|
180
|
+
el.replaceChildren(frag);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createListsView(deps) {
|
|
184
|
+
const {
|
|
185
|
+
doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
|
|
186
|
+
confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
|
|
187
|
+
allViewOpen, openAllView, leaveAllView, setStripHidden,
|
|
188
|
+
} = deps;
|
|
189
|
+
const $ = (s) => doc.querySelector(s);
|
|
190
|
+
|
|
191
|
+
// The strip, its count and the hidden strip's pill leave archived sessions
|
|
192
|
+
// out; the all view shows them with "include archived". sessions() keeps
|
|
193
|
+
// them all (parent chips, the open session).
|
|
194
|
+
function shownSessions() {
|
|
195
|
+
return listedSessions(sessions(), false);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function allViewSessions() {
|
|
199
|
+
return listedSessions(sessions(), $("#includeArchived").checked);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function filterQuery() {
|
|
203
|
+
return $("#filter").value.trim().toLowerCase();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The page's sessions by id, built again only when the list changed.
|
|
207
|
+
let byIdOf = { list: null, map: new Map() };
|
|
208
|
+
function sessionsById() {
|
|
209
|
+
const all = sessions();
|
|
210
|
+
if (byIdOf.list !== all) byIdOf = { list: all, map: new Map(all.map((s) => [s.id, s])) };
|
|
211
|
+
return byIdOf.map;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A card's title: its preview, or its chain's first link's for a link
|
|
215
|
+
// titled with the opener (chains.js chainTitle; display only).
|
|
216
|
+
function cardTitle(s) {
|
|
217
|
+
return chainTitle(s, sessionsById());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cardFor(s, opts) {
|
|
221
|
+
return cardTemplate(s, opts, {
|
|
222
|
+
title: cardTitle(s),
|
|
223
|
+
projectName: scope.projectName,
|
|
224
|
+
activeId: selected(),
|
|
225
|
+
chainOpen: !!opts.family?.chain && chainOpenHead() === s.id,
|
|
226
|
+
parent: sessions().find((x) => x.id === s.parent_id) || null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The strip's card count follows the window: more cards on wide screens
|
|
231
|
+
// (each capped at 440 px) instead of 3 stretched ones. main's 16 px padding
|
|
232
|
+
// sits on both sides; measuring main works while the strip is hidden too.
|
|
233
|
+
let stripCols = 3;
|
|
234
|
+
function fitStripColumns() {
|
|
235
|
+
stripCols = stripColumns(topStripEl.parentElement.clientWidth - 32);
|
|
236
|
+
topStripEl.style.setProperty("--strip-cols", stripCols);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The order a list draws its cards in: +next+, what waits on the user
|
|
240
|
+
// first (waitingFirst), back to its place once answered. Never under the
|
|
241
|
+
// pointer, though: while it is over the list the order last drawn holds,
|
|
242
|
+
// and its leaving draws the list again. A hidden list holds nothing: the
|
|
243
|
+
// strip keeps :hover for a moment after a click on its All sessions tile
|
|
244
|
+
// hid it, and an update held then brought it back from the all view in an
|
|
245
|
+
// order nobody saw (a waiting family behind). +idOf+ names a card: a
|
|
246
|
+
// session's id, or familyId (a card per family: its head's id). drawnOrder
|
|
247
|
+
// is the cards drawn, which select mode picks from.
|
|
248
|
+
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
249
|
+
const heldLists = new Set(); // lists drawn in a held order
|
|
250
|
+
const canHover = win.matchMedia?.("(hover: hover)").matches ?? false;
|
|
251
|
+
function listOrder(el, next, idOf = (s) => s.id) {
|
|
252
|
+
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
253
|
+
// and a touch screen has no hover at all).
|
|
254
|
+
const holding = el.getClientRects().length > 0
|
|
255
|
+
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
|
|
256
|
+
const held = holding ? drawnOrder.get(el) : null;
|
|
257
|
+
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
258
|
+
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
259
|
+
else heldLists.delete(el);
|
|
260
|
+
drawnOrder.set(el, ordered.map(idOf));
|
|
261
|
+
return ordered;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The strip's cards: stripOrder, held under the pointer, as many as fit.
|
|
265
|
+
function stripCards() {
|
|
266
|
+
return listOrder(topStripEl, stripOrder(shownSessions(), sessions()), familyId).slice(0, stripCols);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The strip's family popover (families_view.js): the open family's rows,
|
|
270
|
+
// under its chip.
|
|
271
|
+
let stripFamilies = new Map(); // head id → family, as the strip last drew it
|
|
272
|
+
const familyPop = createFamilyPop({
|
|
273
|
+
doc,
|
|
274
|
+
win,
|
|
275
|
+
rowsFor: (id) => {
|
|
276
|
+
const f = stripFamilies.get(id);
|
|
277
|
+
return f && f.members.length ? familyRowsHtml(f, { activeId: selected() }) : null;
|
|
278
|
+
},
|
|
279
|
+
anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
|
|
280
|
+
onPick: (id) => {
|
|
281
|
+
familyPop.close();
|
|
282
|
+
select(id);
|
|
283
|
+
},
|
|
284
|
+
// A strip order held while it was open catches up (unless the pointer is
|
|
285
|
+
// back over the strip: its leaving does it then).
|
|
286
|
+
onClose: () => {
|
|
287
|
+
if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
win.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
|
|
291
|
+
doc.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
|
|
292
|
+
doc.addEventListener("scroll", () => familyPop.reposition(), true);
|
|
293
|
+
|
|
294
|
+
// A chain's popover (chains.js): the other links of the chain a card's
|
|
295
|
+
// `day N` chip stands for, under the chip, in the strip or all sessions
|
|
296
|
+
// (the list it was opened from: chainFrom). A row opens that link.
|
|
297
|
+
const chainFamilies = new Map(); // head id → family, as the lists last drew it
|
|
298
|
+
let chainFrom = null; // the list element the open popover's chip is in
|
|
299
|
+
const chainPop = createFamilyPop({
|
|
300
|
+
id: "chainPop",
|
|
301
|
+
doc,
|
|
302
|
+
win,
|
|
303
|
+
rowsFor: (id) => {
|
|
304
|
+
const f = chainFamilies.get(id);
|
|
305
|
+
return f?.chain ? chainRowsHtml(f, { activeId: selected() }) : null;
|
|
306
|
+
},
|
|
307
|
+
anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
|
|
308
|
+
onPick: (id) => {
|
|
309
|
+
chainPop.close();
|
|
310
|
+
if (allViewOpen()) leaveAllView(sessionHash(id));
|
|
311
|
+
select(id);
|
|
312
|
+
},
|
|
313
|
+
// chainFrom stays (Esc focuses the chip again after the close); only an
|
|
314
|
+
// open popover holds a list (chainPopIn).
|
|
315
|
+
onClose: () => {
|
|
316
|
+
if (chainFrom === topStripEl && heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
function chainOpenHead() {
|
|
320
|
+
return chainPop.openHead();
|
|
321
|
+
}
|
|
322
|
+
function chainPopIn(el) {
|
|
323
|
+
return chainPop.isOpen() && chainFrom === el;
|
|
324
|
+
}
|
|
325
|
+
function toggleChainPop(from, id, e) {
|
|
326
|
+
if (chainPop.openHead() === id && chainFrom === from) {
|
|
327
|
+
chainPop.close();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
chainPop.close();
|
|
331
|
+
chainFrom = from;
|
|
332
|
+
chainPop.open(id);
|
|
333
|
+
// From the keyboard the focus goes into the popover; Esc brings it back.
|
|
334
|
+
if (e?.detail === 0 && chainPop.isOpen()) $("#chainPop .fr-open")?.focus();
|
|
335
|
+
}
|
|
336
|
+
win.addEventListener("keydown", (e) => chainPop.onKeydown(e), true);
|
|
337
|
+
doc.addEventListener("pointerdown", (e) => chainPop.onPointerDown(e), true);
|
|
338
|
+
doc.addEventListener("scroll", () => chainPop.reposition(), true);
|
|
339
|
+
|
|
340
|
+
function render() {
|
|
341
|
+
fitStripColumns();
|
|
342
|
+
renderTop(stripCards());
|
|
343
|
+
if (allViewOpen()) renderAll();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let stripResizeTimer = null;
|
|
347
|
+
win.addEventListener("resize", () => {
|
|
348
|
+
clearTimeout(stripResizeTimer);
|
|
349
|
+
stripResizeTimer = setTimeout(() => {
|
|
350
|
+
const before = stripCols;
|
|
351
|
+
fitStripColumns();
|
|
352
|
+
if (stripCols !== before && sessions().length) renderTop(stripCards());
|
|
353
|
+
familyPop.reposition();
|
|
354
|
+
chainPop.reposition();
|
|
355
|
+
}, 150);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// onPick: a card's click (open it, or pick it in select mode); onFamily:
|
|
359
|
+
// a family chip's (toggle its list); onRow: a family row's (open that
|
|
360
|
+
// delegate).
|
|
361
|
+
function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
|
|
362
|
+
container.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
363
|
+
el.addEventListener("click", (e) => {
|
|
364
|
+
const chainChip = e.target.closest(".chain-chip");
|
|
365
|
+
if (chainChip) {
|
|
366
|
+
e.stopPropagation();
|
|
367
|
+
toggleChainPop(container, el.dataset.id, e);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const chip = e.target.closest(".family-chip");
|
|
371
|
+
if (chip) {
|
|
372
|
+
e.stopPropagation();
|
|
373
|
+
onFamily(el.dataset.id, chip, e);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const del = e.target.closest(".card-del");
|
|
377
|
+
if (del) {
|
|
378
|
+
e.stopPropagation();
|
|
379
|
+
confirmDelete(del.dataset.del);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Archive from the list: the view stays open, the toast has Undo.
|
|
383
|
+
const arch = e.target.closest(".card-arch");
|
|
384
|
+
if (arch) {
|
|
385
|
+
e.stopPropagation();
|
|
386
|
+
const id = arch.dataset.arch;
|
|
387
|
+
setArchived(id, !sessions().find((x) => x.id === id)?.archived);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const row = e.target.closest(".family-row[data-id]");
|
|
391
|
+
if (row) {
|
|
392
|
+
e.stopPropagation();
|
|
393
|
+
onRow(row.dataset.id, e);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
onPick(el.dataset.id, e);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
paintActive();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// The open session's marks: its card (.active), its row in a family's list
|
|
403
|
+
// (in a card or the strip's popover), and a quieter .contains-active on the
|
|
404
|
+
// card of the family it is folded into.
|
|
405
|
+
function paintActive() {
|
|
406
|
+
const id = selected();
|
|
407
|
+
doc.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
408
|
+
el.classList.toggle("active", !!id && el.dataset.id === id);
|
|
409
|
+
el.classList.toggle("contains-active", !!id && (el.dataset.members || "").split(" ").includes(id));
|
|
410
|
+
});
|
|
411
|
+
doc.querySelectorAll(".family-row[data-id]").forEach((el) => {
|
|
412
|
+
el.classList.toggle("active", !!id && el.dataset.id === id);
|
|
413
|
+
});
|
|
414
|
+
// The open session's card may have come into the strip or left it.
|
|
415
|
+
updateSessionTitle();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The latest sessions (as many as fit, a family one card), then the way
|
|
419
|
+
// to all of them.
|
|
420
|
+
function renderTop(cards) {
|
|
421
|
+
topStripEl.classList.remove("loading");
|
|
422
|
+
renderStripShow();
|
|
423
|
+
stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
|
|
424
|
+
noteChainFamilies(cards);
|
|
425
|
+
const refused = scopeError();
|
|
426
|
+
if (refused) {
|
|
427
|
+
draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
|
|
428
|
+
familyPop.close();
|
|
429
|
+
chainPop.close();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const all = sessions();
|
|
433
|
+
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
434
|
+
if (!all.length) {
|
|
435
|
+
topStripEl.replaceChildren();
|
|
436
|
+
familyPop.close();
|
|
437
|
+
chainPop.close();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length));
|
|
441
|
+
draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id }))}${tile}`);
|
|
442
|
+
bindCards(topStripEl, select, {
|
|
443
|
+
onFamily: (id, _chip, e) => {
|
|
444
|
+
familyPop.toggle(id);
|
|
445
|
+
// From the keyboard (Enter / Space: no pointer, detail 0) the focus
|
|
446
|
+
// goes into the popover, which sits at the end of body; Esc brings it back.
|
|
447
|
+
if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
$("#allTile").addEventListener("click", openAllView);
|
|
451
|
+
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
452
|
+
familyPop.refresh();
|
|
453
|
+
if (chainFrom === topStripEl) chainPop.refresh();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The chain families a list drew: the popover's rows come from them.
|
|
457
|
+
function noteChainFamilies(fams) {
|
|
458
|
+
for (const f of fams) {
|
|
459
|
+
if (f.chain) chainFamilies.set(f.head.id, f);
|
|
460
|
+
else chainFamilies.delete(f.head.id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The hidden strip's pill, following the list: its sessions, the number
|
|
465
|
+
// the All sessions tile shows, and the live and waiting ones (a waiting
|
|
466
|
+
// delegate too).
|
|
467
|
+
function renderStripShow() {
|
|
468
|
+
draw($("#stripShow"), stripPillTemplate(stripShowParts(shownSessions())));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A parent's card holds its delegates (families, by the newest member: a
|
|
472
|
+
// hub snapshot hands the list back in the server's order), folded unless
|
|
473
|
+
// one waits on the user, a search matched one or the user opened it. The
|
|
474
|
+
// search runs over the families, so a matching delegate brings its parent's
|
|
475
|
+
// card along. Select mode picks cards (a family's rows have no checkbox:
|
|
476
|
+
// archiving the parent takes its delegates).
|
|
477
|
+
const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
|
|
478
|
+
const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
|
|
479
|
+
const drawnOpen = new Map(); // head id → open as last drawn in all sessions
|
|
480
|
+
|
|
481
|
+
function renderAll() {
|
|
482
|
+
const listed = allViewSessions();
|
|
483
|
+
const q = filterQuery();
|
|
484
|
+
const { all, shown, delegates } = allViewFamilies(listed, sessions(), {
|
|
485
|
+
query: q, includeArchived: $("#includeArchived").checked, matches: (s) => matchesFilter(s, q, cardTitle(s)),
|
|
486
|
+
});
|
|
487
|
+
noteChainFamilies(all);
|
|
488
|
+
const fams = listOrder(allListEl, shown, familyId);
|
|
489
|
+
$("#count").textContent = allCountText(fams.length, all.length, delegates);
|
|
490
|
+
updateSelectBar();
|
|
491
|
+
if (!fams.length) {
|
|
492
|
+
drawnOpen.clear();
|
|
493
|
+
draw(allListEl, html`<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`);
|
|
494
|
+
if (chainFrom === allListEl) chainPop.refresh();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// A hub event redraws the list under a checkbox or a chip the keyboard is on.
|
|
498
|
+
const focusSel = [".card-pick", ".family-chip", ".chain-chip"].find((sel) => doc.activeElement?.matches?.(`#allList ${sel}`));
|
|
499
|
+
const focused = focusSel ? { id: doc.activeElement.closest(".card")?.dataset.id, sel: focusSel } : null;
|
|
500
|
+
const opts = (s) => (selecting.on
|
|
501
|
+
? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
|
|
502
|
+
: { deletable: true, archivable: true });
|
|
503
|
+
drawnOpen.clear();
|
|
504
|
+
draw(allListEl, html`${fams.map((f) => {
|
|
505
|
+
const matched = !!f.matchIds?.size;
|
|
506
|
+
const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
|
|
507
|
+
drawnOpen.set(f.head.id, open);
|
|
508
|
+
return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
|
|
509
|
+
})}`);
|
|
510
|
+
bindCards(allListEl, (id, e) => {
|
|
511
|
+
if (selecting.on) {
|
|
512
|
+
togglePick(id, e);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
leaveAllView(sessionHash(id));
|
|
516
|
+
select(id);
|
|
517
|
+
}, {
|
|
518
|
+
onFamily: (headId) => {
|
|
519
|
+
const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
|
|
520
|
+
(matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
|
|
521
|
+
renderAll();
|
|
522
|
+
},
|
|
523
|
+
// In select mode a row does nothing: rows aren't picked.
|
|
524
|
+
onRow: (id) => {
|
|
525
|
+
if (selecting.on) return;
|
|
526
|
+
leaveAllView(sessionHash(id));
|
|
527
|
+
select(id);
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
|
|
531
|
+
if (chainFrom === allListEl) chainPop.refresh();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── Select mode (all sessions) ─────────────────────────────────────────────
|
|
535
|
+
// "Select" turns the cards into picks: a click (or Shift-click, a range in
|
|
536
|
+
// the drawn order) toggles one instead of opening it, and the bar acts on
|
|
537
|
+
// the picked ones that are shown. The picks live here, not in the DOM:
|
|
538
|
+
// renderAll draws the list again on every hub event.
|
|
539
|
+
const selecting = {
|
|
540
|
+
on: false,
|
|
541
|
+
picked: new Set(),
|
|
542
|
+
last: null, // the last toggled id: a Shift-click's range starts there
|
|
543
|
+
skipReasons: new Map(), // id → why the last batch skipped it
|
|
544
|
+
running: null, // {done, total, archive} while a batch runs
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
function enterSelect() {
|
|
548
|
+
selecting.on = true;
|
|
549
|
+
renderAll();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function leaveSelect() {
|
|
553
|
+
selecting.on = false;
|
|
554
|
+
selecting.picked.clear();
|
|
555
|
+
selecting.last = null;
|
|
556
|
+
selecting.skipReasons.clear();
|
|
557
|
+
if (allViewOpen()) renderAll();
|
|
558
|
+
else updateSelectBar();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function togglePick(id, e) {
|
|
562
|
+
if (selecting.running) return; // the grid reflows while a batch runs
|
|
563
|
+
const order = drawnOrder.get(allListEl) || [];
|
|
564
|
+
const on = !selecting.picked.has(id);
|
|
565
|
+
const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
|
|
566
|
+
for (const x of ids) {
|
|
567
|
+
if (on) selecting.picked.add(x);
|
|
568
|
+
else selecting.picked.delete(x);
|
|
569
|
+
paintPick(x);
|
|
570
|
+
}
|
|
571
|
+
selecting.last = id;
|
|
572
|
+
updateSelectBar();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A card's pick state in place (no redraw: the focus stays put).
|
|
576
|
+
function paintPick(id) {
|
|
577
|
+
const el = allListEl.querySelector(`.card[data-id="${id}"]`);
|
|
578
|
+
if (!el) return;
|
|
579
|
+
const on = selecting.picked.has(id);
|
|
580
|
+
el.classList.toggle("picked", on);
|
|
581
|
+
const box = el.querySelector(".card-pick");
|
|
582
|
+
if (box) box.checked = on;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The bar's numbers: picks of sessions still in the list (a snapshot can
|
|
586
|
+
// drop one with no session_gone), and the shown ones a batch would act on.
|
|
587
|
+
function selectCounts() {
|
|
588
|
+
const byId = new Map(sessions().map((s) => [s.id, s]));
|
|
589
|
+
const shown = drawnOrder.get(allListEl) || [];
|
|
590
|
+
const shownSet = new Set(shown);
|
|
591
|
+
const live = [...selecting.picked].filter((id) => byId.has(id));
|
|
592
|
+
return {
|
|
593
|
+
picked: live.length,
|
|
594
|
+
hidden: live.filter((id) => !shownSet.has(id)).length,
|
|
595
|
+
archive: batchTargets(selecting.picked, shown, true, byId),
|
|
596
|
+
unarchive: batchTargets(selecting.picked, shown, false, byId),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function updateSelectBar() {
|
|
601
|
+
$("#allView").classList.toggle("selecting", selecting.on);
|
|
602
|
+
$("#selectBtn").hidden = selecting.on;
|
|
603
|
+
const bar = $("#selectBar");
|
|
604
|
+
bar.hidden = !selecting.on;
|
|
605
|
+
if (!selecting.on) return;
|
|
606
|
+
const c = selectCounts();
|
|
607
|
+
const run = selecting.running;
|
|
608
|
+
$("#selectCount").textContent = run
|
|
609
|
+
? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}…`
|
|
610
|
+
: `${c.picked} selected${c.hidden ? ` · ${c.hidden} not shown` : ""}`;
|
|
611
|
+
$("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
|
|
612
|
+
$("#batchArchiveBtn").disabled = !!run || !c.archive.length;
|
|
613
|
+
$("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
|
|
614
|
+
$("#batchUnarchiveBtn").hidden = !c.unarchive.length;
|
|
615
|
+
$("#batchUnarchiveBtn").disabled = !!run;
|
|
616
|
+
$("#selectAllBtn").disabled = !!run;
|
|
617
|
+
$("#selectDoneBtn").disabled = !!run;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The batch: the picked shown ones, one request at a time in the drawn
|
|
621
|
+
// order (parents before their delegates), through the info bar's
|
|
622
|
+
// archiveOne. A refused one stays picked with the server's reason on its
|
|
623
|
+
// card; the end toast's Undo runs the opposite batch on the ones that went.
|
|
624
|
+
async function runSelectBatch(archive) {
|
|
625
|
+
if (selecting.running) return;
|
|
626
|
+
const byId = new Map(sessions().map((s) => [s.id, s]));
|
|
627
|
+
const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
|
|
628
|
+
if (!ids.length) return;
|
|
629
|
+
selecting.skipReasons.clear();
|
|
630
|
+
const outcome = await archiveBatch(ids, archive);
|
|
631
|
+
if (!outcome) return;
|
|
632
|
+
if (!allViewOpen()) leaveSelect(); // left the view while it ran
|
|
633
|
+
else renderAll();
|
|
634
|
+
showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
|
|
635
|
+
label: "Undo",
|
|
636
|
+
run: async () => {
|
|
637
|
+
const undone = await archiveBatch(outcome.done, !archive);
|
|
638
|
+
if (!undone) return;
|
|
639
|
+
if (allViewOpen()) renderAll();
|
|
640
|
+
showToast(batchToastText(batchCounts(undone, !archive)));
|
|
641
|
+
},
|
|
642
|
+
} : null);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// runBatch over archiveOne, with the bar's progress; the picks and skip
|
|
646
|
+
// reasons follow the outcome. null when a batch already runs.
|
|
647
|
+
async function archiveBatch(ids, archive) {
|
|
648
|
+
if (selecting.running) return null;
|
|
649
|
+
selecting.running = { done: 0, total: ids.length, archive };
|
|
650
|
+
updateSelectBar();
|
|
651
|
+
try {
|
|
652
|
+
const outcome = await runBatch(ids, async (id) => {
|
|
653
|
+
const answer = await archiveOne(id, archive);
|
|
654
|
+
if (answer.ok) {
|
|
655
|
+
render();
|
|
656
|
+
updateInfoBar();
|
|
657
|
+
}
|
|
658
|
+
return answer;
|
|
659
|
+
}, (done) => {
|
|
660
|
+
selecting.running.done = done;
|
|
661
|
+
updateSelectBar();
|
|
662
|
+
});
|
|
663
|
+
for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
|
|
664
|
+
selecting.picked.delete(id);
|
|
665
|
+
selecting.skipReasons.delete(id);
|
|
666
|
+
}
|
|
667
|
+
for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
|
|
668
|
+
return outcome;
|
|
669
|
+
} finally {
|
|
670
|
+
selecting.running = null;
|
|
671
|
+
updateSelectBar();
|
|
672
|
+
if (!eventsLive()) refresh();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
$("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
|
|
677
|
+
$("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
|
|
678
|
+
$("#selectBtn").addEventListener("click", enterSelect);
|
|
679
|
+
$("#selectDoneBtn").addEventListener("click", leaveSelect);
|
|
680
|
+
$("#selectAllBtn").addEventListener("click", () => {
|
|
681
|
+
for (const id of drawnOrder.get(allListEl) || []) {
|
|
682
|
+
selecting.picked.add(id);
|
|
683
|
+
paintPick(id);
|
|
684
|
+
}
|
|
685
|
+
updateSelectBar();
|
|
686
|
+
});
|
|
687
|
+
// A Shift-click picks a range: keep the browser from selecting the text
|
|
688
|
+
// between the two cards.
|
|
689
|
+
allListEl.addEventListener("mousedown", (e) => {
|
|
690
|
+
if (selecting.on && e.shiftKey) e.preventDefault();
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
$("#includeArchived").addEventListener("change", () => {
|
|
694
|
+
if (allViewOpen()) renderAll();
|
|
695
|
+
});
|
|
696
|
+
$("#filter").addEventListener("input", () => {
|
|
697
|
+
searchToggles.clear();
|
|
698
|
+
renderAll();
|
|
699
|
+
});
|
|
700
|
+
// A list whose order was held under the pointer (listOrder) catches up.
|
|
701
|
+
topStripEl.addEventListener("pointerleave", () => {
|
|
702
|
+
// Not while its family popover is open: the pointer went there (close() catches up).
|
|
703
|
+
if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
|
|
704
|
+
});
|
|
705
|
+
allListEl.addEventListener("pointerleave", () => {
|
|
706
|
+
if (heldLists.has(allListEl) && allViewOpen()) renderAll();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
render,
|
|
711
|
+
renderAll,
|
|
712
|
+
paintActive,
|
|
713
|
+
cardTitle,
|
|
714
|
+
sessionsById,
|
|
715
|
+
selecting,
|
|
716
|
+
leaveSelect,
|
|
717
|
+
// A session the hub says is gone: no pick, no skip reason.
|
|
718
|
+
forget(id) {
|
|
719
|
+
selecting.picked.delete(id);
|
|
720
|
+
selecting.skipReasons.delete(id);
|
|
721
|
+
},
|
|
722
|
+
familyPop,
|
|
723
|
+
chainPop,
|
|
724
|
+
};
|
|
725
|
+
}
|