rvlib-shipkit 2.5.0 → 2.7.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.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/src/cli/cmd_Review.ts +8 -0
- package/src/collections/review.ts +20 -5
- package/src/fleet/scan-fleet.ts +4 -1
- package/src/review/model.ts +7 -4
- package/src/review/threads.ts +4 -3
- package/src/serve/hub-app/home-st.ts +17 -2
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/hub-app/layout-st.ts +17 -2
- package/src/serve/hub-app/project-list.css +8 -3
- package/src/serve/hub-app/project-list.tsx +17 -6
- package/src/serve/hub-app/projects-svc.ts +21 -8
- package/src/serve/hub-app/review/review-st.ts +5 -4
- package/src/serve/project-gutter-route.ts +18 -1
- package/src/shot/clip-encoder.ts +29 -5
- package/src/trailer/build.ts +3 -2
- package/src/trailer/grammar.ts +56 -11
- package/src/trailer/look.ts +31 -16
- package/src/trailer/prune.ts +59 -12
- package/src/trailer/score/dsp.ts +87 -27
- package/src/trailer/score/pattern.ts +1 -1
- package/src/trailer/score/synth.ts +41 -12
- package/src/trailer/titles.ts +63 -34
- package/src/trailer/vocab.ts +65 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.7.0
|
|
2
|
+
|
|
3
|
+
- **What waits on the owner is a pill on every repo's row of the project list.** Beside the open asks badge, a pill in the review hue counts the cards that wait on the owner's verdict or answer; zero draws none, and folded it rides the icon's top left corner. A click opens that repo's review page on its first waiting card, the list on the owner's move. One count, `waitingCards` (`collections/review.ts`), read by the tree's review row, `sk needs`, the review page and the list; `/api/projects.json` reads it on every request, so a new card shows within one poll and a verdict at once. Below 768 px the list is always the rail, so the pills show on a phone.
|
|
4
|
+
- **A trailer's score is the game's waveform, not only its level.** A note starts on its time's nearest sample frame, as the game's synth now rounds it (a time a hair past a frame, 14.450000000000001, no longer starts a frame late); a noise note's playback rate is held per 128 frame block, the browser's k-rate, its sweep starting at the next block (risers and whooshes were another noise). pkm-multi's main4: 38 of 38 sections within 0.1 dB and every one the same waveform, the difference at least 40 dB under it (four were about 10 dB). Every score renders again once.
|
|
5
|
+
- **`sk fleet` skips a linked worktree.** A dir whose `.git` is a file is its main checkout again, as the home already reads it: `--update` no longer dirties a checkout that only follows main (a `-live` one) and blocks its own updater.
|
|
6
|
+
- **A « fix » or a « no » the agent read is still owed.** It stays `claimed` and active (« hide handled » no longer hides it) until `sk review done`, `wontfix` or a new version; `sk review inbox` lists it under `owed`.
|
|
7
|
+
|
|
8
|
+
## 2.6.0
|
|
9
|
+
|
|
10
|
+
- **A trailer's score sounds as the game's browser synth, section for section.** A note that starts inside a 128 frame block takes the phase the browser gives it (it runs at the default 440 Hz until its own frame of that block), so overlapping notes of one pitch, a timpani roll, add or cancel as in the browser; `osc: 'square'` is the browser's own square wave, no longer a sine; the master's compressor and limiter are the browser's kernel (32 frame divisions, adaptive release, the sine warp on the gain). pkm-multi's main4 score: 38 of 38 sections within 0.1 dB of the game's render (were 18), each octave band within 0.15 dB. Every score renders again once.
|
|
11
|
+
|
|
1
12
|
## 2.5.0
|
|
2
13
|
|
|
3
14
|
- **A review card hands itself to any agent session.** A small copy icon on the card's header (or C) copies one block: its title and state, its repo, `cd <repo> && sk review show <id>`, its link here and on the phone when the repo has a public host; a toast says what was copied. `sk review show <id> --paste` prints the same block.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/cmd_Review.ts
CHANGED
|
@@ -436,6 +436,14 @@ export class ReviewInboxCommand extends Command {
|
|
|
436
436
|
if (t) printed.add(t.head.item.id)
|
|
437
437
|
h.out(`${t ? threadDetail(t, h.dir, false, h.say) : detail(x, h.dir, false, false, h.say)}\n`)
|
|
438
438
|
}
|
|
439
|
+
// a « fix » or a « no » an agent read and has not done: owed, never an unseen answer again
|
|
440
|
+
const owed = threads.filter((t) => of(t.head) && !printed.has(t.head.item.id) && t.head.item.origin.by === 'agent' && verdictOf(t.head)?.seen === true && stateOf(t.head, false).state === 'claimed')
|
|
441
|
+
const owedLine = (x: (typeof all.items)[number]): string => {
|
|
442
|
+
const fix = verdictOf(x)?.status === 'approved-with-fix'
|
|
443
|
+
const close = fix ? `sk review done ${x.item.id} "<what changed>"` : `--supersedes ${x.item.id} on its new version`
|
|
444
|
+
return ` ${x.item.id} ${fix ? 'ok with a fix' : 'rejected'} "${x.item.title}": ${close}, or sk review wontfix ${x.item.id} --note "<why>"`
|
|
445
|
+
}
|
|
446
|
+
if (owed.length > 0) h.out(`owed, read and not done:\n${owed.map((t) => owedLine(t.head)).join('\n')}\n`)
|
|
439
447
|
const watchers = liveWatchers(h.dir)
|
|
440
448
|
const untaken = all.items.flatMap((x) => {
|
|
441
449
|
const why = untakenOf(x, followed.has(x.item.id), watchers, new Date(), h.say.spec.dispatcher)
|
|
@@ -4,12 +4,27 @@
|
|
|
4
4
|
// Each collection exists only where the repo has its folder, so a repo with none draws the section greyed
|
|
5
5
|
import { existsSync } from 'node:fs'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
|
-
import { STATUS_LABEL, isFrame, stateOf } from '../review/model.ts'
|
|
8
|
-
import { readAllCached, storeDir } from '../review/store.ts'
|
|
9
|
-
import { statusOf, waitingOnOwner } from '../review/threads.ts'
|
|
7
|
+
import { STATUS_LABEL, isFrame, stateOf, type ReviewItem } from '../review/model.ts'
|
|
8
|
+
import { readAllCached, storeDir, storeStamp } from '../review/store.ts'
|
|
9
|
+
import { statusOf, waitingOnOwner, type Thread } from '../review/threads.ts'
|
|
10
10
|
import { cutsOf, trailerIds } from '../review/trailer-desk.ts'
|
|
11
11
|
import { REVIEW_COL, TRAILER_COL, type Collection, type Instance } from './collection-types.ts'
|
|
12
12
|
|
|
13
|
+
const waiting = new Map<string, { stamp: string; threads: Thread[]; items: ReviewItem[] }>()
|
|
14
|
+
|
|
15
|
+
/** what waits on the owner in a repo, the ONE count every surface shows (the tree's review row, the project list's pill,
|
|
16
|
+
* `sk needs`): read again only when the store's stamp moved */
|
|
17
|
+
export function waitingCards(root: string): { threads: Thread[]; items: ReviewItem[] } {
|
|
18
|
+
const dir = storeDir(root)
|
|
19
|
+
const stamp = storeStamp(dir)
|
|
20
|
+
const kept = waiting.get(dir)
|
|
21
|
+
if (kept?.stamp === stamp) return kept
|
|
22
|
+
const items = stamp === 'none' ? [] : readAllCached(dir).items
|
|
23
|
+
const fresh = { stamp, threads: waitingOnOwner(items), items }
|
|
24
|
+
waiting.set(dir, fresh)
|
|
25
|
+
return fresh
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
export function reviewCollection(root: string): Collection | null {
|
|
14
29
|
const dir = storeDir(root)
|
|
15
30
|
if (!existsSync(dir)) return null
|
|
@@ -19,8 +34,8 @@ export function reviewCollection(root: string): Collection | null {
|
|
|
19
34
|
kind: 'card',
|
|
20
35
|
media: 'image',
|
|
21
36
|
list: async () => {
|
|
22
|
-
const { items } =
|
|
23
|
-
return
|
|
37
|
+
const { threads, items } = waitingCards(root)
|
|
38
|
+
return threads.map(({ head: x }): Instance => ({
|
|
24
39
|
id: `card:${x.item.id}`,
|
|
25
40
|
text: `${x.item.title} (${STATUS_LABEL[statusOf(x, items)]})`,
|
|
26
41
|
label: x.item.title,
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { isArchived } from '../core/archived.ts'
|
|
16
|
+
import { isLinkedWorktree } from '../core/git-root.ts'
|
|
16
17
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
17
18
|
import { isBreaking, isLinkedInstall, pinFloor } from '../core/update-policy.ts'
|
|
18
19
|
|
|
@@ -97,7 +98,9 @@ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[
|
|
|
97
98
|
continue
|
|
98
99
|
}
|
|
99
100
|
const pkg = readPkg(dir)
|
|
100
|
-
|
|
101
|
+
// a linked worktree is its main checkout again: an update dirties a
|
|
102
|
+
// tree that only follows main and blocks that tree's own updater
|
|
103
|
+
if (pkg == null || isArchived(dir) || isLinkedWorktree(dir)) continue
|
|
101
104
|
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
102
105
|
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
103
106
|
if (names.length === 0) continue
|
package/src/review/model.ts
CHANGED
|
@@ -444,12 +444,12 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
|
|
|
444
444
|
let s: ItemState = startOf(i)
|
|
445
445
|
// what his latest verdict left: a released or superseded rework of an agent's card goes back to it
|
|
446
446
|
let given: ItemState = s
|
|
447
|
-
const read = readBy(x, 'agent')
|
|
448
447
|
for (const m of x.thread) {
|
|
449
448
|
const keep = s.topic ? { topic: s.topic } : {}
|
|
450
449
|
if (m.verdict !== undefined) {
|
|
451
450
|
const v = m.verdict
|
|
452
|
-
|
|
451
|
+
// « fix » and « no » owe the lane work: reading the verdict is never doing it (done, wontfix or a new version closes it)
|
|
452
|
+
if (v === 'approved' || v === 'archived') s = { ...keep, state: 'closed', why: v }
|
|
453
453
|
else s = { ...keep, state: 'claimed', ...(lane ? { worker: lane } : {}) }
|
|
454
454
|
given = s
|
|
455
455
|
continue
|
|
@@ -502,8 +502,9 @@ export function stateOf(x: ReviewItem, superseded: boolean): ItemState {
|
|
|
502
502
|
break
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
|
-
// the card that redoes it landed: the
|
|
506
|
-
|
|
505
|
+
// the card that redoes it landed: the work its verdict owed is over
|
|
506
|
+
const owed = verdictOf(x)?.status
|
|
507
|
+
if (superseded && i.origin.by === 'agent' && s.state === 'claimed' && owed !== undefined) return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: owed }
|
|
507
508
|
if (verdictOf(x) === null) {
|
|
508
509
|
if (withdrawnOf(x) !== undefined) return { state: 'closed', why: 'withdrawn' }
|
|
509
510
|
if (superseded && s.state !== 'closed') return { ...(s.topic ? { topic: s.topic } : {}), state: 'closed', why: 'superseded' }
|
|
@@ -612,6 +613,8 @@ export function untakenOf(x: ReviewItem, superseded: boolean, watchers: readonly
|
|
|
612
613
|
if (s.state === 'open') return `raised${s.topic ? ` on ${s.topic}` : ''}, nobody claimed it`
|
|
613
614
|
if (s.state !== 'claimed') return null
|
|
614
615
|
if (s.since !== undefined) return quiet > STALE_CLAIM_MS ? `${s.worker ?? 'its'} claim went stale` : null
|
|
616
|
+
// an agent read the verdict: the work it owes is taken, only not done yet
|
|
617
|
+
if (verdictOf(x)?.seen) return null
|
|
615
618
|
return listenersOf([x], watchers).length === 0 ? `the owner's verdict went back to ${s.worker ?? 'its author'}, and no watch reads it` : null
|
|
616
619
|
}
|
|
617
620
|
|
package/src/review/threads.ts
CHANGED
|
@@ -124,8 +124,9 @@ export const isFollowed = (id: string, items: readonly ReviewItem[]): boolean =>
|
|
|
124
124
|
export const threadOfItem = (id: string, threads: readonly Thread[]): Thread | null =>
|
|
125
125
|
threads.find((t) => t.head.item.id === id) ?? threads.find((t) => membersOf(t).some((x) => x.item.id === id)) ?? null
|
|
126
126
|
|
|
127
|
-
/** a thread still needs someone: its head pending, or answered
|
|
128
|
-
*
|
|
127
|
+
/** a thread still needs someone: its head pending, or answered with work owed (« fix » until `done` or a new version,
|
|
128
|
+
* « no » until a new version or `wontfix`; reading the verdict is never doing it; an approval needs nothing), or a
|
|
129
|
+
* lane reworking it; what he or a player raised, until it is closed */
|
|
129
130
|
export function isActive(t: Thread, now = new Date()): boolean {
|
|
130
131
|
if (t.versions.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
131
132
|
if (t.answers.some((x) => unreadBy(x, 'agent').some((m) => m !== openingOf(x)))) return true
|
|
@@ -133,7 +134,7 @@ export function isActive(t: Thread, now = new Date()): boolean {
|
|
|
133
134
|
if (reworkOf(t.head, false, now)) return true
|
|
134
135
|
const f = verdictOf(t.head)
|
|
135
136
|
if (!f) return withdrawnOf(t.head) === undefined
|
|
136
|
-
return
|
|
137
|
+
return stateOf(t.head, false).state !== 'closed'
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/** the thread's conversation: every version's and every answered item's replies, oldest first, each with the item it
|
|
@@ -36,6 +36,9 @@ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/acti
|
|
|
36
36
|
/** where a repo's hub answers on the home */
|
|
37
37
|
export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
|
|
38
38
|
/** the page of a repo on the home */
|
|
39
|
+
/** the review list's address on the cards whose move is the owner's */
|
|
40
|
+
const WAITING = 'whose=owner'
|
|
41
|
+
|
|
39
42
|
export const repoPage = (name: string): string => `${repoBase(name)}/`
|
|
40
43
|
|
|
41
44
|
/** where a check's own page answers on the home */
|
|
@@ -112,6 +115,7 @@ export class HomeSt {
|
|
|
112
115
|
const m = this.machine
|
|
113
116
|
if (m != null) {
|
|
114
117
|
this.projects.onSelect = (name) => this.select(name)
|
|
118
|
+
this.projects.onReview = (name, card) => this.openReview(name, card)
|
|
115
119
|
this.projects.onPlace = (p) => this.navigate(PLACE_PATH[p])
|
|
116
120
|
this.projects.onPicker = () => this.palette.toggle()
|
|
117
121
|
this.projects.pickerOpen = () => this.palette.open
|
|
@@ -121,6 +125,10 @@ export class HomeSt {
|
|
|
121
125
|
} else {
|
|
122
126
|
this.projects.onSelect = (name) => void this.openInHome(name)
|
|
123
127
|
const own = this.repo('')
|
|
128
|
+
this.projects.onReview = (name, card) => {
|
|
129
|
+
if (this.projects.list.some((p) => p.name === name && p.current)) own.showReview(card, WAITING)
|
|
130
|
+
else void this.openInHome(name, `#${hashFor({ t: 'review', id: card, q: WAITING })}`)
|
|
131
|
+
}
|
|
124
132
|
own.markSeen()
|
|
125
133
|
this.selected = own.key
|
|
126
134
|
}
|
|
@@ -212,6 +220,13 @@ export class HomeSt {
|
|
|
212
220
|
this.show('repo', repo, 'push')
|
|
213
221
|
}
|
|
214
222
|
|
|
223
|
+
/** the cards that wait on the owner in a repo: its review page in place, on the
|
|
224
|
+
* first one, the list on the owner's move */
|
|
225
|
+
openReview(repo: string, card: string | null): void {
|
|
226
|
+
this.repo(repo).showReview(card, WAITING)
|
|
227
|
+
this.show('repo', repo, 'push')
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
/** a link to a page of the home: the same page, in place */
|
|
216
231
|
navigate(path: string): void {
|
|
217
232
|
const u = new URL(path, 'http://home.invalid')
|
|
@@ -303,9 +318,9 @@ export class HomeSt {
|
|
|
303
318
|
|
|
304
319
|
/** a list click on a hub serving on its own: that repo's page on the
|
|
305
320
|
* home when one serves, else the folder in VS Code */
|
|
306
|
-
async openInHome(name: string): Promise<void> {
|
|
321
|
+
async openInHome(name: string, hash = ''): Promise<void> {
|
|
307
322
|
const home = await fetch('/api/home-url').then((r) => r.json() as Promise<{ port: number; up: boolean }>).catch(() => null)
|
|
308
323
|
if (home?.up !== true) return this.projects.open(name)
|
|
309
|
-
window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}`
|
|
324
|
+
window.location.href = `${window.location.protocol}//${window.location.hostname}:${home.port}${repoPage(name)}${hash}`
|
|
310
325
|
}
|
|
311
326
|
}
|
|
@@ -304,7 +304,7 @@ const App = observer(() => {
|
|
|
304
304
|
const dockRef = usePanelRef()
|
|
305
305
|
const listRef = usePanelRef()
|
|
306
306
|
// ⌘B folds the list to its rail and back; a drag that already did it finds it there
|
|
307
|
-
const rail = l.
|
|
307
|
+
const rail = l.folded
|
|
308
308
|
const railW = l.railPx
|
|
309
309
|
useLayoutEffect(() => {
|
|
310
310
|
const p = listRef.current
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// value can hand the page a list wider than LIST.max. The list also folds to
|
|
7
7
|
// its icon rail: a drag below its min or ⌘B, kept per browser too, its icons
|
|
8
8
|
// small, medium or large (RAIL_ICONS), the rail as wide as they need.
|
|
9
|
-
import { makeAutoObservable } from 'mobx'
|
|
9
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
10
10
|
|
|
11
11
|
export type Bounds = { key: string; min: number; max: number; def: number }
|
|
12
12
|
|
|
@@ -62,16 +62,31 @@ function loadRail(): boolean {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** a page narrower than this draws the list as its rail, whatever the pick: a list squeezed under its min hides its pills */
|
|
66
|
+
export const NARROW_PX = 768
|
|
67
|
+
|
|
65
68
|
export class LayoutSt {
|
|
66
69
|
listWidth = loadWidth(LIST)
|
|
67
|
-
/** the list shows its icons only */
|
|
70
|
+
/** the owner's pick: the list shows its icons only */
|
|
68
71
|
listRail = loadRail()
|
|
72
|
+
/** the page is narrower than NARROW_PX, read live */
|
|
73
|
+
narrow = false
|
|
69
74
|
/** the rail's icon size */
|
|
70
75
|
railIcon: RailIcon = loadRailIcon()
|
|
71
76
|
sidebarWidth = loadWidth(SIDEBAR)
|
|
72
77
|
|
|
73
78
|
constructor() {
|
|
74
79
|
makeAutoObservable(this)
|
|
80
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
81
|
+
const mq = window.matchMedia(`(max-width: ${NARROW_PX - 1}px)`)
|
|
82
|
+
this.narrow = mq.matches
|
|
83
|
+
mq.addEventListener('change', (e) => runInAction(() => (this.narrow = e.matches)))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** the list as drawn: the rail when he picked it or the page is narrow */
|
|
88
|
+
get folded(): boolean {
|
|
89
|
+
return this.listRail || this.narrow
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
/** a drag of the list's separator ended at this width */
|
|
@@ -74,9 +74,11 @@
|
|
|
74
74
|
/* ⌘1 to ⌘9 select the first nine rows (keys.ts): the hint sits at the row's end */
|
|
75
75
|
.pjkey{flex:none;font:10px/1 ui-monospace,Menlo,monospace;color:var(--dim);opacity:.6;background:none;border:0;padding:0}
|
|
76
76
|
.pjrow:hover .pjkey,.pjrow.cur .pjkey,.pjpl:hover .pjkey,.pjpl.cur .pjkey{opacity:1}
|
|
77
|
-
/*
|
|
78
|
-
.pga{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
|
|
77
|
+
/* what waits on the owner, at the end of the row: the review cards (a click opens them), then the open asks */
|
|
78
|
+
.pga,.pgr{flex:none;min-width:16px;height:16px;padding:0 var(--s1);border-radius:8px;
|
|
79
79
|
font-size:10px;font-weight:700;line-height:16px;text-align:center;color:var(--bg);background:var(--warn)}
|
|
80
|
+
.pgr{background:var(--review);cursor:pointer}
|
|
81
|
+
.pgr:hover{box-shadow:0 0 0 2px var(--review)}
|
|
80
82
|
/* Claude sessions running: a thin edge on the left that breathes */
|
|
81
83
|
.pgs{position:absolute;left:0;top:5px;bottom:5px;width:2px;border-radius:1px;background:var(--accent);pointer-events:none;animation:pgpulse 2.4s ease-in-out infinite}
|
|
82
84
|
/* the home only: the repo's hub state (starting, crashed) */
|
|
@@ -102,6 +104,7 @@
|
|
|
102
104
|
/* NEEDS YOU: a warn toned bullet, set apart from the facts */
|
|
103
105
|
.pgcneeds{margin-top:var(--s2);padding:var(--s1) var(--s2);border-left:2px solid var(--warn);background:var(--panel2);border-radius:0 3px 3px 0}
|
|
104
106
|
.pgcneed{display:flex;gap:var(--s1);min-width:0;color:var(--warn)}
|
|
107
|
+
.pgcneed.rev{color:var(--review)}
|
|
105
108
|
.pgcneed.red{color:var(--red);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
106
109
|
.pgcask{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
107
110
|
.pgcmore{flex:none}
|
|
@@ -134,7 +137,9 @@
|
|
|
134
137
|
/* the app icon is a rounded plate already (core/app-icon.ts) */
|
|
135
138
|
.pjlist.rail .pjrow img.pjapp{border-radius:0}
|
|
136
139
|
.pjsize{font:600 10px/1 ui-monospace,Menlo,monospace}
|
|
137
|
-
.pjlist.rail .pga{position:absolute;top:0;
|
|
140
|
+
.pjlist.rail .pga,.pjlist.rail .pgr{position:absolute;top:0;min-width:14px;height:14px;padding:0 var(--s0);border-radius:7px;font-size:9px;line-height:14px}
|
|
141
|
+
.pjlist.rail .pga{right:calc(50% - var(--ri,24px) / 2 - 4px)}
|
|
142
|
+
.pjlist.rail .pgr{left:calc(50% - var(--ri,24px) / 2 - 4px)}
|
|
138
143
|
.pjlist.rail .pgd,.pjlist.rail .pgw{position:absolute;bottom:var(--s0);right:calc(50% - var(--ri,24px) / 2 - 2px)}
|
|
139
144
|
.pjlist.rail .pjfoot{flex-direction:column;height:auto;padding:var(--s1) 0}
|
|
140
145
|
.pjlist.rail .pjfoot button{justify-content:center;width:28px;padding:0}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// down the left of the page, a small icon and the repo's name on one line,
|
|
3
3
|
// the shown repo's row marked in its identity colour. Its width is the
|
|
4
4
|
// panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
|
|
5
|
-
// three things and no more (faceOf):
|
|
5
|
+
// three things and no more (faceOf): what waits on the owner as pills (the review
|
|
6
|
+
// cards, a click away from them, then the open asks), a dim
|
|
6
7
|
// name (never the icon) while VS Code does not have the folder open, a thin edge breathing
|
|
7
8
|
// while Claude sessions run in it; on the home, a dot while its hub starts
|
|
8
9
|
// or crashed and a mark while a global process it uses is off. Everything
|
|
@@ -15,8 +16,8 @@
|
|
|
15
16
|
// (names only, no ⌘ number, a row opens the archived page, nothing loads),
|
|
16
17
|
// the foot never moving. ⌘B (or the foot's fold button, or a drag of the
|
|
17
18
|
// separator below the list's min) folds the list to its icon rail: the
|
|
18
|
-
// names, the ⌘ hints and the step hint hide, the
|
|
19
|
-
//
|
|
19
|
+
// names, the ⌘ hints and the step hint hide, the pills ride the icon's
|
|
20
|
+
// corners, the hover card still names the row; a phone width is always the rail. On the home a section of four lines on top, in the
|
|
20
21
|
// repo rows' style, opens the picker, the overview, the activity and the
|
|
21
22
|
// plan, each with its shortcut at the line's end, the page shown lit. It
|
|
22
23
|
// renders from the first paint with the list this browser kept, so a load
|
|
@@ -34,7 +35,7 @@ import { RAIL_ICONS, type RailIcon } from './layout-st.ts'
|
|
|
34
35
|
|
|
35
36
|
/** what the list needs of its page: the hub and the home both carry one;
|
|
36
37
|
* the layout, when the page can fold the list to its rail */
|
|
37
|
-
export type ListHost = { projects: ProjectsSvc; layout?: {
|
|
38
|
+
export type ListHost = { projects: ProjectsSvc; layout?: { folded: boolean; toggleRail(): void; railIcon: RailIcon; cycleRailIcon(): void }; theme?: { pick: ThemePick; cycle(): void } }
|
|
38
39
|
|
|
39
40
|
/** the lines on top of the list, in their order: the picker, the overview,
|
|
40
41
|
* then the places, each with its shortcut (keys.ts) */
|
|
@@ -96,10 +97,14 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
|
|
|
96
97
|
},
|
|
97
98
|
onDragEnd: () => s.dragEnd(),
|
|
98
99
|
})}>
|
|
99
|
-
{app.layout?.
|
|
100
|
+
{app.layout?.folded === true && p.appIcon != null
|
|
100
101
|
? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
|
|
101
102
|
: <Face p={p} px={ROW_ICON} />}
|
|
102
103
|
<span className="pjname">{p.name}</span>
|
|
104
|
+
{face.cards != null ? (
|
|
105
|
+
<b className="pgr" aria-label={`${face.cards} review card${face.cards === 1 ? '' : 's'} wait on you: open them`}
|
|
106
|
+
onClick={(e) => { e.stopPropagation(); s.review(p.name) }} onDoubleClick={(e) => e.stopPropagation()}>{face.cards}</b>
|
|
107
|
+
) : null}
|
|
103
108
|
{face.badge != null ? <b className="pga">{face.badge}</b> : null}
|
|
104
109
|
{face.busy ? <i className="pgs" /> : null}
|
|
105
110
|
{dot != null ? <i className={`pgd ${dot}`} /> : null}
|
|
@@ -138,6 +143,12 @@ export const Card = ({ p, now }: { p: Project; now: number }) => {
|
|
|
138
143
|
{m.head.description == null ? null : <div className="pgcdesc">{m.head.description}</div>}
|
|
139
144
|
{m.needs == null ? null : (
|
|
140
145
|
<div className="pgcneeds">
|
|
146
|
+
{m.needs.cards == null ? null : (
|
|
147
|
+
<div className="pgcneed rev">
|
|
148
|
+
<span className="pgcask">{m.needs.cards.count} card{m.needs.cards.count === 1 ? '' : 's'} to review: {m.needs.cards.titles.join(' · ')}</span>
|
|
149
|
+
{m.needs.cards.more > 0 ? <span className="pgcmore">+{m.needs.cards.more}</span> : null}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
141
152
|
{m.needs.asks == null ? null : (
|
|
142
153
|
<div className="pgcneed">
|
|
143
154
|
<span className="pgcask">{m.needs.asks.count} ask{m.needs.asks.count === 1 ? '' : 's'}: {m.needs.asks.titles.join(' · ')}</span>
|
|
@@ -165,7 +176,7 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
|
|
|
165
176
|
useEffect(() => app.projects.start(), [app])
|
|
166
177
|
const s = app.projects
|
|
167
178
|
const lay = app.layout
|
|
168
|
-
const rail = lay?.
|
|
179
|
+
const rail = lay?.folded === true
|
|
169
180
|
return (
|
|
170
181
|
<nav className={`pjlist${rail ? ' rail' : ''}`} aria-label="projects" style={rail ? ({ '--ri': `${RAIL_ICONS[lay!.railIcon]}px` } as CSSProperties) : undefined}>
|
|
171
182
|
{s.onPlace == null && s.onPicker == null && s.error === '' ? null : (
|
|
@@ -62,7 +62,8 @@ const keptSort = (): SortMode | null => {
|
|
|
62
62
|
export function loadList(): Project[] {
|
|
63
63
|
try {
|
|
64
64
|
const v = JSON.parse(globalThis.localStorage?.getItem(LIST_KEY) ?? '[]') as unknown
|
|
65
|
-
|
|
65
|
+
// a list kept before a row carried its cards reads as none waiting until the first poll
|
|
66
|
+
return Array.isArray(v) && v.every((p) => typeof (p as Project)?.name === 'string') ? (v as Project[]).map((p) => ({ ...p, cards: p.cards ?? [] })) : []
|
|
66
67
|
} catch {
|
|
67
68
|
return []
|
|
68
69
|
}
|
|
@@ -133,12 +134,13 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
|
|
|
133
134
|
* its hub starts or crashed. A hub that is up is the normal case and draws none */
|
|
134
135
|
export type HubDot = 'starting' | 'crashed' | null
|
|
135
136
|
|
|
136
|
-
/** what the icon face shows, and nothing more:
|
|
137
|
-
*
|
|
138
|
-
* does not have the folder open (the
|
|
139
|
-
* while Claude sessions run */
|
|
140
|
-
export type Face = { badge: number | null; dim: boolean; busy: boolean }
|
|
137
|
+
/** what the icon face shows, and nothing more: what waits on the owner as
|
|
138
|
+
* two pills (the review cards waiting on a verdict, the open asks waiting
|
|
139
|
+
* on hands), a dim name while VS Code does not have the folder open (the
|
|
140
|
+
* icon stays whole), and a pulsing edge while Claude sessions run */
|
|
141
|
+
export type Face = { cards: number | null; badge: number | null; dim: boolean; busy: boolean }
|
|
141
142
|
export const faceOf = (p: Project): Face => ({
|
|
143
|
+
cards: p.cards.length > 0 ? p.cards.length : null,
|
|
142
144
|
badge: p.asks > 0 ? p.asks : null,
|
|
143
145
|
dim: p.vscode === false,
|
|
144
146
|
busy: p.sessions != null && p.sessions > 0,
|
|
@@ -149,7 +151,7 @@ export const faceOf = (p: Project): Face => ({
|
|
|
149
151
|
* human, and label and value rows. Each part leaves out an unknown fact */
|
|
150
152
|
export type CardModel = {
|
|
151
153
|
head: { name: string; current: boolean; icon: string | null; version: string | null; branch: string | null; description: string | null }
|
|
152
|
-
needs: { asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
|
|
154
|
+
needs: { cards: { count: number; titles: string[]; more: number } | null; asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
|
|
153
155
|
facts: { k: 'release' | 'live' | 'activity'; v: string; tone?: 'warn' }[]
|
|
154
156
|
}
|
|
155
157
|
|
|
@@ -159,6 +161,8 @@ export const CARD_NAMES = 3
|
|
|
159
161
|
export function cardModel(p: Project, now: number): CardModel {
|
|
160
162
|
const branch = p.branch == null ? null : p.dirty != null && p.dirty > 0 ? `${p.branch} · ${p.dirty} uncommitted` : p.branch
|
|
161
163
|
const asks = p.asks > 0 ? { count: p.asks, titles: p.askTitles.slice(0, CARD_NAMES), more: Math.max(0, p.asks - CARD_NAMES) } : null
|
|
164
|
+
const n = p.cards.length
|
|
165
|
+
const cards = n > 0 ? { count: n, titles: p.cards.slice(0, CARD_NAMES).map((c) => c.title), more: Math.max(0, n - CARD_NAMES) } : null
|
|
162
166
|
const reds = p.gate != null && p.gate !== 'green' ? p.gate.reds : []
|
|
163
167
|
const gate = reds.length === 0 ? null : `gate red: ${reds.slice(0, CARD_NAMES).join(', ')}${reds.length > CARD_NAMES ? ` +${reds.length - CARD_NAMES}` : ''}`
|
|
164
168
|
const facts: CardModel['facts'] = []
|
|
@@ -177,7 +181,7 @@ export function cardModel(p: Project, now: number): CardModel {
|
|
|
177
181
|
if (act.length > 0) facts.push({ k: 'activity', v: act.join(' · ') })
|
|
178
182
|
return {
|
|
179
183
|
head: { name: p.name, current: p.current, icon: p.icon, version: p.version, branch, description: p.description },
|
|
180
|
-
needs: asks == null && gate == null ? null : { asks, gate },
|
|
184
|
+
needs: cards == null && asks == null && gate == null ? null : { cards, asks, gate },
|
|
181
185
|
facts,
|
|
182
186
|
}
|
|
183
187
|
}
|
|
@@ -216,6 +220,9 @@ export class ProjectsSvc {
|
|
|
216
220
|
/** what a single click does: the home shows that repo in place, a hub
|
|
217
221
|
* of its own opens the home on it. Default: the folder in VS Code */
|
|
218
222
|
onSelect: (name: string) => void = (name) => void this.open(name)
|
|
223
|
+
/** a click on a row's review pill: that repo's review page on its first
|
|
224
|
+
* waiting card. The home and a hub of its own each set it */
|
|
225
|
+
onReview: (name: string, card: string | null) => void = (name) => this.select(name)
|
|
219
226
|
/** the two places at the top of the list, the overview and the plan:
|
|
220
227
|
* the home sets it, a hub of its own has none */
|
|
221
228
|
onPlace: ((p: Place) => void) | null = null
|
|
@@ -281,6 +288,12 @@ export class ProjectsSvc {
|
|
|
281
288
|
this.onSelect(name)
|
|
282
289
|
}
|
|
283
290
|
|
|
291
|
+
/** the review pill: the cards that wait on the owner in that repo */
|
|
292
|
+
review(name: string): void {
|
|
293
|
+
this.selected = name
|
|
294
|
+
this.onReview(name, this.list.find((p) => p.name === name)?.cards[0]?.id ?? null)
|
|
295
|
+
}
|
|
296
|
+
|
|
284
297
|
|
|
285
298
|
/** the repo above (-1) or below (+1) the selected one, round the ends;
|
|
286
299
|
* with none selected, down starts at the top and up at the bottom */
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type Side,
|
|
29
29
|
type Spec,
|
|
30
30
|
} from '../../../review/model.ts'
|
|
31
|
-
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
|
|
31
|
+
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waitingOnOwner, waits, type Thread } from '../../../review/threads.ts'
|
|
32
32
|
import { reach } from '../reach.ts'
|
|
33
33
|
import type { RepoSt } from '../repo-st.ts'
|
|
34
34
|
import type { Role } from '../../../review/role.ts'
|
|
@@ -450,9 +450,9 @@ export class ReviewSt {
|
|
|
450
450
|
return orderedThreads(this.items)
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
-
/** what waits on
|
|
453
|
+
/** what waits on the owner, the one count of every surface (collections/review.ts waitingCards) */
|
|
454
454
|
get pendingCount(): number {
|
|
455
|
-
return
|
|
455
|
+
return waitingOnOwner(this.items).length
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
get activeCount(): number {
|
|
@@ -908,8 +908,9 @@ export class ReviewSt {
|
|
|
908
908
|
store.put(`${this.prefix}draft.${id}`, null)
|
|
909
909
|
})
|
|
910
910
|
void this.refresh()
|
|
911
|
-
// the tree's count of what waits on
|
|
911
|
+
// the tree's count of what waits on the owner moves at once, and so does the project list's pill
|
|
912
912
|
void this.st.cols.poll(true)
|
|
913
|
+
void this.st.home.projects.load()
|
|
913
914
|
} catch (e) {
|
|
914
915
|
runInAction(() => (this.error = (e as Error).message))
|
|
915
916
|
} finally {
|
|
@@ -23,11 +23,21 @@ import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
|
23
23
|
import { identityOfText, identityProblem, type Identity } from '../core/identity-color.ts'
|
|
24
24
|
import { writeIdentity } from '../core/identity.ts'
|
|
25
25
|
import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
|
|
26
|
+
import { waitingCards } from '../collections/review.ts'
|
|
26
27
|
import { writeArchived } from '../core/archived.ts'
|
|
27
28
|
|
|
28
29
|
export type { Gate }
|
|
29
30
|
|
|
30
31
|
export const POLL_MS = 5_000
|
|
32
|
+
|
|
33
|
+
/** a repo's waiting cards as its row carries them; a store that cannot be read waits on nobody, and its hub says why */
|
|
34
|
+
function cardsOf(path: string): ProjectRow['cards'] {
|
|
35
|
+
try {
|
|
36
|
+
return waitingCards(path).threads.map((t) => ({ id: t.head.item.id, title: t.head.item.title }))
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
/** the app icon preview: sharp on a 2x screen at the admin page's 128px */
|
|
32
42
|
const PREVIEW_PX = 256
|
|
33
43
|
/** the rail's app icon, cropped to the plate: 40px at 2x, the largest rail size */
|
|
@@ -59,6 +69,8 @@ export type ProjectRow = {
|
|
|
59
69
|
asks: number
|
|
60
70
|
/** their titles, in file order */
|
|
61
71
|
askTitles: string[]
|
|
72
|
+
/** the review cards that wait on the owner (collections/review.ts waitingCards), oldest first: read on every request */
|
|
73
|
+
cards: { id: string; title: string }[]
|
|
62
74
|
/** package.json `description`, else the README's first line of prose */
|
|
63
75
|
description: string | null
|
|
64
76
|
/** package.json `version` */
|
|
@@ -338,6 +350,7 @@ export function projectGutter(d: GutterDeps) {
|
|
|
338
350
|
sessions: counts?.get(r.path) ?? null,
|
|
339
351
|
asks: asks.length,
|
|
340
352
|
askTitles: asks,
|
|
353
|
+
cards: cardsOf(r.path),
|
|
341
354
|
description,
|
|
342
355
|
version: versionOf(pkg),
|
|
343
356
|
branch: branchOf(readText(join(r.path, '.git', 'HEAD'))),
|
|
@@ -386,7 +399,11 @@ export function projectGutter(d: GutterDeps) {
|
|
|
386
399
|
|
|
387
400
|
/** null when the path is not this route's */
|
|
388
401
|
async function handle(req: Request, path: string): Promise<Response | null> {
|
|
389
|
-
if (path === '/api/projects.json')
|
|
402
|
+
if (path === '/api/projects.json') {
|
|
403
|
+
// the measure holds 5 s, the cards never: a verdict he just gave shows on the next ask
|
|
404
|
+
const a = await answer()
|
|
405
|
+
return Response.json({ ...a, projects: a.projects.map((p) => ({ ...p, cards: cardsOf(p.path) })) })
|
|
406
|
+
}
|
|
390
407
|
if (path.startsWith('/api/projects/icon/')) {
|
|
391
408
|
if (repos.length === 0) await answer()
|
|
392
409
|
const r = findAny(decodeURIComponent(path.slice('/api/projects/icon/'.length)))
|
package/src/shot/clip-encoder.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// `CLIP_INIT` run IN the page, so they import nothing and read only their
|
|
9
9
|
// argument and the document. A WebGL canvas keeps no drawing buffer after it
|
|
10
10
|
// presents, so the clip's page is opened with CLIP_INIT, which asks every
|
|
11
|
-
// WebGL context for `preserveDrawingBuffer
|
|
11
|
+
// WebGL context for `preserveDrawingBuffer`; a WebGPU canvas read on a frame
|
|
12
|
+
// it did not draw gives an older drawing, so it is read only after a draw.
|
|
12
13
|
|
|
13
14
|
/** what the page films: seconds of its largest canvas at fps, H.264 at a fixed quantizer */
|
|
14
15
|
export type PageClipAsk = { seconds: number; fps: number; quantizer: number; keyEvery: number }
|
|
@@ -16,13 +17,22 @@ export type PageClipAsk = { seconds: number; fps: number; quantizer: number; key
|
|
|
16
17
|
/** what the page says once the film ran */
|
|
17
18
|
export type PageClipDone = { frames: number; repeated: number; width: number; height: number; canvasOfViewport: string }
|
|
18
19
|
|
|
19
|
-
/** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents
|
|
20
|
+
/** IN THE PAGE, as an init script: every WebGL context keeps its drawing buffer, so a frame reads back after it presents;
|
|
21
|
+
* a WebGPU canvas counts its drawings (window.__skDraws): read on a frame it did not draw, it gives an older drawing */
|
|
20
22
|
export const CLIP_INIT = `(() => {
|
|
21
23
|
const get = HTMLCanvasElement.prototype.getContext
|
|
22
24
|
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
|
23
25
|
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true })
|
|
26
|
+
if (type === 'webgpu') window.__skDraws ??= 0
|
|
24
27
|
return get.call(this, type, attrs)
|
|
25
28
|
}
|
|
29
|
+
if (typeof GPUCanvasContext !== 'undefined') {
|
|
30
|
+
const current = GPUCanvasContext.prototype.getCurrentTexture
|
|
31
|
+
GPUCanvasContext.prototype.getCurrentTexture = function () {
|
|
32
|
+
window.__skDraws = (window.__skDraws ?? 0) + 1
|
|
33
|
+
return current.call(this)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
26
36
|
})()`
|
|
27
37
|
|
|
28
38
|
type ClipState = {
|
|
@@ -70,25 +80,37 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
|
|
|
70
80
|
const total = Math.round(a.seconds * a.fps)
|
|
71
81
|
const step = 1000 / a.fps
|
|
72
82
|
const rect = { x: 0, y: 0, width, height }
|
|
83
|
+
// a WebGPU canvas is read only on a frame it drew (CLIP_INIT counts them), else its last drawing is filmed again
|
|
84
|
+
const gpu = w as unknown as { __skDraws?: number }
|
|
73
85
|
st.done = new Promise<PageClipDone>((ok, fail) => {
|
|
74
86
|
let n = 0
|
|
75
87
|
let repeated = 0
|
|
76
88
|
let t0 = -1
|
|
89
|
+
let last: VideoFrame | null = null
|
|
90
|
+
let seen = -1
|
|
77
91
|
const tick = (t: number): void => {
|
|
78
92
|
try {
|
|
79
93
|
if (st.failed) throw new Error(st.failed)
|
|
80
94
|
if (t0 < 0) t0 = t
|
|
95
|
+
const draws = gpu.__skDraws
|
|
96
|
+
const fresh = last == null || draws === undefined || draws !== seen
|
|
97
|
+
if (fresh) {
|
|
98
|
+
last?.close()
|
|
99
|
+
last = new VideoFrame(canvas, { timestamp: 0, visibleRect: rect })
|
|
100
|
+
seen = draws ?? -1
|
|
101
|
+
}
|
|
81
102
|
// a frame for every step of real time passed: a slow page repeats its last drawing, so the clip keeps its length
|
|
82
103
|
let made = 0
|
|
83
104
|
while (n < total && n * step <= t - t0) {
|
|
84
|
-
const frame = new VideoFrame(
|
|
105
|
+
const frame = new VideoFrame(last!, { timestamp: Math.round(n * step * 1000), duration: Math.round(step * 1000) })
|
|
85
106
|
enc.encode(frame, { keyFrame: n % a.keyEvery === 0, avc: { quantizer: a.quantizer } } as VideoEncoderEncodeOptions)
|
|
86
107
|
frame.close()
|
|
87
|
-
if (made++ > 0) repeated++
|
|
108
|
+
if (made++ > 0 || !fresh) repeated++
|
|
88
109
|
n++
|
|
89
110
|
}
|
|
90
111
|
if (n < total) requestAnimationFrame(tick)
|
|
91
|
-
else
|
|
112
|
+
else {
|
|
113
|
+
last?.close()
|
|
92
114
|
void enc
|
|
93
115
|
.flush()
|
|
94
116
|
.then(() => {
|
|
@@ -97,7 +119,9 @@ export async function installClip(a: PageClipAsk): Promise<string | null> {
|
|
|
97
119
|
ok({ frames: n, repeated, width, height, canvasOfViewport })
|
|
98
120
|
})
|
|
99
121
|
.catch(fail)
|
|
122
|
+
}
|
|
100
123
|
} catch (e) {
|
|
124
|
+
last?.close()
|
|
101
125
|
if (enc.state !== 'closed') enc.close()
|
|
102
126
|
fail(e)
|
|
103
127
|
}
|