V.1
This commit is contained in:
340
app/app.vue
340
app/app.vue
@@ -1,29 +1,95 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useDump, type RowView } from './composables/useDump'
|
import { useDump, type RowView } from './composables/useDump'
|
||||||
|
import { useFrames } from './composables/useFrames'
|
||||||
import { hue, abbreviate } from './lib/type-hue'
|
import { hue, abbreviate } from './lib/type-hue'
|
||||||
import { listDumps, dropDump, type CachedDump } from './lib/persist'
|
import { listDumps, dropDump, type CachedDump } from './lib/persist'
|
||||||
|
import { listHosted, download, fileNameOf, type HostedDump } from './lib/hosted'
|
||||||
|
import { encodeView, decodeView, emptyView, type ViewState } from './lib/view-state'
|
||||||
|
import type { SortKey } from './lib/dump-store'
|
||||||
import HeaderBar from '@/components/HeaderBar.vue'
|
import HeaderBar from '@/components/HeaderBar.vue'
|
||||||
const d = useDump()
|
const d = useDump()
|
||||||
|
const frames = useFrames()
|
||||||
|
|
||||||
const nameQuery = ref('')
|
const nameQuery = ref('')
|
||||||
const pathQuery = ref('')
|
const pathQuery = ref('')
|
||||||
const pathPrefix = ref('')
|
const pathPrefix = ref('')
|
||||||
const activeTypes = ref<number[]>([])
|
const activeTypes = ref<number[]>([])
|
||||||
const sort = ref<'natural' | 'path' | 'address' | 'offset'>('natural')
|
const sort = ref<SortKey>('natural')
|
||||||
|
const descending = ref(false)
|
||||||
const inspected = ref<RowView | null>(null)
|
const inspected = ref<RowView | null>(null)
|
||||||
const outerOf = ref<RowView | null>(null)
|
const outerOf = ref<RowView | null>(null)
|
||||||
const cached = ref<CachedDump[]>([])
|
const cached = ref<CachedDump[]>([])
|
||||||
|
const hosted = ref<HostedDump[]>([])
|
||||||
const showAllTypes = ref(false)
|
const showAllTypes = ref(false)
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const treeVersion = ref(0)
|
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.
|
||||||
|
*/
|
||||||
|
const pending = ref<{ hash: string; view: ViewState } | null>(null)
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat()
|
const fmt = new Intl.NumberFormat()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ the 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,
|
||||||
|
name: nameQuery.value.trim(),
|
||||||
|
path: pathQuery.value.trim(),
|
||||||
|
sort: sort.value,
|
||||||
|
desc: descending.value,
|
||||||
|
types: activeTypes.value
|
||||||
|
.map((id) => d.types.value[id])
|
||||||
|
.filter((n): n is string => !!n)
|
||||||
|
.sort(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyView(v: ViewState) {
|
||||||
|
pathPrefix.value = v.prefix
|
||||||
|
nameQuery.value = v.name
|
||||||
|
pathQuery.value = v.path
|
||||||
|
sort.value = v.sort
|
||||||
|
descending.value = v.desc
|
||||||
|
activeTypes.value = v.types.map((n) => d.types.value.indexOf(n)).filter((i) => i >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl(v: ViewState) {
|
||||||
|
history.replaceState(null, '', encodeView(d.dumpKey.value, v))
|
||||||
|
}
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout>
|
let timer: ReturnType<typeof setTimeout>
|
||||||
function schedule() {
|
function schedule() {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(run, 120)
|
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)
|
||||||
|
syncUrl(v)
|
||||||
|
run()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -34,19 +100,61 @@ async function run() {
|
|||||||
nameContains: nameQuery.value.trim() || undefined,
|
nameContains: nameQuery.value.trim() || undefined,
|
||||||
pathContains: pathQuery.value.trim() || undefined,
|
pathContains: pathQuery.value.trim() || undefined,
|
||||||
sort: sort.value,
|
sort: sort.value,
|
||||||
|
descending: descending.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([nameQuery, pathQuery], schedule)
|
watch([nameQuery, pathQuery], schedule)
|
||||||
watch([pathPrefix, activeTypes, sort], run, { deep: true })
|
watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })
|
||||||
watch(d.count, () => {
|
|
||||||
treeVersion.value++
|
/** A new dump invalidates every frame: the paths and types are a different
|
||||||
pathPrefix.value = ''
|
* vocabulary, so the queue restarts rather than carrying over. */
|
||||||
activeTypes.value = []
|
function resetTo(v: ViewState) {
|
||||||
|
applyView(v)
|
||||||
inspected.value = null
|
inspected.value = null
|
||||||
|
outerOf.value = null
|
||||||
|
showAllTypes.value = false
|
||||||
|
treeVersion.value++
|
||||||
|
frames.reset(currentView())
|
||||||
|
syncUrl(frames.current.value)
|
||||||
run()
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(d.count, () => {
|
||||||
|
const p = pending.value
|
||||||
|
const claimed = !!p && p.hash === d.dumpKey.value
|
||||||
|
resetTo(claimed ? p!.view : emptyView())
|
||||||
|
if (claimed) {
|
||||||
|
pending.value = null
|
||||||
|
linkMiss.value = false
|
||||||
|
}
|
||||||
|
refreshCached()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setSort(key: SortKey, desc: boolean) {
|
||||||
|
sort.value = key
|
||||||
|
descending.value = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(delta: -1 | 1) {
|
||||||
|
const f = delta < 0 ? frames.back() : frames.forward()
|
||||||
|
if (!f) return
|
||||||
|
applyView(f)
|
||||||
|
syncUrl(f)
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
async function copyLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(location.href)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 1400)
|
||||||
|
} catch {
|
||||||
|
/* clipboard blocked; the URL bar already shows the link */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleType(id: number) {
|
function toggleType(id: number) {
|
||||||
const i = activeTypes.value.indexOf(id)
|
const i = activeTypes.value.indexOf(id)
|
||||||
i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1)
|
i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1)
|
||||||
@@ -96,22 +204,75 @@ async function forget(key: string) {
|
|||||||
refreshCached()
|
refreshCached()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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. */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async function grab(h: HostedDump) {
|
||||||
|
grabbing.value = h.id
|
||||||
|
grabError.value = ''
|
||||||
|
grabPct.value = 0
|
||||||
|
try {
|
||||||
|
const buffer = await download(h, (f) => (grabPct.value = f))
|
||||||
|
await d.loadBuffer(buffer, fileNameOf(h), { format: h.format ?? 'text', source: h.id })
|
||||||
|
await refreshCached()
|
||||||
|
} catch (e) {
|
||||||
|
grabError.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
grabbing.value = ''
|
||||||
|
grabPct.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Unload the dump and drop back to the intro screen. The parsed copy stays
|
/** Unload the dump and drop back to the intro screen. The parsed copy stays
|
||||||
* in IndexedDB, so it reappears under "Already parsed". */
|
* in IndexedDB, so it reappears under "Already parsed". */
|
||||||
async function clearDump() {
|
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()
|
await d.reset()
|
||||||
// The d.count watcher clears pathPrefix / activeTypes / inspected; the rest
|
|
||||||
// of the query surface is ours.
|
|
||||||
nameQuery.value = ''
|
|
||||||
pathQuery.value = ''
|
|
||||||
sort.value = 'natural'
|
|
||||||
showAllTypes.value = false
|
|
||||||
outerOf.value = null
|
|
||||||
dragging.value = false
|
dragging.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshCached)
|
/**
|
||||||
watch(d.count, refreshCached)
|
* 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.
|
||||||
|
*/
|
||||||
|
const linkMiss = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCached()
|
||||||
|
listHosted()
|
||||||
|
.then((h) => (hosted.value = h))
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
const { dump, view } = decodeView(location.search)
|
||||||
|
if (!dump) return
|
||||||
|
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.
|
||||||
|
d.error.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tail = (s: string, max = 72) =>
|
const tail = (s: string, max = 72) =>
|
||||||
s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||||
@@ -161,6 +322,34 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p v-if="d.error.value" class="err mono">{{ d.error.value }}</p>
|
<p v-if="d.error.value" class="err mono">{{ d.error.value }}</p>
|
||||||
|
<p v-if="linkMiss" class="err mono">
|
||||||
|
That link points at a dump this browser has not loaded yet. Open the same
|
||||||
|
file below or drop it above, and the link will work from then on.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="offered.length" class="cached">
|
||||||
|
<p class="eyebrow">Available from this site</p>
|
||||||
|
<div v-for="h in offered" :key="h.id" class="cache-row mono">
|
||||||
|
<span class="cache-name">{{ h.title }}</span>
|
||||||
|
<span class="cache-meta">
|
||||||
|
<template v-if="h.bytes">{{ mb(h.bytes) }}</template>
|
||||||
|
<template v-if="h.note"> · {{ h.note }}</template>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="get"
|
||||||
|
:disabled="!!grabbing"
|
||||||
|
@click="grab(h)"
|
||||||
|
>
|
||||||
|
<template v-if="grabbing !== h.id">download</template>
|
||||||
|
<template v-else-if="grabPct === null">downloading…</template>
|
||||||
|
<template v-else>{{ Math.round(grabPct * 100) }}%</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="grabbing" class="meter thin">
|
||||||
|
<i :style="{ width: (grabPct ?? 0) * 100 + '%' }" />
|
||||||
|
</div>
|
||||||
|
<p v-if="grabError" class="err mono">{{ grabError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="cached.length" class="cached">
|
<div v-if="cached.length" class="cached">
|
||||||
<p class="eyebrow">Already parsed</p>
|
<p class="eyebrow">Already parsed</p>
|
||||||
@@ -184,14 +373,36 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
<section class="main">
|
<section class="main">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="searches">
|
<div class="searches">
|
||||||
|
<!-- 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
|
||||||
|
:disabled="!frames.canBack.value"
|
||||||
|
title="Previous view"
|
||||||
|
aria-label="Previous view"
|
||||||
|
@click="step(-1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span class="tally" :title="`Frame ${frames.index.value + 1} of ${frames.frames.value.length}`">
|
||||||
|
{{ frames.index.value + 1 }}/{{ frames.frames.value.length }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
:disabled="!frames.canForward.value"
|
||||||
|
title="Next view"
|
||||||
|
aria-label="Next view"
|
||||||
|
@click="step(1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input v-model="nameQuery" placeholder="search name…" spellcheck="false" />
|
<input v-model="nameQuery" placeholder="search name…" spellcheck="false" />
|
||||||
<input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />
|
<input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />
|
||||||
<select v-model="sort" class="mono">
|
|
||||||
<option value="natural">dump order</option>
|
<button class="share mono" title="Copy a link to this exact view" @click="copyLink">
|
||||||
<option value="path">path</option>
|
{{ copied ? 'copied' : 'link' }}
|
||||||
<option value="address">address</option>
|
</button>
|
||||||
<option value="offset">field offset</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pathPrefix" class="crumb mono">
|
<div v-if="pathPrefix" class="crumb mono">
|
||||||
@@ -226,8 +437,11 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
<ObjectTable
|
<ObjectTable
|
||||||
:total="d.total.value"
|
:total="d.total.value"
|
||||||
:shared-digits="d.sharedAddressDigits.value"
|
:shared-digits="d.sharedAddressDigits.value"
|
||||||
|
:sort="sort"
|
||||||
|
:descending="descending"
|
||||||
:fetch-window="d.fetchWindow"
|
:fetch-window="d.fetchWindow"
|
||||||
@inspect="inspect"
|
@inspect="inspect"
|
||||||
|
@sort="setSort"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer class="status mono">
|
<footer class="status mono">
|
||||||
@@ -375,6 +589,32 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
color: var(--t-bool);
|
color: var(--t-bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.get {
|
||||||
|
flex: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.get:hover:not(:disabled) {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
.get:disabled {
|
||||||
|
color: var(--dimmer);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter.thin {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* explorer */
|
/* explorer */
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -402,18 +642,64 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
.searches {
|
.searches {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.searches input {
|
.searches input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.searches select {
|
|
||||||
|
.frames {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 11px;
|
|
||||||
color: var(--ink);
|
|
||||||
background: var(--void);
|
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 8px;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.frames button {
|
||||||
|
width: 22px;
|
||||||
|
align-self: stretch;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--amber);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.frames button:disabled {
|
||||||
|
color: var(--dimmer);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.frames button:not(:disabled):hover {
|
||||||
|
background: var(--raise);
|
||||||
|
}
|
||||||
|
.tally {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-left: 1px solid var(--rule);
|
||||||
|
border-right: 1px solid var(--rule);
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share {
|
||||||
|
flex: none;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.share:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.crumb {
|
.crumb {
|
||||||
|
|||||||
@@ -1,15 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import type { RowView } from '../composables/useDump'
|
import type { RowView } from '../composables/useDump'
|
||||||
|
import type { SortKey } from '../lib/dump-store'
|
||||||
import { hue, abbreviate } from '../lib/type-hue'
|
import { hue, abbreviate } from '../lib/type-hue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
total: number
|
total: number
|
||||||
sharedDigits: number
|
sharedDigits: number
|
||||||
|
sort: SortKey
|
||||||
|
descending: boolean
|
||||||
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'inspect', row: RowView): void }>()
|
const emit = defineEmits<{
|
||||||
|
(e: 'inspect', row: RowView): void
|
||||||
|
(e: 'sort', key: SortKey, descending: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorting is free here, and that is not obvious given the table streams its
|
||||||
|
* rows in windows.
|
||||||
|
*
|
||||||
|
* The windowing is over an index array the worker already holds — `query`
|
||||||
|
* produces a Uint32Array of matching row numbers, sorts it, and the UI asks
|
||||||
|
* for slices of it by position. Order is therefore decided before any chunk
|
||||||
|
* is handed out, not assembled across chunks, so a re-sort is one comparator
|
||||||
|
* pass in the worker (a few hundred ms at ~800k rows, the same cost the
|
||||||
|
* existing path sort already pays) followed by the scroll reset that a new
|
||||||
|
* query does anyway. Nothing has to be re-fetched or merged.
|
||||||
|
*/
|
||||||
|
const COLUMNS: { key: SortKey; label: string; cls: string }[] = [
|
||||||
|
{ key: 'address', label: 'ADDRESS', cls: 'col-addr' },
|
||||||
|
{ key: 'type', label: 'TYPE', cls: 'col-type' },
|
||||||
|
{ key: 'name', label: 'NAME', cls: 'col-name' },
|
||||||
|
{ key: 'path', label: 'CONTAINER', cls: 'col-path' },
|
||||||
|
{ key: 'offset', label: 'OFF', cls: 'col-off' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** asc → desc → off. The third click restores dump order, which is a view
|
||||||
|
* people want back and would otherwise have no control for. */
|
||||||
|
function cycle(key: SortKey) {
|
||||||
|
if (props.sort !== key) emit('sort', key, false)
|
||||||
|
else if (!props.descending) emit('sort', key, true)
|
||||||
|
else emit('sort', 'natural', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = (key: SortKey) =>
|
||||||
|
props.sort !== key ? '' : props.descending ? '↓' : '↑'
|
||||||
|
|
||||||
const ROW_H = 26
|
const ROW_H = 26
|
||||||
const OVERSCAN = 24
|
const OVERSCAN = 24
|
||||||
@@ -88,11 +125,24 @@ function tail(s: string, max = 64) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="table">
|
<div class="table">
|
||||||
<header class="head mono">
|
<header class="head mono">
|
||||||
<span class="col-addr">ADDRESS</span>
|
<button
|
||||||
<span class="col-type">TYPE</span>
|
v-for="c in COLUMNS"
|
||||||
<span class="col-name">NAME</span>
|
:key="c.key"
|
||||||
<span class="col-path">CONTAINER</span>
|
type="button"
|
||||||
<span class="col-off">OFF</span>
|
class="sorter"
|
||||||
|
:class="[c.cls, { on: sort === c.key }]"
|
||||||
|
:title="
|
||||||
|
sort === c.key && descending
|
||||||
|
? 'Sorted descending — click for dump order'
|
||||||
|
: sort === c.key
|
||||||
|
? 'Sorted ascending — click for descending'
|
||||||
|
: `Sort by ${c.label.toLowerCase()}`
|
||||||
|
"
|
||||||
|
:aria-sort="sort === c.key ? (descending ? 'descending' : 'ascending') : 'none'"
|
||||||
|
@click="cycle(c.key)"
|
||||||
|
>
|
||||||
|
{{ c.label }}<i class="arrow">{{ arrow(c.key) }}</i>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
|
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
|
||||||
@@ -157,6 +207,37 @@ function tail(s: string, max = 64) {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Headers are buttons but must keep the exact column geometry of the rows
|
||||||
|
below them, so they take the same .col-* classes and add nothing but the
|
||||||
|
affordance. */
|
||||||
|
.head .sorter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.head .sorter:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.head .sorter.on {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.head .sorter.col-off {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -43,8 +43,25 @@ async function toggle(node: Node) {
|
|||||||
node.open = true
|
node.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordering is applied at render rather than at fetch, so flipping it costs a
|
||||||
|
* re-sort of the nodes already on screen and never re-walks the store. The
|
||||||
|
* store hands back count order; `localeCompare` with numeric collation is
|
||||||
|
* what makes `Item_2` precede `Item_10` in the alphabetical mode.
|
||||||
|
*/
|
||||||
|
const order = ref<'count' | 'alpha'>('count')
|
||||||
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
|
||||||
|
function arrange(nodes: Node[]): Node[] {
|
||||||
|
const by =
|
||||||
|
order.value === 'alpha'
|
||||||
|
? (a: Node, b: Node) => collator.compare(a.segment, b.segment)
|
||||||
|
: (a: Node, b: Node) => b.count - a.count || collator.compare(a.segment, b.segment)
|
||||||
|
return nodes.slice().sort(by)
|
||||||
|
}
|
||||||
|
|
||||||
function flatten(nodes: Node[], out: Node[] = []): Node[] {
|
function flatten(nodes: Node[], out: Node[] = []): Node[] {
|
||||||
for (const n of nodes) {
|
for (const n of arrange(nodes)) {
|
||||||
out.push(n)
|
out.push(n)
|
||||||
if (n.open && n.children) flatten(n.children, out)
|
if (n.open && n.children) flatten(n.children, out)
|
||||||
}
|
}
|
||||||
@@ -60,8 +77,26 @@ watch(() => props.version, loadRoots)
|
|||||||
<nav class="spine">
|
<nav class="spine">
|
||||||
<div class="spine-head">
|
<div class="spine-head">
|
||||||
<span class="eyebrow">Script path</span>
|
<span class="eyebrow">Script path</span>
|
||||||
|
<div class="head-tools">
|
||||||
|
<div class="order" role="group" aria-label="Sort tree">
|
||||||
|
<button
|
||||||
|
:class="{ on: order === 'count' }"
|
||||||
|
title="Sort by number of objects"
|
||||||
|
@click="order = 'count'"
|
||||||
|
>
|
||||||
|
count
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ on: order === 'alpha' }"
|
||||||
|
title="Sort alphabetically"
|
||||||
|
@click="order = 'alpha'"
|
||||||
|
>
|
||||||
|
a–z
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
|
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<button
|
<button
|
||||||
@@ -118,6 +153,12 @@ watch(() => props.version, loadRoots)
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.clear {
|
.clear {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -125,6 +166,33 @@ watch(() => props.version, loadRoots)
|
|||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.order button {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.order button + button {
|
||||||
|
border-left: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.order button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.order button.on {
|
||||||
|
color: var(--ink-strong);
|
||||||
|
background: var(--raise);
|
||||||
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 6px 0 20px;
|
padding: 6px 0 20px;
|
||||||
|
|||||||
@@ -59,30 +59,43 @@ function call<T = any>(op: string, payload: Record<string, unknown> = {}, transf
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDump() {
|
export function useDump() {
|
||||||
async function loadFile(file: File) {
|
/**
|
||||||
|
* @param buffer is transferred, not copied: a 60 MB structured clone is a
|
||||||
|
* visible stall. The caller must not touch it afterwards.
|
||||||
|
*/
|
||||||
|
async function loadBuffer(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
name: string,
|
||||||
|
opts: { format?: 'text' | 'pdx'; source?: string } = {},
|
||||||
|
): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
try {
|
try {
|
||||||
const buffer = await file.arrayBuffer()
|
applyMeta(await call('parse', { buffer, name, ...opts }, [buffer]))
|
||||||
// Transferred, not copied: a 60 MB structured clone is a visible stall.
|
return true
|
||||||
const r = await call('parse', { buffer, name: file.name }, [buffer])
|
|
||||||
applyMeta(r)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message
|
error.value = (e as Error).message
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(key: string) {
|
const loadFile = async (file: File) => loadBuffer(await file.arrayBuffer(), file.name)
|
||||||
|
|
||||||
|
/** Resolves false when the key is not in the cache, which the URL restore
|
||||||
|
* path treats as "link received without the dump" rather than an error. */
|
||||||
|
async function restore(key: string): Promise<boolean> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
applyMeta(await call('restore', { key }))
|
applyMeta(await call('restore', { key }))
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message
|
error.value = (e as Error).message
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -140,6 +153,7 @@ export function useDump() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loadFile,
|
loadFile,
|
||||||
|
loadBuffer,
|
||||||
restore,
|
restore,
|
||||||
reset,
|
reset,
|
||||||
runQuery,
|
runQuery,
|
||||||
|
|||||||
64
app/composables/useFrames.ts
Normal file
64
app/composables/useFrames.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { ref, computed, shallowRef } from 'vue'
|
||||||
|
import { emptyView, sameView, type ViewState } from '../lib/view-state'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter history: a queue of frames plus a cursor into it.
|
||||||
|
*
|
||||||
|
* This is deliberately the app's own history rather than the browser's.
|
||||||
|
* Browser history cannot be truncated — pushing after going back leaves the
|
||||||
|
* forward entries reachable by gesture — and it is shared with whatever the
|
||||||
|
* user did before arriving here, so "back" would eventually walk off the
|
||||||
|
* site mid-investigation. Owning the queue means the URL can be rewritten in
|
||||||
|
* place (replaceState) and still be exactly as linkable.
|
||||||
|
*/
|
||||||
|
export function useFrames() {
|
||||||
|
const frames = shallowRef<ViewState[]>([emptyView()])
|
||||||
|
const index = ref(0)
|
||||||
|
|
||||||
|
const current = computed(() => frames.value[index.value] ?? emptyView())
|
||||||
|
const canBack = computed(() => index.value > 0)
|
||||||
|
const canForward = computed(() => index.value < frames.value.length - 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a frame. A change made from anywhere but the newest frame discards
|
||||||
|
* everything after the cursor first: the user has branched, and the frames
|
||||||
|
* they walked back past are no longer reachable from where they now are.
|
||||||
|
*
|
||||||
|
* The equality check is what makes replay safe. Stepping back sets the
|
||||||
|
* filter refs to the frame at the cursor, which re-triggers the watchers
|
||||||
|
* that call this — and a frame identical to the current one is dropped, so
|
||||||
|
* no flag is needed to tell a replay from a real change.
|
||||||
|
*/
|
||||||
|
function push(v: ViewState) {
|
||||||
|
if (sameView(v, current.value)) return
|
||||||
|
const kept = frames.value.slice(0, index.value + 1)
|
||||||
|
kept.push(v)
|
||||||
|
frames.value = kept
|
||||||
|
index.value = kept.length - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the whole queue — used when a different dump is opened. */
|
||||||
|
function reset(v: ViewState = emptyView()) {
|
||||||
|
frames.value = [v]
|
||||||
|
index.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = (delta: number) => {
|
||||||
|
const next = index.value + delta
|
||||||
|
if (next < 0 || next >= frames.value.length) return null
|
||||||
|
index.value = next
|
||||||
|
return frames.value[next]!
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
frames,
|
||||||
|
index,
|
||||||
|
current,
|
||||||
|
canBack,
|
||||||
|
canForward,
|
||||||
|
push,
|
||||||
|
reset,
|
||||||
|
back: () => go(-1),
|
||||||
|
forward: () => go(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ const COLON = 0x3a
|
|||||||
const DOT = 0x2e
|
const DOT = 0x2e
|
||||||
const SLASH = 0x2f
|
const SLASH = 0x2f
|
||||||
|
|
||||||
|
/** `natural` is dump order — the order the lines appeared in the file. */
|
||||||
|
export type SortKey = 'natural' | 'path' | 'address' | 'offset' | 'name' | 'type'
|
||||||
|
|
||||||
export interface Query {
|
export interface Query {
|
||||||
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
||||||
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
||||||
nameContains?: string // Case-insensitive substring on the leaf name.
|
nameContains?: string // Case-insensitive substring on the leaf name.
|
||||||
pathContains?: string // Case-insensitive substring on the whole path.
|
pathContains?: string // Case-insensitive substring on the whole path.
|
||||||
sort?: 'path' | 'address' | 'offset' | 'natural'
|
sort?: SortKey
|
||||||
descending?: boolean
|
descending?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +174,11 @@ export class DumpStore {
|
|||||||
const { addr, offset } = this.cols
|
const { addr, offset } = this.cols
|
||||||
|
|
||||||
let cmp: (a: number, b: number) => number
|
let cmp: (a: number, b: number) => number
|
||||||
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]!
|
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]! || a - b
|
||||||
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]!
|
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]! || a - b
|
||||||
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
|
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
|
||||||
|
else if (mode === 'name') cmp = (a, b) => this.cmpName(a, b) || this.cmpPath(a, b)
|
||||||
|
else if (mode === 'type') cmp = (a, b) => this.cmpType(a, b) || this.cmpPath(a, b)
|
||||||
else cmp = (a, b) => a - b
|
else cmp = (a, b) => a - b
|
||||||
|
|
||||||
const sorted = rows.slice().sort(cmp)
|
const sorted = rows.slice().sort(cmp)
|
||||||
@@ -195,6 +200,44 @@ export class DumpStore {
|
|||||||
return ae - i - (be - j)
|
return ae - i - (be - j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-folded byte order over the leaf name. Folding matters here in a way
|
||||||
|
* it does not for `cmpPath`: names are the column people read, and raw byte
|
||||||
|
* order files every lowercase name after every uppercase one.
|
||||||
|
*/
|
||||||
|
private cmpName(a: number, b: number): number {
|
||||||
|
const { pathStart, pathBlob, nameOff } = this.cols
|
||||||
|
let i = pathStart[a]! + nameOff[a]!
|
||||||
|
let j = pathStart[b]! + nameOff[b]!
|
||||||
|
const ae = pathStart[a + 1]!
|
||||||
|
const be = pathStart[b + 1]!
|
||||||
|
while (i < ae && j < be) {
|
||||||
|
const d = fold(pathBlob[i++]!) - fold(pathBlob[j++]!)
|
||||||
|
if (d) return d
|
||||||
|
}
|
||||||
|
return ae - i - (be - j)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type ids are assigned in first-seen order, so they say nothing about
|
||||||
|
* alphabetical order. Rank them once — there are a few hundred types
|
||||||
|
* against up to a million rows, so this turns every row comparison into
|
||||||
|
* one array lookup.
|
||||||
|
*/
|
||||||
|
private typeRank: Int32Array | null = null
|
||||||
|
private cmpType(a: number, b: number): number {
|
||||||
|
if (!this.typeRank) {
|
||||||
|
const { types } = this.cols
|
||||||
|
const order = types.map((_, i) => i)
|
||||||
|
order.sort((x, y) => types[x]!.localeCompare(types[y]!))
|
||||||
|
const rank = new Int32Array(types.length)
|
||||||
|
order.forEach((id, r) => (rank[id] = r))
|
||||||
|
this.typeRank = rank
|
||||||
|
}
|
||||||
|
const { typeId } = this.cols
|
||||||
|
return this.typeRank[typeId[a]!]! - this.typeRank[typeId[b]!]!
|
||||||
|
}
|
||||||
|
|
||||||
// tree
|
// tree
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
70
app/lib/hosted.ts
Normal file
70
app/lib/hosted.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Dumps the site ships with.
|
||||||
|
*
|
||||||
|
* These are never fetched on load. A prebaked dump is tens of megabytes, and
|
||||||
|
* most visitors arrive with their own file or with one already in IndexedDB;
|
||||||
|
* spending their bandwidth on a speculative download would be rude. The
|
||||||
|
* manifest is a few hundred bytes and is all we read until someone asks for
|
||||||
|
* a dump by name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HostedDump {
|
||||||
|
/** Stable id, independent of the file name. Recorded on the cached copy so
|
||||||
|
* a downloaded dump can be struck from the offer list. */
|
||||||
|
id: string
|
||||||
|
/** Path under the site root. */
|
||||||
|
file: string
|
||||||
|
title: string
|
||||||
|
note?: string
|
||||||
|
/** Bytes on the wire, for the "how big is this" question. Optional. */
|
||||||
|
bytes?: number
|
||||||
|
/** 'pdx' is a prebaked column bundle; 'text' is a raw UE4SS dump. */
|
||||||
|
format?: 'text' | 'pdx'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MANIFEST = '/dumps.json'
|
||||||
|
|
||||||
|
export async function listHosted(): Promise<HostedDump[]> {
|
||||||
|
const res = await fetch(MANIFEST, { cache: 'no-cache' })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const body = await res.json()
|
||||||
|
return Array.isArray(body) ? body : (body.dumps ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with byte progress. `Content-Length` is absent under chunked transfer
|
||||||
|
* encoding, which nginx will use for these once gzip is on, so the caller has
|
||||||
|
* to tolerate a null fraction rather than a bogus one.
|
||||||
|
*/
|
||||||
|
export async function download(
|
||||||
|
d: HostedDump,
|
||||||
|
onProgress?: (fraction: number | null, received: number) => void,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const res = await fetch(d.file)
|
||||||
|
if (!res.ok) throw new Error(`Could not fetch ${d.file} (${res.status})`)
|
||||||
|
|
||||||
|
const declared = Number(res.headers.get('content-length')) || d.bytes || 0
|
||||||
|
if (!res.body) return res.arrayBuffer()
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let received = 0
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
chunks.push(value)
|
||||||
|
received += value.length
|
||||||
|
onProgress?.(declared ? Math.min(1, received / declared) : null, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Uint8Array(received)
|
||||||
|
let at = 0
|
||||||
|
for (const c of chunks) {
|
||||||
|
out.set(c, at)
|
||||||
|
at += c.length
|
||||||
|
}
|
||||||
|
return out.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File name to record on the cached copy, so titles read like a real file. */
|
||||||
|
export const fileNameOf = (d: HostedDump) => d.file.split('/').pop() || d.id
|
||||||
@@ -5,8 +5,14 @@ const STORE = 'dumps'
|
|||||||
const VERSION = 1
|
const VERSION = 1
|
||||||
|
|
||||||
export interface CachedDump {
|
export interface CachedDump {
|
||||||
|
/** Content hash. Stable across machines, so it is safe to put in a URL. */
|
||||||
key: string
|
key: string
|
||||||
|
/** Display title: the original file name plus the moment it was parsed. */
|
||||||
label: string
|
label: string
|
||||||
|
/** File name as it arrived, without the datetime suffix. */
|
||||||
|
name: string
|
||||||
|
/** Manifest id when this came from the site's own dump list, else absent. */
|
||||||
|
source?: string
|
||||||
bytes: number
|
bytes: number
|
||||||
parsedAt: number
|
parsedAt: number
|
||||||
cols: DumpColumns
|
cols: DumpColumns
|
||||||
@@ -43,16 +49,66 @@ function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const PRIME = 0x01000193
|
||||||
* Identity for a dump. Size plus a sparse byte sample: reading 60 MB to hash it properly costs more than re-parsing, and two dumps that agree on size and 64 spread samples are the same dump for our purposes.
|
|
||||||
*/
|
/** Final avalanche, so that near-identical lane states diverge in the output. */
|
||||||
export function fingerprint(name: string, buf: Uint8Array): string {
|
function mix(h: number): number {
|
||||||
let h = 0x811c9dc5
|
h ^= h >>> 16
|
||||||
const step = Math.max(1, Math.floor(buf.length / 64))
|
h = Math.imul(h, 0x85ebca6b)
|
||||||
for (let i = 0; i < buf.length; i += step) h = Math.imul(h ^ buf[i], 0x01000193)
|
h ^= h >>> 13
|
||||||
return `${name}:${buf.length}:${(h >>> 0).toString(16)}`
|
h = Math.imul(h, 0xc2b2ae35)
|
||||||
|
return (h ^ (h >>> 16)) >>> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity for a dump: 64 bits of FNV-1a over every byte of the file, and
|
||||||
|
* nothing else.
|
||||||
|
*
|
||||||
|
* Content-only is the whole point — this hash goes in the URL, so two people
|
||||||
|
* holding the same file must derive the same id from it. That rules out the
|
||||||
|
* file name (people rename dumps) and the parse time. It also rules out the
|
||||||
|
* sparse sampling this used to do: a shared link is only as trustworthy as
|
||||||
|
* the odds that two different dumps collide, and 64 sampled bytes out of
|
||||||
|
* 60 MB is not a bet worth taking when the dumps being compared are near
|
||||||
|
* identical by construction (same game, one patch apart).
|
||||||
|
*
|
||||||
|
* Four interleaved lanes rather than one, because a single-lane FNV over
|
||||||
|
* 60 MB is a serial dependency chain; the lanes let the CPU overlap the
|
||||||
|
* multiplies. Costs ~100 ms on a 60 MB dump, against ~1 s to parse it.
|
||||||
|
*/
|
||||||
|
export function contentHash(buf: Uint8Array): string {
|
||||||
|
let a = 0x811c9dc5
|
||||||
|
let b = 0x9e3779b9
|
||||||
|
let c = 0x85ebca6b
|
||||||
|
let d = 0xc2b2ae35
|
||||||
|
|
||||||
|
const n = buf.length
|
||||||
|
const quads = n - (n % 4)
|
||||||
|
let i = 0
|
||||||
|
for (; i < quads; i += 4) {
|
||||||
|
a = Math.imul(a ^ buf[i]!, PRIME)
|
||||||
|
b = Math.imul(b ^ buf[i + 1]!, PRIME)
|
||||||
|
c = Math.imul(c ^ buf[i + 2]!, PRIME)
|
||||||
|
d = Math.imul(d ^ buf[i + 3]!, PRIME)
|
||||||
|
}
|
||||||
|
for (; i < n; i++) a = Math.imul(a ^ buf[i]!, PRIME)
|
||||||
|
|
||||||
|
// Length participates too, so that a truncated file cannot land on the
|
||||||
|
// same lanes as the whole one.
|
||||||
|
const hi = mix(a ^ Math.imul(b, PRIME) ^ n)
|
||||||
|
const lo = mix(c ^ Math.imul(d, PRIME) ^ n)
|
||||||
|
return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = (t: number) => {
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const d = new Date(t)
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titles carry the datetime so two loads of the same file stay tellable apart. */
|
||||||
|
export const titleFor = (name: string, parsedAt: number) => `${name} · ${stamp(parsedAt)}`
|
||||||
|
|
||||||
/** Typed arrays survive structured clone, so there is no serialisation step. */
|
/** Typed arrays survive structured clone, so there is no serialisation step. */
|
||||||
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d))
|
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d))
|
||||||
export const loadDump = (key: string) => tx<CachedDump | undefined>('readonly', (s) => s.get(key))
|
export const loadDump = (key: string) => tx<CachedDump | undefined>('readonly', (s) => s.get(key))
|
||||||
|
|||||||
77
app/lib/view-state.ts
Normal file
77
app/lib/view-state.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { SortKey } from './dump-store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about "what am I looking at" that is worth putting in a link.
|
||||||
|
*
|
||||||
|
* Types are carried as names rather than as the numeric ids the store uses.
|
||||||
|
* Ids are assigned in first-seen order, so they are stable for a given dump —
|
||||||
|
* but only for that dump, and a link that silently means something else
|
||||||
|
* against a different dump is worse than one that drops a filter it cannot
|
||||||
|
* resolve.
|
||||||
|
*/
|
||||||
|
export interface ViewState {
|
||||||
|
prefix: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
sort: SortKey
|
||||||
|
desc: boolean
|
||||||
|
types: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyView = (): ViewState => ({
|
||||||
|
prefix: '',
|
||||||
|
name: '',
|
||||||
|
path: '',
|
||||||
|
sort: 'natural',
|
||||||
|
desc: false,
|
||||||
|
types: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const SORTS: SortKey[] = ['natural', 'path', 'address', 'offset', 'name', 'type']
|
||||||
|
|
||||||
|
export function sameView(a: ViewState, b: ViewState): boolean {
|
||||||
|
return (
|
||||||
|
a.prefix === b.prefix &&
|
||||||
|
a.name === b.name &&
|
||||||
|
a.path === b.path &&
|
||||||
|
a.sort === b.sort &&
|
||||||
|
a.desc === b.desc &&
|
||||||
|
a.types.length === b.types.length &&
|
||||||
|
a.types.every((t, i) => t === b.types[i])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only non-default fields are written, so the common case — a dump open with
|
||||||
|
* no filters — is a URL you can read aloud.
|
||||||
|
*/
|
||||||
|
export function encodeView(dump: string, v: ViewState): string {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (dump) q.set('d', dump)
|
||||||
|
if (v.prefix) q.set('p', v.prefix)
|
||||||
|
if (v.name) q.set('n', v.name)
|
||||||
|
if (v.path) q.set('q', v.path)
|
||||||
|
if (v.sort !== 'natural') q.set('s', v.sort)
|
||||||
|
if (v.desc) q.set('o', 'desc')
|
||||||
|
if (v.types.length) q.set('t', v.types.join(','))
|
||||||
|
const s = q.toString()
|
||||||
|
return s ? '?' + s : location.pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeView(search: string): { dump: string; view: ViewState } {
|
||||||
|
const q = new URLSearchParams(search)
|
||||||
|
const sort = q.get('s') as SortKey | null
|
||||||
|
return {
|
||||||
|
dump: q.get('d') ?? '',
|
||||||
|
view: {
|
||||||
|
prefix: q.get('p') ?? '',
|
||||||
|
name: q.get('n') ?? '',
|
||||||
|
path: q.get('q') ?? '',
|
||||||
|
sort: sort && SORTS.includes(sort) ? sort : 'natural',
|
||||||
|
desc: q.get('o') === 'desc',
|
||||||
|
// Sorted on the way in as well as out: `sameView` compares positionally,
|
||||||
|
// and a hand-edited URL should not read as a different view.
|
||||||
|
types: (q.get('t') ?? '').split(',').filter(Boolean).sort(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { parseDump } from '../lib/dump-parse'
|
import { parseDump } from '../lib/dump-parse'
|
||||||
|
import { decodeBundle } from '../lib/bundle'
|
||||||
import { DumpStore, type Query } from '../lib/dump-store'
|
import { DumpStore, type Query } from '../lib/dump-store'
|
||||||
import { fingerprint, saveDump, loadDump } from '../lib/persist'
|
import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The store never crosses to the main thread. The UI asks for a window of
|
* The store never crosses to the main thread. The UI asks for a window of
|
||||||
@@ -11,7 +12,16 @@ let store: DumpStore | null = null
|
|||||||
let current: Uint32Array = new Uint32Array(0)
|
let current: Uint32Array = new Uint32Array(0)
|
||||||
|
|
||||||
type Req =
|
type Req =
|
||||||
| { id: number; op: 'parse'; buffer: ArrayBuffer; name: string }
|
| {
|
||||||
|
id: number
|
||||||
|
op: 'parse'
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
name: string
|
||||||
|
/** 'pdx' skips the parser: the file is already a serialised column set. */
|
||||||
|
format?: 'text' | 'pdx'
|
||||||
|
/** Manifest id, when this arrived from the site's own dump list. */
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
| { id: number; op: 'restore'; key: string }
|
| { id: number; op: 'restore'; key: string }
|
||||||
| { id: number; op: 'query'; query: Query }
|
| { id: number; op: 'query'; query: Query }
|
||||||
| { id: number; op: 'window'; start: number; end: number }
|
| { id: number; op: 'window'; start: number; end: number }
|
||||||
@@ -43,26 +53,35 @@ async function handle(msg: Req) {
|
|||||||
switch (msg.op) {
|
switch (msg.op) {
|
||||||
case 'parse': {
|
case 'parse': {
|
||||||
const src = new Uint8Array(msg.buffer)
|
const src = new Uint8Array(msg.buffer)
|
||||||
const key = fingerprint(msg.name, src)
|
// Hashed before anything else: the id is the file's content, so it is
|
||||||
|
// known even for a dump we turn out to already hold.
|
||||||
|
const key = contentHash(src)
|
||||||
const cached = await loadDump(key).catch(() => undefined)
|
const cached = await loadDump(key).catch(() => undefined)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
store = new DumpStore(cached.cols)
|
store = new DumpStore(cached.cols)
|
||||||
return { ...summary(), key, label: cached.label, cached: true }
|
return { ...summary(), key, label: cached.label, cached: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
const cols = parseDump(src, (f) =>
|
const cols =
|
||||||
self.postMessage({ id: -1, event: 'progress', fraction: f }),
|
msg.format === 'pdx'
|
||||||
)
|
? decodeBundle(msg.buffer)
|
||||||
|
: parseDump(src, (f) => self.postMessage({ id: -1, event: 'progress', fraction: f }))
|
||||||
const ms = performance.now() - t0
|
const ms = performance.now() - t0
|
||||||
|
|
||||||
store = new DumpStore(cols)
|
store = new DumpStore(cols)
|
||||||
|
const parsedAt = Date.now()
|
||||||
|
const label = titleFor(msg.name, parsedAt)
|
||||||
await saveDump({
|
await saveDump({
|
||||||
key,
|
key,
|
||||||
label: msg.name,
|
label,
|
||||||
|
name: msg.name,
|
||||||
|
source: msg.source,
|
||||||
bytes: src.length,
|
bytes: src.length,
|
||||||
parsedAt: Date.now(),
|
parsedAt,
|
||||||
cols,
|
cols,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
return { ...summary(), key, label: msg.name, cached: false, parseMs: ms }
|
return { ...summary(), key, label, cached: false, parseMs: ms }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'restore': {
|
case 'restore': {
|
||||||
|
|||||||
Binary file not shown.
12
public/dumps.json
Normal file
12
public/dumps.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"dumps": [
|
||||||
|
{
|
||||||
|
"id": "palworld-2026-08-07",
|
||||||
|
"file": "/PalDump_08_07_26.pdx",
|
||||||
|
"title": "Palworld · 2026-08-07",
|
||||||
|
"note": "Prebaked object dump. Loads without parsing.",
|
||||||
|
"bytes": 40128248,
|
||||||
|
"format": "pdx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user