Make the log viewer resizable via a drag handle (ticket 010)
Adds a drag-to-resize handle between the log viewer and the sections below it in the main layout, addressing the log view being too small with no way to enlarge it. Height is tracked in state, clamped to a sane range, and persisted to localStorage (ottyl.logViewerHeightPx) following the app's existing per-viewer UI-state persistence pattern (see CollapsibleSection.tsx, LogPreview.tsx). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed1524139a
commit
f6a32b2b30
@@ -1,8 +0,0 @@
|
||||
# Make layout sections resizable
|
||||
|
||||
The sections should be resizable. Currently the log view is too small and can't be resized.
|
||||
|
||||
## Acceptance criteria
|
||||
- Main layout sections (including the log view) can be resized by the user, e.g. via a drag handle between panes.
|
||||
- Resized proportions are reasonably persisted (e.g. in local storage) across reloads, consistent with other UI state persistence in the app.
|
||||
- Log view in particular can be enlarged.
|
||||
+101
-3
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
||||
import { NavMenu } from './components/NavMenu'
|
||||
import { FilterBar } from './components/FilterBar'
|
||||
import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
|
||||
@@ -22,6 +22,85 @@ import { DocsNavProvider } from './lib/docsNav'
|
||||
|
||||
const POLL_MS = 2000
|
||||
|
||||
// Height (px) the log viewer had before it became resizable — kept as the default so existing
|
||||
// layouts don't jump on first load after this change.
|
||||
const DEFAULT_LOG_VIEWER_HEIGHT_PX = 352
|
||||
const MIN_LOG_VIEWER_HEIGHT_PX = 200
|
||||
// Leaves room below the log viewer for the drag handle and at least a sliver of the next
|
||||
// section, so a drag can never grow it to swallow the whole viewport.
|
||||
const LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX = 160
|
||||
|
||||
// Namespaced under `ottyl.` per the app's existing localStorage convention (see
|
||||
// CollapsibleSection.tsx's COLLAPSED_SECTIONS_STORAGE_KEY, LogPreview.tsx's
|
||||
// VISIBLE_PARAM_COLUMNS_STORAGE_KEY).
|
||||
const LOG_VIEWER_HEIGHT_STORAGE_KEY = 'ottyl.logViewerHeightPx'
|
||||
|
||||
function clampLogViewerHeight(px: number): number {
|
||||
const max = Math.max(MIN_LOG_VIEWER_HEIGHT_PX, window.innerHeight - LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX)
|
||||
return Math.min(max, Math.max(MIN_LOG_VIEWER_HEIGHT_PX, px))
|
||||
}
|
||||
|
||||
function loadLogViewerHeight(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOG_VIEWER_HEIGHT_STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_LOG_VIEWER_HEIGHT_PX
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) ? clampLogViewerHeight(parsed) : DEFAULT_LOG_VIEWER_HEIGHT_PX
|
||||
} catch {
|
||||
return DEFAULT_LOG_VIEWER_HEIGHT_PX
|
||||
}
|
||||
}
|
||||
|
||||
function saveLogViewerHeight(px: number) {
|
||||
try {
|
||||
localStorage.setItem(LOG_VIEWER_HEIGHT_STORAGE_KEY, String(px))
|
||||
} catch {
|
||||
// best-effort — private browsing or full storage just means the size won't persist across reloads
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move
|
||||
/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so
|
||||
/// the final size can be persisted once, not on every pixel of movement.
|
||||
function ResizeHandle({ onResize, onResizeEnd }: { onResize: (deltaY: number) => void; onResizeEnd: () => void }) {
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
function handlePointerDown(e: ReactPointerEvent) {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
let lastY = e.clientY
|
||||
|
||||
function handlePointerMove(ev: PointerEvent) {
|
||||
if (!draggingRef.current) return
|
||||
onResize(ev.clientY - lastY)
|
||||
lastY = ev.clientY
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
draggingRef.current = false
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
onResizeEnd()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize log viewer"
|
||||
title="Drag to resize"
|
||||
onPointerDown={handlePointerDown}
|
||||
className="group relative h-1.5 shrink-0 cursor-ns-resize touch-none"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border-soft group-hover:bg-border-strong" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one.
|
||||
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
@@ -190,6 +269,18 @@ function FullApp() {
|
||||
const [filter, setFilter] = useState('')
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
||||
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
|
||||
|
||||
function handleLogViewerResize(deltaY: number) {
|
||||
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
|
||||
}
|
||||
|
||||
function handleLogViewerResizeEnd() {
|
||||
setLogViewerHeight((current) => {
|
||||
saveLogViewerHeight(current)
|
||||
return current
|
||||
})
|
||||
}
|
||||
|
||||
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
|
||||
const rangeRef = useRef(range)
|
||||
@@ -289,8 +380,14 @@ function FullApp() {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* min-h-[...] keeps this from being squeezed to 0 by the sections below it in the shared scroll container — flex-1 alone lets a flex item shrink past its content, which let this section's absolutely-unclipped content spill over Params et al instead of the column just scrolling. */}
|
||||
<div className="flex min-h-[22rem] flex-1 flex-col" hidden={logViewerCollapsed}>
|
||||
{/* An explicit height (user-resizable via the handle below, persisted to localStorage) keeps this
|
||||
from being squeezed by the sections below it in the shared scroll container, and bounds
|
||||
LogPreview's own internal `flex-1 overflow-auto` scroll area. */}
|
||||
<div
|
||||
className="flex shrink-0 flex-col overflow-hidden"
|
||||
style={{ height: logViewerHeight }}
|
||||
hidden={logViewerCollapsed}
|
||||
>
|
||||
<LogTimeline
|
||||
buckets={buckets}
|
||||
range={range}
|
||||
@@ -300,6 +397,7 @@ function FullApp() {
|
||||
/>
|
||||
<LogPreview logs={logs} params={params} />
|
||||
</div>
|
||||
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
||||
<Params params={params} onDeclared={loadParams} />
|
||||
<Metrics range={range} />
|
||||
<Dashboards range={range} />
|
||||
|
||||
Reference in New Issue
Block a user