V.1
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'"
|
||||
>
|
||||
a–z
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user