This commit is contained in:
Hunter
2026-08-07 17:11:04 -04:00
parent 6702030406
commit 45b1539c68
12 changed files with 851 additions and 61 deletions

View File

@@ -1,15 +1,52 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { RowView } from '../composables/useDump'
import type { SortKey } from '../lib/dump-store'
import { hue, abbreviate } from '../lib/type-hue'
const props = defineProps<{
total: number
sharedDigits: number
sort: SortKey
descending: boolean
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 OVERSCAN = 24
@@ -88,11 +125,24 @@ function tail(s: string, max = 64) {
<template>
<div class="table">
<header class="head mono">
<span class="col-addr">ADDRESS</span>
<span class="col-type">TYPE</span>
<span class="col-name">NAME</span>
<span class="col-path">CONTAINER</span>
<span class="col-off">OFF</span>
<button
v-for="c in COLUMNS"
:key="c.key"
type="button"
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>
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
@@ -157,6 +207,37 @@ function tail(s: string, max = 64) {
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 {
flex: 1;
overflow: auto;

View File

@@ -43,8 +43,25 @@ 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[] {
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[] {
for (const n of nodes) {
for (const n of arrange(nodes)) {
out.push(n)
if (n.open && n.children) flatten(n.children, out)
}
@@ -60,7 +77,25 @@ watch(() => props.version, loadRoots)
<nav class="spine">
<div class="spine-head">
<span class="eyebrow">Script path</span>
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
<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'"
>
az
</button>
</div>
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
</div>
</div>
<div class="scroll">
@@ -118,6 +153,12 @@ watch(() => props.version, loadRoots)
flex: none;
}
.head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.clear {
font-size: 10px;
letter-spacing: 0.08em;
@@ -125,6 +166,33 @@ watch(() => props.version, loadRoots)
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 {
overflow: auto;
padding: 6px 0 20px;