Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ swift test # engine tests
| Time | Unix time (live clock, results table, date picker, time zones, differences), Cron (plain words, next runs) |
| Tokens & Security | JWT decoder with HMAC check, UUID v4/v7 + ULID generator and inspector, password generator |
| Data | CSV ↔ JSON with table preview |
| Text | Diff (side by side / unified, word highlights, folding), Regex tester, character/word/line counter |
| Text | Diff (side by side / unified, word highlights, folding), Git Diff cleanup for AI review (drops lockfiles, generated and binary files; path filters), Regex tester, character/word/line counter |
| Miscellaneous | Color converter (color panel, eyedropper, contrast), QR code generator and reader |

Shortcuts: ⌘K command palette, ⌘1–⌘9 tools (order from Settings → Sidebar), ⌘N new document, ⌘O open file, ⌘S save output, ⇧⌘C copy output, ⌘F find in output, ⌘+ / ⌘− font size, ⌥⌘Z wrap lines.
Expand Down
6 changes: 4 additions & 2 deletions Sources/Decant/DecantApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ enum Category: String, CaseIterable {
}

enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, diff, regex, counter, markdown, color, qr
case detect, formatter, base64, url, unixTime, cron, jwt, uuid, password, csv, diff, gitDiff, regex, counter, markdown, color, qr

var id: String { rawValue }

Expand All @@ -132,6 +132,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case .password: "Passwords"
case .csv: "CSV ↔ JSON"
case .diff: "Diff"
case .gitDiff: "Git Diff"
case .regex: "Regex"
case .counter: "Counter"
case .markdown: "Markdown"
Expand All @@ -153,6 +154,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case .password: "lock"
case .csv: "tablecells"
case .diff: "plusminus"
case .gitDiff: "arrow.triangle.branch"
case .regex: "asterisk"
case .counter: "textformat.123"
case .markdown: "doc.richtext"
Expand All @@ -168,7 +170,7 @@ enum Tool: String, CaseIterable, Identifiable, Codable, Hashable {
case .unixTime, .cron: .time
case .jwt, .uuid, .password: .security
case .csv: .data
case .diff, .regex, .counter: .text
case .diff, .gitDiff, .regex, .counter: .text
case .color, .qr: .misc
}
}
Expand Down
106 changes: 106 additions & 0 deletions Sources/Decant/TextToolPages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,112 @@ struct DiffView: View {
}
}

// MARK: - Git diff

struct GitDiffPage: View {
@Environment(Store.self) private var store
@AppStorage("gitDiffSkipNoise") private var skipNoise = true
@AppStorage("gitDiffCollapseDeleted") private var collapseDeleted = false
@State private var result = GitDiff.Result()

private struct Key: Equatable { var diff, patterns: String; var noise, deleted: Bool }

var body: some View {
@Bindable var store = store
let doc = store[doc: .gitDiff]
let kept = result.files.filter { $0.skipped == nil }.count
ToolPage(tool: .gitDiff,
status: doc.input.isEmpty ? nil : result.files.isEmpty ? .warning("No git diff found") : Status(text: "\(kept) of \(result.files.count) files"),
meta: doc.input.isEmpty ? ["Trim a diff down to what a reviewer needs"] : ["~\(tokens(result.output)) tokens", "was ~\(tokens(doc.input))"]) {
HStack(spacing: 8) {
Image(systemName: "line.3.horizontal.decrease").foregroundStyle(Palette.text4)
TextField("Paths, e.g. src/api !*.test.ts", text: $store[doc: .gitDiff].aux)
.textFieldStyle(.plain)
.font(.system(size: 13.5, design: .monospaced))
.frame(width: 320)
}
.padding(.horizontal, 14)
.frame(height: 32)
.glassCapsule()
OptionToggle(title: "Skip lockfiles & generated", isOn: $skipNoise)
OptionToggle(title: "Collapse deleted files", isOn: $collapseDeleted)
} content: {
GeometryReader { g in
HStack(spacing: 14) {
VStack(spacing: 14) {
EditorPane(title: "Git Diff", text: $store[doc: .gitDiff].input,
placeholder: "Paste the output of git diff, git show or a .patch file, e.g. git diff main | pbcopy")
Pane("Files") { fileList }
.frame(height: max(140, g.size.height * 0.4))
}
EditorPane(title: "Cleaned", text: .constant(result.output), editable: false, output: true, primary: true, fileExtension: "diff")
}
}
}
.task(id: Key(diff: doc.input, patterns: doc.aux, noise: skipNoise, deleted: collapseDeleted)) {
let (d, p, n, c) = (doc.input, doc.aux, skipNoise, collapseDeleted)
result = await Task.detached { GitDiff.clean(d, patterns: p, skipNoise: n, collapseDeleted: c) }.value
}
}

@ViewBuilder private var fileList: some View {
if result.files.isEmpty {
Text("Files in the diff show up here. Untick one to leave it out.")
.foregroundStyle(Palette.text4).frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(result.files, id: \.self) { f in
fileRow(f)
Rectangle().fill(Palette.cardSeparator).frame(height: 1)
}
}
}
}
}

