Added selective dump features

This commit is contained in:
Hunter
2026-08-22 12:16:54 -04:00
parent eef9854cc1
commit 43e9f6a14c
9 changed files with 680 additions and 111 deletions

View File

@@ -25,26 +25,13 @@ const showAllTypes = ref(false)
const dragging = ref(false)
const treeVersion = ref(0)
/**
* Filters carried in from a link, held until the dump they belong to is up.
*
* The hash is kept alongside them so that a recipient who did not have the
* dump, and then loads the file by hand, still lands on the sender's view —
* while someone who opens an unrelated dump instead does not inherit filters
* meant for a different one.
*/
// pending view
const pending = ref<{ hash: string; view: ViewState } | null>(null)
const fmt = new Intl.NumberFormat()
// ------------------------------------------------------------------ the view
// --- view
/**
* Type filters travel as names, not as the ids the store indexes by, so both
* directions have to go through the dump's type table. A name the current
* dump does not have simply drops out — a link from a newer dump should lose
* the filter it cannot express, not fail to open.
*/
function currentView(): ViewState {
return {
prefix: pathPrefix.value,
@@ -78,13 +65,6 @@ function schedule() {
timer = setTimeout(commit, 120)
}
/**
* The one path by which a filter change becomes a frame, a URL and a query.
*
* Replaying a frame runs through here too, and needs no guard against
* recording itself: after `applyView` the refs are *equal* to the frame at
* the cursor, and `push` drops a frame identical to the current one.
*/
function commit() {
const v = currentView()
frames.push(v)
@@ -107,8 +87,7 @@ async function run() {
watch([nameQuery, pathQuery], schedule)
watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })
/** A new dump invalidates every frame: the paths and types are a different
* vocabulary, so the queue restarts rather than carrying over. */
/** A new dump invalidates every frame: the paths and types are a different vocabulary, so the queue restarts rather than carrying over. */
function resetTo(v: ViewState) {
applyView(v)
inspected.value = null
@@ -136,6 +115,11 @@ function setSort(key: SortKey, desc: boolean) {
descending.value = desc
}
function clearHistory() {
frames.clear()
syncUrl(currentView())
}
function step(delta: -1 | 1) {
const f = delta < 0 ? frames.back() : frames.forward()
if (!f) return
@@ -184,6 +168,25 @@ function jumpTo(path: string) {
pathQuery.value = ''
}
// -------------------------------------------------------------- subtree dump
const report = ref({ open: false, root: '', text: '', objects: 0, truncated: false, error: '' })
const reportLoading = ref(false)
/** Walk everything at or under `path` and render it as a reference sheet. */
async function dumpSubtree(path: string) {
report.value = { open: true, root: path, text: '', objects: 0, truncated: false, error: '' }
reportLoading.value = true
try {
const r = await d.buildReport(path)
report.value = { ...report.value, ...r }
} catch (e) {
report.value = { ...report.value, error: (e as Error).message }
} finally {
reportLoading.value = false
}
}
async function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files?.[0]
@@ -204,25 +207,22 @@ async function forget(key: string) {
refreshCached()
}
// ---------------------------------------------------------------- site dumps
// --- site dumps
const grabbing = ref('')
const grabPct = ref<number | null>(0)
const grabError = ref('')
/** Only dumps this browser has not already taken. The cached copy records the
* manifest id it came from, which is what makes "already have it" answerable
* without re-hashing a 37 MB file. */
/** Only dumps this browser has not already taken. */
const offered = computed(() => {
const have = new Set(cached.value.map((c) => c.source).filter(Boolean))
return hosted.value.filter((h) => !have.has(h.id))
})
/**
* Download on demand, never on load. One interaction fetches, caches, and
* opens the dump, so the offer disappearing from this list is the same
* gesture that puts it on screen.
* Download on demand, never on load.
*/
async function grab(h: HostedDump) {
grabbing.value = h.id
grabError.value = ''
@@ -239,21 +239,15 @@ async function grab(h: HostedDump) {
}
}
/** Unload the dump and drop back to the intro screen. The parsed copy stays
* in IndexedDB, so it reappears under "Already parsed". */
async function clearDump() {
// The d.count watcher runs resetTo(), which clears every filter, the frame
// queue and the URL. Only the things outside that surface are left here.
await d.reset()
dragging.value = false
}
/**
* A link carries a dump hash and a set of filters, but never the dump — 37 MB
* does not fit in a URL. The recipient either already has that dump in
* IndexedDB, in which case it opens with the sender's filters intact, or they
* do not, and they land on the normal intro with a note saying so.
*/
// 'cache miss' client did not have downloaded dump
const linkMiss = ref(false)
onMounted(async () => {
@@ -267,9 +261,7 @@ onMounted(async () => {
pending.value = { hash: dump, view }
if (!(await d.restore(dump))) {
linkMiss.value = true
// Not surfaced as an error: a link arriving without its dump is the
// expected case, and `linkMiss` says so in plainer terms. The pending
// view stays put so that loading the file by hand still lands on it.
// a link arriving without its dump is the expected case
d.error.value = ''
}
})
@@ -368,6 +360,7 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
v-model="pathPrefix"
:fetch-children="d.fetchChildren"
:version="treeVersion"
@dump="dumpSubtree"
/>
<section class="main">
@@ -376,6 +369,15 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
<!-- Filter history. Every change to the query surface lands here as
a frame, so stepping back is stepping back through views. -->
<div class="frames mono">
<button
class="wipe"
:disabled="frames.frames.value.length < 2"
title="Clear filter history"
aria-label="Clear filter history"
@click="clearHistory"
>
×
</button>
<button
:disabled="!frames.canBack.value"
title="Previous view"
@@ -479,9 +481,21 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
</dd>
</dl>
<button class="scope" @click="jumpTo(inspected.path)">Scope tree to this object</button>
<button class="scope" @click="dumpSubtree(inspected.path)">Dump tree under this object</button>
</aside>
</main>
<ReportModal
:open="report.open"
:root="report.root"
:text="report.text"
:objects="report.objects"
:truncated="report.truncated"
:error="report.error"
:loading="reportLoading"
@close="report.open = false"
/>
<div v-if="dragging && d.ready.value" class="veil mono">Release to parse a new dump</div>
</div>
</template>
@@ -674,6 +688,17 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
.frames button:not(:disabled):hover {
background: var(--raise);
}
/* Destructive, so it reads as an aside to the two arrows rather than a third
navigation control. */
.frames .wipe {
color: var(--dim);
font-size: 13px;
border-right: 1px solid var(--rule);
}
.frames .wipe:not(:disabled):hover {
color: var(--t-bool);
}
.tally {
font-size: 10px;
color: var(--dim);
@@ -834,6 +859,10 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
text-decoration: underline;
}
.scope + .scope {
margin-top: 6px;
}
.scope {
margin: 4px 12px 0;
width: calc(100% - 24px);