diff --git a/memory-bank/uxArchitecture.md b/memory-bank/uxArchitecture.md index c8d3dfe..1df3b94 100644 --- a/memory-bank/uxArchitecture.md +++ b/memory-bank/uxArchitecture.md @@ -233,8 +233,7 @@ Not a modal. Not an inline expanding pane. A **persistent bottom drawer** that o โ ๐บ /opt/homebrew/Cellar/wget/1.21.4: 89 files, 4.5MB โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ ``` - -- **Default height:** 280px (โ 30% of 720 default window). User-draggable handle. +- **Sizing:** default height is 280 px (โ 30% of the 720-px default window). User-draggable handle to resize. The expanded drawer has a 252-px minimum (200-pt console plus drawer chrome) and a maximum of 60% of the current window height. The current height persists across sessions using localStorage on Tauri builds or UserDefaults on SwiftUI builds. - **Header strip:** current op name, elapsed time, collapse `โ` and dismiss `โ`. When collapsed, only the strip remains (~32px tall). - **Console body:** monospace, system mono (SF Mono), 12px. Each event line includes microsecond-relative timestamp on hover. Auto-scroll with smart pause when user scrolls up. - **History tabs:** when more than one op has run this session, drawer header shows tabs: `wget` (active) ยท `git (succeeded)` ยท `node (failed)`. Closing a completed-op tab removes it. The full history is also browsable in the **Activity** sidebar section. diff --git a/native/Sources/BrewBrowserKit/ActivityView.swift b/native/Sources/BrewBrowserKit/ActivityView.swift index bda194e..4783779 100644 --- a/native/Sources/BrewBrowserKit/ActivityView.swift +++ b/native/Sources/BrewBrowserKit/ActivityView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit /// Bottom Activity drawer โ live console for the active streaming job (install / /// upgrade / uninstall / update). Mirrors the Tauri `ActivityDrawer`: collapsed @@ -7,6 +8,23 @@ import SwiftUI /// switcher. Mounted as a bottom bar below the split view. struct ActivityDrawer: View { @Bindable var model: AppModel + /// `VSplitView` owns the live divider when expanded. The collapsed drawer + /// keeps its existing compact bar layout. + let fillsAvailableHeight: Bool + + init(model: AppModel, fillsAvailableHeight: Bool = false) { + self.model = model + self.fillsAvailableHeight = fillsAvailableHeight + } + + /// The existing 200 px console plus the header and native split divider. + /// This keeps an expanded drawer from becoming smaller than before. + static let minimumConsoleHeight: CGFloat = 200 + static let minimumDrawerHeight: CGFloat = 252 + + static func clampedDrawerHeight(_ height: CGFloat, maximum: CGFloat) -> CGFloat { + min(max(height, minimumDrawerHeight), max(minimumDrawerHeight, maximum)) + } /// The drawer shows the explicitly-active job only. nil `activeJobId` /// (after Close) hides the drawer entirely; selecting a job in the Activity @@ -47,7 +65,7 @@ struct ActivityDrawer: View { // Full bleed edge-to-edge so the bar covers the window's rounded // bottom corners (no dark notch where the split-view corner shows // through). .bar material fills the whole width. - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: fillsAvailableHeight && model.drawerOpen ? .infinity : nil) .background(.bar) // Auto-collapse the console when a job finishes successfully โ keeps // the one-line status visible (+ in Activity history) without the @@ -142,12 +160,13 @@ struct ActivityDrawer: View { private func expandedContent(_ job: ActivityJob) -> some View { HStack(alignment: .top, spacing: 0) { console(job) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: fillsAvailableHeight ? .infinity : nil) if job.status == .failed { failureNotice(job) .frame(width: 360) } } + .frame(maxHeight: fillsAvailableHeight ? .infinity : nil) } @ViewBuilder @@ -244,7 +263,8 @@ struct ActivityDrawer: View { .padding(.horizontal, 12) .padding(.vertical, 6) } - .frame(height: 200) + .frame(minHeight: Self.minimumConsoleHeight, + maxHeight: fillsAvailableHeight ? .infinity : Self.minimumConsoleHeight) .onChange(of: job.lines.count) { _, count in if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) } } diff --git a/native/Sources/BrewBrowserKit/ContentView.swift b/native/Sources/BrewBrowserKit/ContentView.swift index eb74c4f..9142628 100644 --- a/native/Sources/BrewBrowserKit/ContentView.swift +++ b/native/Sources/BrewBrowserKit/ContentView.swift @@ -26,10 +26,15 @@ public struct ContentView: View { var updater: UpdaterController @Environment(\.openSettings) private var openSettings @Environment(\.scenePhase) private var scenePhase + private static let activityDrawerHeightKey = "activity.drawerHeight.v2" + @State private var initialActivityDrawerHeight: CGFloat public init(model: AppModel, updater: UpdaterController) { self.model = model self.updater = updater + let savedHeight = UserDefaults.standard.object(forKey: Self.activityDrawerHeightKey) as? Double + ?? Double(ActivityDrawer.minimumDrawerHeight) + _initialActivityDrawerHeight = State(initialValue: CGFloat(savedHeight)) } public var body: some View { @@ -47,7 +52,83 @@ public struct ContentView: View { } private var mainContent: some View { - VStack(spacing: 0) { + activityLayout + // In-window toasts (Bundle F) โ layered top-trailing over everything so + // they're clear of the bottom Activity drawer. See Toast.swift. + .overlay { ToastOverlay(model: model) } + .task { + model.loadJobs() + model.loadVulns() + if model.installed.isEmpty { await model.loadLibrary() } + } + // โK command palette โ stock `.sheet` overlay (BrewBrowserApp's .commands + // flips `paletteOpen`). The catalog backs the palette's index search, so + // make sure it's loaded the first time the palette opens. + .sheet(isPresented: $model.paletteOpen) { + CommandPaletteView(model: model) + } + // Custom About box (replaces the bare standard panel) โ opened from the + // app menu's "About brew-browser". Mirrors the Tauri AboutModal. + .sheet(isPresented: $model.aboutOpen) { + AboutView(model: model) + } + // Esc closes the open detail inspector. The palette is a `.sheet`, which + // handles its own Esc, so by the time Esc reaches here the palette is + // already closed and only the inspector remains. Returns `.ignored` when + // there's nothing to close so Esc keeps its default behavior elsewhere. + .onKeyPress(.escape) { + model.closeTopmostOverlay() ? .handled : .ignored + } + } + + @ViewBuilder + private var activityLayout: some View { + if model.drawerOpen, model.activeJobId != nil { + // Match the sidebar's smooth native divider: VSplitView is AppKit- + // backed, so live resizing does not recreate the console hierarchy. + VSplitView { + navigationContent + resizableActivityDrawer + } + } else { + VStack(spacing: 0) { + navigationContent + ActivityDrawer(model: model) + } + } + } + + private var maximumActivityDrawerHeight: CGFloat { + let window = NSApp.keyWindow ?? NSApp.mainWindow + return max(ActivityDrawer.minimumDrawerHeight, (window?.contentLayoutRect.height ?? 720) * 0.6) + } + + private var resizableActivityDrawer: some View { + let maximum = maximumActivityDrawerHeight + let ideal = ActivityDrawer.clampedDrawerHeight(initialActivityDrawerHeight, maximum: maximum) + return ActivityDrawer(model: model, fillsAvailableHeight: true) + .frame(minHeight: ActivityDrawer.minimumDrawerHeight, + idealHeight: ideal, + maxHeight: maximum) + // The split view owns the current size. Persisting the observed size + // does not feed it back into this layout, avoiding resize feedback. + .background { + GeometryReader { proxy in + Color.clear + .onAppear { persistActivityDrawerHeight(proxy.size.height) } + .onChange(of: proxy.size.height) { _, height in + persistActivityDrawerHeight(height) + } + } + } + } + + private func persistActivityDrawerHeight(_ height: CGFloat) { + guard height >= ActivityDrawer.minimumDrawerHeight else { return } + UserDefaults.standard.set(Double(height), forKey: Self.activityDrawerHeightKey) + } + + private var navigationContent: some View { NavigationSplitView { List(Section.allCases, selection: $model.selection) { section in Label(section.rawValue, systemImage: section.symbol) @@ -221,37 +302,6 @@ public struct ContentView: View { .inspectorColumnWidth(min: 360, ideal: 400, max: 560) } } - // Activity drawer as a true full-width bottom bar BELOW the whole split - // view (sibling, not an inset/overlay) โ so the split view + the - // inspector's own footer live entirely above it and nothing is covered. - ActivityDrawer(model: model) - } - // In-window toasts (Bundle F) โ layered top-trailing over everything so - // they're clear of the bottom Activity drawer. See Toast.swift. - .overlay { ToastOverlay(model: model) } - .task { - model.loadJobs() - model.loadVulns() - if model.installed.isEmpty { await model.loadLibrary() } - } - // โK command palette โ stock `.sheet` overlay (BrewBrowserApp's .commands - // flips `paletteOpen`). The catalog backs the palette's index search, so - // make sure it's loaded the first time the palette opens. - .sheet(isPresented: $model.paletteOpen) { - CommandPaletteView(model: model) - } - // Custom About box (replaces the bare standard panel) โ opened from the - // app menu's "About brew-browser". Mirrors the Tauri AboutModal. - .sheet(isPresented: $model.aboutOpen) { - AboutView(model: model) - } - // Esc closes the open detail inspector. The palette is a `.sheet`, which - // handles its own Esc, so by the time Esc reaches here the palette is - // already closed and only the inspector remains. Returns `.ignored` when - // there's nothing to close so Esc keeps its default behavior elsewhere. - .onKeyPress(.escape) { - model.closeTopmostOverlay() ? .handled : .ignored - } } @ViewBuilder diff --git a/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift b/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift new file mode 100644 index 0000000..027895b --- /dev/null +++ b/native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import BrewBrowserKit + +@Suite("ActivityDrawer sizing") +struct ActivityDrawerSizingTests { + @Test func preservesExistingDrawerHeightAsMinimum() { + #expect(ActivityDrawer.clampedDrawerHeight(120, maximum: 500) == 252) + } + + @Test func capsDrawerAtTheWindowBound() { + #expect(ActivityDrawer.clampedDrawerHeight(500, maximum: 360) == 360) + } + + @Test func retainsAValidPersistedHeight() { + #expect(ActivityDrawer.clampedDrawerHeight(280, maximum: 360) == 280) + } + + @Test func favorsTheMinimumWhenBoundsWouldOtherwiseConflict() { + #expect(ActivityDrawer.clampedDrawerHeight(100, maximum: 150) == 252) + } +} diff --git a/src/lib/components/ActivityDrawer.svelte b/src/lib/components/ActivityDrawer.svelte index d5ef460..9bcaa45 100644 --- a/src/lib/components/ActivityDrawer.svelte +++ b/src/lib/components/ActivityDrawer.svelte @@ -10,7 +10,12 @@ import AlertTriangle from "@lucide/svelte/icons/alert-triangle"; import { activity } from "$lib/stores/activity.svelte"; - import { ui } from "$lib/stores/ui.svelte"; + import ResizeHandle from "$lib/components/ResizeHandle.svelte"; + import { + ACTIVITY_DRAWER_DEFAULT_HEIGHT, + ACTIVITY_DRAWER_MIN_HEIGHT, + ui, + } from "$lib/stores/ui.svelte"; import { toast } from "$lib/stores/toast.svelte"; import type { ActivityLine } from "$lib/types"; import { openReportIssueFromJob } from "$lib/util/reportIssue"; @@ -22,6 +27,12 @@ type RecoveryKind, } from "$lib/util/recovery"; + type Props = { + maxHeight: number; + }; + + let { maxHeight }: Props = $props(); + let consoleEl: HTMLDivElement | undefined = $state(); let autoScroll = $state(true); @@ -230,7 +241,24 @@ {#if ui.drawerOpen} -