Added selective dump features
This commit is contained in:
117
app/app.vue
117
app/app.vue
@@ -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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import type { TreeChild } from '../lib/dump-store'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -7,18 +7,21 @@ const props = defineProps<{
|
||||
fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }>
|
||||
version: number
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string): void
|
||||
(e: 'dump', path: string): void
|
||||
}>()
|
||||
|
||||
interface Node extends TreeChild {
|
||||
interface TreeNode extends TreeChild {
|
||||
depth: number
|
||||
open: boolean
|
||||
children: Node[] | null
|
||||
children: TreeNode[] | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const roots = ref<Node[]>([])
|
||||
const roots = ref<TreeNode[]>([])
|
||||
|
||||
function toNode(c: TreeChild, depth: number): Node {
|
||||
function toNode(c: TreeChild, depth: number): TreeNode {
|
||||
return { ...c, depth, open: false, children: null, loading: false }
|
||||
}
|
||||
|
||||
@@ -29,7 +32,7 @@ async function loadRoots() {
|
||||
if (roots.value.length === 1 && roots.value[0]) toggle(roots.value[0])
|
||||
}
|
||||
|
||||
async function toggle(node: Node) {
|
||||
async function toggle(node: TreeNode) {
|
||||
if (node.open) {
|
||||
node.open = false
|
||||
return
|
||||
@@ -43,22 +46,18 @@ async function toggle(node: Node) {
|
||||
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[] {
|
||||
function arrange(nodes: TreeNode[]): TreeNode[] {
|
||||
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)
|
||||
? (a: TreeNode, b: TreeNode) => collator.compare(a.segment, b.segment)
|
||||
: (a: TreeNode, b: TreeNode) => 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: TreeNode[], out: TreeNode[] = []): TreeNode[] {
|
||||
for (const n of arrange(nodes)) {
|
||||
out.push(n)
|
||||
if (n.open && n.children) flatten(n.children, out)
|
||||
@@ -66,8 +65,66 @@ function flatten(nodes: Node[], out: Node[] = []): Node[] {
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ context menu
|
||||
|
||||
const menu = ref<{ x: number; y: number; path: string; label: string } | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
|
||||
let armed = false
|
||||
|
||||
function arm() {
|
||||
if (armed) return
|
||||
armed = true
|
||||
window.addEventListener('pointerdown', onOutside)
|
||||
window.addEventListener('keydown', onKey)
|
||||
window.addEventListener('resize', closeMenu)
|
||||
// Capture, because the scroll that matters happens in .scroll, not on window.
|
||||
window.addEventListener('scroll', closeMenu, true)
|
||||
}
|
||||
|
||||
function disarm() {
|
||||
if (!armed) return
|
||||
armed = false
|
||||
window.removeEventListener('pointerdown', onOutside)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('resize', closeMenu)
|
||||
window.removeEventListener('scroll', closeMenu, true)
|
||||
}
|
||||
|
||||
function onOutside(e: PointerEvent) {
|
||||
if (menuEl.value?.contains(e.target as Node)) return
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeMenu()
|
||||
}
|
||||
|
||||
function openMenu(e: MouseEvent, node: TreeNode) {
|
||||
// Clamped so a right-click near the bottom or right edge does not open the
|
||||
// menu off-screen; the sizes are the menu's own, which is fixed content.
|
||||
menu.value = {
|
||||
x: Math.min(e.clientX, window.innerWidth - 240),
|
||||
y: Math.min(e.clientY, window.innerHeight - 60),
|
||||
path: node.path,
|
||||
label: node.segment,
|
||||
}
|
||||
requestAnimationFrame(arm)
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menu.value = null
|
||||
disarm()
|
||||
}
|
||||
|
||||
function dumpFromMenu() {
|
||||
if (menu.value) emit('dump', menu.value.path)
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const fmt = new Intl.NumberFormat()
|
||||
onMounted(loadRoots)
|
||||
onBeforeUnmount(disarm)
|
||||
watch(() => props.version, loadRoots)
|
||||
</script>
|
||||
|
||||
@@ -109,8 +166,9 @@ watch(() => props.version, loadRoots)
|
||||
v-for="node in flatten(roots)"
|
||||
:key="node.path"
|
||||
class="node mono"
|
||||
:class="{ active: modelValue === node.path }"
|
||||
:class="{ active: modelValue === node.path, aimed: menu?.path === node.path }"
|
||||
:style="{ paddingLeft: 8 + node.depth * 13 + 'px' }"
|
||||
@contextmenu.prevent="openMenu($event, node)"
|
||||
>
|
||||
<button
|
||||
class="twist"
|
||||
@@ -128,6 +186,21 @@ watch(() => props.version, loadRoots)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teleported out of the .spine, which scrolls and clips. -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menu"
|
||||
ref="menuEl"
|
||||
class="menu mono"
|
||||
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button @click="dumpFromMenu">
|
||||
Dump tree under <b>{{ menu.label }}</b>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -214,6 +287,11 @@ watch(() => props.version, loadRoots)
|
||||
background: var(--amber-soft);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
/* Keeps the right-clicked row identifiable while the menu is over it. */
|
||||
.node.aimed {
|
||||
background: var(--raise);
|
||||
box-shadow: inset 2px 0 0 var(--amber);
|
||||
}
|
||||
.node.root {
|
||||
padding-left: 21px;
|
||||
color: var(--dim);
|
||||
@@ -268,6 +346,42 @@ watch(() => props.version, loadRoots)
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Teleported to <body>, but Vue still stamps it with this component's scope
|
||||
id, so these rules reach it. */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 210px;
|
||||
padding: 4px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 10px 28px rgb(0 0 0 / 45%);
|
||||
}
|
||||
.menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--ink);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.menu button b {
|
||||
font-weight: 400;
|
||||
color: var(--amber);
|
||||
}
|
||||
.menu button:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.spine {
|
||||
width: 100%;
|
||||
|
||||
247
app/components/ReportModal.vue
Normal file
247
app/components/ReportModal.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
root: string
|
||||
text: string
|
||||
objects: number
|
||||
truncated: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const PREVIEW_LINES = 400
|
||||
|
||||
const lines = computed(() => (props.text ? props.text.split('\n') : []))
|
||||
const preview = computed(() => lines.value.slice(0, PREVIEW_LINES).join('\n'))
|
||||
const hidden = computed(() => Math.max(0, lines.value.length - PREVIEW_LINES))
|
||||
|
||||
/** Last path segment, which is what names the file usefully. */
|
||||
const slug = computed(() => {
|
||||
const seg = props.root.split(/[/.:]/).filter(Boolean).pop() || 'objects'
|
||||
return seg.replace(/[^\w.-]+/g, '_').slice(0, 60)
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1400)
|
||||
} catch {
|
||||
/* clipboard blocked; the download still works */
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
const blob = new Blob([props.text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${slug.value}.dump.txt`
|
||||
a.click()
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
/** Escape has to come off the window: the scrim is a plain div, so it never
|
||||
* takes focus and never sees a key event of its own. */
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
copied.value = false
|
||||
window.addEventListener('keydown', onKey)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
const fmt = new Intl.NumberFormat()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="scrim" @click.self="emit('close')">
|
||||
<section class="sheet">
|
||||
<header>
|
||||
<div class="what">
|
||||
<p class="eyebrow">Subtree dump</p>
|
||||
<p class="root mono" :title="root">{{ root || 'everything' }}</p>
|
||||
</div>
|
||||
<button class="x" aria-label="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div class="meta mono">
|
||||
<template v-if="loading">Walking the subtree…</template>
|
||||
<span v-else-if="error" class="bad">{{ error }}</span>
|
||||
<template v-else>
|
||||
<span>{{ fmt.format(objects) }} objects</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ fmt.format(lines.length) }} lines</span>
|
||||
<span v-if="truncated" class="sep">·</span>
|
||||
<span v-if="truncated" class="warn">capped</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<pre v-if="!loading && !error" class="body mono">{{ preview }}</pre>
|
||||
<p v-if="hidden" class="more mono">
|
||||
… {{ fmt.format(hidden) }} more lines. Download or copy for the whole report.
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
<button class="act" :disabled="loading || !!error" @click="copy">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<button class="act primary" :disabled="loading || !!error" @click="save">
|
||||
Download .txt
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in srgb, var(--void) 78%, transparent);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(920px, 100%);
|
||||
max-height: 100%;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
.what {
|
||||
min-width: 0;
|
||||
}
|
||||
.root {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ink-strong);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.x {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
color: var(--dim);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
color: var(--t-bool);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
flex: none;
|
||||
}
|
||||
.sep {
|
||||
color: var(--dimmer);
|
||||
}
|
||||
.warn {
|
||||
color: var(--amber);
|
||||
}
|
||||
.bad {
|
||||
color: var(--t-bool);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
background: var(--void);
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.more {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
padding: 7px 14px;
|
||||
font-size: 10px;
|
||||
color: var(--dimmer);
|
||||
background: var(--void);
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
.act {
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
background: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.act:hover:not(:disabled) {
|
||||
color: var(--ink);
|
||||
border-color: var(--dim);
|
||||
}
|
||||
.act.primary {
|
||||
color: var(--amber);
|
||||
}
|
||||
.act.primary:hover:not(:disabled) {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.act:disabled {
|
||||
color: var(--dimmer);
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -151,6 +151,9 @@ export function useDump() {
|
||||
const resolveAddress = (address: number) =>
|
||||
call<{ row: RowView | null }>('resolve', { address })
|
||||
|
||||
const buildReport = (prefix: string) =>
|
||||
call<{ text: string; objects: number; truncated: boolean }>('report', { prefix })
|
||||
|
||||
return {
|
||||
loadFile,
|
||||
loadBuffer,
|
||||
@@ -160,6 +163,7 @@ export function useDump() {
|
||||
fetchWindow,
|
||||
fetchChildren,
|
||||
resolveAddress,
|
||||
buildReport,
|
||||
loading,
|
||||
progress,
|
||||
error,
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
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 const FRAME_LIMIT = 50
|
||||
|
||||
export function useFrames() {
|
||||
const frames = shallowRef<ViewState[]>([emptyView()])
|
||||
const index = ref(0)
|
||||
@@ -19,25 +11,20 @@ export function useFrames() {
|
||||
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
|
||||
|
||||
frames.value = kept.length > FRAME_LIMIT ? kept.slice(kept.length - FRAME_LIMIT) : kept
|
||||
index.value = frames.value.length - 1
|
||||
}
|
||||
|
||||
function clear() {
|
||||
frames.value = [current.value]
|
||||
index.value = 0
|
||||
}
|
||||
|
||||
/** Drop the whole queue — used when a different dump is opened. */
|
||||
function reset(v: ViewState = emptyView()) {
|
||||
frames.value = [v]
|
||||
index.value = 0
|
||||
@@ -57,6 +44,7 @@ export function useFrames() {
|
||||
canBack,
|
||||
canForward,
|
||||
push,
|
||||
clear,
|
||||
reset,
|
||||
back: () => go(-1),
|
||||
forward: () => go(1),
|
||||
|
||||
@@ -12,17 +12,13 @@
|
||||
* next line, leaving one line with no trailing groups and
|
||||
* one with no `[ADDR]` prefix.
|
||||
*
|
||||
* Two things make shape 1 harder than it looks:
|
||||
* Notes:
|
||||
*
|
||||
* - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrame`,
|
||||
* `Default__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the
|
||||
* path is not "up to the first space"; the trailing `[k: v]` groups have
|
||||
* to be peeled off the RIGHT and the path is whatever is left.
|
||||
* - Enum constants have no path at all, so a left-to-right scan reads the
|
||||
* first group as the path and files 15k objects under a bogus `[n` root.
|
||||
* - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrameDefault__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the
|
||||
* path is not "up to the first space"; the trailing `[k: v]` groups have to be peeled off the RIGHT and the path is whatever is left.
|
||||
* - Enum constants have no path at all, so a left-to-right scan reads the first group as the path and files 15k objects under a bogus `[n` root.
|
||||
*
|
||||
* Peeling from the right is only safe because no object path in the dump
|
||||
* contains `[` or `]`; that is asserted by treating a leftover bracket in the
|
||||
* Peeling from the right is only safe because no object path in the dump contains `[` or `]`; that is asserted by treating a leftover bracket in the
|
||||
* path region as damage rather than as text.
|
||||
*/
|
||||
|
||||
@@ -56,10 +52,10 @@ export interface DumpColumns {
|
||||
*
|
||||
* rootOff:
|
||||
*
|
||||
* Byte offset at which the real `/...` path begins. Array inner properties
|
||||
* are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`;
|
||||
* that leading qualifier is kept for display but skipped when indexing, so
|
||||
* the inner property files under its array rather than at the tree root.
|
||||
* Byte offset at which the real `/...` path begins.
|
||||
*
|
||||
* Array inner properties are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`;
|
||||
* that leading qualifier is kept for display but skipped when indexing, so the inner property files under its array rather than at the tree root.
|
||||
*/
|
||||
rootOff: Uint16Array
|
||||
outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none.
|
||||
|
||||
@@ -35,7 +35,7 @@ function encodeLower(s: string): Uint8Array {
|
||||
export class DumpStore {
|
||||
readonly cols: DumpColumns
|
||||
private decoder = new TextDecoder()
|
||||
/** Row indices ordered by path bytes. Built lazily; powers the tree. */
|
||||
/** Row indices ordered by path bytes. */
|
||||
private byPath: Uint32Array | null = null
|
||||
|
||||
constructor(cols: DumpColumns) {
|
||||
@@ -51,11 +51,7 @@ export class DumpStore {
|
||||
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, pathStart[row + 1]!))
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero here is a real value, not a missing one: row 0 starts at
|
||||
* pathStart 0, and nameOff is 0 for any path with no separator at all
|
||||
* (`CIM_Linear`). Guarding with `!x` dropped both.
|
||||
*/
|
||||
|
||||
name(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
return this.decoder.decode(
|
||||
@@ -63,7 +59,6 @@ export class DumpStore {
|
||||
)
|
||||
}
|
||||
|
||||
/** Path minus the leaf name, minus the trailing separator. */
|
||||
container(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
if (!nameOff[row]) return '' // the whole path is the name
|
||||
@@ -95,7 +90,7 @@ export class DumpStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matching
|
||||
// --- matching
|
||||
|
||||
// rootOff is 0 for every path that already starts with '/', which is nearly
|
||||
// all of them, so the old `!rootOff[row]` guard rejected everything except
|
||||
@@ -128,14 +123,9 @@ export class DumpStore {
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ query
|
||||
// --- query
|
||||
|
||||
|
||||
/**
|
||||
* Single linear pass over every row. At 800k rows this is single-digit
|
||||
* milliseconds for type filters and ~40 ms for a substring scan, which is
|
||||
* why there is no inverted index here — it would cost more to maintain
|
||||
* than it saves.
|
||||
*/
|
||||
query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } {
|
||||
const { count, typeId, types } = this.cols
|
||||
const typeCounts = new Int32Array(types.length)
|
||||
@@ -290,6 +280,30 @@ export class DumpStore {
|
||||
return [start, lo]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every row at exactly `prefix` or beneath it, in path order.
|
||||
*/
|
||||
subtree(prefix: string): Uint32Array {
|
||||
const order = this.ensurePathOrder()
|
||||
if (!prefix) return order.slice()
|
||||
|
||||
const pat = new TextEncoder().encode(prefix)
|
||||
const [lo, hi] = this.range(pat)
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
|
||||
const out = new Uint32Array(hi - lo)
|
||||
let n = 0
|
||||
for (let i = lo; i < hi; i++) {
|
||||
const row = order[i]!
|
||||
const after = pathStart[row]! + rootOff[row]! + pat.length
|
||||
const end = pathStart[row + 1]!
|
||||
if (after > end) continue
|
||||
const c = pathBlob[after]
|
||||
if (after === end || c === SLASH || c === DOT || c === COLON) out[n++] = row
|
||||
}
|
||||
return out.slice(0, n)
|
||||
}
|
||||
|
||||
private childCache = new Map<string, TreeChild[]>()
|
||||
|
||||
/**
|
||||
|
||||
160
app/lib/report.ts
Normal file
160
app/lib/report.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { DumpStore } from './dump-store'
|
||||
import { KIND_ENUM } from './dump-parse'
|
||||
|
||||
/**
|
||||
* A subtree rendered as a reference sheet for writing reflection code.
|
||||
*
|
||||
* The audience is someone about to hook a UFunction: they need the object
|
||||
* path to look it up, and the parameter layout — names, types, and byte
|
||||
* offsets in declaration order — to lay out the params struct the hook
|
||||
* receives. So the report is two passes over the same rows. The outline
|
||||
* answers "what is in here", and the detail section answers "what do I have
|
||||
* to write", with functions given the offset table that is the whole point.
|
||||
*/
|
||||
|
||||
const FUNCTION_TYPES = new Set(['Function', 'DelegateFunction', 'SparseDelegateFunction'])
|
||||
|
||||
/** Beyond this the report stops being a reference and starts being the dump. */
|
||||
export const REPORT_CAP = 25_000
|
||||
|
||||
export interface ReportResult {
|
||||
text: string
|
||||
objects: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const hex16 = (n: number) => '0x' + n.toString(16).toUpperCase().padStart(16, '0')
|
||||
const hexOff = (n: number) => '+0x' + n.toString(16).toUpperCase().padStart(4, '0')
|
||||
|
||||
function stamp(t: number): string {
|
||||
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())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a display path that hangs below `root`.
|
||||
*
|
||||
* Not simply `path.slice(root.length)`. Array and map inner properties are
|
||||
* dumped with a leading qualifier — `K2Node_MakeArray_Array./Game/…:UpdateHP`
|
||||
* — which the store skips when indexing, so such a row is legitimately inside
|
||||
* the subtree while its *displayed* path does not begin with the root at all.
|
||||
* Slicing blind turns those lines into garbage at a random offset.
|
||||
*/
|
||||
function relativeTo(root: string, path: string): string {
|
||||
if (!root) return path
|
||||
const at = path.indexOf(root)
|
||||
return at < 0 ? path : path.slice(at + root.length)
|
||||
}
|
||||
|
||||
/** Separator depth, for indenting the outline. */
|
||||
function depthOf(rel: string): number {
|
||||
let d = 0
|
||||
for (let i = 0; i < rel.length; i++) {
|
||||
const c = rel[i]
|
||||
if (c === '/' || c === '.' || c === ':') d++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export function buildReport(
|
||||
store: DumpStore,
|
||||
rows: Uint32Array,
|
||||
meta: { root: string; label: string },
|
||||
): ReportResult {
|
||||
const truncated = rows.length > REPORT_CAP
|
||||
const used = truncated ? rows.subarray(0, REPORT_CAP) : rows
|
||||
|
||||
// One pass to materialise, because both sections read the same rows and
|
||||
// `store.row` decodes strings out of the path blob each time it is called.
|
||||
const objs = Array.from(used, (r) => store.row(r))
|
||||
|
||||
// Children are grouped by their container so that a function can find its
|
||||
// parameters without a second scan per function.
|
||||
const byContainer = new Map<string, typeof objs>()
|
||||
for (const o of objs) {
|
||||
const list = byContainer.get(o.container)
|
||||
if (list) list.push(o)
|
||||
else byContainer.set(o.container, [o])
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
const { root, label } = meta
|
||||
|
||||
out.push('UE4SS OBJECT EXPLORER — SUBTREE REPORT')
|
||||
out.push('='.repeat(72))
|
||||
out.push(`root ${root || '(everything)'}`)
|
||||
out.push(`dump ${label}`)
|
||||
out.push(`objects ${objs.length.toLocaleString()}${truncated ? ` (capped from ${rows.length.toLocaleString()})` : ''}`)
|
||||
out.push(`generated ${stamp(Date.now())}`)
|
||||
if (truncated) {
|
||||
out.push('')
|
||||
out.push(`NOTE This subtree holds ${rows.length.toLocaleString()} objects; only the first`)
|
||||
out.push(` ${REPORT_CAP.toLocaleString()} are listed. Scope to a narrower node for a complete report.`)
|
||||
}
|
||||
out.push('')
|
||||
|
||||
// ------------------------------------------------------------- outline
|
||||
out.push('OUTLINE')
|
||||
out.push('-'.repeat(72))
|
||||
for (const o of objs) {
|
||||
const rel = relativeTo(root, o.path)
|
||||
const bits = [`${' '.repeat(depthOf(rel))}${rel || o.path}`]
|
||||
// Flag the qualifier rather than dropping it: an inner property listed
|
||||
// only by its tail reads as a duplicate of the array it belongs to.
|
||||
if (root && !o.path.startsWith(root)) bits.push(`(inner of ${o.path.slice(0, o.path.indexOf(root))})`)
|
||||
bits.push(`· ${o.type}`)
|
||||
if (o.address) bits.push(`@ ${hex16(o.address)}`)
|
||||
if (o.offset >= 0) bits.push(hexOff(o.offset))
|
||||
if (o.kind === KIND_ENUM && o.value !== null) bits.push(`= ${o.value}`)
|
||||
out.push(bits.join(' '))
|
||||
}
|
||||
out.push('')
|
||||
|
||||
// ------------------------------------------------------------ functions
|
||||
const fns = objs.filter((o) => FUNCTION_TYPES.has(o.type))
|
||||
if (fns.length) {
|
||||
out.push('FUNCTIONS')
|
||||
out.push('-'.repeat(72))
|
||||
out.push(`${fns.length} callable${fns.length === 1 ? '' : 's'} in this subtree.`)
|
||||
out.push('')
|
||||
|
||||
for (const f of fns) {
|
||||
// Declaration order is offset order: UE lays parameters out in the
|
||||
// params struct in the order they are declared, return value last.
|
||||
const params = (byContainer.get(f.path) ?? []).slice().sort((a, b) => a.offset - b.offset)
|
||||
|
||||
out.push(f.path)
|
||||
out.push(` name ${f.name}`)
|
||||
out.push(` type ${f.type}`)
|
||||
out.push(` address ${hex16(f.address)}`)
|
||||
if (f.outer) out.push(` outer ${hex16(f.outer)}`)
|
||||
if (!params.length) {
|
||||
out.push(' params none')
|
||||
} else {
|
||||
out.push(` params ${params.length}`)
|
||||
const w = Math.max(...params.map((p) => p.name.length), 4)
|
||||
out.push(` ${'OFFSET'.padEnd(9)}${'NAME'.padEnd(w + 2)}TYPE`)
|
||||
for (const p of params) {
|
||||
out.push(` ${hexOff(p.offset).padEnd(9)}${p.name.padEnd(w + 2)}${p.type}`)
|
||||
}
|
||||
}
|
||||
out.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- objects
|
||||
out.push('OBJECTS')
|
||||
out.push('-'.repeat(72))
|
||||
for (const o of objs) {
|
||||
out.push(o.path)
|
||||
out.push(` type ${o.type}`)
|
||||
out.push(` address ${hex16(o.address)}`)
|
||||
if (o.outer) out.push(` outer ${hex16(o.outer)}`)
|
||||
if (o.offset >= 0) out.push(` offset ${hexOff(o.offset)} (${o.offset})`)
|
||||
if (o.kind === KIND_ENUM && o.value !== null) out.push(` value ${o.value}`)
|
||||
out.push('')
|
||||
}
|
||||
|
||||
return { text: out.join('\n'), objects: objs.length, truncated }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { parseDump } from '../lib/dump-parse'
|
||||
import { decodeBundle } from '../lib/bundle'
|
||||
import { DumpStore, type Query } from '../lib/dump-store'
|
||||
import { buildReport } from '../lib/report'
|
||||
import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist'
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,8 @@ import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist'
|
||||
*/
|
||||
let store: DumpStore | null = null
|
||||
let current: Uint32Array = new Uint32Array(0)
|
||||
/** Title of the open dump, so a report can name what it was taken from. */
|
||||
let currentLabel = ''
|
||||
|
||||
type Req =
|
||||
| {
|
||||
@@ -27,6 +30,7 @@ type Req =
|
||||
| { id: number; op: 'window'; start: number; end: number }
|
||||
| { id: number; op: 'children'; prefix: string }
|
||||
| { id: number; op: 'resolve'; address: number }
|
||||
| { id: number; op: 'report'; prefix: string }
|
||||
| { id: number; op: 'close' }
|
||||
|
||||
function summary() {
|
||||
@@ -59,6 +63,7 @@ async function handle(msg: Req) {
|
||||
const cached = await loadDump(key).catch(() => undefined)
|
||||
if (cached) {
|
||||
store = new DumpStore(cached.cols)
|
||||
currentLabel = cached.label
|
||||
return { ...summary(), key, label: cached.label, cached: true }
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ async function handle(msg: Req) {
|
||||
store = new DumpStore(cols)
|
||||
const parsedAt = Date.now()
|
||||
const label = titleFor(msg.name, parsedAt)
|
||||
currentLabel = label
|
||||
await saveDump({
|
||||
key,
|
||||
label,
|
||||
@@ -88,6 +94,7 @@ async function handle(msg: Req) {
|
||||
const cached = await loadDump(msg.key)
|
||||
if (!cached) throw new Error('That dump is no longer cached. Load the file again.')
|
||||
store = new DumpStore(cached.cols)
|
||||
currentLabel = cached.label
|
||||
return { ...summary(), key: cached.key, label: cached.label, cached: true }
|
||||
}
|
||||
|
||||
@@ -118,11 +125,21 @@ async function handle(msg: Req) {
|
||||
return { row: row >= 0 ? store!.row(row) : null }
|
||||
}
|
||||
|
||||
// Built here rather than on the main thread: the report walks the whole
|
||||
// subtree and decodes a string per field, and only the finished text
|
||||
// crosses back.
|
||||
case 'report': {
|
||||
const s = store!
|
||||
const rows = s.subtree(msg.prefix)
|
||||
return buildReport(s, rows, { root: msg.prefix, label: currentLabel })
|
||||
}
|
||||
|
||||
// Drops the only references to the columns; the dump stays in IndexedDB,
|
||||
// so 'restore' can bring it back without re-parsing.
|
||||
case 'close': {
|
||||
store = null
|
||||
current = new Uint32Array(0)
|
||||
currentLabel = ''
|
||||
return { closed: true }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user