private func fileRow(_ f: GitDiff.File) -> some View {
let pattern = GitDiff.excludePattern(f.path)
// Only files kept, or dropped by their own `!path`, can be ticked here; the rest follow the filter and toggles.
let ownExclude = patternTokens.contains(pattern)
return HStack(spacing: 10) {
Toggle(f.path, isOn: Binding(get: { f.skipped == nil }, set: { on in
var t = patternTokens.filter { $0 != pattern }
if !on { t.append(pattern) }
store[doc: .gitDiff].aux = t.joined(separator: " ")
}))
.labelsHidden()
.toggleStyle(.checkbox)
.disabled(f.skipped != nil && !ownExclude)
Text(f.path)
.font(.system(size: 12.5, design: .monospaced))
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(f.skipped == nil ? AnyShapeStyle(.primary) : AnyShapeStyle(Palette.text4))
.help(f.path)
Spacer(minLength: 8)
if let reason = f.skipped, !ownExclude {
Text(reason).font(.system(size: 11.5)).foregroundStyle(Palette.text4)
}
HStack(spacing: 6) {
Text("+\(f.added)").foregroundStyle(Palette.added)
Text("−\(f.removed)").foregroundStyle(Palette.removed)
}
.font(.system(size: 11.5, weight: .semibold, design: .monospaced))
.opacity(f.skipped == nil ? 1 : 0.5)
}
.padding(.horizontal, 16)
.frame(height: 30)
}

private var patternTokens: [String] {
store[doc: .gitDiff].aux.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map(String.init)
}

/// Rough count at four characters per token.
private func tokens(_ s: String) -> String { (s.utf8.count / 4).formatted(.number.notation(.compactName)) }
}

// MARK: - Regex

struct RegexPage: View {
Expand Down
1 change: 1 addition & 0 deletions Sources/Decant/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ struct DetailView: View {
case .password: PasswordPage()
case .csv: CSVPage()
case .diff: DiffPage()
case .gitDiff: GitDiffPage()
case .regex: RegexPage()
case .counter: CounterPage()
case .markdown: MarkdownPage()
Expand Down
119 changes: 119 additions & 0 deletions Sources/DecantCore/TextTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,125 @@ public enum Diff {
}
}

// MARK: - Git diff cleanup

/// Splits `git diff` output into files and drops the ones a reviewer doesn't need (lockfiles, generated, filtered).
public enum GitDiff {
public struct File: Sendable, Hashable {
public let path: String
public let added: Int, removed: Int
public let binary: Bool, deleted: Bool
/// Why the file was left out, or nil when it's kept.
public let skipped: String?
}

public struct Result: Sendable, Equatable {
public var files: [File] = []
public var output = ""
public init() {}
}

static let lockfiles: Set<String> = [
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "deno.lock",
"Cargo.lock", "Gemfile.lock", "composer.lock", "poetry.lock", "uv.lock", "Pipfile.lock", "go.sum", "Package.resolved",
"Podfile.lock", "pubspec.lock", "mix.lock", "flake.lock", "packages.lock.json", "gradle.lockfile",
]
static let generated = [".min.js", ".min.css", ".map"]

/// `patterns`: globs separated by spaces or commas. Plain ones keep only matching files, `!`-prefixed ones drop files.
/// A pattern with a slash is anchored at the repo root and also matches everything below a directory; one without
/// matches any file or directory name (`*.test.ts`, `docs`). `*` stays within a name, `**` crosses directories.
public static func clean(_ diff: String, patterns: String = "", skipNoise: Bool = true, collapseDeleted: Bool = false) -> Result {
var preamble: [Substring] = [], sections: [[Substring]] = []
for line in diff.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("diff --git ") { sections.append([line]) }
else if sections.isEmpty { preamble.append(line) }
else { sections[sections.count - 1].append(line) }
}
let rules = patterns.split(whereSeparator: { $0 == "," || $0.isWhitespace }).compactMap(Rule.init)
let includes = rules.filter { !$0.exclude }, excludes = rules.filter(\.exclude)
var result = Result(), out = preamble
for lines in sections {
let header = lines.prefix { !$0.hasPrefix("@@") }
var added = 0, removed = 0
for l in lines[header.endIndex...] {
if l.hasPrefix("+") { added += 1 } else if l.hasPrefix("-") { removed += 1 }
}
let path = path(lines[0], header)
let name = path.split(separator: "/").last.map(String.init) ?? path
let binary = header.contains { $0.hasPrefix("Binary files ") || $0 == "GIT binary patch" }
let deleted = header.contains { $0.hasPrefix("deleted file mode") }
var skipped: String?
if skipNoise, lockfiles.contains(name) { skipped = "Lockfile" }
else if skipNoise, generated.contains(where: name.hasSuffix) { skipped = "Generated" }
else if skipNoise, binary { skipped = "Binary" }
else if !includes.isEmpty, !includes.contains(where: { $0.matches(path) }) { skipped = "Not in filter" }
else if excludes.contains(where: { $0.matches(path) }) { skipped = "Excluded" }
result.files.append(File(path: path, added: added, removed: removed, binary: binary, deleted: deleted, skipped: skipped))
if skipped == nil { out += deleted && collapseDeleted ? Array(header) : lines }
}
result.output = out.joined(separator: "\n")
return result
}

/// The new path, or the old one for deletions; `diff --git a/P b/P` as the fallback (binary files, mode changes).
static func path(_ first: Substring, _ header: ArraySlice<Substring>) -> String {
func value(_ prefix: String) -> String? {
guard let l = header.first(where: { $0.hasPrefix(prefix) }) else { return nil }
var v = String(l.dropFirst(prefix.count))
if v.hasSuffix("\t") { v.removeLast() }
if v.hasPrefix("\""), v.hasSuffix("\""), v.count > 1 { v = String(v.dropFirst().dropLast()) }
if v == "/dev/null" { return nil }
return v.hasPrefix("a/") || v.hasPrefix("b/") ? String(v.dropFirst(2)) : v
}
if let p = value("+++ ") ?? value("rename to ") ?? value("--- ") { return p }
let rest = first.dropFirst("diff --git ".count) // "a/P b/P"
return String(rest.dropFirst(2).prefix(max(0, (rest.count - 5) / 2)))
}

/// A pattern that drops exactly this file, for unticking it in a file list.
public static func excludePattern(_ path: String) -> String {
let p = path.replacingOccurrences(of: " ", with: "?").replacingOccurrences(of: ",", with: "?")
return "!" + (p.contains("/") ? p : "/" + p)
}

struct Rule {
let exclude: Bool
let anchored: Bool
let regex: Regex<AnyRegexOutput>

init?(_ raw: Substring) {
exclude = raw.hasPrefix("!")
var p = exclude ? raw.dropFirst() : raw
if p.hasPrefix("./") { p = p.dropFirst(2) }
anchored = p.hasPrefix("/") || p.dropLast().contains("/")
p = p.drop { $0 == "/" }
while p.hasSuffix("/") { p = p.dropLast() }
guard !p.isEmpty else { return nil }
var r = "", c = Array(p), i = 0
while i < c.count {
if c[i] == "*", i + 1 < c.count, c[i + 1] == "*" {
let slash = i + 2 < c.count && c[i + 2] == "/"
r += slash ? "(?:.*/)?" : ".*"
i += slash ? 3 : 2
continue
}
r += c[i] == "*" ? "[^/]*" : c[i] == "?" ? "[^/]" : NSRegularExpression.escapedPattern(for: String(c[i]))
i += 1
}
guard let regex = try? Regex(r) else { return nil }
self.regex = regex
}

/// Anchored patterns match the path or a parent directory; the others any single name in the path.
func matches(_ path: String) -> Bool {
let parts = path.split(separator: "/")
let candidates = anchored ? parts.indices.map { parts[...$0].joined(separator: "/") } : parts.map(String.init)
return candidates.contains { (try? regex.wholeMatch(in: $0)) != nil }
}
}
}

