commit a96e61aaac2402b13a137ae15cb2c7b04695aefe
parent 2eb188a6a4faaeff12bf183b48ae5c68a170a10f
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Sat, 29 Aug 2026 12:58:41 +0530
Merge pull request #112 from notamitgamer/feature/snapshot-progress-ui
Snapshot capture: concurrent fetches + button turns into a progress bar
Diffstat:
1 file changed, 105 insertions(+), 19 deletions(-)
diff --git a/docs/.vitepress/theme/components/SnapshotManager.vue b/docs/.vitepress/theme/components/SnapshotManager.vue
@@ -1,10 +1,15 @@
<script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
import { listSnapshots, putSnapshot, deleteSnapshotMeta } from '../lib/snapshotDb'
const snapshots = ref([])
const isCapturing = ref(false)
-const progress = ref({ done: 0, total: 0, label: '' })
+const progress = ref({ done: 0, total: 0, phaseLabel: '', pagesTotal: 0, assetsTotal: 0 })
+
+const progressPct = computed(() => {
+ if (!progress.value.total) return 0
+ return Math.min(100, Math.round((progress.value.done / progress.value.total) * 100))
+})
const error = ref('')
onMounted(refresh)
@@ -50,26 +55,44 @@ function extractAssetUrls(html) {
return urls
}
+// Runs `worker` over `items` with up to `concurrency` in flight at once,
+// instead of one-at-a-time — this is what makes capture fast, and it's what
+// lets the progress bar move continuously instead of stalling.
+async function runPool(items, concurrency, worker) {
+ let i = 0
+ async function runNext() {
+ while (i < items.length) {
+ const idx = i++
+ await worker(items[idx], idx)
+ }
+ }
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, runNext)
+ await Promise.all(workers)
+}
+
+const CONCURRENCY = 12
+
async function takeSnapshot() {
error.value = ''
isCapturing.value = true
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const cacheName = `snapshot-${id}`
let sizeBytes = 0
- let assetUrls = new Set()
+ const assetUrls = new Set()
try {
const pagePaths = await getPagePaths()
- progress.value = { done: 0, total: pagePaths.length, label: 'Fetching pages…' }
+ // "total" spans pages + assets so the bar reflects overall progress, not
+ // just the current phase resetting to 0.
+ progress.value = { done: 0, total: pagePaths.length, phaseLabel: 'Fetching pages…', pagesTotal: pagePaths.length, assetsTotal: 0 }
const cache = await caches.open(cacheName)
- for (const path of pagePaths) {
+ await runPool(pagePaths, CONCURRENCY, async (path) => {
try {
const res = await fetch(path, { cache: 'no-store' })
if (res && res.ok) {
- const clone = res.clone()
- const html = await clone.text()
+ const html = await res.clone().text()
sizeBytes += html.length
extractAssetUrls(html).forEach((u) => assetUrls.add(u))
await cache.put(path, res)
@@ -78,23 +101,30 @@ async function takeSnapshot() {
/* skip page that failed, continue capturing the rest */
}
progress.value.done++
- }
+ })
const assetList = [...assetUrls]
- progress.value = { done: 0, total: assetList.length, label: 'Fetching assets…' }
- for (const url of assetList) {
+ progress.value = {
+ done: pagePaths.length,
+ total: pagePaths.length + assetList.length,
+ phaseLabel: 'Fetching assets…',
+ pagesTotal: pagePaths.length,
+ assetsTotal: assetList.length,
+ }
+
+ await runPool(assetList, CONCURRENCY, async (url) => {
try {
const res = await fetch(url, { cache: 'no-store' })
if (res && res.ok) {
- const buf = await res.clone().arrayBuffer()
- sizeBytes += buf.byteLength
+ const len = res.headers.get('content-length')
+ sizeBytes += len ? Number(len) : 0
await cache.put(url, res)
}
} catch (_) {
/* skip */
}
progress.value.done++
- }
+ })
await putSnapshot({
id,
@@ -112,7 +142,7 @@ async function takeSnapshot() {
try { await caches.delete(cacheName) } catch (_) {}
} finally {
isCapturing.value = false
- progress.value = { done: 0, total: 0, label: '' }
+ progress.value = { done: 0, total: 0, phaseLabel: '', pagesTotal: 0, assetsTotal: 0 }
}
}
@@ -143,11 +173,27 @@ async function renameSnapshot(snap) {
Take one before you go offline, and come back to it any time from this page.
</p>
- <button class="capture-btn" :disabled="isCapturing" @click="takeSnapshot">
- <span v-if="!isCapturing">📸 Take snapshot now</span>
- <span v-else>Capturing… {{ progress.label }} ({{ progress.done }}/{{ progress.total }})</span>
+ <button
+ v-if="!isCapturing"
+ class="capture-btn"
+ @click="takeSnapshot"
+ >
+ 📸 Take snapshot now
</button>
+ <div v-else class="capture-btn capture-progress" role="progressbar"
+ :aria-valuenow="progressPct" aria-valuemin="0" aria-valuemax="100">
+ <div class="capture-progress-fill" :style="{ width: progressPct + '%' }"></div>
+ <!-- Two copies of the same label, each clipped to one side of the fill,
+ so the text stays readable no matter how far the bar has progressed. -->
+ <div class="capture-progress-label capture-progress-label-track">
+ {{ progress.phaseLabel }} {{ progress.done }}/{{ progress.total }} ({{ progressPct }}%)
+ </div>
+ <div class="capture-progress-label capture-progress-label-fill" :style="{ clipPath: `inset(0 ${100 - progressPct}% 0 0)` }">
+ {{ progress.phaseLabel }} {{ progress.done }}/{{ progress.total }} ({{ progressPct }}%)
+ </div>
+ </div>
+
<p v-if="error" class="error">{{ error }}</p>
<div v-if="snapshots.length" class="snapshot-list">
@@ -187,10 +233,50 @@ async function renameSnapshot(snap) {
font-weight: 600;
cursor: pointer;
margin-bottom: 20px;
+ min-width: 260px;
+ height: 40px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
}
-.capture-btn:disabled {
- opacity: 0.7;
+.capture-progress {
+ position: relative;
+ overflow: hidden;
cursor: default;
+ justify-content: flex-start;
+ padding: 0;
+ background: var(--vp-c-bg-soft);
+ border: 1px solid var(--vp-c-brand-1);
+}
+.capture-progress-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: var(--vp-c-brand-1);
+ transition: width 0.25s ease;
+}
+.capture-progress-label {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ font-size: 12.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+}
+/* Text over the unfilled track: use the normal (theme-adaptive) text color. */
+.capture-progress-label-track {
+ color: var(--vp-c-text-1);
+}
+/* Text over the colored fill: always white, revealed only where the fill covers it. */
+.capture-progress-label-fill {
+ color: #fff;
}
.error {
color: var(--vp-c-danger-1, #d33);