diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6f68a83..abdc41c 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -366,7 +366,7 @@ loadLabels: item => issueController.loadLabels(item), loadMilestones: item => issueController.loadMilestones(item), }); - const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + let issueCapture = null; const unfiledCaptures = createUnfiledCaptures({ storage: localStorage, getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), @@ -416,8 +416,28 @@ text: shareParams.get('text') || '', url: shareParams.get('url') || '', }; - let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ? - issueCapture.stageSharedContent(sharedLaunch) : null; + const issueCaptureFeatures = createFeatureLoader({ + document, + urls: { + 'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '', + }, + }); + let sharedLaunchState = null; + let sharedLaunchHandled = false; + async function ensureIssueCapture() { + return await issueCaptureFeatures.run('issue-capture', { + trigger: qs('#new-issue'), status: qs('#my-work-action-status'), + }, () => { + if (!issueCapture) { + issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + } + if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) { + sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch); + sharedLaunchHandled = true; + } + }); + } + if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture(); const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); outboxCoordinator.subscribe(() => refreshMyWorkView()); @@ -2759,6 +2779,7 @@ } function saveIssueCaptureDraft() { + if (!issueCapture) return; issueCapture.saveDraft({ repository: qs('#create-issue-repository').value, title: qs('#create-issue-title').value, @@ -2955,7 +2976,8 @@ sharedLaunchState = null; } - function openCreateIssueSheet(navigate = true) { + async function openCreateIssueSheet(navigate = true) { + if (!issueCapture && !await ensureIssueCapture()) return; if (navigate) { taskOverlayHistory.open('new'); return; diff --git a/frontend/feature-loader.js b/frontend/feature-loader.js new file mode 100644 index 0000000..4fa4beb --- /dev/null +++ b/frontend/feature-loader.js @@ -0,0 +1,60 @@ +function createFeatureLoader({ document, urls, timeoutMs = 10000 }) { + const pending = new Map(); + const loaded = new Set(); + + function load(name) { + if (loaded.has(name)) return Promise.resolve(); + if (pending.has(name)) return pending.get(name); + const url = urls[name]; + if (!url) return Promise.reject(new Error('Unknown feature: ' + name)); + + const request = new Promise((resolve, reject) => { + const script = document.createElement('script'); + let timeout; + const fail = () => { + clearTimeout(timeout); + script.remove(); + pending.delete(name); + reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.')); + }; + script.src = url; + script.async = true; + script.onload = () => { + clearTimeout(timeout); + pending.delete(name); + loaded.add(name); + resolve(); + }; + script.onerror = fail; + timeout = setTimeout(fail, timeoutMs); + document.head.appendChild(script); + }); + pending.set(name, request); + return request; + } + + async function run(name, elements, callback) { + const trigger = elements?.trigger; + const status = elements?.status; + if (trigger) trigger.disabled = true; + if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…'; + try { + await load(name); + callback(); + if (status) status.textContent = ''; + return true; + } catch (_error) { + if (status) { + const label = name === 'issue-capture' ? 'Issue capture' : name.replace(/-/g, ' '); + status.textContent = label + ' could not load. Tap New issue to retry.'; + } + return false; + } finally { + if (trigger) trigger.disabled = false; + } + } + + return { load, run, ready: name => loaded.has(name) }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 7ea3752..5a9b399 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -718,6 +718,7 @@
+ diff --git a/frontend/service-worker.js b/frontend/service-worker.js index e0ef9e7..4c84dee 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -12,6 +12,7 @@ const SHELL = [ BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', + BASE + 'static/feature-loader.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/search-preview.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 143cd17..1a9815c 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -13,11 +13,20 @@ import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" +FEATURE_SOURCES = {"issue-capture": ("static/create-issue-sheet.js",)} CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" ) +@dataclass(frozen=True) +class RuntimeBundle: + runtime_bytes: bytes + runtime_gzip_bytes: bytes + runtime_digest: str + runtime_name: str + + @dataclass(frozen=True) class FrontendBuild: dashboard_html: str @@ -27,6 +36,7 @@ class FrontendBuild: runtime_name: str shell_digest: str page_sources: tuple[str, ...] + feature_bundles: dict[str, RuntimeBundle] service_worker_source: str @@ -41,6 +51,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: return minified + f';"source-sha256:{revision}";'.encode() +def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle: + content = _bundle(frontend_dir, sources) + digest = hashlib.sha256(content).hexdigest()[:16] + return RuntimeBundle( + runtime_bytes=content, + runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0), + runtime_digest=digest, + runtime_name=f"{prefix}-{digest}.js", + ) + + def build_frontend(frontend_dir: Path) -> FrontendBuild: """Return one internally consistent HTML, runtime, and offline-worker build.""" source_html = (frontend_dir / "index.html").read_text() @@ -48,13 +69,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: if not sources: raise ValueError("dashboard entry document has no page scripts") - runtime = _bundle(frontend_dir, sources) - runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0) - digest = hashlib.sha256(runtime).hexdigest()[:16] - runtime_name = f"runtime-{digest}.js" + feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group} + core_sources = tuple(source for source in sources if source not in feature_source_names) + core = _runtime(frontend_dir, core_sources, "runtime") + feature_bundles = { + name: _runtime(frontend_dir, feature_sources, f"feature-{name}") + for name, feature_sources in FEATURE_SOURCES.items() + } dashboard_html = SCRIPT_TAG.sub("", source_html) + feature_metadata = "\n".join( + f'' + for name, bundle in feature_bundles.items() + ) + dashboard_html = dashboard_html.replace("", feature_metadata + "\n") dashboard_html = dashboard_html.replace( - "