Files
luma/Luma/SidebarBrowserPopover.swift
Ole André Vadla Ravnås 38e5198209 Move modules and threads into the sidebar
Drop the modules/threads tabs; show both as collapsible sidebar
groups mirroring the hooks UX: a handful of top picks plus a
browse popover for the rest. Default threads to collapsed.

Revert the eager re-analysis of persisted modules on attach; keep
disassembly analysis lazy and add a per-module Analyze action and
status indicator instead.

Share one sidebar-row and one browser-popover component across
hooks, modules, and threads in both the SwiftUI and GTK frontends.
2026-06-27 14:53:01 +02:00

128 lines
3.8 KiB
Swift

import LumaCore
import SwiftUI
struct SidebarBrowserPopover<Item: Identifiable>: View {
let placeholder: String
let emptyMessage: String
let items: [Item]
let groupName: (Item) -> String
let title: (Item) -> String
let help: (Item) -> String
let isDimmed: (Item) -> Bool
let matches: (Item, String) -> Bool
let onChoose: (Item) -> Void
@State private var query: String = ""
@FocusState private var isSearchFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
Divider()
resultsList
}
.padding(.vertical, 10)
.frame(width: 360, height: 420)
.onAppear { isSearchFocused = true }
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(placeholder, text: $query)
.textFieldStyle(.plain)
.focused($isSearchFocused)
.onSubmit { selectFirstMatch() }
}
.padding(.horizontal, 12)
}
private var resultsList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
ForEach(groupedMatches) { group in
Section {
ForEach(group.items) { item in
itemButton(for: item)
}
} header: {
groupHeader(group.name, count: group.items.count)
}
}
if groupedMatches.isEmpty {
Text(emptyMessage)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
}
}
private func groupHeader(_ name: String, count: Int) -> some View {
HStack {
Text(name)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Spacer()
Text("\(count)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.background)
}
private func itemButton(for item: Item) -> some View {
Button {
onChoose(item)
} label: {
HStack(spacing: 6) {
Text(title(item))
.lineLimit(1)
.truncationMode(.tail)
Spacer()
}
.contentShape(Rectangle())
.padding(.horizontal, 12)
.padding(.vertical, 4)
.opacity(isDimmed(item) ? 0.5 : 1)
.help(help(item))
}
.buttonStyle(.plain)
}
private var groupedMatches: [BrowserGroup<Item>] {
let filtered = filteredItems
guard !filtered.isEmpty else { return [] }
var byName: [String: [Item]] = [:]
var order: [String] = []
for item in filtered {
let key = groupName(item)
if byName[key] == nil { order.append(key) }
byName[key, default: []].append(item)
}
return order.map { BrowserGroup(name: $0, items: byName[$0] ?? []) }
}
private var filteredItems: [Item] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return items }
return items.filter { matches($0, trimmed) }
}
private func selectFirstMatch() {
guard let first = filteredItems.first else { return }
onChoose(first)
}
}
private struct BrowserGroup<Item: Identifiable>: Identifiable {
let name: String
let items: [Item]
var id: String { name }
}