From ede614af25bd1c7c583227c71b05f39cb7c69e05 Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 10 Sep 2024 19:40:21 -0400 Subject: [PATCH] Minor design improvements --- .../Console/List/ConsoleListContentView.swift | 4 +- .../Console/List/ConsoleListView.swift | 3 +- .../Console/Views/ConsoleHelperViews.swift | 61 ++++ .../Console/Views/ConsoleMessageCell.swift | 31 +- .../Console/Views/ConsoleTaskCell.swift | 330 ++---------------- .../Console/Views/ConsoleToolbarView.swift | 6 +- .../SettingsConsoleCellDesignView.swift | 32 +- .../Features/Settings/SettingsView-ios.swift | 4 +- Sources/PulseUI/Helpers/TextRenderer.swift | 2 +- Sources/PulseUI/Helpers/UserSettings.swift | 25 +- Sources/PulseUI/Mocks/MockTask.swift | 4 +- 11 files changed, 147 insertions(+), 355 deletions(-) create mode 100644 Sources/PulseUI/Features/Console/Views/ConsoleHelperViews.swift diff --git a/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift b/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift index d0ae052d3..836f10bac 100644 --- a/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift +++ b/Sources/PulseUI/Features/Console/List/ConsoleListContentView.swift @@ -23,6 +23,7 @@ struct ConsoleListContentView: View { Text("Empty") .font(.subheadline) .foregroundColor(.secondary) + .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) } else { ForEach(viewModel.visibleEntities, id: \.objectID) { entity in let objectID = entity.objectID @@ -33,11 +34,12 @@ struct ConsoleListContentView: View { .onDisappear { viewModel.onDisappearCell(with: objectID) } #endif #if os(iOS) - .listRowInsets(EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 16)) + .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) #endif } } footerView + .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 16)) } @ViewBuilder diff --git a/Sources/PulseUI/Features/Console/List/ConsoleListView.swift b/Sources/PulseUI/Features/Console/List/ConsoleListView.swift index 0e86f38a8..28fea36d1 100644 --- a/Sources/PulseUI/Features/Console/List/ConsoleListView.swift +++ b/Sources/PulseUI/Features/Console/List/ConsoleListView.swift @@ -69,7 +69,8 @@ private struct _ConsoleListView: View { ConsoleSearchListContentView() } else { ConsoleToolbarView() - .listRowSeparator(.hidden, edges: .top) + .listRowSeparator(.hidden, edges: .all) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 8, trailing: 16)) ConsoleListContentView() } } diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleHelperViews.swift b/Sources/PulseUI/Features/Console/Views/ConsoleHelperViews.swift new file mode 100644 index 000000000..22e8ff61e --- /dev/null +++ b/Sources/PulseUI/Features/Console/Views/ConsoleHelperViews.swift @@ -0,0 +1,61 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import SwiftUI +import Pulse + +struct ConsoleTimestampView: View { + let date: Date + + var body: some View { + Text(ConsoleMessageCell.timeFormatter.string(from: date)) +#if os(tvOS) + .font(.system(size: 21)) +#else + .font(.caption) +#endif + .monospacedDigit() + .tracking(-0.5) + .lineLimit(1) + .foregroundStyle(.secondary) + } +} + +@available(iOS 15, visionOS 1, *) +struct MockBadgeView: View { + var body: some View { + Text("MOCK") + .foregroundStyle(.background) + .font(.caption2.weight(.semibold)) + .padding(EdgeInsets(top: 2, leading: 5, bottom: 1, trailing: 5)) + .background(Color.secondary.opacity(0.66)) + .clipShape(Capsule()) + } +} + +struct StatusIndicatorView: View { + let state: NetworkTaskEntity.State? + + var body: some View { + Image(systemName: "circle.fill") + .foregroundStyle(color) +#if os(tvOS) + .font(.system(size: 12)) +#else + .font(.system(size: 10)) +#endif + .clipShape(RoundedRectangle(cornerRadius: 3)) + } + + private var color: Color { + guard let state else { + return .secondary + } + switch state { + case .pending: return .orange + case .success: return .green + case .failure: return .red + } + } +} diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift index e31a1c602..e599b5f39 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift @@ -7,7 +7,7 @@ import Pulse import CoreData import Combine -@available(iOS 15, visionOS 1.0, *) +@available(iOS 15, visionOS 1, *) struct ConsoleMessageCell: View { let message: LoggerMessageEntity var isDisclosureNeeded = false @@ -38,15 +38,16 @@ struct ConsoleMessageCell: View { .font(.footnote) .foregroundColor(titleColor) Spacer() +#if !os(watchOS) Components.makePinView(for: message) - HStack(spacing: 3) { - ConsoleTimestampView(date: message.createdAt) - .overlay(alignment: .trailing) { - if isDisclosureNeeded { - ListDisclosureIndicator() - .offset(x: 11, y: 0) - } - } + ConsoleTimestampView(date: message.createdAt) + .padding(.trailing, 3) +#endif + } + .overlay(alignment: .trailing) { + if isDisclosureNeeded { + ListDisclosureIndicator() + .offset(x: 8, y: 0) } } } @@ -125,15 +126,3 @@ struct ConsoleMessageCell_Previews: PreviewProvider { } } #endif - -struct ConsoleConstants { -#if os(watchOS) - static let fontTitle = Font.system(size: 14) -#elseif os(macOS) - static let fontTitle = Font.subheadline -#elseif os(iOS) || os(visionOS) - static let fontTitle = Font.subheadline.monospacedDigit() -#else - static let fontTitle = Font.caption -#endif -} diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift index e0608bebd..ee0bf5712 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleTaskCell.swift @@ -7,9 +7,7 @@ import Pulse import Combine import CoreData -#if os(iOS) - -@available(iOS 15, visionOS 1.0, *) +@available(iOS 15, visionOS 1, *) struct ConsoleTaskCell: View { @ObservedObject var task: NetworkTaskEntity var isDisclosureNeeded = false @@ -19,15 +17,25 @@ struct ConsoleTaskCell: View { @Environment(\.store) private var store: LoggerStore var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 4) { header + content // .padding(.top, 4) +#if os(iOS) || os(watchOS) details - content.padding(.top, 3) +#endif } } // MARK: – Header +#if os(watchOS) + private var header: some View { + HStack { + StatusIndicatorView(state: task.state(in: store)) + info + } + } +#else private var header: some View { HStack(spacing: 6) { if task.isMocked { @@ -36,25 +44,35 @@ struct ConsoleTaskCell: View { info Spacer() ConsoleTimestampView(date: task.createdAt) + .padding(.trailing, 3) } .overlay(alignment: .leading) { StatusIndicatorView(state: task.state(in: store)) +#if os(tvOS) + .offset(x: -20) +#else .offset(x: -15) +#endif } .overlay(alignment: .trailing) { if isDisclosureNeeded { ListDisclosureIndicator() - .offset(x: 11) + .offset(x: 8) } } } +#endif private var info: some View { - var text: Text { - let status: Text = Text(ConsoleFormatter.status(for: task, store: store)) - .font(.footnote.weight(.medium)) - .foregroundColor(task.state == .failure ? .red : .primary) + let status: Text = Text(ConsoleFormatter.status(for: task, store: store)) + .font(detailsFont.weight(.medium)) + .foregroundColor(task.state == .failure ? .red : .primary) +#if os(watchOS) + return status // Not enough space for anything else +#else + + var text: Text { guard settings.displayOptions.isShowingDetails else { return status } @@ -64,18 +82,18 @@ struct ConsoleTaskCell: View { guard !details.isEmpty else { return status } - return status + Text(" · \(details)").font(.footnote) + return status + Text(" · \(details)").font(detailsFont) } return text .tracking(-0.1) .lineLimit(1) .foregroundStyle(.secondary) +#endif } - private func makeInfoText(for detail: DisplayOptions.Field) -> String? { switch detail { - case .method: + case .method: task.httpMethod case .requestSize: byteCount(for: task.requestBodySize) @@ -103,7 +121,7 @@ struct ConsoleTaskCell: View { if let host = task.host, !host.isEmpty { Text(host) .lineLimit(1) - .font(.footnote) + .font(detailsFont) .foregroundStyle(.secondary) } } @@ -155,289 +173,6 @@ struct ConsoleTaskCell: View { } } -private struct StatusIndicatorView: View { - let state: NetworkTaskEntity.State? - - var body: some View { - Image(systemName: "circle.fill") - .foregroundStyle(color) - .font(.system(size: 10)) - .clipShape(RoundedRectangle(cornerRadius: 3)) - } - - private var color: Color { - guard let state else { - return .secondary - } - switch state { - case .pending: return .orange - case .success: return .green - case .failure: return .red - } - } -} - -struct ConsoleTimestampView: View { - let date: Date - - var body: some View { - Text(ConsoleMessageCell.timeFormatter.string(from: date)) - .font(.caption) - .monospacedDigit() - .tracking(-0.5) - .foregroundStyle(.secondary) - } -} - -#else - -@available(iOS 15, visionOS 1.0, *) -struct ConsoleTaskCell: View { - @ObservedObject var task: NetworkTaskEntity - var isDisclosureNeeded = false - - @ScaledMetric(relativeTo: .body) private var fontMultiplier = 1.0 - @ObservedObject private var settings: UserSettings = .shared - @Environment(\.store) private var store: LoggerStore - - var body: some View { -#if os(macOS) - let spacing: CGFloat = 3 -#else - let spacing: CGFloat = 6 -#endif - - let contents = VStack(alignment: .leading, spacing: spacing) { - title.dynamicTypeSize(...DynamicTypeSize.xxxLarge) - content -#if !os(macOS) - details -#endif -#if os(iOS) || os(visionOS) - requestHeaders -#endif - } - .animation(.default, value: task.state) -#if os(macOS) - contents.padding(.vertical, 5) -#else - if #unavailable(iOS 16) { - contents.padding(.vertical, 4) - } else { - contents - } -#endif - } - - private var title: some View { - HStack(spacing: titleSpacing) { - if task.isMocked { - MockBadgeView() - .padding(.trailing, 2) - } - StatusLabelViewModel(task: task, store: store).text - .font(ConsoleConstants.fontTitle) - .fontWeight(.medium) - .foregroundColor(task.state.tintColor) - .lineLimit(1) -#if os(macOS) - details -#endif - Spacer() - Components.makePinView(for: task) -#if !os(watchOS) - HStack(spacing: 3) { - time - if isDisclosureNeeded { - ListDisclosureIndicator() - } - } -#endif - } - } - - private var time: some View { - Text(ConsoleMessageCell.timeFormatter.string(from: task.createdAt)) - .lineLimit(1) - .font(detailsFont) - .foregroundColor(.secondary) - .monospacedDigit() - } - - private var content: some View { - Text(task.getFormattedContent(options: settings.displayOptions) ?? "–") - .font(contentFont) - .lineLimit(settings.displayOptions.contentLineLimit) - .foregroundColor(.primary) - } - - @ViewBuilder - private var details: some View { -#if os(watchOS) - HStack { - Text(task.httpMethod ?? "GET") - .font(contentFont) - .foregroundColor(.secondary) - Spacer() - time - } -#elseif os(iOS) || os(visionOS) - infoText? - .lineLimit(settings.displayOptions.detailsLineLimit) - .font(detailsFont) - .foregroundColor(.secondary) - .padding(.top, 2) -#else - infoText? - .lineLimit(1) - .font(ConsoleConstants.fontTitle) - .foregroundColor(.secondary) -#endif - } - - private var infoText: Text? { - guard settings.displayOptions.isShowingDetails else { - return nil - } - var text = Text("") - var isEmpty = true - for detail in settings.displayOptions.detailsFields { - if let value = makeText(for: detail) { - if !isEmpty { - text = text + Text(" ") - } - isEmpty = false - text = text + value - } - } - return isEmpty ? nil : text - } - - private func makeText(for detail: DisplayOptions.Field) -> Text? { - switch detail { - case .method: - Text(task.httpMethod ?? "GET") - case .requestSize: - makeInfoText("arrow.up", byteCount(for: task.requestBodySize)) - case .responseSize: - makeInfoText("arrow.down", byteCount(for: task.responseBodySize)) - case .responseContentType: - task.responseContentType.map(NetworkLogger.ContentType.init).map { - Text($0.lastComponent.uppercased()) - } - case .duration: - ConsoleFormatter.duration(for: task).map { makeInfoText("clock", $0) } - case .host: - task.host.map { Text($0) } - case .statusCode: - task.statusCode != 0 ? Text(task.statusCode.description) : nil - case .taskType: - NetworkLogger.TaskType(rawValue: task.taskType).map { - Text($0.urlSessionTaskClassName) - } - case .taskDescription: - task.taskDescription.map { Text($0) } - } - } - - @ViewBuilder - private var requestHeaders: some View { - let headerValueMap = settings.displayHeaders.reduce(into: [String: String]()) { partialResult, header in - partialResult[header] = task.originalRequest?.headers[header] - } - ForEach(headerValueMap.keys.sorted(), id: \.self) { key in - HStack { - (Text(key + ": ") - .foregroundColor(.secondary) + - Text(headerValueMap[key] ?? "-")) - .font(.footnote) - .allowsTightening(true) - .lineLimit(3) - - Spacer() - } - .padding(.top, 6) - .padding(.trailing, -7) - } - } - - // MARK: - Helpers - - private var contentFont: Font { - let baseSize = CGFloat(settings.displayOptions.contentFontSize) - return Font.system(size: baseSize * fontMultiplier) - } - - private var detailsFont: Font { - let baseSize = CGFloat(settings.displayOptions.detailsFontSize) - return Font.system(size: baseSize * fontMultiplier).monospacedDigit() - } - - private func makeInfoText(_ image: String, _ text: String) -> Text { - Text(Image(systemName: image)).fontWeight(.light) + Text(" " + text) - } - - private func byteCount(for size: Int64) -> String { - guard size > 0 else { return "0 KB" } - return ByteCountFormatter.string(fromByteCount: size) - } -} - -#if os(macOS) -private let infoSpacing: CGFloat = 8 -#else -private let infoSpacing: CGFloat = 14 -#endif - -#if os(tvOS) -private let titleSpacing: CGFloat = 20 -#else -private let titleSpacing: CGFloat? = nil -#endif - -#endif - -#if os(iOS) -@available(iOS 15, visionOS 1.0, *) -struct MockBadgeView: View { - var body: some View { - Text("MOCK") - .foregroundStyle(.background) - .font(.caption2.weight(.semibold)) - .padding(EdgeInsets(top: 2, leading: 5, bottom: 1, trailing: 5)) - .background(Color.secondary.opacity(0.66)) - .clipShape(Capsule()) - } -} -#else -@available(iOS 15, visionOS 1.0, *) -struct MockBadgeView: View { - var body: some View { - Text("MOCK") -#if os(watchOS) - .font(.footnote) -#elseif os(tvOS) - .font(.caption2) -#else - .font(ConsoleConstants.fontTitle) - .fontWeight(.medium) -#endif - .foregroundStyle(Color.white) - .background(background) - } - - private var background: some View { - Capsule() - .foregroundStyle(Color.indigo) - .padding(-2) - .padding(.horizontal, -3) -#if os(tvOS) - .padding(-2) -#endif - } -} -#endif - #if DEBUG @available(iOS 15, visionOS 1.0, *) struct ConsoleTaskCell_Previews: PreviewProvider { @@ -448,4 +183,3 @@ struct ConsoleTaskCell_Previews: PreviewProvider { } } #endif - diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift index 4d35dd9b3..e3b9d4314 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleToolbarView.swift @@ -139,11 +139,13 @@ struct ConsoleListOptionsView: View { Button(action: { filters.options.isOnlyErrors.toggle() }) { Text(Image(systemName: filters.options.isOnlyErrors ? "exclamationmark.octagon.fill" : "exclamationmark.octagon")) .font(.body) - .foregroundColor(.red) + .foregroundColor(filters.options.isOnlyErrors ? .white : .secondary) } .cornerRadius(4) - .padding(.leading, 1) .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(7) + .background(filters.options.isOnlyErrors ? .red : Color(.secondarySystemFill).opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 10)) } } diff --git a/Sources/PulseUI/Features/Settings/SettingsConsoleCellDesignView.swift b/Sources/PulseUI/Features/Settings/SettingsConsoleCellDesignView.swift index 885b09036..58d840d96 100644 --- a/Sources/PulseUI/Features/Settings/SettingsConsoleCellDesignView.swift +++ b/Sources/PulseUI/Features/Settings/SettingsConsoleCellDesignView.swift @@ -2,11 +2,11 @@ // // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). -#if os(iOS) - import SwiftUI import Pulse +#if os(iOS) + struct SettingsConsoleCellDesignView: View { @EnvironmentObject private var settings: UserSettings @@ -172,6 +172,20 @@ private struct ConsoleFieldPicker: View { } } +#if DEBUG +#Preview { + NavigationView { + SettingsConsoleCellDesignView() + .injecting(ConsoleEnvironment(store: StorePreview.store!)) + .environmentObject(UserSettings.shared) + .navigationTitle("Cell Design") + .navigationBarTitleDisplayMode(.inline) + } +} +#endif + +#endif + enum StorePreview { static let store = try? LoggerStore(storeURL: URL(fileURLWithPath: "/dev/null"), options: [.synchronous, .inMemory]) @@ -200,17 +214,3 @@ enum StorePreview { return task }() } - -#if DEBUG -#Preview { - NavigationView { - SettingsConsoleCellDesignView() - .injecting(ConsoleEnvironment(store: StorePreview.store!)) - .environmentObject(UserSettings.shared) - .navigationTitle("Cell Design") - .navigationBarTitleDisplayMode(.inline) - } -} -#endif - -#endif diff --git a/Sources/PulseUI/Features/Settings/SettingsView-ios.swift b/Sources/PulseUI/Features/Settings/SettingsView-ios.swift index c2db2b49a..067c24f78 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-ios.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-ios.swift @@ -25,6 +25,7 @@ public struct SettingsView: View { store === RemoteLogger.shared.store { RemoteLoggerSettingsView(viewModel: .shared) } +#if os(iOS) Section("Appearance") { NavigationLink { SettingsConsoleCellDesignView() @@ -32,9 +33,10 @@ public struct SettingsView: View { } label: { Text("Cell Design") } - + Toggle("Link Detection", isOn: $settings.isLinkDetectionEnabled) } +#endif Section(header: Text("List headers"), footer: Text("These headers will be included in the list view")) { ForEach(settings.displayHeaders, id: \.self) { Text($0) diff --git a/Sources/PulseUI/Helpers/TextRenderer.swift b/Sources/PulseUI/Helpers/TextRenderer.swift index 0648b555c..8ac808606 100644 --- a/Sources/PulseUI/Helpers/TextRenderer.swift +++ b/Sources/PulseUI/Helpers/TextRenderer.swift @@ -447,7 +447,7 @@ struct ConsoleTextRenderer_Previews: PreviewProvider { .previewLayout(.fixed(width: 1160, height: 2000)) // Disable interaction to view it .previewDisplayName("HTML (Raw)") -#if !os(tvOS) +#if os(iOS) WebView(data: html, contentType: "application/html") .edgesIgnoringSafeArea([.bottom]) .previewDisplayName("HTML") diff --git a/Sources/PulseUI/Helpers/UserSettings.swift b/Sources/PulseUI/Helpers/UserSettings.swift index 8e4d8089e..84cab29be 100644 --- a/Sources/PulseUI/Helpers/UserSettings.swift +++ b/Sources/PulseUI/Helpers/UserSettings.swift @@ -102,8 +102,13 @@ public final class UserSettings: ObservableObject { /// The line limit for messages in the console. By default, `1`. public var detailsLineLimit: Int = 1 +#if os(macOS) || os(tvOS) /// Fields to display below the main text label. - public var detailsFields: [Field] + public var detailsFields: [Field] = [.responseSize, .duration, .host] +#else + /// Fields to display below the main text label. + public var detailsFields: [Field] = [.responseSize, .duration] +#endif public enum ContentComponent: String, Identifiable, CaseIterable, Codable { case scheme, user, password, host, port, path, query, fragment @@ -149,26 +154,22 @@ public final class UserSettings: ObservableObject { case extraLarge = 1.2 } - public init( - detailsFields: [Field] = [.responseSize, .duration] - ) { - self.detailsFields = detailsFields - } + public init() {} } } #if os(watchOS) -let defaultContentFontSize = 15 -let defaultDefailsFontSize = 13 +let defaultContentFontSize = 17 +let defaultDefailsFontSize = 14 #elseif os(macOS) let defaultContentFontSize = 13 let defaultDefailsFontSize = 11 #elseif os(iOS) || os(visionOS) -let defaultContentFontSize = 16 -let defaultDefailsFontSize = 12 +let defaultContentFontSize = 17 +let defaultDefailsFontSize = 13 #elseif os(tvOS) -let defaultContentFontSize = 25 -let defaultDefailsFontSize = 20 +let defaultContentFontSize = 27 +let defaultDefailsFontSize = 21 #endif typealias DisplayOptions = UserSettings.DisplayOptions diff --git a/Sources/PulseUI/Mocks/MockTask.swift b/Sources/PulseUI/Mocks/MockTask.swift index 8ccfbec73..8a596264b 100644 --- a/Sources/PulseUI/Mocks/MockTask.swift +++ b/Sources/PulseUI/Mocks/MockTask.swift @@ -474,7 +474,7 @@ private let mockDownloadNukeResponse = HTTPURLResponse(url: "https://codeload.gi // MARK: - Upload (POST) -private let mockUploadPulseOriginalRequest = URLRequest(url: "https://objects-origin.githubusercontent.com/github-production-release-asset-2e65be", method: "POST", headers: [ +private let mockUploadPulseOriginalRequest = URLRequest(url: "https://objects-origin.githubusercontent.com/github-production-release-asset-2e65be/upload-we9zs7v.zip", method: "POST", headers: [ "Content-Length": "21851748", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryrv8XAHQPtQcWta3k" ]) @@ -486,7 +486,7 @@ private let mockUploadPulseCurrentRequest = mockUploadPulseOriginalRequest.addin "Accept": "*/*" ]) -private let mockUploadPulseResponse = HTTPURLResponse(url: "https://objects-origin.githubusercontent.com/github-production-release-asset-2e65be", statusCode: 204, headers: [ +private let mockUploadPulseResponse = HTTPURLResponse(url: "https://objects-origin.githubusercontent.com/github-production-release-asset-2e65be/upload-we9zs7v.zip", statusCode: 204, headers: [ "Vary": "Origin", "Access-Control-Allow-Origin": "https://github.com" ])