Improve JSON editor for macOS

This commit is contained in:
世界
2025-11-26 22:25:52 +08:00
parent 3f1c716a0a
commit c5fd6a6b4e
25 changed files with 979 additions and 322 deletions
@@ -19,6 +19,7 @@
}
@Environment(\.dismiss) private var dismiss
@Environment(\.profileEditor) private var profileEditor
public var body: some View {
viewBuilder {
@@ -29,30 +30,10 @@
}
}
} else {
#if os(iOS)
RunestoneTextView(
text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
isEditable: !readOnly
)
editorView
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
#elseif os(macOS)
viewBuilder {
if readOnly {
TextEditor(text: .constant(viewModel.profileContent))
} else {
TextEditor(text: $viewModel.profileContent)
}
}
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
.textContentType(.init(rawValue: ""))
.padding()
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
#endif
}
}
.alertBinding($viewModel.alert)
@@ -105,6 +86,38 @@
return String(localized: "Edit Content")
}
}
@ViewBuilder
private var editorView: some View {
if let profileEditor {
profileEditor(
readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
!readOnly
)
#if os(macOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#endif
} else {
defaultEditorView
}
}
@ViewBuilder
private var defaultEditorView: some View {
viewBuilder {
if readOnly {
TextEditor(text: .constant(viewModel.profileContent))
} else {
TextEditor(text: $viewModel.profileContent)
}
}
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
#if os(macOS)
.textContentType(.init(rawValue: ""))
.padding()
#endif
}
}
#endif
@@ -21,6 +21,14 @@
isChanged = true
}
public func reset() {
isLoading = true
profile = nil
profileContent = ""
isChanged = false
alert = nil
}
public func loadContent() async {
do {
try await loadContentBackground()
@@ -10,7 +10,16 @@ public struct EditProfileView: View {
@StateObject private var viewModel = EditProfileViewModel()
public init() {}
public var body: some View {
#if os(macOS)
macOSBody
#else
iOSBody
#endif
}
private var formContent: some View {
FormView {
FormItem(String(localized: "Name")) {
TextField("Name", text: $profile.name, prompt: Text("Required"))
@@ -60,48 +69,61 @@ public struct EditProfileView: View {
ProfileActionToolbar(profile: profile, viewModel: viewModel)
#endif
}
#if os(macOS)
.safeAreaInset(edge: .bottom) {
ProfileActionToolbar(profile: profile, viewModel: viewModel)
}
#endif
.onChangeCompat(of: profile.name) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.remoteURL) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.autoUpdate) {
viewModel.markAsChanged()
}
.disabled(viewModel.isLoading)
#if os(macOS)
.toolbar {
ToolbarItemGroup(placement: .navigation) {
Button {
viewModel.isLoading = true
Task {
await viewModel.saveProfile(profile, environments: environments)
}
} label: {
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
}
.disabled(viewModel.isLoading || !viewModel.isChanged)
}
}
#elseif os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
viewModel.isLoading = true
Task {
await viewModel.saveProfile(profile, environments: environments)
}
}.disabled(!viewModel.isChanged)
}
}
#endif
.alertBinding($viewModel.alert)
.navigationTitle("Edit Profile")
}
#if os(macOS)
private var macOSBody: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Edit Profile")
.font(.headline)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
formContent
}
.safeAreaInset(edge: .bottom) {
ProfileActionToolbar(profile: profile, viewModel: viewModel)
}
.onChangeCompat(of: profile.name) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.remoteURL) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.autoUpdate) {
viewModel.markAsChanged()
}
.disabled(viewModel.isLoading)
.alertBinding($viewModel.alert)
}
#else
private var iOSBody: some View {
formContent
.onChangeCompat(of: profile.name) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.remoteURL) {
viewModel.markAsChanged()
}
.onChangeCompat(of: profile.autoUpdate) {
viewModel.markAsChanged()
}
.disabled(viewModel.isLoading)
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
viewModel.isLoading = true
Task {
await viewModel.saveProfile(profile, environments: environments)
}
}.disabled(!viewModel.isChanged)
}
}
#endif
.alertBinding($viewModel.alert)
.navigationTitle("Edit Profile")
}
#endif
}
@@ -25,6 +25,14 @@ public struct NewProfileView: View {
}
public var body: some View {
#if os(macOS)
macOSBody
#else
iOSBody
#endif
}
private var formContent: some View {
FormView {
FormItem(String(localized: "Name")) {
TextField("Name", text: $viewModel.profileName, prompt: Text("Required"))
@@ -113,8 +121,19 @@ public struct NewProfileView: View {
}
#endif
}
.navigationTitle("New Profile")
#if os(macOS)
}
#if os(macOS)
private var macOSBody: some View {
VStack(alignment: .leading, spacing: 0) {
Text("New Profile")
.font(.headline)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
formContent
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
@@ -138,10 +157,8 @@ public struct NewProfileView: View {
}
}
}
#endif
.disabled(viewModel.isSaving)
.alertBinding($viewModel.alert)
#if os(iOS) || os(macOS)
.fileImporter(
isPresented: $viewModel.pickerPresented,
allowedContentTypes: [.json],
@@ -157,6 +174,30 @@ public struct NewProfileView: View {
return
}
}
#endif
}
}
#else
private var iOSBody: some View {
formContent
.navigationTitle("New Profile")
.disabled(viewModel.isSaving)
.alertBinding($viewModel.alert)
#if os(iOS)
.fileImporter(
isPresented: $viewModel.pickerPresented,
allowedContentTypes: [.json],
allowsMultipleSelection: false
) { result in
do {
let urls = try result.get()
if !urls.isEmpty {
viewModel.fileURL = urls[0]
}
} catch {
viewModel.alert = Alert(error)
return
}
}
#endif
}
#endif
}
@@ -6,6 +6,9 @@ import SwiftUI
public struct ProfileActionToolbar: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@Environment(\.dismiss) private var dismiss
#if os(macOS)
@Environment(\.openWindow) private var openWindow
#endif
@ObservedObject private var profile: Profile
@ObservedObject private var viewModel: EditProfileViewModel
@@ -69,16 +72,12 @@ public struct ProfileActionToolbar: View {
HStack(spacing: 12) {
if profile.type != .remote {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
} label: {
Text("Edit Content")
Button("Edit Content") {
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
}
} else {
NavigationLink {
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
} label: {
Text("View Content")
Button("View Content") {
openWindow(value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
}
Button {
@@ -92,14 +91,27 @@ public struct ProfileActionToolbar: View {
.disabled(viewModel.isLoading)
}
Spacer()
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteProfile(profile, environments: environments, dismiss: dismiss)
}
}
.foregroundColor(.red)
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
viewModel.isLoading = true
Task {
await viewModel.saveProfile(profile, environments: environments)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading || !viewModel.isChanged)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
@@ -0,0 +1,14 @@
#if os(iOS) || os(macOS)
import SwiftUI
public struct ProfileEditorKey: EnvironmentKey {
public static let defaultValue: ((Binding<String>, Bool) -> AnyView)? = nil
}
public extension EnvironmentValues {
var profileEditor: ((Binding<String>, Bool) -> AnyView)? {
get { self[ProfileEditorKey.self] }
set { self[ProfileEditorKey.self] = newValue }
}
}
#endif
@@ -1,73 +0,0 @@
#if os(iOS)
import Runestone
import UIKit
final class ProfileEditorTheme: Theme {
let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
let textColor: UIColor = .label
let gutterBackgroundColor: UIColor = .secondarySystemBackground
let gutterHairlineColor: UIColor = .separator
let lineNumberColor: UIColor = .secondaryLabel
let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
let selectedLineBackgroundColor: UIColor = .systemFill
let selectedLinesLineNumberColor: UIColor = .label
let selectedLinesGutterBackgroundColor: UIColor = .secondarySystemBackground
let invisibleCharactersColor: UIColor = .tertiaryLabel
let pageGuideHairlineColor: UIColor = .separator
let pageGuideBackgroundColor: UIColor = .secondarySystemBackground
let markedTextBackgroundColor: UIColor = .systemFill
let markedTextBackgroundCornerRadius: CGFloat = 4
func textColor(for rawHighlightName: String) -> UIColor? {
guard let highlightName = HighlightName(rawHighlightName) else {
return nil
}
switch highlightName {
case .comment:
return .secondaryLabel
case .property:
return .systemCyan
case .string:
return .systemGreen
case .number:
return .systemOrange
case .constantBuiltin:
return .systemPurple
case .error:
return .systemRed
}
}
func fontTraits(for _: String) -> FontTraits {
[]
}
}
private enum HighlightName: String {
case comment
case property
case string
case number
case constantBuiltin = "constant.builtin"
case error
init?(_ rawHighlightName: String) {
var components = rawHighlightName.split(separator: ".")
while !components.isEmpty {
let candidateRawHighlightName = components.joined(separator: ".")
if let highlightName = Self(rawValue: candidateRawHighlightName) {
self = highlightName
return
}
components.removeLast()
}
return nil
}
}
#endif
@@ -18,14 +18,14 @@ public enum SheetSize {
@MainActor
public struct NavigationSheet<Content: View>: View {
private let title: String
private let title: String?
private let size: SheetSize
private let showDoneButton: Bool
private let onDismiss: (() -> Void)?
private let content: () -> Content
public init(
title: String,
title: String? = nil,
size: SheetSize = .large,
showDoneButton: Bool = false,
onDismiss: (() -> Void)? = nil,
@@ -39,13 +39,25 @@ public struct NavigationSheet<Content: View>: View {
}
public var body: some View {
NavigationStackCompat {
content()
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(macOS)
#if os(macOS)
macOSBody
#else
iOSBody
#endif
}
#if os(macOS)
private var macOSBody: some View {
VStack(alignment: .leading, spacing: 0) {
if let title {
Text(title)
.font(.headline)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
}
content()
}
.toolbar {
if showDoneButton {
ToolbarItem(placement: .confirmationAction) {
@@ -55,12 +67,19 @@ public struct NavigationSheet<Content: View>: View {
}
}
}
#endif
}
#if os(iOS) || os(tvOS)
.sheetDetent(size)
#endif
}
#else
private var iOSBody: some View {
NavigationStackCompat {
content()
.navigationTitle(title ?? "")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
.sheetDetent(size)
}
#endif
}
#if os(iOS) || os(tvOS)
@@ -1,85 +0,0 @@
#if os(iOS)
import Runestone
import SwiftUI
import TreeSitterJSON5Runestone
struct RunestoneTextView: UIViewRepresentable {
@Binding var text: String
let isEditable: Bool
func makeUIView(context: Context) -> TextView {
let textView = TextView()
textView.showLineNumbers = true
textView.isLineWrappingEnabled = false
textView.showTabs = false
textView.showSpaces = false
textView.showLineBreaks = false
textView.showSoftLineBreaks = false
textView.showNonBreakingSpaces = false
textView.autocorrectionType = .no
textView.autocapitalizationType = .none
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.smartInsertDeleteType = .no
textView.backgroundColor = .secondarySystemGroupedBackground
textView.contentInsetAdjustmentBehavior = .always
textView.alwaysBounceVertical = true
textView.kern = 0.3
textView.lineHeightMultiplier = 1.3
textView.characterPairs = [
BasicCharacterPair(leading: "{", trailing: "}"),
BasicCharacterPair(leading: "[", trailing: "]"),
BasicCharacterPair(leading: "\"", trailing: "\""),
]
let theme = ProfileEditorTheme()
let state = TextViewState(text: text, theme: theme, language: .json5)
textView.setState(state)
textView.isEditable = isEditable
textView.editorDelegate = context.coordinator
return textView
}
func updateUIView(_ textView: TextView, context _: Context) {
if textView.text != text {
textView.text = text
}
if textView.isEditable != isEditable {
textView.isEditable = isEditable
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: TextViewDelegate {
var parent: RunestoneTextView
init(_ parent: RunestoneTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: TextView) {
parent.text = textView.text
}
}
}
final class BasicCharacterPair: CharacterPair {
let leading: String
let trailing: String
init(leading: String, trailing: String) {
self.leading = leading
self.trailing = trailing
}
}
#endif