// MARK: - Regex

public enum RegexGroups {
Expand Down
47 changes: 47 additions & 0 deletions Tests/DecantCoreTests/ToolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,50 @@ import Testing
#expect(bad.errorLine == 3 && bad.errorColumn == 5)
#expect(f.format("x", language: .xml, indent: .two) == nil) // Prettier doesn't do XML without a plugin
}

@Test func gitDiffDropsNoiseAndFilters() {
let diff = """
commit abc
diff --git a/src/api/user.ts b/src/api/user.ts
index 1..2 100644
--- a/src/api/user.ts
+++ b/src/api/user.ts
@@ -1 +1 @@
-old
+new
diff --git a/yarn.lock b/yarn.lock
--- a/yarn.lock
+++ b/yarn.lock
@@ -1 +1 @@
-a
+b
diff --git a/src/api/user.test.ts b/src/api/user.test.ts
deleted file mode 100644
--- a/src/api/user.test.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-x
-y
diff --git a/logo.png b/logo.png
Binary files a/logo.png and b/logo.png differ
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1 +1 @@
-r
+s
"""
let all = GitDiff.clean(diff)
#expect(all.files.map(\.path) == ["src/api/user.ts", "yarn.lock", "src/api/user.test.ts", "logo.png", "README.md"])
#expect(all.files.map(\.skipped) == [nil, "Lockfile", nil, "Binary", nil])
#expect(all.files[2].removed == 2 && all.files[2].deleted)
#expect(all.output.hasPrefix("commit abc\ndiff --git a/src/api/user.ts"))
#expect(!all.output.contains("yarn.lock"))

let api = GitDiff.clean(diff, patterns: "src/api, !*.test.ts")
#expect(api.files.filter { $0.skipped == nil }.map(\.path) == ["src/api/user.ts"])
#expect(GitDiff.clean(diff, patterns: "src/**/*.ts").files.filter { $0.skipped == nil }.count == 2)
#expect(GitDiff.clean(diff, patterns: GitDiff.excludePattern("README.md")).files.last?.skipped == "Excluded")
#expect(GitDiff.clean(diff, skipNoise: false).files.allSatisfy { $0.skipped == nil })
#expect(!GitDiff.clean(diff, collapseDeleted: true).output.contains("-x"))
}