Files
luma/Luma/SidebarView.swift

991 lines
34 KiB
Swift

import Frida
import LumaCore
import SwiftUI
let sidebarRowLeadingPad: CGFloat = 3
let sidebarChevronWidth: CGFloat = 14
let sidebarChevronToIconSpacing: CGFloat = 5
let sidebarIconToLabelSpacing: CGFloat = 6
let sidebarParentIconWidth: CGFloat = 24
let sidebarChildIconWidth: CGFloat = 16
let sidebarChildIndent: CGFloat =
sidebarRowLeadingPad + sidebarChevronWidth + sidebarChevronToIconSpacing + sidebarParentIconWidth
let sidebarGrandchildIndent: CGFloat = sidebarChildIndent + sidebarChildIconWidth
private let subrowIconWidth: CGFloat = sidebarChildIconWidth
struct SidebarView: View {
let engine: Engine
@Binding var selection: SidebarItemID?
var sessions: [LumaCore.ProcessSession] { engine.sessions }
var packages: [LumaCore.InstalledPackage] { engine.installedPackages }
var missions: [LumaCore.Mission] { engine.missions }
var body: some View {
List(selection: $selection) {
Section {
SidebarNotebookRow()
.tag(SidebarItemID.notebook)
SidebarPharoRow()
.tag(SidebarItemID.pharo)
SidebarMissionsRow(count: missions.count)
.tag(SidebarItemID.missions)
ForEach(missions) { mission in
SidebarMissionRow(mission: mission, engine: engine, selection: $selection)
.tag(SidebarItemID.mission(mission.id))
}
PatternsSidebarRows(engine: engine, selection: $selection)
}
Section("Sessions") {
ForEach(sessions) { session in
let node = engine.node(forSessionID: session.id)
let instruments = engine.instrumentsBySession[session.id] ?? []
let insights = engine.insightsBySession[session.id] ?? []
let traces = engine.tracesBySession[session.id] ?? []
let isExpanded = engine.sidebarExpansion(forSessionID: session.id) == .expanded
SidebarSessionHeaderRow(
session: session,
node: node,
engine: engine,
selection: $selection,
isExpanded: isExpanded,
onToggleExpansion: { toggleSessionExpansion(sessionID: session.id) }
)
.tag(SidebarItemID.session(session.id))
if isExpanded {
moduleGroup(session: session)
threadGroup(session: session)
SidebarSessionREPLRow(sessionID: session.id)
.tag(SidebarItemID.repl(session.id))
ForEach(instruments) { instance in
let hasChildren = hasInstrumentChildren(instance: instance)
let hooksExpanded = engine.hooksExpansion(forSessionID: session.id, instrumentID: instance.id) == .expanded
SidebarInstrumentRow(
session: session,
node: node,
instance: instance,
engine: engine,
hasChildren: hasChildren,
isExpanded: hooksExpanded,
onToggle: { toggleHooksExpansion(sessionID: session.id, instrumentID: instance.id) },
selection: $selection
)
.tag(SidebarItemID.instrument(session.id, instance.id))
if hasChildren, hooksExpanded {
instrumentChildren(sessionID: session.id, instance: instance)
}
}
ForEach(insights.sorted(by: { $0.createdAt < $1.createdAt })) { insight in
SidebarInsightRow(
session: session,
insight: insight,
engine: engine,
selection: $selection
)
.tag(SidebarItemID.insight(session.id, insight.id))
}
ForEach(traces.sorted(by: { $0.startedAt < $1.startedAt })) { trace in
SidebarITraceRow(
session: session,
trace: trace,
engine: engine,
selection: $selection
)
.tag(SidebarItemID.itrace(session.id, trace.id))
}
}
}
}
CustomInstrumentsSidebarSection(engine: engine, selection: $selection)
if !packages.isEmpty {
Section("Packages") {
ForEach(packages) { pkg in
SidebarPackageRow(package: pkg)
.tag(SidebarItemID.package(pkg.id))
}
}
}
}
.listStyle(.sidebar)
.onChange(of: selection) { _, newSelection in
ensureSessionExpanded(for: newSelection)
}
}
private func ensureSessionExpanded(for newSelection: SidebarItemID?) {
guard let sessionID = sessionID(in: newSelection),
engine.sidebarExpansion(forSessionID: sessionID) == .collapsed
else { return }
engine.setSidebarExpansion(sessionID: sessionID, .expanded)
}
private func sessionID(in selection: SidebarItemID?) -> UUID? {
switch selection {
case .session(let id),
.repl(let id),
.module(let id, _),
.thread(let id, _),
.instrument(let id, _),
.instrumentComponent(let id, _, _),
.insight(let id, _),
.itrace(let id, _):
return id
default:
return nil
}
}
@ViewBuilder
private func moduleGroup(session: LumaCore.ProcessSession) -> some View {
let count = session.lastKnownModules?.count ?? 0
let isExpanded = engine.sidebarExpansion(forSessionID: session.id, group: .modules) == .expanded
SidebarGroupHeaderRow(
title: "Modules",
systemImage: "shippingbox",
count: count,
isExpanded: isExpanded,
onToggle: { toggleGroupExpansion(sessionID: session.id, group: .modules) }
)
if isExpanded, count > 0 {
ModuleSidebarChildren(sessionID: session.id, engine: engine, selection: $selection)
}
}
@ViewBuilder
private func threadGroup(session: LumaCore.ProcessSession) -> some View {
let count = session.lastKnownThreads?.count ?? 0
let isExpanded = engine.sidebarExpansion(forSessionID: session.id, group: .threads) == .expanded
SidebarGroupHeaderRow(
title: "Threads",
systemImage: "cpu",
count: count,
isExpanded: isExpanded,
onToggle: { toggleGroupExpansion(sessionID: session.id, group: .threads) }
)
if isExpanded, count > 0 {
ThreadSidebarChildren(sessionID: session.id, engine: engine, selection: $selection)
}
}
private func toggleGroupExpansion(sessionID: UUID, group: SessionSidebarGroup) {
let current = engine.sidebarExpansion(forSessionID: sessionID, group: group)
let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded
withAnimation(.easeInOut(duration: 0.18)) {
engine.setSidebarExpansion(sessionID: sessionID, group: group, next)
}
}
private func toggleSessionExpansion(sessionID: UUID) {
let current = engine.sidebarExpansion(forSessionID: sessionID)
let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded
withAnimation(.easeInOut(duration: 0.18)) {
engine.setSidebarExpansion(sessionID: sessionID, next)
}
}
@ViewBuilder
private func instrumentChildren(sessionID: UUID, instance: LumaCore.InstrumentInstance) -> some View {
if let ui = InstrumentUIRegistry.shared.ui(for: instance) {
ui.sidebarChildren(
sessionID: sessionID,
instance: instance,
engine: engine,
selection: $selection
)
}
}
private func hasInstrumentChildren(instance: LumaCore.InstrumentInstance) -> Bool {
InstrumentUIRegistry.shared.ui(for: instance)?.hasSidebarChildren(instance: instance) ?? false
}
private func toggleHooksExpansion(sessionID: UUID, instrumentID: UUID) {
let current = engine.hooksExpansion(forSessionID: sessionID, instrumentID: instrumentID)
let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded
withAnimation(.easeInOut(duration: 0.18)) {
engine.setHooksExpansion(sessionID: sessionID, instrumentID: instrumentID, next)
}
}
}
private struct SidebarNotebookRow: View {
var body: some View {
HStack(spacing: 8) {
Image(systemName: "book.pages")
.frame(width: 18, alignment: .center)
Text("Notebook")
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.notebook")
}
}
private struct SidebarPharoRow: View {
var body: some View {
HStack(spacing: 8) {
Image(systemName: "curlybraces.square")
.frame(width: 18, alignment: .center)
Text("Playground")
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.pharo")
}
}
private struct SidebarMissionsRow: View {
let count: Int
var body: some View {
HStack(spacing: 8) {
Image(systemName: "scope")
.frame(width: 18, alignment: .center)
Text("Missions")
Spacer()
if count > 0 {
Text("\(count)").font(.caption).foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.missions")
}
}
private struct SidebarMissionRow: View {
let mission: LumaCore.Mission
let engine: Engine
@Binding var selection: SidebarItemID?
@State private var isShowingDeleteConfirmation = false
var body: some View {
HStack(spacing: 8) {
Image(systemName: iconName)
.foregroundStyle(iconColor)
.frame(width: subrowIconWidth)
Text(displayTitle)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 0)
}
.padding(.leading, sidebarChildIndent)
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.mission.\(mission.id.uuidString)")
.contextMenu {
Button(role: .destructive) {
isShowingDeleteConfirmation = true
} label: {
Label("Delete Mission", systemImage: "trash")
}
}
.confirmationDialog(
"Delete Mission?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Mission", role: .destructive) { deleteMission() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will remove the mission and all of its turns, tool calls, and findings.")
}
}
private func deleteMission() {
if selection == .mission(mission.id) {
selection = .missions
}
engine.deleteMission(missionID: mission.id)
}
private var displayTitle: String {
if let title = mission.title, !title.isEmpty { return title }
return mission.goalText.isEmpty ? "(untitled mission)" : mission.goalText
}
private var iconName: String {
switch mission.status {
case .running, .drafting: return "circle.dotted"
case .awaitingApproval: return "hourglass"
case .paused: return "pause.circle"
case .completed: return "checkmark.circle.fill"
case .failed: return "xmark.octagon"
case .cancelled: return "stop.circle"
}
}
private var iconColor: Color {
switch mission.status {
case .running: return .blue
case .awaitingApproval: return .orange
case .completed: return .green
case .failed: return .red
default: return .secondary
}
}
}
struct SidebarDisclosure: View {
let isExpanded: Bool
var canToggle: Bool = true
let onToggle: () -> Void
var body: some View {
chevron
.padding(.leading, sidebarChildIndent - sidebarChevronWidth - sidebarChevronToIconSpacing)
.padding(.trailing, sidebarChevronToIconSpacing)
}
@ViewBuilder
private var chevron: some View {
if canToggle {
Button(action: onToggle) {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: sidebarChevronWidth, height: sidebarChevronWidth)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
} else {
Color.clear.frame(width: sidebarChevronWidth, height: sidebarChevronWidth)
}
}
}
private struct SidebarGroupHeaderRow: View {
let title: String
let systemImage: String
let count: Int
let isExpanded: Bool
let onToggle: () -> Void
var body: some View {
HStack(spacing: 0) {
SidebarDisclosure(isExpanded: isExpanded, onToggle: onToggle)
.accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)")
Image(systemName: systemImage)
.frame(width: sidebarChildIconWidth, alignment: .center)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.trailing, sidebarIconToLabelSpacing)
Text(title)
.font(.callout)
Spacer()
if count > 0 {
Text("\(count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.onTapGesture { onToggle() }
}
}
private struct SidebarSessionHeaderRow: View {
let session: LumaCore.ProcessSession
let node: LumaCore.ProcessNode?
let engine: Engine
@Binding var selection: SidebarItemID?
let isExpanded: Bool
let onToggleExpansion: () -> Void
@Environment(TargetPicker.self) private var picker
@State private var isShowingConfirmation = false
@State private var confirmationTitle: String = ""
@State private var confirmationMessage: String?
@State private var confirmationDestructiveLabel: String = "Confirm"
@State private var pendingConfirmation: (() -> Void)?
@State private var isShowingArmPrompt = false
@State private var armPatternDraft: String = ""
var body: some View {
HStack(spacing: 0) {
Button(action: onToggleExpansion) {
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: sidebarChevronWidth, height: sidebarChevronWidth)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.leading, sidebarRowLeadingPad)
.padding(.trailing, sidebarChevronToIconSpacing)
.accessibilityLabel(isExpanded ? "Collapse session" : "Expand session")
iconView
.frame(width: sidebarParentIconWidth, height: sidebarParentIconWidth)
.padding(.trailing, sidebarIconToLabelSpacing)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(displayProcessName).font(.headline)
if isArmed {
Image(systemName: "scope")
.font(.caption2)
.help("Armed for next matching launch")
}
}
if session.phase == .attaching, let stage = engine.connectionActivity.stage(for: session.deviceID) {
Text("\(stage.status)…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(displayDeviceName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
hostBadge
if isDetached {
detachedIndicator
}
}
.padding(.vertical, 4)
.contextMenu {
if !engine.localUserHosts(session.id) {
if engine.canTakeHosting(session) {
Button {
rehost()
} label: {
Label("Run on My Device…", systemImage: "rectangle.connected.to.line.below")
}
}
} else if let node {
Button(role: .destructive) {
presentConfirmation(
title: "Kill Process?",
message: "This will force-terminate \"\(displayProcessName)\".",
destructiveLabel: "Kill Process"
) { killProcess() }
} label: {
Label("Kill Process", systemImage: "xmark.circle")
}
Button {
engine.removeNode(node)
} label: {
Label("Detach Session", systemImage: "bolt.slash")
}
Divider()
armingMenuButton
Divider()
Button(role: .destructive) {
presentConfirmation(
title: "Delete Session?",
message: "This will remove the session and its history.",
destructiveLabel: "Delete Session"
) { deleteSession() }
} label: {
Label("Delete Session", systemImage: "trash")
}
} else {
if session.lastAttachedAt != nil, engine.canTakeHosting(session) {
Button {
reestablish()
} label: {
Label("\(session.kind.reestablishLabel)…", systemImage: "arrow.clockwise")
}
}
armingMenuButton
Divider()
Button(role: .destructive) {
presentConfirmation(
title: "Delete Session?",
message: "This will remove the session and its history.",
destructiveLabel: "Delete Session"
) { deleteSession() }
} label: {
Label("Delete Session", systemImage: "trash")
}
}
}
.confirmationDialog(
confirmationTitle,
isPresented: $isShowingConfirmation,
titleVisibility: .visible
) {
Button(confirmationDestructiveLabel, role: .destructive) {
pendingConfirmation?()
pendingConfirmation = nil
}
Button("Cancel", role: .cancel) {
pendingConfirmation = nil
}
} message: {
if let confirmationMessage { Text(confirmationMessage) }
}
.alert("Arm for Next Launch", isPresented: $isShowingArmPrompt) {
TextField("Identifier regex", text: $armPatternDraft)
.disableAutocorrection(true)
Button("Arm") { commitArm() }
.disabled(armPatternDraft.trimmingCharacters(in: .whitespaces).isEmpty)
Button("Cancel", role: .cancel) {}
} message: {
Text("Match the next spawn whose identifier matches this regex on \(session.deviceName).")
}
}
@ViewBuilder
private var armingMenuButton: some View {
if isArmed {
Button {
disarm()
} label: {
Label("Disarm", systemImage: "scope")
}
} else if session.supportsArmForNextLaunch {
Button {
presentArmPrompt()
} label: {
Label("Arm for Next Launch…", systemImage: "scope")
}
}
}
private var isArmed: Bool {
if case .armed = session.armingState { return true }
return false
}
private var isDetached: Bool {
guard node == nil, !isArmed else { return false }
if isHostedRemotelyLive { return false }
if session.lastAttachedAt != nil { return true }
if case .attach = session.kind { return true }
return false
}
private var isHostedRemotelyLive: Bool {
engine.isHostedRemotelyLive(session.id)
}
@ViewBuilder
private var detachedIndicator: some View {
if session.phase == .attaching {
ProgressView()
.controlSize(.small)
.help(session.kind.inProgressLabel)
} else {
Button(action: reestablish) {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.system(size: 14))
.foregroundStyle(detachedTint)
}
.buttonStyle(.plain)
.help("\(session.kind.reestablishLabel)")
}
}
private var detachedTint: Color {
switch session.detachReason {
case .applicationRequested:
return .orange
default:
return .red
}
}
private func presentArmPrompt() {
armPatternDraft = engine.defaultArmPattern(for: session)
isShowingArmPrompt = true
}
private func commitArm() {
let pattern = armPatternDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
let sessionID = session.id
Task { @MainActor in
await engine.armSession(id: sessionID, matchPattern: pattern)
}
}
private func disarm() {
let sessionID = session.id
Task { @MainActor in
await engine.disarmSession(id: sessionID)
}
}
private var displayProcessName: String { node?.processName ?? session.processName }
private var displayDeviceName: String { node?.deviceName ?? session.deviceName }
@ViewBuilder
private var iconView: some View {
if let data = session.iconPNGData {
Icon.png(data: Array(data)).swiftUIImage
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.cornerRadius(4)
} else {
IconPlaceholderView(
seed: placeholderSeed,
displayName: displayProcessName,
cornerRadius: 4
)
}
}
@ViewBuilder
private var hostBadge: some View {
if let host = session.host,
!engine.isHostingNode(session.id),
host.id != engine.collaboration.localUser?.id
{
UserAvatarView(user: host, size: 16)
.help("Hosted by @\(host.id) on \(session.deviceName)")
}
}
private var placeholderSeed: String {
"\(session.deviceID)/\(displayProcessName)"
}
private func reestablish() {
Task { @MainActor in
let result: Engine.ReestablishResult
if localUserOwnsHost {
result = await engine.reestablishSession(id: session.id)
} else {
result = await engine.reHost(sessionID: session.id)
}
if case .needsUserInput(let reason, let session) = result {
picker.context = .reestablish(session: session, reason: reason)
}
}
}
private func rehost() {
Task { @MainActor in
let result = await engine.reHost(sessionID: session.id)
if case .needsUserInput(let reason, let session) = result {
picker.context = .reestablish(session: session, reason: reason)
}
}
}
private var localUserOwnsHost: Bool {
guard let host = session.host else { return true }
return host.id == engine.collaboration.localUser?.id
}
private func killProcess() {
guard let node else { return }
Task { @MainActor in
do { try await node.kill() } catch {
engine.updateSession(id: session.id) { $0.lastError = error.localizedDescription }
}
}
}
private func deleteSession() {
if let node { engine.removeNode(node) }
let sessionID = session.id
try? engine.store.deleteSession(id: sessionID)
switch selection {
case .session(let id) where id == sessionID,
.repl(let id) where id == sessionID,
.instrument(let id, _) where id == sessionID,
.insight(let id, _) where id == sessionID,
.itrace(let id, _) where id == sessionID:
selection = .notebook
default:
break
}
}
private func presentConfirmation(
title: String,
message: String? = nil,
destructiveLabel: String,
action: @escaping () -> Void
) {
confirmationTitle = title
confirmationMessage = message
confirmationDestructiveLabel = destructiveLabel
pendingConfirmation = action
isShowingConfirmation = true
}
}
private struct SidebarSessionREPLRow: View {
let sessionID: UUID
private let iconWidth: CGFloat = 16
var body: some View {
HStack(spacing: 6) {
Image(systemName: "terminal")
.frame(width: iconWidth, alignment: .center)
.font(.system(size: 12))
Text("REPL")
Spacer()
}
.font(.callout)
.padding(.leading, sidebarChildIndent)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityIdentifier("sidebar.repl")
}
}
private struct SidebarInstrumentRow: View {
let session: LumaCore.ProcessSession
let node: LumaCore.ProcessNode?
let instance: LumaCore.InstrumentInstance
let engine: Engine
let hasChildren: Bool
let isExpanded: Bool
let onToggle: () -> Void
@Binding var selection: SidebarItemID?
@State private var isShowingDeleteConfirm = false
var body: some View {
HStack(spacing: 0) {
SidebarDisclosure(isExpanded: isExpanded, canToggle: hasChildren, onToggle: onToggle)
InstrumentIconView(icon: descriptor.icon, pointSize: 12)
.frame(width: subrowIconWidth, alignment: .center)
.padding(.trailing, sidebarIconToLabelSpacing)
Text(descriptor.displayName)
if let status = runtimeStatus {
InstrumentStatusIndicator(status: status)
.padding(.leading, 6)
}
Spacer()
}
.font(.callout)
.contentShape(Rectangle())
.opacity(instance.state == .enabled ? 1 : 0.3)
.contextMenu {
Button {
let newState: LumaCore.InstrumentState = instance.state == .enabled ? .disabled : .enabled
Task { @MainActor in
await engine.setInstrumentState(instance, state: newState)
}
} label: {
Label(
instance.state == .enabled
? "Disable \"\(descriptor.displayName)\""
: "Enable \"\(descriptor.displayName)\"",
systemImage: instance.state == .enabled ? "pause.circle" : "play.circle"
)
}
Divider()
Button(role: .destructive) {
isShowingDeleteConfirm = true
} label: {
Label("Delete Instrument", systemImage: "trash")
}
}
.confirmationDialog(
"Delete Instrument?",
isPresented: $isShowingDeleteConfirm,
titleVisibility: .visible
) {
Button("Delete Instrument", role: .destructive) {
deleteInstrument()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will remove \"\(descriptor.displayName)\" from this session.")
}
}
private var descriptor: InstrumentDescriptor {
engine.descriptor(for: instance)
}
private var runtimeStatus: InstrumentStatus? {
node?.instruments.first(where: { $0.id == instance.id })?.status
}
private func deleteInstrument() {
Task {
await engine.removeInstrument(instance)
}
if selection == .instrument(session.id, instance.id) {
selection = .repl(session.id)
}
}
}
private struct SidebarInsightRow: View {
let session: LumaCore.ProcessSession
let insight: LumaCore.AddressInsight
let engine: Engine
@Binding var selection: SidebarItemID?
@State private var isRenaming = false
@State private var renameDraft = ""
var body: some View {
HStack(spacing: 6) {
Image(systemName: insight.kind == .memory ? "memorychip" : "cpu")
.frame(width: subrowIconWidth, alignment: .center)
.font(.system(size: 12))
Text(engine.displayTitle(for: insight))
Spacer()
}
.font(.callout)
.contentShape(Rectangle())
.padding(.leading, sidebarChildIndent)
.help(insight.anchor.displayString)
.contextMenu {
Button {
renameDraft = engine.displayTitle(for: insight)
isRenaming = true
} label: {
Label("Rename…", systemImage: "pencil")
}
Divider()
Button {
Task { await engine.rereadBytes(of: insight) }
} label: {
Label("Reread Bytes", systemImage: "arrow.clockwise")
}
.disabled(!isAttached)
Button {
engine.reanalyzeModule(of: insight)
} label: {
Label("Reanalyze Module", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(!isAttached || engine.enclosingModule(of: insight) == nil)
Divider()
Button(role: .destructive) {
deleteInsight()
} label: {
Label("Delete Insight", systemImage: "trash")
}
}
.alert("Rename Insight", isPresented: $isRenaming) {
TextField("Name", text: $renameDraft)
Button("Save") {
let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != engine.displayTitle(for: insight) else { return }
engine.renameInsight(insight, to: trimmed)
}
Button("Cancel", role: .cancel) {}
}
}
private var isAttached: Bool {
engine.node(forSessionID: session.id) != nil
}
private func deleteInsight() {
try? engine.store.deleteInsight(id: insight.id)
if selection == .insight(session.id, insight.id) {
selection = .repl(session.id)
}
}
}
private struct SidebarITraceRow: View {
let session: LumaCore.ProcessSession
let trace: LumaCore.ITrace
let engine: Engine
@Binding var selection: SidebarItemID?
@State private var isShowingDeleteConfirm = false
var body: some View {
HStack(spacing: 6) {
Image(systemName: trace.isRunning ? "record.circle" : "waveform.path")
.frame(width: subrowIconWidth, alignment: .center)
.font(.system(size: 12))
.foregroundStyle(trace.isRunning ? .red : .primary)
Text(trace.displayName)
Spacer()
}
.font(.callout)
.contentShape(Rectangle())
.padding(.leading, sidebarChildIndent)
.contextMenu {
Button(role: .destructive) {
isShowingDeleteConfirm = true
} label: {
Label("Delete Trace", systemImage: "trash")
}
}
.confirmationDialog(
"Delete trace \(trace.displayName)?",
isPresented: $isShowingDeleteConfirm,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
deleteTrace()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This removes the recorded ITrace data from the project.")
}
}
private func deleteTrace() {
engine.deleteITrace(id: trace.id, sessionID: session.id)
if selection == .itrace(session.id, trace.id) {
selection = .repl(session.id)
}
}
}
private struct SidebarPackageRow: View {
let package: LumaCore.InstalledPackage
var body: some View {
HStack(spacing: 8) {
Image(systemName: "shippingbox")
VStack(alignment: .leading, spacing: 2) {
Text(package.name)
.font(.headline)
Text(package.version)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
}
}