Skip to content

Commit dc6917a

Browse files
committed
Merge add-git-diff-panel
2 parents d5fdfca + b6d200f commit dc6917a

13 files changed

Lines changed: 1445 additions & 25 deletions
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import AppKit
2+
import Foundation
3+
4+
enum DiffSyntaxHighlighter {
5+
private static let regexCache = RegexCache()
6+
struct Theme {
7+
let font: NSFont
8+
let textColor: NSColor
9+
let secondaryTextColor: NSColor
10+
let hunkColor: NSColor
11+
let addBackground: NSColor
12+
let deleteBackground: NSColor
13+
let addPrefix: NSColor
14+
let deletePrefix: NSColor
15+
let keywordColor: NSColor
16+
let stringColor: NSColor
17+
let commentColor: NSColor
18+
let numberColor: NSColor
19+
let typeColor: NSColor
20+
21+
static var `default`: Theme {
22+
Theme(
23+
font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
24+
textColor: .labelColor,
25+
secondaryTextColor: .secondaryLabelColor,
26+
hunkColor: .systemPurple,
27+
addBackground: NSColor.systemGreen.withAlphaComponent(0.15),
28+
deleteBackground: NSColor.systemRed.withAlphaComponent(0.15),
29+
addPrefix: .systemGreen,
30+
deletePrefix: .systemRed,
31+
keywordColor: .systemPurple,
32+
stringColor: .systemGreen,
33+
commentColor: .systemGray,
34+
numberColor: .systemOrange,
35+
typeColor: .systemTeal
36+
)
37+
}
38+
}
39+
40+
static func highlightedDiff(
41+
text: String,
42+
filePath: String?,
43+
theme: Theme = .default
44+
) -> NSAttributedString {
45+
let output = NSMutableAttributedString()
46+
let language = Language.from(filePath: filePath)
47+
48+
var currentLocation = 0
49+
let lines = splitLines(text)
50+
for line in lines {
51+
let lineAttr = NSMutableAttributedString(string: line)
52+
applyBaseAttributes(lineAttr, theme: theme)
53+
54+
let isHeader = line.hasPrefix("diff --git")
55+
|| line.hasPrefix("index ")
56+
|| line.hasPrefix("+++ ")
57+
|| line.hasPrefix("--- ")
58+
let isHunk = line.hasPrefix("@@")
59+
let isAdd = line.hasPrefix("+") && !line.hasPrefix("+++ ")
60+
let isDel = line.hasPrefix("-") && !line.hasPrefix("--- ")
61+
62+
if isHeader {
63+
lineAttr.addAttribute(.foregroundColor, value: theme.secondaryTextColor, range: NSRange(location: 0, length: lineAttr.length))
64+
} else if isHunk {
65+
lineAttr.addAttribute(.foregroundColor, value: theme.hunkColor, range: NSRange(location: 0, length: lineAttr.length))
66+
} else if isAdd {
67+
lineAttr.addAttribute(.backgroundColor, value: theme.addBackground, range: NSRange(location: 0, length: lineAttr.length))
68+
if lineAttr.length > 0 {
69+
lineAttr.addAttribute(.foregroundColor, value: theme.addPrefix, range: NSRange(location: 0, length: 1))
70+
}
71+
let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1))
72+
applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme)
73+
} else if isDel {
74+
lineAttr.addAttribute(.backgroundColor, value: theme.deleteBackground, range: NSRange(location: 0, length: lineAttr.length))
75+
if lineAttr.length > 0 {
76+
lineAttr.addAttribute(.foregroundColor, value: theme.deletePrefix, range: NSRange(location: 0, length: 1))
77+
}
78+
let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1))
79+
applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme)
80+
} else if line.hasPrefix(" ") {
81+
let contentRange = NSRange(location: 1, length: max(0, lineAttr.length - 1))
82+
applySyntax(lineAttr, language: language, contentRange: contentRange, theme: theme)
83+
} else {
84+
applySyntax(lineAttr, language: language, contentRange: NSRange(location: 0, length: lineAttr.length), theme: theme)
85+
}
86+
87+
output.append(lineAttr)
88+
currentLocation += line.count
89+
}
90+
91+
return output
92+
}
93+
94+
private static func applyBaseAttributes(_ attr: NSMutableAttributedString, theme: Theme) {
95+
attr.addAttribute(.font, value: theme.font, range: NSRange(location: 0, length: attr.length))
96+
attr.addAttribute(.foregroundColor, value: theme.textColor, range: NSRange(location: 0, length: attr.length))
97+
}
98+
99+
private static func applySyntax(
100+
_ attr: NSMutableAttributedString,
101+
language: Language,
102+
contentRange: NSRange,
103+
theme: Theme
104+
) {
105+
guard contentRange.length > 0 else { return }
106+
let string = attr.string as NSString
107+
let patterns = regexCache.patterns(for: language)
108+
109+
for range in matchRanges(patterns.comment, in: string, range: contentRange) {
110+
attr.addAttribute(.foregroundColor, value: theme.commentColor, range: range)
111+
}
112+
113+
for range in matchRanges(patterns.string, in: string, range: contentRange) {
114+
attr.addAttribute(.foregroundColor, value: theme.stringColor, range: range)
115+
}
116+
117+
for range in matchRanges(patterns.number, in: string, range: contentRange) {
118+
attr.addAttribute(.foregroundColor, value: theme.numberColor, range: range)
119+
}
120+
121+
for range in matchRanges(patterns.type, in: string, range: contentRange) {
122+
attr.addAttribute(.foregroundColor, value: theme.typeColor, range: range)
123+
}
124+
125+
for range in matchRanges(patterns.keyword, in: string, range: contentRange) {
126+
attr.addAttribute(.foregroundColor, value: theme.keywordColor, range: range)
127+
}
128+
}
129+
130+
private static func matchRanges(_ regex: NSRegularExpression, in string: NSString, range: NSRange) -> [NSRange] {
131+
regex.matches(in: string as String, options: [], range: range).map { $0.range }
132+
}
133+
134+
private static func splitLines(_ text: String) -> [String] {
135+
if text.isEmpty { return [""] }
136+
var lines: [String] = []
137+
var current = ""
138+
for ch in text {
139+
current.append(ch)
140+
if ch == "\n" {
141+
lines.append(current)
142+
current = ""
143+
}
144+
}
145+
if !current.isEmpty {
146+
lines.append(current)
147+
}
148+
return lines
149+
}
150+
151+
enum Language {
152+
case swift
153+
case js
154+
case ts
155+
case python
156+
case go
157+
case rust
158+
case zig
159+
case cpp
160+
case c
161+
case yaml
162+
case json
163+
case shell
164+
case unknown
165+
166+
static func from(filePath: String?) -> Language {
167+
guard let filePath else { return .unknown }
168+
let ext = URL(fileURLWithPath: filePath).pathExtension.lowercased()
169+
switch ext {
170+
case "swift": return .swift
171+
case "js", "jsx": return .js
172+
case "ts", "tsx": return .ts
173+
case "py": return .python
174+
case "go": return .go
175+
case "rs": return .rust
176+
case "zig": return .zig
177+
case "c", "h": return .c
178+
case "cc", "cpp", "cxx", "hpp", "hh", "hxx": return .cpp
179+
case "yml", "yaml": return .yaml
180+
case "json": return .json
181+
case "sh", "bash", "zsh": return .shell
182+
default: return .unknown
183+
}
184+
}
185+
186+
}
187+
}
188+
189+
private final class RegexCache {
190+
struct Patterns {
191+
let keyword: NSRegularExpression
192+
let type: NSRegularExpression
193+
let string: NSRegularExpression
194+
let comment: NSRegularExpression
195+
let number: NSRegularExpression
196+
}
197+
198+
private var cache: [DiffSyntaxHighlighter.Language: Patterns] = [:]
199+
200+
func patterns(for language: DiffSyntaxHighlighter.Language) -> Patterns {
201+
if let cached = cache[language] { return cached }
202+
let patterns = makePatterns(for: language)
203+
cache[language] = patterns
204+
return patterns
205+
}
206+
207+
private func makePatterns(for language: DiffSyntaxHighlighter.Language) -> Patterns {
208+
let keywordPattern: String
209+
switch language {
210+
case .swift:
211+
keywordPattern = "\\b(class|struct|enum|protocol|extension|func|let|var|if|else|for|while|switch|case|default|return|break|continue|import|guard|throw|throws|try|catch|public|private|fileprivate|internal|open|static|mutating|inout|where|as|is|nil|true|false)\\b"
212+
case .js:
213+
keywordPattern = "\\b(function|const|let|var|if|else|for|while|switch|case|default|return|break|continue|import|from|export|class|extends|new|try|catch|finally|throw|async|await|this|super|null|true|false)\\b"
214+
case .ts:
215+
keywordPattern = "\\b(function|const|let|var|if|else|for|while|switch|case|default|return|break|continue|import|from|export|class|extends|new|try|catch|finally|throw|async|await|this|super|null|true|false|interface|type|implements|enum)\\b"
216+
case .python:
217+
keywordPattern = "\\b(def|class|import|from|as|if|elif|else|for|while|return|try|except|finally|with|yield|lambda|pass|break|continue|None|True|False)\\b"
218+
case .go:
219+
keywordPattern = "\\b(func|package|import|if|else|for|range|switch|case|default|return|break|continue|type|struct|interface|map|chan|go|defer|select|const|var)\\b"
220+
case .rust:
221+
keywordPattern = "\\b(fn|let|mut|pub|struct|enum|impl|trait|use|mod|crate|if|else|match|while|for|in|loop|return|break|continue|self|super|crate|const|static|ref)\\b"
222+
case .zig:
223+
keywordPattern = "\\b(const|var|fn|struct|enum|union|if|else|switch|while|for|break|continue|return|try|catch|async|await|comptime|anytype)\\b"
224+
case .cpp, .c:
225+
keywordPattern = "\\b(auto|bool|break|case|catch|class|const|constexpr|continue|default|delete|do|else|enum|explicit|extern|false|for|friend|goto|if|inline|namespace|new|nullptr|operator|private|protected|public|return|sizeof|static|struct|switch|template|this|throw|true|try|typedef|typename|union|using|virtual|void|volatile|while)\\b"
226+
case .yaml:
227+
keywordPattern = "^(\\s*)([\\w\\-]+)(?=\\:)"
228+
case .json:
229+
keywordPattern = "\"(\\\\.|[^\"])*\"(?=\\s*\\:)"
230+
case .shell:
231+
keywordPattern = "\\b(if|then|else|fi|for|in|do|done|case|esac|while|until|function|select|time|return|break|continue)\\b"
232+
case .unknown:
233+
keywordPattern = "$^"
234+
}
235+
236+
let typePattern: String = switch language {
237+
case .swift, .ts, .js: "\\b[A-Z][A-Za-z0-9_]*\\b"
238+
default: "$^"
239+
}
240+
241+
let stringPattern: String = switch language {
242+
case .python: "(\"\"\"[\\s\\S]*?\"\"\"|'''[\\s\\S]*?'''|\"(\\\\.|[^\"])*\"|'(\\\\.|[^'])*')"
243+
default: "(\"(\\\\.|[^\"])*\"|'(\\\\.|[^'])*')"
244+
}
245+
246+
let commentPattern: String = switch language {
247+
case .python, .yaml, .shell: "#.*$"
248+
case .json: "$^"
249+
default: "(//.*$|/\\*[\\s\\S]*?\\*/)"
250+
}
251+
252+
let numberPattern = "\\b\\d+(\\.\\d+)?\\b"
253+
254+
let keyword = (try? NSRegularExpression(pattern: keywordPattern, options: [.anchorsMatchLines])) ?? NSRegularExpression()
255+
let type = (try? NSRegularExpression(pattern: typePattern, options: [])) ?? NSRegularExpression()
256+
let string = (try? NSRegularExpression(pattern: stringPattern, options: [])) ?? NSRegularExpression()
257+
let comment = (try? NSRegularExpression(pattern: commentPattern, options: [.anchorsMatchLines])) ?? NSRegularExpression()
258+
let number = (try? NSRegularExpression(pattern: numberPattern, options: [])) ?? NSRegularExpression()
259+
260+
return Patterns(keyword: keyword, type: type, string: string, comment: comment, number: number)
261+
}
262+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
struct GitDiffMainView: View {
5+
@ObservedObject var state: GitDiffSidebarState
6+
7+
var body: some View {
8+
content
9+
.frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude)
10+
}
11+
12+
@ViewBuilder
13+
private var content: some View {
14+
if state.isDiffLoading {
15+
VStack {
16+
Spacer()
17+
ProgressView()
18+
.controlSize(.small)
19+
Spacer()
20+
}
21+
} else if let diffError = state.diffError, !diffError.isEmpty {
22+
Text(diffError)
23+
.font(.caption)
24+
.foregroundStyle(.secondary)
25+
.padding(12)
26+
.frame(maxWidth: .infinity, alignment: .leading)
27+
} else if state.diffText.isEmpty {
28+
Text("No diff")
29+
.font(.caption)
30+
.foregroundStyle(.secondary)
31+
.padding(12)
32+
.frame(maxWidth: .infinity, alignment: .leading)
33+
} else {
34+
DiffTextViewRepresentable(
35+
text: state.diffText,
36+
filePath: state.selectedPath
37+
)
38+
}
39+
}
40+
}
41+
42+
private struct DiffTextViewRepresentable: NSViewRepresentable {
43+
let text: String
44+
let filePath: String?
45+
46+
func makeNSView(context: Context) -> NSScrollView {
47+
let textView = DiffTextView()
48+
textView.isEditable = false
49+
textView.isSelectable = true
50+
textView.isRichText = true
51+
textView.drawsBackground = false
52+
textView.textContainerInset = NSSize(width: 12, height: 12)
53+
textView.textContainer?.widthTracksTextView = false
54+
textView.textContainer?.heightTracksTextView = false
55+
textView.isHorizontallyResizable = true
56+
textView.isVerticallyResizable = true
57+
textView.autoresizingMask = [.width, .height]
58+
59+
let scrollView = NSScrollView()
60+
scrollView.drawsBackground = false
61+
scrollView.hasVerticalScroller = true
62+
scrollView.hasHorizontalScroller = true
63+
scrollView.documentView = textView
64+
return scrollView
65+
}
66+
67+
func updateNSView(_ nsView: NSScrollView, context: Context) {
68+
guard let textView = nsView.documentView as? DiffTextView else { return }
69+
let key = DiffTextView.RenderKey(text: text, filePath: filePath)
70+
if textView.lastRenderKey == key { return }
71+
textView.lastRenderKey = key
72+
73+
DispatchQueue.global(qos: .userInitiated).async {
74+
let highlighted = DiffSyntaxHighlighter.highlightedDiff(text: text, filePath: filePath)
75+
DispatchQueue.main.async {
76+
textView.textStorage?.setAttributedString(highlighted)
77+
}
78+
}
79+
}
80+
}
81+
82+
private final class DiffTextView: NSTextView {
83+
struct RenderKey: Equatable {
84+
let textHash: Int
85+
let filePath: String?
86+
87+
init(text: String, filePath: String?) {
88+
self.textHash = text.hashValue
89+
self.filePath = filePath
90+
}
91+
}
92+
93+
var lastRenderKey: RenderKey?
94+
}

0 commit comments

Comments
 (0)