Files
luma/Luma/MainWindowView.swift
Ole André Vadla Ravnås a6f769221f ui: List pattern types in the sidebar
Show each file's declared types under it, with a browse popover when
there are many, and reveal a type's declaration when it is chosen.
2026-10-01 20:49:11 +02:00

584 lines
21 KiB
Swift

import Frida
import SwiftUI
import LumaCore
#if canImport(AppKit)
import AppKit
#endif
struct MainWindowView: View {
@Binding private var document: LumaProject
private let projectURL: URL
private let fileURL: URL?
@State private var engineResult: Result<Engine, any Swift.Error>
@State private var picker = TargetPicker()
@State private var collapsedEventBaselineVersion: Int = 0
@State private var collapsedNewEvents: Int = 0
@State private var isShowingHostingBlockedAlert = false
init(document: Binding<LumaProject>, fileURL: URL? = nil) {
self._document = document
self.projectURL = document.wrappedValue.workingProjectURL
self.fileURL = fileURL
let result: Result<Engine, any Swift.Error>
do {
let engine = try EngineRegistry.shared.engine(
for: projectURL,
dataDirectory: LumaAppPaths.shared.dataDirectory,
gitHubAuth: sharedGitHubAuth()
)
result = .success(engine)
} catch {
result = .failure(error)
}
self._engineResult = State(initialValue: result)
}
var body: some View {
switch engineResult {
case .success(let engine):
ProjectContentView(
engine: engine,
picker: picker,
projectURL: projectURL,
restorationPath: restorationPath,
markDocumentEdited: { document.unsavedChangeCount &+= 1 },
collapsedEventBaselineVersion: $collapsedEventBaselineVersion,
collapsedNewEvents: $collapsedNewEvents,
isShowingHostingBlockedAlert: $isShowingHostingBlockedAlert
)
case .failure(let error):
VStack(spacing: 12) {
Text("Failed to open project")
.font(.title3)
Text(error.localizedDescription)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.frame(minWidth: 480, minHeight: 240)
}
}
private var restorationPath: String {
(fileURL ?? projectURL).path
}
}
private struct ProjectContentView: View {
let engine: Engine
let picker: TargetPicker
let projectURL: URL
let restorationPath: String
let markDocumentEdited: () -> Void
@Binding var collapsedEventBaselineVersion: Int
@Binding var collapsedNewEvents: Int
@Binding var isShowingHostingBlockedAlert: Bool
@State private var availableHeight: CGFloat = 800
@State private var dragStartHeight: Double?
@State private var detailColumnWidth: CGFloat = .infinity
@State private var sidePanelWidth: CGFloat = 300
@State private var sidePanelResizingFrom: CGFloat?
private static let collapsedEventStreamHeight: CGFloat = 32
private static let minEventStreamHeight: Double = 120
private static let minMainContentHeight: CGFloat = 160
private static let minDetailWidth: CGFloat = 480
private static let sidePanelWidthRange: ClosedRange<CGFloat> = 260...520
var body: some View {
VStack(spacing: 0) {
NavigationSplitView {
SidebarView(engine: engine, selection: selection)
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 320)
} detail: {
detailWithSidePanel
}
.sheet(
item: Binding(
get: { picker.context },
set: { newValue in
Task { @MainActor in
picker.context = newValue
}
}
),
onDismiss: {
picker.context = nil
},
content: { context in
targetPickerSheet(context: context)
}
)
.toolbarRole(.editor)
.toolbar {
ProjectToolbar(
engine: engine,
picker: picker,
selection: selection,
isShowingHostingBlockedAlert: $isShowingHostingBlockedAlert
)
}
.toolbarBackground(.hidden, for: .windowToolbar)
.alert(
"Only lab owners can host sessions",
isPresented: $isShowingHostingBlockedAlert
) {
Button("OK", role: .cancel) {}
} message: {
Text("You're a member of this lab. Ask an owner to promote you before starting a session.")
}
.frame(
minWidth: 900,
idealWidth: 1100,
maxWidth: .infinity,
minHeight: 600,
idealHeight: 680,
maxHeight: .infinity,
alignment: .topLeading
)
.environment(picker)
.task {
await EngineRegistry.shared.startIfNeeded(for: projectURL)
engine.attachInstrumentUIs()
#if os(macOS)
engine.attachLocalNotifier()
#endif
if engine.collaboration.isCollaborative {
engine.setSidePanel(.collaboration)
}
if engine.selectedSidebarItem == nil {
engine.selectedSidebarItem = .notebook
}
}
.onChange(of: restorationPath, initial: true) { _, newPath in
LumaAppState.shared.lastDocumentPath = newPath
}
.onDisappear {
let url = projectURL
Task { @MainActor in
await EngineRegistry.shared.release(workingProjectURL: url)
}
}
.onReceive(NotificationCenter.default.publisher(for: ProjectStore.didCommitNotification)) { note in
guard let id = note.userInfo?["instanceID"] as? UUID,
id == engine.store.instanceID
else { return }
markDocumentEdited()
}
.onChange(of: engine.eventLog.totalReceived) { _, newVersion in
if engine.projectUIState.isEventStreamCollapsed {
let delta = max(0, newVersion - collapsedEventBaselineVersion)
collapsedNewEvents += delta
collapsedEventBaselineVersion = newVersion
} else {
collapsedEventBaselineVersion = newVersion
collapsedNewEvents = 0
}
}
.onChange(of: engine.projectUIState.isEventStreamCollapsed) { _, isCollapsed in
collapsedEventBaselineVersion = engine.eventLog.totalReceived
if !isCollapsed {
collapsedNewEvents = 0
}
}
eventStreamBottomBar
}
.background {
GeometryReader { proxy in
Color.clear
.onChange(of: proxy.size.height, initial: true) { _, height in
availableHeight = height
}
}
}
}
private var selection: Binding<SidebarItemID?> {
Binding(
get: { engine.selectedSidebarItem },
set: { engine.selectedSidebarItem = $0 }
)
}
private var detailWithSidePanel: some View {
HStack(spacing: 0) {
DetailView(engine: engine, selection: selection)
.frame(maxWidth: .infinity)
if let panel = engine.projectUIState.sidePanel {
Divider()
sidePanel(panel)
.frame(width: displayedSidePanelWidth)
.overlay(alignment: .leading) { sidePanelResizeHandle }
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { detailColumnWidth = $0 }
}
@ViewBuilder
private func sidePanel(_ panel: SidePanel) -> some View {
switch panel {
case .collaboration:
CollaborationPanel(engine: engine)
case .virtualMachines:
#if canImport(AppKit)
VirtualMachinePanel(engine: engine)
#endif
}
}
private var displayedSidePanelWidth: CGFloat {
min(sidePanelWidth, maxSidePanelWidth)
}
private var sidePanelResizeHandle: some View {
Rectangle()
.fill(.clear)
.frame(width: 8)
.contentShape(Rectangle())
.platformPointer(.columnResize)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged { drag in
let base = sidePanelResizingFrom ?? displayedSidePanelWidth
sidePanelResizingFrom = base
sidePanelWidth = min(max(base - drag.translation.width, Self.sidePanelWidthRange.lowerBound), maxSidePanelWidth)
}
.onEnded { _ in sidePanelResizingFrom = nil })
}
private var maxSidePanelWidth: CGFloat {
let range = Self.sidePanelWidthRange
return min(max(detailColumnWidth - Self.minDetailWidth, range.lowerBound), range.upperBound)
}
private func targetPickerSheet(context: TargetPickerContext) -> some View {
TargetPickerView(
engine: engine,
deviceManager: engine.deviceManager,
reason: {
if case .reestablish(_, let reason) = context {
reason
} else {
nil
}
}(),
onSpawn: handleSpawn(device:config:),
onAttach: handleAttach(device:proc:),
onArm: handleArm(device:config:regex:)
)
}
private func handleSpawn(device: Device, config: SpawnConfig) {
Task { @MainActor in
let session = engine.prepareSpawnSession(device: device, config: config)
engine.selectedSidebarItem = .session(session.id)
_ = try? await engine.spawnAndAttach(device: device, session: session)
}
}
private func handleArm(device: Device, config: SpawnConfig, regex: String) {
Task { @MainActor in
let session = await engine.armNewSession(
device: device,
config: config,
matchPattern: regex
)
engine.selectedSidebarItem = .session(session.id)
}
}
private func handleAttach(device: Device, proc: ProcessDetails) {
let pickerContext = picker.context
Task { @MainActor in
if let existingNode = engine.processNodes.first(where: {
$0.deviceID == device.id && $0.pid == proc.pid
}) {
engine.selectedSidebarItem = .session(engine.sessionID(for: existingNode))
return
}
let reusedFromReestablish: LumaCore.ProcessSession? =
if case .reestablish(let session, _) = pickerContext { session } else { nil }
let session = engine.prepareAttachSession(device: device, process: proc, reusing: reusedFromReestablish)
engine.selectedSidebarItem = .session(session.id)
_ = try? await engine.attach(device: device, process: proc, session: session)
}
}
@ViewBuilder
private var eventStreamBottomBar: some View {
Group {
if engine.projectUIState.isEventStreamCollapsed {
VStack(spacing: 0) {
Divider()
collapsedEventStreamBar
.frame(height: Self.collapsedEventStreamHeight)
}
} else {
VStack(spacing: 0) {
eventStreamResizeHandle
EventStreamView(
engine: engine,
selection: selection,
onCollapseRequested: {
engine.setEventStreamCollapsed(true)
}
)
.clipped()
}
.frame(height: currentEventStreamHeight)
}
}
.frame(maxWidth: .infinity)
.background(eventStreamBackground)
}
private var eventStreamBackground: Color {
#if canImport(AppKit)
Color(nsColor: .windowBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}
private var currentEventStreamHeight: CGFloat {
let maxHeight = max(Self.minEventStreamHeight, Double(availableHeight - Self.minMainContentHeight))
return CGFloat(min(max(engine.projectUIState.eventStreamBottomHeight, Self.minEventStreamHeight), maxHeight))
}
private var eventStreamResizeHandle: some View {
Divider()
.frame(height: 6)
.contentShape(Rectangle())
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged { value in
let start = dragStartHeight ?? engine.projectUIState.eventStreamBottomHeight
dragStartHeight = start
let maxHeight = max(Self.minEventStreamHeight, Double(availableHeight - Self.minMainContentHeight))
let proposed = start - Double(value.translation.height)
engine.setEventStreamBottomHeight(min(max(proposed, Self.minEventStreamHeight), maxHeight))
}
.onEnded { _ in dragStartHeight = nil }
)
#if os(macOS)
.onHover { inside in
if inside {
NSCursor.resizeUpDown.push()
} else {
NSCursor.pop()
}
}
#endif
}
private var collapsedEventStreamBar: some View {
HStack {
Button {
engine.setEventStreamCollapsed(false)
collapsedNewEvents = 0
collapsedEventBaselineVersion = engine.eventLog.totalReceived
} label: {
if collapsedNewEvents > 0 {
Label(
"Show Event Stream (\(collapsedNewEvents) new)",
systemImage: "chevron.up")
} else {
Label("Show Event Stream", systemImage: "chevron.up")
}
}
.buttonStyle(.borderless)
.font(.footnote)
.accessibilityIdentifier("eventStream.expand")
Spacer()
}
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
collapsedNewEvents > 0
? Color.accentColor.opacity(0.12)
: Color.clear
)
}
}
struct ProjectToolbar: ToolbarContent {
let engine: Engine
let picker: TargetPicker
@Binding var selection: SidebarItemID?
@Binding var isShowingHostingBlockedAlert: Bool
@State var showingAddInstrumentSheetForProcess: LumaCore.ProcessSession?
@State private var showingCodeShareSheetForProcess: LumaCore.ProcessSession?
@State private var pendingCodeShareAfterAddInstrumentDismiss: LumaCore.ProcessSession?
@State private var isShowingPackageManager = false
var body: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
Button {
if engine.canHostNewSessions {
picker.context = .newSession
} else {
isShowingHostingBlockedAlert = true
}
} label: {
Label("New Session…", systemImage: "target")
}
.keyboardShortcut("n", modifiers: [.command, .option])
.accessibilityIdentifier("toolbar.newSession")
let session = selectedProcessSession
Button {
showingAddInstrumentSheetForProcess = session
} label: {
Label("Add Instrument…", systemImage: "waveform.path.ecg")
}
.disabled(session == nil)
.keyboardShortcut("i", modifiers: [.command, .shift])
.sheet(
item: $showingAddInstrumentSheetForProcess,
onDismiss: {
if let session = pendingCodeShareAfterAddInstrumentDismiss {
pendingCodeShareAfterAddInstrumentDismiss = nil
showingCodeShareSheetForProcess = session
}
}
) { session in
AddInstrumentSheet(
session: session,
engine: engine,
selection: $selection,
onInstrumentAdded: { instrument in
selection = .instrument(session.id, instrument.id)
},
onBrowseCodeShare: {
pendingCodeShareAfterAddInstrumentDismiss = session
}
)
}
.sheet(item: $showingCodeShareSheetForProcess) { session in
CodeShareBrowserView(
session: session,
engine: engine,
onInstrumentAdded: { instrument in
showingCodeShareSheetForProcess = nil
selection = .instrument(session.id, instrument.id)
}
)
}
if let node = selectedProcessNode,
let session = session,
session.phase == .awaitingInitialResume
{
Button {
Task { @MainActor in
await engine.resumeSpawnedProcess(node: node)
}
} label: {
Label("Resume Process", systemImage: "play.fill")
}
.help("Call resume(\(session.lastKnownPID)) on this device.")
.keyboardShortcut("r", modifiers: [.command])
}
Button {
isShowingPackageManager = true
} label: {
Label("Manage Packages…", systemImage: "shippingbox")
}
.keyboardShortcut("p", modifiers: [.command, .option])
.sheet(isPresented: $isShowingPackageManager) {
VStack(alignment: .leading) {
Text("Add Package")
.font(.title2)
.bold()
.padding(.bottom, 8)
PackageSearchView(engine: engine, selection: $selection)
}
.padding()
}
GlobalActionQueueToolbarItem(engine: engine)
Button {
engine.toggleSidePanel(.virtualMachines)
} label: {
Label(
"Machines",
systemImage: engine.projectUIState.sidePanel == .virtualMachines
? "desktopcomputer.and.arrow.down"
: "desktopcomputer"
)
}
.help("Show or hide the virtual machines panel")
Button {
engine.toggleSidePanel(.collaboration)
} label: {
Label(
"Collaboration",
systemImage: engine.projectUIState.sidePanel == .collaboration
? "person.2.wave.2.fill"
: "person.2.wave.2"
)
}
.help("Show or hide the collaboration panel")
.keyboardShortcut("c", modifiers: [.command, .option])
}
}
var selectedProcessSession: LumaCore.ProcessSession? {
guard let id = selection else { return nil }
switch id {
case .notebook, .pharo, .missions, .mission(_), .patterns, .pattern(_), .patternType(_, _), .package(_), .customInstrumentDef(_),
.customInstrumentFile(_, _):
return nil
case .session(let sessionID),
.repl(let sessionID),
.module(let sessionID, _),
.thread(let sessionID, _),
.instrument(let sessionID, _),
.instrumentComponent(let sessionID, _, _),
.insight(let sessionID, _),
.itrace(let sessionID, _):
return engine.session(id: sessionID)
}
}
var selectedProcessNode: LumaCore.ProcessNode? {
guard let id = selection else { return nil }
switch id {
case .notebook, .pharo, .missions, .mission(_), .patterns, .pattern(_), .patternType(_, _), .package(_), .customInstrumentDef(_),
.customInstrumentFile(_, _):
return nil
case .session(let sessionID),
.repl(let sessionID),
.module(let sessionID, _),
.thread(let sessionID, _),
.instrument(let sessionID, _),
.instrumentComponent(let sessionID, _, _),
.insight(let sessionID, _),
.itrace(let sessionID, _):
return engine.node(forSessionID: sessionID)
}
}
}