Added selective dump features
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user