Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions memory-bank/uxArchitecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 23 additions & 3 deletions native/Sources/BrewBrowserKit/ActivityView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
}
Expand Down
114 changes: 82 additions & 32 deletions native/Sources/BrewBrowserKit/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions native/Tests/BrewBrowserKitTests/ActivityDrawerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 32 additions & 4 deletions src/lib/components/ActivityDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -230,7 +241,24 @@
</script>

{#if ui.drawerOpen}
<div class="drawer" class:minimized={ui.drawerMinimized}>
<div
class="drawer"
class:minimized={ui.drawerMinimized}
style:height={`${ui.drawerMinimized ? 32 : ui.drawerHeight}px`}
>
{#if !ui.drawerMinimized}
<ResizeHandle
size={ui.drawerHeight}
min={ACTIVITY_DRAWER_MIN_HEIGHT}
max={maxHeight}
defaultSize={ACTIVITY_DRAWER_DEFAULT_HEIGHT}
orientation="horizontal"
direction="up"
label="Resize Activity drawer"
onChange={(height) => (ui.drawerHeight = height)}
onCommit={(height) => ui.setActivityDrawerHeight(height)}
/>
{/if}
<header class="strip">
<button class="title" onclick={() => ui.toggleDrawer()} title={ui.drawerMinimized ? "Expand drawer" : "Minimize drawer"}>
{#if ui.drawerMinimized}<ChevronUp size={14} />{:else}<ChevronDown size={14} />{/if}
Expand Down Expand Up @@ -385,10 +413,10 @@
background: var(--color-surface);
display: flex;
flex-direction: column;
min-height: 0;
flex: none;
height: 280px;
transition: height var(--motion-duration-base) var(--motion-ease-out);
}
.drawer:not(.minimized) { min-height: 252px; }
.drawer.minimized { height: 32px; }

.strip {
Expand Down
Loading