@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
3
|
+
/** @typedef {import('./contract-types.js').RiskTag} RiskTag */
|
|
4
|
+
import { setDisabledReason } from './composer.js'
|
|
5
|
+
import { esc, timeAgo } from './dom.js'
|
|
6
|
+
import { refreshRail } from './layers.js'
|
|
7
|
+
import { progressSummary } from './progress.js'
|
|
8
|
+
import { approveBlockedReason } from './signoff.js'
|
|
9
|
+
import { skinLabel } from './skin.js'
|
|
10
|
+
import { themeLabel } from './theme.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {ReadonlyArray<RiskTag>} risk
|
|
14
|
+
* @returns {string} the `touches:` line, or '' when nothing is tagged
|
|
15
|
+
*/
|
|
16
|
+
export function riskLineHtml(risk) {
|
|
17
|
+
if (risk.length === 0) {
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
const pills = risk
|
|
21
|
+
.map(r => {
|
|
22
|
+
const title = r.source === 'model' && r.reason ? ` title="${esc(r.reason)}"` : ''
|
|
23
|
+
return `<span class="pill risk ${r.source}"${title}>${esc(r.label)}</span>`
|
|
24
|
+
})
|
|
25
|
+
.join('<span aria-hidden="true">·</span>')
|
|
26
|
+
return `<p class="touches"><span>touches:</span>${pills}</p>`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One line saying the canvas was generated under the large-change-set caps, so a reader who finds
|
|
31
|
+
* a file with no annotation knows why.
|
|
32
|
+
* @param {PrBundle} bundle
|
|
33
|
+
*/
|
|
34
|
+
export function largePrNoticeHtml(bundle) {
|
|
35
|
+
if (!bundle.largePr) {
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
'<p class="notice large-pr" role="status">Large change set: the canvas keeps at most 6 annotations per file ' +
|
|
40
|
+
'and 12 attention points, and diffs were not inlined for the generator.</p>'
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {PrBundle['pr']} pr */
|
|
45
|
+
export function statePill(pr) {
|
|
46
|
+
const state = pr.state === 'open' && pr.draft ? 'draft' : pr.state
|
|
47
|
+
return `<span class="pill ${esc(state)}">${esc(state)}</span>`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {PrBundle} bundle
|
|
52
|
+
* @param {{ host: string, theme: import('./theme.js').Theme, skin: import('./skin.js').Skin, now: Date }} opts
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function renderHeader(bundle, opts) {
|
|
56
|
+
const { pr, artifact } = bundle
|
|
57
|
+
const ready = bundle.status === 'ready' && artifact !== undefined
|
|
58
|
+
// A stale canvas can be exported and regenerated too, so both commands stay live for it.
|
|
59
|
+
const hasCanvas = artifact !== undefined && (bundle.status === 'ready' || bundle.status === 'stale')
|
|
60
|
+
const agent = ready
|
|
61
|
+
? `<span class="pill agent">${esc(artifact.generator.agent)}${artifact.generator.model ? ` · ${esc(artifact.generator.model)}` : ''} · ${esc(artifact.generator.harness)}</span><span>generated ${esc(timeAgo(artifact.generatedAt, opts.now))}</span>`
|
|
62
|
+
: ''
|
|
63
|
+
const number = pr.number === null ? '' : `<span class="mono num">#${pr.number}</span>`
|
|
64
|
+
const progress = ready ? progressHtml(artifact, bundle.state) : ''
|
|
65
|
+
const risk = ready ? riskLineHtml(artifact.risk) : ''
|
|
66
|
+
return (
|
|
67
|
+
'<header class="hdr">' +
|
|
68
|
+
`<div class="hdr-bar"><div class="brand"><span class="box">PR review canvas</span><span class="mono muted">${esc(opts.host)}</span></div>` +
|
|
69
|
+
'<div class="hdr-actions" role="group" aria-label="Canvas actions">' +
|
|
70
|
+
`<button class="cmd" type="button" id="regenerate" title="Generate a new canvas for this PR" aria-haspopup="dialog"${hasCanvas ? '' : ' disabled'}>regenerate</button>` +
|
|
71
|
+
`<button class="cmd" type="button" id="export-zip" title="Download this canvas as a zip to share on GitHub"${hasCanvas ? '' : ' disabled'}>export zip</button>` +
|
|
72
|
+
'<button class="cmd" type="button" id="refresh" title="Fetch the latest PR, comments, and shared canvas from GitHub">refresh</button>' +
|
|
73
|
+
`<button class="cmd" type="button" id="settings" data-act="settings" aria-haspopup="dialog"${bundle.chat.enabled || bundle.chat.acpx ? ' title="Configure the AI chat agent, model, and limits"' : ' disabled title="acpx is not installed"'}>settings</button>` +
|
|
74
|
+
'<button class="cmd" type="button" data-act="help" title="Show keyboard shortcuts and review help" aria-haspopup="dialog">help</button>' +
|
|
75
|
+
`<button class="cmd" type="button" id="skin-toggle" title="Switch between Terminal and GitHub styling">${esc(skinLabel(opts.skin))}</button>` +
|
|
76
|
+
`<button class="cmd" type="button" id="theme-toggle" title="Switch between Light, Dark, and Auto themes">${esc(themeLabel(opts.theme))}</button>` +
|
|
77
|
+
'</div></div>' +
|
|
78
|
+
'<div class="stripe" aria-hidden="true"></div>' +
|
|
79
|
+
'<div class="hdr-title">' +
|
|
80
|
+
`<div class="title"><h1>${number}${esc(pr.title)}</h1><a class="cmd" href="${esc(pr.url)}" target="_blank" rel="noopener noreferrer">github</a></div>` +
|
|
81
|
+
`<p class="meta"><span>by <a href="https://github.com/${esc(pr.author)}" target="_blank" rel="noopener noreferrer">${esc(pr.author)}</a></span>` +
|
|
82
|
+
`<span class="mono">${esc(pr.headRef)} → ${esc(pr.baseRef)}</span>${statePill(pr)}` +
|
|
83
|
+
`<span class="diffstat"><span class="ok">+${pr.additions}</span> <span class="bad">−${pr.deletions}</span></span>${agent}</p>` +
|
|
84
|
+
`${largePrNoticeHtml(bundle)}${risk}${progress}</div></header>`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The thin line, its text, and the two sign-off commands. Approve stays disabled with the
|
|
90
|
+
* reason until every layer that is not Other has been marked reviewed.
|
|
91
|
+
* @param {import('./contract-types.js').ReviewArtifact} artifact
|
|
92
|
+
* @param {import('./contract-types.js').PrState} state
|
|
93
|
+
*/
|
|
94
|
+
export function progressHtml(artifact, state) {
|
|
95
|
+
const p = progressSummary(artifact, state)
|
|
96
|
+
const blocked = approveBlockedReason(artifact, state)
|
|
97
|
+
const approve =
|
|
98
|
+
`<button class="cmd" type="button" id="approve" data-tooltip="Write and preview an approving review on GitHub" data-act="signoff" data-event="APPROVE" data-needs-post` +
|
|
99
|
+
`${blocked === null ? ' title="Write and preview an approving review on GitHub"' : ` disabled data-disabled-reason="${esc(blocked)}" title="${esc(blocked)}"`}>approve on github</button>`
|
|
100
|
+
return (
|
|
101
|
+
`<div class="progress"><div class="pline" role="progressbar" aria-valuenow="${p.done}" aria-valuemin="0" aria-valuemax="${p.total}" aria-label="Layers reviewed"><span style="width:${p.percent}%"></span></div>` +
|
|
102
|
+
`<span class="ptext">${p.done} of ${p.total} layers reviewed</span></div>` +
|
|
103
|
+
`<div class="signoff">${approve}` +
|
|
104
|
+
'<button class="cmd" type="button" id="request-changes" data-tooltip="Write and preview a review requesting changes on GitHub" title="Write and preview a review requesting changes on GitHub" data-act="signoff" data-event="REQUEST_CHANGES" data-needs-post>request changes</button>' +
|
|
105
|
+
'<span class="capability-note" role="status"></span></div>'
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Draws the progress line, its text, and the approve command again from the current state, and
|
|
111
|
+
* with them the rail. Called after every reviewed change.
|
|
112
|
+
* @param {ParentNode} root
|
|
113
|
+
* @param {import('./contract-types.js').ReviewArtifact} artifact
|
|
114
|
+
* @param {import('./contract-types.js').PrState} state
|
|
115
|
+
*/
|
|
116
|
+
export function refreshProgress(root, artifact, state) {
|
|
117
|
+
const p = progressSummary(artifact, state)
|
|
118
|
+
const line = root.querySelector('.pline')
|
|
119
|
+
const fill = line?.querySelector('span')
|
|
120
|
+
if (line !== null) {
|
|
121
|
+
line.setAttribute('aria-valuenow', String(p.done))
|
|
122
|
+
line.setAttribute('aria-valuemax', String(p.total))
|
|
123
|
+
}
|
|
124
|
+
if (fill instanceof HTMLElement) {
|
|
125
|
+
fill.style.width = `${p.percent}%`
|
|
126
|
+
}
|
|
127
|
+
const text = root.querySelector('.ptext')
|
|
128
|
+
if (text !== null) {
|
|
129
|
+
text.textContent = `${p.done} of ${p.total} layers reviewed`
|
|
130
|
+
}
|
|
131
|
+
setDisabledReason(root.querySelector('#approve'), approveBlockedReason(artifact, state))
|
|
132
|
+
refreshRail(root, artifact, state)
|
|
133
|
+
return p
|
|
134
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Hunk header parsing and the hunk-for-line lookup. One implementation for the browser and the
|
|
3
|
+
// server (src/git/patch-lines.ts re-exports it), so both sides agree on which hunk owns a line.
|
|
4
|
+
/** @typedef {import('./contract-types.js').Side} Side */
|
|
5
|
+
/** @typedef {{ oldStart: number, oldLines: number, newStart: number, newLines: number }} HunkRange */
|
|
6
|
+
|
|
7
|
+
export const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Line numbers and counts of a unified-diff hunk header; a missing count means 1.
|
|
11
|
+
* @param {string} line
|
|
12
|
+
* @returns {HunkRange | null} null when the line is not a hunk header
|
|
13
|
+
*/
|
|
14
|
+
export function parseHunkHeader(line) {
|
|
15
|
+
const m = HUNK_HEADER_RE.exec(line)
|
|
16
|
+
if (!m) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
oldStart: Number(m[1]),
|
|
21
|
+
oldLines: m[2] === undefined ? 1 : Number(m[2]),
|
|
22
|
+
newStart: Number(m[3]),
|
|
23
|
+
newLines: m[4] === undefined ? 1 : Number(m[4]),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The hunk that covers `line` on `side`, or null when the line is outside the diff.
|
|
29
|
+
* @template {HunkRange} H
|
|
30
|
+
* @param {ReadonlyArray<H>} hunks
|
|
31
|
+
* @param {Side} side
|
|
32
|
+
* @param {number} line
|
|
33
|
+
* @returns {H | null}
|
|
34
|
+
*/
|
|
35
|
+
export function hunkForLine(hunks, side, line) {
|
|
36
|
+
for (const h of hunks) {
|
|
37
|
+
const start = side === 'new' ? h.newStart : h.oldStart
|
|
38
|
+
const count = side === 'new' ? h.newLines : h.oldLines
|
|
39
|
+
// A zero-length range (pure deletion or pure addition) still anchors on its start line.
|
|
40
|
+
const end = start + Math.max(count, 1) - 1
|
|
41
|
+
if (line >= start && line <= end) {
|
|
42
|
+
return h
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Valid anchor ranges, using the same zero-length anchor rule as hunkForLine.
|
|
50
|
+
* @param {ReadonlyArray<HunkRange>} hunks
|
|
51
|
+
* @param {Side} side
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function hunkLineRanges(hunks, side) {
|
|
55
|
+
const ranges = hunks.map(h => {
|
|
56
|
+
const start = side === 'new' ? h.newStart : h.oldStart
|
|
57
|
+
const count = side === 'new' ? h.newLines : h.oldLines
|
|
58
|
+
const end = start + Math.max(count, 1) - 1
|
|
59
|
+
return start === end ? `${start}` : `${start}-${end}`
|
|
60
|
+
})
|
|
61
|
+
return `${side}-side lines ${ranges.length ? ranges.join(', ') : 'none'}`
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The drop zone of the empty and stale screens: pick or drop a zip, POST it, and show what
|
|
3
|
+
// happened next to the zone instead of in the console.
|
|
4
|
+
/** @typedef {import('./contract-types.js').ImportResult} ImportResult */
|
|
5
|
+
/** @typedef {import('./empty-state.js').DropState} DropState */
|
|
6
|
+
/** @typedef {typeof import('./api.js').importCanvas} ImportCanvas */
|
|
7
|
+
import { clearCommandError, showCommandError } from './commands.js'
|
|
8
|
+
import { qs } from './dom.js'
|
|
9
|
+
import { DROP_ZONE_ID, dropReducer, INITIAL_DROP_STATE, validateCanvasFilename } from './empty-state.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {DropState} state
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function dropStatusText(state) {
|
|
16
|
+
if (state.phase === 'busy') {
|
|
17
|
+
return `importing… ${Math.round(state.progress * 100)}%`
|
|
18
|
+
}
|
|
19
|
+
return state.phase === 'over' ? 'drop to import' : ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* prNumber: number,
|
|
25
|
+
* importImpl: ImportCanvas,
|
|
26
|
+
* onImported: (result: ImportResult) => void,
|
|
27
|
+
* }} DropZoneOptions
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ element: HTMLElement, send: (file: File) => Promise<void>, state: () => DropState }} DropZone
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wires the zone under `root`. Returns null when the screen has no drop zone.
|
|
36
|
+
* @param {HTMLElement} root
|
|
37
|
+
* @param {DropZoneOptions} opts
|
|
38
|
+
* @returns {DropZone | null}
|
|
39
|
+
*/
|
|
40
|
+
export function wireDropZone(root, opts) {
|
|
41
|
+
const label = qs('.drop', root)
|
|
42
|
+
const input = qs(`#${DROP_ZONE_ID}`, root)
|
|
43
|
+
const status = qs('.drop-status', root)
|
|
44
|
+
if (!(label instanceof HTMLElement && input instanceof HTMLInputElement && status instanceof HTMLElement)) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
let state = INITIAL_DROP_STATE
|
|
48
|
+
const apply = (/** @type {import('./empty-state.js').DropEvent} */ event) => {
|
|
49
|
+
state = dropReducer(state, event)
|
|
50
|
+
label.classList.toggle('over', state.phase === 'over')
|
|
51
|
+
status.textContent = dropStatusText(state)
|
|
52
|
+
if (state.error === null) {
|
|
53
|
+
clearCommandError(status)
|
|
54
|
+
} else {
|
|
55
|
+
showCommandError(status, state.error)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const send = async (/** @type {File} */ file) => {
|
|
59
|
+
const problem = validateCanvasFilename(file.name)
|
|
60
|
+
if (problem !== null) {
|
|
61
|
+
apply({ type: 'fail', message: problem })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
apply({ type: 'start' })
|
|
65
|
+
try {
|
|
66
|
+
const result = await opts.importImpl(opts.prNumber, file, {
|
|
67
|
+
onProgress: fraction => apply({ type: 'progress', fraction }),
|
|
68
|
+
})
|
|
69
|
+
apply({ type: 'done' })
|
|
70
|
+
opts.onImported(result)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
apply({ type: 'fail', message: err instanceof Error ? err.message : String(err) })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
label.addEventListener('dragover', event => {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
apply({ type: 'enter' })
|
|
78
|
+
})
|
|
79
|
+
label.addEventListener('dragleave', () => apply({ type: 'leave' }))
|
|
80
|
+
label.addEventListener('drop', event => {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
apply({ type: 'leave' })
|
|
83
|
+
const file = event instanceof DragEvent ? event.dataTransfer?.files[0] : undefined
|
|
84
|
+
if (file) {
|
|
85
|
+
void send(file)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
input.addEventListener('change', () => {
|
|
89
|
+
const file = input.files?.[0]
|
|
90
|
+
if (file) {
|
|
91
|
+
void send(file)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
return { element: label, send, state: () => state }
|
|
95
|
+
}
|