diff --git a/Sources/App/Extensions/JSONLexer.swift b/Sources/App/Extensions/JSONLexer.swift new file mode 100644 index 00000000..d168341f --- /dev/null +++ b/Sources/App/Extensions/JSONLexer.swift @@ -0,0 +1,24 @@ +// +// Mocka +// + +import Sourceful + +/// A lexer for JSON files. +final class JSONLexer: SourceCodeRegexLexer { + /// The list of tokens used for the lexing. + lazy var generators: [TokenGenerator] = { + [ + keywordGenerator(["true", "false", "null"], tokenType: .keyword), + + regexGenerator("(-?)(0|[1-9][0-9]*)(\\.[0-9]*)?([eE][+\\-]?[0-9]*)?", tokenType: .number), + + regexGenerator("(\"|@\")[^\"\\n]*(@\"|\")", tokenType: .string), + ] + .compactMap { $0 } + }() + + func generators(source: String) -> [TokenGenerator] { + return generators + } +} diff --git a/Sources/App/Extensions/MockaSourceCodeTheme.swift b/Sources/App/Extensions/MockaSourceCodeTheme.swift new file mode 100644 index 00000000..234ecbcb --- /dev/null +++ b/Sources/App/Extensions/MockaSourceCodeTheme.swift @@ -0,0 +1,47 @@ +// +// Mocka +// + +import Sourceful + +/// An object describing the default Mocka theme for the source editor. +final class MockaSourceCodeTheme: SourceCodeTheme { + + let backgroundColor = Color(.lungo) + + let font: Font = .monospacedSystemFont(ofSize: 14, weight: .regular) + + let gutterStyle = GutterStyle(backgroundColor: Color(.espresso), minimumWidth: 32) + + let lineNumbersStyle: LineNumbersStyle? = LineNumbersStyle( + font: .monospacedSystemFont(ofSize: 14, weight: .regular), + textColor: Color(.macchiato) + ) + + // MARK: - Functions + + func color(for syntaxColorType: SourceCodeTokenType) -> Color { + switch syntaxColorType { + case .plain: + return Color.labelColor + + case .keyword: + return Color(named: "Keyword")! + + case .string: + return Color(named: "String")! + + case .comment: + return Color.secondaryLabelColor + + case .identifier: + return Color(named: "Keyword")! + + case .number: + return Color(named: "Number")! + + case .editorPlaceholder: + return Color.labelColor + } + } +} diff --git a/Sources/App/Helpers/NSTextView+Extensions.swift b/Sources/App/Helpers/NSTextView+Extensions.swift index 440d4982..fb58b685 100644 --- a/Sources/App/Helpers/NSTextView+Extensions.swift +++ b/Sources/App/Helpers/NSTextView+Extensions.swift @@ -12,12 +12,4 @@ extension NSTextView { drawsBackground = true } } - - /// Remove `TextEditor` scroll. - open override func scrollWheel(with event: NSEvent) { - // The 1st nextResponder is NSClipView. - // The 2nd nextResponder is NSScrollView. - // The 3rd nextResponder is NSResponder SwiftUIPlatformViewHost. - nextResponder?.nextResponder?.nextResponder?.scrollWheel(with: event) - } } diff --git a/Sources/App/Resources/Assets.xcassets/Highlighter/Contents.json b/Sources/App/Resources/Assets.xcassets/Highlighter/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/Highlighter/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App/Resources/Assets.xcassets/Highlighter/Keyword.colorset/Contents.json b/Sources/App/Resources/Assets.xcassets/Highlighter/Keyword.colorset/Contents.json new file mode 100644 index 00000000..2a011872 --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/Highlighter/Keyword.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x93", + "green" : "0x23", + "red" : "0x9B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.639", + "green" : "0.373", + "red" : "0.988" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App/Resources/Assets.xcassets/Highlighter/Number.colorset/Contents.json b/Sources/App/Resources/Assets.xcassets/Highlighter/Number.colorset/Contents.json new file mode 100644 index 00000000..eba4edc0 --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/Highlighter/Number.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xCF", + "green" : "0x00", + "red" : "0x1C" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x69", + "green" : "0xBF", + "red" : "0xD0" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App/Resources/Assets.xcassets/Highlighter/String.colorset/Contents.json b/Sources/App/Resources/Assets.xcassets/Highlighter/String.colorset/Contents.json new file mode 100644 index 00000000..9d2e6571 --- /dev/null +++ b/Sources/App/Resources/Assets.xcassets/Highlighter/String.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x16", + "green" : "0x1A", + "red" : "0xC4" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5D", + "green" : "0x6A", + "red" : "0xFC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/App/Views/Common/Editor.swift b/Sources/App/Views/Common/Editor.swift index 21386fff..6e8b59cf 100644 --- a/Sources/App/Views/Common/Editor.swift +++ b/Sources/App/Views/Common/Editor.swift @@ -2,6 +2,7 @@ // Mocka // +import Sourceful import SwiftUI import UniformTypeIdentifiers @@ -17,38 +18,35 @@ struct Editor: View { // MARK: - Body var body: some View { - ZStack { - VStack(spacing: 10) { - HStack { - Text("Response Body") - .frame(maxWidth: .infinity, alignment: .leading) - .font(.headline) - .foregroundColor(Color.latte) + VStack(spacing: 16) { + HStack { + Text("Response Body") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.headline) + .foregroundColor(Color.latte) - Spacer() + Spacer() - Button("Importa") { - viewModel.fileImporterIsPresented = true - } - .fileImporter( - isPresented: $viewModel.fileImporterIsPresented, - allowedContentTypes: [.html, .css, .csv, .text, .json, .xml], - allowsMultipleSelection: false, - onCompletion: viewModel.importFile(from:) - ) + Button("Import") { + viewModel.fileImporterIsPresented = true } - - TextEditor( - text: viewModel.mode == .write ? viewModel.text : .constant(viewModel.text.wrappedValue) + .fileImporter( + isPresented: $viewModel.fileImporterIsPresented, + allowedContentTypes: [.html, .css, .csv, .text, .json, .xml], + allowsMultipleSelection: false, + onCompletion: viewModel.importFile(from:) ) - .font(.body) - .frame(minHeight: 40) - .padding(4) - .background(Color.doppio) - .cornerRadius(8) - .padding(.bottom, 10) - .fixedSize(horizontal: false, vertical: true) } + + MockaSourceCodeTextEditor( + text: viewModel.text, + theme: MockaSourceCodeTheme(), + isEnabled: viewModel.mode == .write + ) + .font(.body) + .frame(height: 320) + .background(Color.lungo) + .clipShape(RoundedRectangle(cornerRadius: 8)) } .onDrop( of: [UTType.fileURL.identifier], @@ -62,6 +60,6 @@ struct Editor: View { struct EditorPreviews: PreviewProvider { static var previews: some View { - Editor(viewModel: EditorViewModel()) + Editor(viewModel: EditorViewModel(text: .constant(""))) } } diff --git a/Sources/App/Views/Common/EditorViewModel.swift b/Sources/App/Views/Common/EditorViewModel.swift index cbeb6903..18f19b6d 100644 --- a/Sources/App/Views/Common/EditorViewModel.swift +++ b/Sources/App/Views/Common/EditorViewModel.swift @@ -24,7 +24,7 @@ class EditorViewModel: ObservableObject { // MARK: - Stored Properties /// The text of the editor. - @Published var text: Binding + var text: Binding /// Wether the user is dragging a file over the editor. @Published var isDraggingOver = false @@ -37,7 +37,7 @@ class EditorViewModel: ObservableObject { // MARK: - Init - init(text: Binding = .constant(""), mode: Mode = .read) { + init(text: Binding, mode: Mode = .read) { self.text = text self.mode = mode } diff --git a/Sources/App/Views/Common/KeyValueTable/KeyValueTable.swift b/Sources/App/Views/Common/KeyValueTable/KeyValueTable.swift index f9e4c3d6..f6874585 100644 --- a/Sources/App/Views/Common/KeyValueTable/KeyValueTable.swift +++ b/Sources/App/Views/Common/KeyValueTable/KeyValueTable.swift @@ -30,7 +30,7 @@ struct KeyValueTable: View { } .drawingGroup(on: viewModel.mode == .read) } - .padding() + .padding(.horizontal) .background(Color.doppio) .cornerRadius(6) } diff --git a/Sources/App/Views/Common/MockaSourceCodeEditor.swift b/Sources/App/Views/Common/MockaSourceCodeEditor.swift new file mode 100644 index 00000000..cf7c60f6 --- /dev/null +++ b/Sources/App/Views/Common/MockaSourceCodeEditor.swift @@ -0,0 +1,81 @@ +// +// Mocka +// + +import Foundation +import Sourceful +import SwiftUI + +/// A source code editor with custom theme. +struct MockaSourceCodeTextEditor: NSViewRepresentable { + /// The text in the text view. + @Binding var text: String + + /// The lexer used to lex the content of the `SyntaxTextView`. + let lexer: SourceCodeRegexLexer + + /// The theme to apply to the content of the `SyntaxTextView`. + let theme: SourceCodeTheme + + /// Whether the `SyntaxTextView` should be enabled. + let isEnabled: Bool + + // MARK: - Init + + init(text: Binding, lexer: SourceCodeRegexLexer = JSONLexer(), theme: SourceCodeTheme, isEnabled: Bool = true) { + self._text = text + self.lexer = lexer + self.theme = theme + self.isEnabled = isEnabled + } + + // MARK: - Functions + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> SyntaxTextView { + let wrappedView = SyntaxTextView() + wrappedView.delegate = context.coordinator + wrappedView.theme = theme + wrappedView.text = text + + return wrappedView + } + + func updateNSView(_ view: SyntaxTextView, context: Context) { + context.coordinator.parent = self + + view.contentTextView.isEditable = isEnabled + view.text = text + } +} + +extension MockaSourceCodeTextEditor { + /// The `Coordinator` object managing the `MockaSourceCodeTextEditor`. + class Coordinator: SyntaxTextViewDelegate { + /// The parent `UIViewRepresentable` managed by the `Coordinator.` + var parent: MockaSourceCodeTextEditor + + // MARK: - Init + + init(_ parent: MockaSourceCodeTextEditor) { + self.parent = parent + } + + // MARK: - Functions + + public func lexerForSource(_ source: String) -> Lexer { + parent.lexer + } + + public func didChangeText(_ syntaxTextView: SyntaxTextView) { + guard parent.isEnabled else { + return + } + + parent.text = syntaxTextView.text + } + } +} diff --git a/Sources/App/Views/Sections/Editor/Detail/EditorDetail.swift b/Sources/App/Views/Sections/Editor/Detail/EditorDetail.swift index 96a45318..79b47087 100644 --- a/Sources/App/Views/Sections/Editor/Detail/EditorDetail.swift +++ b/Sources/App/Views/Sections/Editor/Detail/EditorDetail.swift @@ -15,88 +15,24 @@ struct EditorDetail: View { // MARK: - Body var body: some View { - VStack { + Group { if viewModel.shouldShowEmptyState { EmptyState(symbol: .document, text: "Select a request to display its details") } else { ScrollView { - RoundedTextField(title: "API custom name", text: $viewModel.displayedRequestName) - .padding(.horizontal, 26) - .padding(.vertical, 5) - .disabled(viewModel.isRequestNameTextFieldEnabled.isFalse) - - RoundedBorderDropdown( - title: "Select Folder", - items: viewModel.namespaceFolders, - itemTitleKeyPath: \.name, - selection: $viewModel.selectedRequestParentFolder, - isEnabled: viewModel.isRequestParentFolderTextFieldEnabled - ) - .padding(.horizontal, 26) - .padding(.vertical, 5) - - RoundedTextField(title: "Path", text: $viewModel.displayedRequestPath) - .padding(.horizontal, 26) - .padding(.vertical, 5) - .disabled(viewModel.isRequestPathTextFieldEnabled.isFalse) - - RoundedBorderDropdown( - title: "HTTP Method", - items: viewModel.allHTTPMethods, - itemTitleKeyPath: \.rawValue, - selection: $viewModel.selectedHTTPMethod, - isEnabled: viewModel.isHTTPMethodTextFieldEnabled - ) - .padding(.horizontal, 26) - .padding(.vertical, 5) - - RoundedTextField(title: "Response status code", text: $viewModel.displayedStatusCode) - .padding(.horizontal, 26) - .padding(.vertical, 5) - .disabled(viewModel.isStatusCodeTextFieldEnabled.isFalse) - - RoundedBorderDropdown( - title: "Response Content-Type", - items: viewModel.allContentTypes, - itemTitleKeyPath: \.rawValue, - selection: $viewModel.selectedContentType, - isEnabled: viewModel.isContentTypeTextFieldEnabled - ) - .padding(.horizontal, 26) - .padding(.vertical, 5) - - Text("If a Response Content-Type is selected, you need to provide a body. Otherwise, select \"none\".") - .padding(.horizontal, 26) - .padding(.top, -5) - .foregroundColor(.americano) - .frame(maxWidth: .infinity, alignment: .leading) - - VStack(spacing: 0) { - Text("Response Headers") - .font(.system(size: 13, weight: .semibold, design: .default)) - .foregroundColor(Color.latte) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - - KeyValueTable( - viewModel: KeyValueTableViewModel( - keyValueItems: $viewModel.displayedResponseHeaders, - mode: viewModel.currentMode == .read ? .read : .write - ) - ) - .padding(.horizontal, 10) - .padding(.bottom, 20) + VStack(spacing: 24) { + fieldsSection + + headersSection Editor(viewModel: EditorViewModel(text: $viewModel.displayedResponseBody, mode: viewModel.currentMode == .read ? .read : .write)) .disabled(viewModel.isResponseHeadersKeyValueTableEnabled.isFalse || viewModel.isResponseBodyEditorEnabled.isFalse) - .padding(.horizontal, 10) - .isVisible(viewModel.isEditorDetailResponseBodyVisible, remove: true) + .isVisible(viewModel.isEditorDetailResponseBodyVisible) } - .background(Color.lungo) - .cornerRadius(5) - .padding(24) + .padding(.horizontal, 16) + .padding(.vertical, 16) } - .padding(.top, 24) + .padding(.top, 1) } } .frame(minWidth: Size.minimumListWidth) @@ -137,4 +73,61 @@ struct EditorDetail: View { } } } + + private var fieldsSection: some View { + VStack(spacing: 12) { + RoundedTextField(title: "API custom name", text: $viewModel.displayedRequestName) + .disabled(viewModel.isRequestNameTextFieldEnabled.isFalse) + + RoundedBorderDropdown( + title: "Parent Folder", + items: viewModel.namespaceFolders, + itemTitleKeyPath: \.name, + selection: $viewModel.selectedRequestParentFolder, + isEnabled: viewModel.isRequestParentFolderTextFieldEnabled + ) + + RoundedTextField(title: "Path", text: $viewModel.displayedRequestPath) + .disabled(viewModel.isRequestPathTextFieldEnabled.isFalse) + + RoundedBorderDropdown( + title: "HTTP Method", + items: viewModel.allHTTPMethods, + itemTitleKeyPath: \.rawValue, + selection: $viewModel.selectedHTTPMethod, + isEnabled: viewModel.isHTTPMethodTextFieldEnabled + ) + + RoundedTextField(title: "Response status code", text: $viewModel.displayedStatusCode) + .disabled(viewModel.isStatusCodeTextFieldEnabled.isFalse) + + RoundedBorderDropdown( + title: "Response Content-Type", + items: viewModel.allContentTypes, + itemTitleKeyPath: \.rawValue, + selection: $viewModel.selectedContentType, + isEnabled: viewModel.isContentTypeTextFieldEnabled + ) + + Text("If a Response Content-Type is selected, you need to provide a body. Otherwise, select \"none\".") + .foregroundColor(.americano) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var headersSection: some View { + VStack(spacing: 16) { + Text("Response Headers") + .font(.system(size: 13, weight: .semibold, design: .default)) + .foregroundColor(Color.latte) + .frame(maxWidth: .infinity, alignment: .leading) + + KeyValueTable( + viewModel: KeyValueTableViewModel( + keyValueItems: $viewModel.displayedResponseHeaders, + mode: viewModel.currentMode == .read ? .read : .write + ) + ) + } + } } diff --git a/project.yml b/project.yml index 9ec1cfc6..3b29ddf6 100644 --- a/project.yml +++ b/project.yml @@ -22,6 +22,10 @@ packages: Vapor: url: https://github.com/vapor/vapor from: 4.0.0 + Sourceful: + url: https://github.com/twostraws/Sourceful + branch: main + Introspect: url: https://github.com/siteline/SwiftUI-Introspect from: 0.1.3 @@ -40,6 +44,7 @@ targets: - path: Sources/App dependencies: - target: MockaServer + - package: Sourceful - package: Introspect scheme: testTargets: