ReferenceGithubPermalinkView.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. //
  2. // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
  3. // SPDX-License-Identifier: GPL-3.0-or-later
  4. //
  5. import Foundation
  6. import SwiftyAttributes
  7. @objcMembers class ReferenceGithubPermalinkView: UIView {
  8. @IBOutlet var contentView: UIView!
  9. @IBOutlet weak var referenceTypeIcon: UIImageView!
  10. @IBOutlet weak var referenceTitle: UILabel!
  11. @IBOutlet weak var referenceBody: UITextView!
  12. var url: String?
  13. var allLines: [String]?
  14. var lineBegin = 0
  15. var lineEnd = 0
  16. var fileName = ""
  17. var owner = ""
  18. var repo = ""
  19. override init(frame: CGRect) {
  20. super.init(frame: frame)
  21. commonInit()
  22. }
  23. required init?(coder aDecoder: NSCoder) {
  24. super.init(coder: aDecoder)
  25. commonInit()
  26. }
  27. func commonInit() {
  28. Bundle.main.loadNibNamed("ReferenceGithubPermalinkView", owner: self, options: nil)
  29. contentView.frame = self.bounds
  30. contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
  31. referenceTitle.text = ""
  32. referenceBody.text = ""
  33. referenceTypeIcon.image = nil
  34. // Remove padding from textView and adjust lineBreakMode
  35. referenceBody.textContainerInset = .zero
  36. referenceBody.textContainer.lineFragmentPadding = .zero
  37. referenceBody.textContainer.lineBreakMode = .byTruncatingTail
  38. referenceBody.textContainer.maximumNumberOfLines = 3
  39. let tap = UITapGestureRecognizer(target: self, action: #selector(self.handleTap))
  40. contentView.addGestureRecognizer(tap)
  41. self.addSubview(contentView)
  42. }
  43. func handleTap() {
  44. if let url = url, let allLines = allLines {
  45. // Use a monospaced font here to make overlaying the two textViews possible
  46. guard let font = Font(name: "Menlo", size: 16) else {
  47. return
  48. }
  49. // Calculate the size/width of the line numbers at the front of each line
  50. let sizeOfLineNumbersAndTab = ("\(self.lineEnd): " as NSString).size(withAttributes: [NSAttributedString.Key.font: font])
  51. // Create a paragraph with
  52. let paragraphStyle = NSMutableParagraphStyle()
  53. paragraphStyle.headIndent = sizeOfLineNumbersAndTab.width.rounded(.up)
  54. // We have actually two different attributed strings, one has line numbers prefixed to the actual line
  55. // and one is without line numbers to allow overlaying it in the view controller
  56. var sourceWithNumbers = NSAttributedString()
  57. var sourceWithoutNumbers = NSAttributedString()
  58. var lineCounter = self.lineBegin
  59. // In case of a single line reference, we don't receive a lineEnd property
  60. if self.lineEnd < self.lineBegin {
  61. self.lineEnd = self.lineBegin
  62. }
  63. // Remove any global indentation (preview only does it for the first 3 lines)
  64. var tempLines = removeIndentation(for: " ", in: allLines)
  65. tempLines = removeIndentation(for: "\t", in: tempLines)
  66. // We need to pad the line numbers with a space to have them align properly
  67. // so we determine the character count of the largest linenumber
  68. let maximumLineNumberLength = String(lineEnd).count
  69. for line in tempLines {
  70. // Tabs might have a bad impact on indentation, so we replace them by default with spaces
  71. var tempLine = line.replacingOccurrences(of: "\t", with: " ")
  72. // Empty lines have a different height in the textview, so we replace them with a space
  73. if line.isEmpty {
  74. tempLine = " "
  75. }
  76. // Create the plain source code as a attributed string
  77. let formattedLine = tempLine.withFont(font).withTextColor(.label) + "\n".attributedString
  78. sourceWithoutNumbers += formattedLine
  79. // Make sure the line numbers are probably padded to the left
  80. let lineCounterString = String(lineCounter)
  81. let lineNumberString = String(repeating: " ", count: maximumLineNumberLength - lineCounterString.count) + lineCounterString
  82. // Create the source code as a attributed string including the line counter
  83. var attributedLineNumber = lineNumberString.withTextColor(.secondaryLabel) + ": ".withTextColor(.secondaryLabel)
  84. attributedLineNumber = attributedLineNumber.withFont(font)
  85. // Include a paragraph style to make sure that breaked lines are indented after the line numbers
  86. let formattedLineWithLineNumber = attributedLineNumber + formattedLine
  87. sourceWithNumbers += formattedLineWithLineNumber.withParagraphStyle(paragraphStyle)
  88. lineCounter += 1
  89. }
  90. let permalinkVC = GithubPermalinkViewController(url: url,
  91. sourceWithLineNumbers: sourceWithNumbers,
  92. sourceWithoutLineNumbers: sourceWithoutNumbers,
  93. owner: self.owner,
  94. repo: self.repo,
  95. filePath: self.fileName,
  96. lineNumberWidth: sizeOfLineNumbersAndTab.width)
  97. let navigationVC = UINavigationController(rootViewController: permalinkVC)
  98. NCUserInterfaceController.sharedInstance().mainViewController.present(navigationVC, animated: true)
  99. }
  100. }
  101. // This method tries to remove "global" indentation, while keeping the "local" indentation
  102. //
  103. // Input:
  104. // <div>
  105. // Test
  106. // </div>
  107. //
  108. // Output:
  109. // <div>
  110. // Test
  111. // </div>
  112. func removeIndentation(for character: Character, in elements: [String]) -> [String] {
  113. let firstLines = elements
  114. var totalIndentation: Int?
  115. // Calculate the "global" indentation count
  116. for line in firstLines {
  117. // Check how many indentation characters are at the beginning of the string
  118. let lineIndentation = line.prefix(while: { $0 == character }).count
  119. if totalIndentation == nil {
  120. // There was no previous totalIdentation, so we use this one as a starting point
  121. totalIndentation = lineIndentation
  122. } else if lineIndentation < totalIndentation ?? 0, !line.isEmpty {
  123. // We found a line with a lower number of intendation characters -> use this
  124. totalIndentation = lineIndentation
  125. }
  126. }
  127. // Remove indentation for each line
  128. if let totalIndentation = totalIndentation, totalIndentation > 0 {
  129. var tempLines: [String] = []
  130. // Example: If we calculated a total indentation of 5, the search string would be " "
  131. let searchString = String(repeating: character, count: totalIndentation)
  132. for line in firstLines {
  133. // Check if the search string actually appears in the current line
  134. if let range = line.range(of: searchString) {
  135. // Detect if the search string is at the beginning of the current line
  136. let startIndexOfRange = line.distance(from: line.startIndex, to: range.lowerBound)
  137. if startIndexOfRange == 0 {
  138. // Replace the global indentation at the beginning of this line and keep the rest
  139. let replacedLine = line.replacingOccurrences(of: searchString, with: "", range: range)
  140. tempLines.append(replacedLine)
  141. continue
  142. }
  143. }
  144. tempLines.append(line)
  145. }
  146. return tempLines
  147. } else {
  148. return elements
  149. }
  150. }
  151. func update(for reference: [String: AnyObject], and url: String) {
  152. self.url = url
  153. self.referenceTypeIcon.image = UIImage(named: "github")?.withTintColor(.systemGray)
  154. let font = Font.systemFont(ofSize: 15)
  155. if let type = reference["github_type"] as? String, type == "code-error" {
  156. referenceTitle.text = NSLocalizedString("GitHub API error", comment: "")
  157. if let bodyDict = reference["body"] as? [String: String],
  158. let body = bodyDict["message"] {
  159. referenceBody.attributedText = body.withFont(font).withTextColor(.secondaryLabel)
  160. } else {
  161. referenceBody.attributedText = NSLocalizedString("Unknown error", comment: "").withFont(font).withTextColor(.secondaryLabel)
  162. }
  163. return
  164. }
  165. if let filePath = reference["filePath"] as? String {
  166. let filePathUrl = URL(string: filePath)
  167. self.referenceTitle.text = filePathUrl?.lastPathComponent
  168. self.fileName = filePathUrl?.lastPathComponent ?? ""
  169. }
  170. self.lineBegin = reference["lineBegin"] as? Int ?? 0
  171. self.lineEnd = reference["lineEnd"] as? Int ?? 0
  172. self.owner = reference["owner"] as? String ?? ""
  173. self.repo = reference["repo"] as? String ?? ""
  174. if let lines = reference["lines"] as? [String], !lines.isEmpty {
  175. // Remove global indentation if possible
  176. let previewLines = Array(lines.prefix(upTo: min(3, lines.count)))
  177. var tempLines = removeIndentation(for: " ", in: previewLines)
  178. tempLines = removeIndentation(for: "\t", in: tempLines)
  179. var previewString = NSAttributedString()
  180. // Each line should have its own lineBreakMode, therefore each line has a paragraph style attached
  181. let paragraphStyle = NSMutableParagraphStyle()
  182. paragraphStyle.lineBreakMode = .byTruncatingTail
  183. for line in tempLines {
  184. let attributedLine = line.withParagraphStyle(paragraphStyle).withFont(font).withTextColor(.secondaryLabel)
  185. previewString += attributedLine + NSAttributedString(string: "\n")
  186. }
  187. self.allLines = lines
  188. self.referenceBody.attributedText = previewString
  189. } else {
  190. self.allLines = []
  191. self.referenceBody.text = ""
  192. }
  193. }
  194. }