Files
luma/Luma/CodeShareBrowserView.swift
Ole André Vadla Ravnås b88a9be290 Replace SwiftyMonaco with native editors
Drop the Monaco webview. Edit in a GtkSourceView on GTK and in a
native text view on Apple, both speaking to Frida.Compiler's
in-process language server for completions, signature help,
hovers and diagnostics.

Color, complete and indent code natively, matching the GitHub
theme in light and dark.
2026-09-08 19:52:31 +02:00

400 lines
12 KiB
Swift

import CryptoKit
import SwiftUI
import LumaCore
struct CodeShareBrowserView: View {
let session: LumaCore.ProcessSession
let engine: Engine
let onInstrumentAdded: ((LumaCore.InstrumentInstance) -> Void)?
@Environment(\.dismiss) private var dismiss
@State private var mode: CodeShareService.Mode = .popular
@State private var searchQuery: String = ""
@State private var projects: [CodeShareService.ProjectSummary] = []
@State private var selectedProject: CodeShareService.ProjectSummary?
@State private var isLoading: Bool = false
@State private var loadError: String?
@State private var addInstrumentHandler: (() -> Void)?
@State private var isAddingInstrument: Bool = false
#if canImport(UIKit)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
#else
private var isCompactWidth: Bool { false }
#endif
var body: some View {
NavigationSplitView {
sidebar
.navigationTitle("CodeShare")
} detail: {
detailView
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
.frame(minWidth: isCompactWidth ? 0 : 900, minHeight: isCompactWidth ? 0 : 600)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Add Instrument") {
addInstrumentHandler?()
}
.disabled(addInstrumentHandler == nil || isAddingInstrument)
}
}
.onAppear {
Task {
await loadPopular()
}
}
.onChange(of: searchQuery) { _, newValue in
Task {
await performSearchDebounced(query: newValue)
}
}
.onChange(of: selectedProject) { _, _ in
addInstrumentHandler = nil
isAddingInstrument = false
}
}
private var sidebar: some View {
VStack(alignment: .leading, spacing: 8) {
Picker(
"Mode",
selection: Binding<Int>(
get: {
switch mode {
case .popular:
return 0
case .search:
return 1
}
},
set: { idx in
switch idx {
case 0:
mode = .popular
projects = []
selectedProject = nil
Task { await loadPopular() }
default:
mode = .search(query: searchQuery)
projects = []
selectedProject = nil
Task { await performSearch() }
}
}
)
) {
Text("Popular").tag(0)
Text("Search").tag(1)
}
.pickerStyle(.segmented)
if case .search = mode {
HStack {
TextField("Search CodeShare", text: $searchQuery)
.textFieldStyle(.roundedBorder)
.disabled(isLoading)
if isLoading {
ProgressView()
.controlSize(.small)
}
}
}
if let loadError {
Text(loadError)
.font(.caption)
.foregroundStyle(.red)
}
ZStack {
List(selection: $selectedProject) {
ForEach(projects) { project in
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
Text("@\(project.owner)/\(project.slug)")
.font(.caption2)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
Image(systemName: "heart.fill")
.font(.caption2)
Text("\(project.likes)")
.font(.caption2)
}
.foregroundStyle(.secondary)
}
.tag(project)
}
}
.listStyle(.sidebar)
if case .popular = mode, isLoading && projects.isEmpty {
ProgressView()
}
}
}
.padding()
.frame(minWidth: isCompactWidth ? 0 : 260, idealWidth: isCompactWidth ? nil : 300)
}
@ViewBuilder
private var detailView: some View {
if let project = selectedProject {
CodeShareProjectDetailView(
project: project,
engine: engine,
session: session,
registerAddAction: { handler in
addInstrumentHandler = handler
},
isAddingInstrument: $isAddingInstrument,
onInstrumentAdded: { instance in
onInstrumentAdded?(instance)
dismiss()
}
)
} else {
Text("Select a snippet to preview and add it as an instrument.")
.foregroundStyle(.secondary)
}
}
private func loadPopular() async {
isLoading = true
loadError = nil
do {
let items = try await CodeShareService.fetchPopular()
projects = items
if selectedProject == nil, !isCompactWidth {
selectedProject = projects.first
}
} catch {
loadError = String(describing: error)
projects = []
selectedProject = nil
}
isLoading = false
}
private func performSearch() async {
guard case .search = mode else { return }
isLoading = true
loadError = nil
do {
let items = try await CodeShareService.searchProjects(query: searchQuery)
projects = items
if selectedProject == nil, !isCompactWidth {
selectedProject = projects.first
}
} catch {
loadError = String(describing: error)
projects = []
selectedProject = nil
}
isLoading = false
}
private func performSearchDebounced(query: String) async {
guard case .search = mode else { return }
try? await Task.sleep(nanoseconds: 300_000_000)
if query == searchQuery {
await performSearch()
}
}
}
struct CodeShareProjectDetailView: View {
let project: CodeShareService.ProjectSummary
let engine: Engine
let session: LumaCore.ProcessSession
let registerAddAction: (((() -> Void)?) -> Void)
@Binding var isAddingInstrument: Bool
let onInstrumentAdded: (LumaCore.InstrumentInstance) -> Void
@State private var details: CodeShareService.ProjectDetails?
@State private var source: String = ""
@State private var isLoadingDetails = false
@State private var loadError: String?
@StateObject private var introspector = CodeIntrospector()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if let loadError {
Text(loadError)
.foregroundStyle(.red)
}
if let details {
VStack(alignment: .leading, spacing: 8) {
Text(details.description)
.font(.subheadline)
Text("Frida \(details.fridaVersion)")
.font(.caption)
.foregroundStyle(.secondary)
Divider()
Text("Source")
.font(.headline)
CodeEditorView(
text: $source,
profile: EditorProfile.fridaCodeShare(),
introspector: introspector,
engine: engine,
)
}
} else if isLoadingDetails {
Text("Loading…")
.foregroundStyle(.secondary)
} else {
Text("Select a snippet to load details.")
.foregroundStyle(.secondary)
}
}
.task(id: project.id) {
await loadDetails()
}
.onDisappear {
registerAddAction(nil)
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(project.name)
.font(.title3.weight(.semibold))
Text("@\(project.owner)/\(project.slug)")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
Image(systemName: "heart.fill")
.font(.caption2)
Text("\(project.likes) likes")
.font(.caption2)
}
.foregroundStyle(.secondary)
}
}
private func loadDetails() async {
isLoadingDetails = true
loadError = nil
details = nil
registerAddAction(nil)
let currentID = project.id
defer {
if project.id == currentID {
isLoadingDetails = false
}
}
do {
let d = try await CodeShareService.fetchProjectDetails(
owner: project.owner,
slug: project.slug
)
guard project.id == currentID else { return }
details = d
source = d.source
registerAddAction {
Task { @MainActor in
guard !isAddingInstrument else { return }
isAddingInstrument = true
defer { isAddingInstrument = false }
await addInstrument(details: d)
}
}
} catch is CancellationError {
return
} catch {
guard project.id == currentID else { return }
loadError = String(describing: error)
details = nil
source = ""
}
}
private func addInstrument(details: CodeShareService.ProjectDetails) async {
let symbols = await introspector.topLevelSymbols()
let projectRef = CodeShareProjectRef(
id: details.id,
owner: details.owner,
slug: details.slug
)
let finalSource = source
let hash: String = {
let data = Data(finalSource.utf8)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}()
let cfg = CodeShareConfig(
name: details.name,
description: details.description,
source: finalSource,
exports: symbols.map(\.text),
project: projectRef,
lastSyncedHash: hash,
lastReviewedHash: hash,
fridaVersion: details.fridaVersion,
allowRemoteUpdates: false
)
guard let configData = try? JSONEncoder().encode(cfg) else {
return
}
let sourceIdentifier = "@\(details.owner)/\(details.slug)"
let descriptor = InstrumentDescriptor(
id: "codeshare:\(sourceIdentifier)",
kind: .codeShare,
sourceIdentifier: sourceIdentifier,
displayName: cfg.name,
icon: .symbolic("cloud"),
makeInitialConfigJSON: { configData }
)
engine.registerDescriptor(descriptor)
InstrumentUIRegistry.shared.register(for: descriptor.id, ui: CodeShareUI())
let newInstrument = await engine.addInstrument(
kind: descriptor.kind,
sourceIdentifier: descriptor.sourceIdentifier,
configJSON: configData,
sessionID: session.id
)
if let newInstrument {
onInstrumentAdded(newInstrument)
}
}
}