diff --git a/Cargo.lock b/Cargo.lock index 3185d51..5b1670e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,7 +1734,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jayjay-cli" -version = "0.2.15" +version = "0.2.16" dependencies = [ "clap", "urlencoding", diff --git a/crates/jayjay-cli/Cargo.toml b/crates/jayjay-cli/Cargo.toml index d9839eb..45f0f8b 100644 --- a/crates/jayjay-cli/Cargo.toml +++ b/crates/jayjay-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jayjay-cli" -version = "0.2.15" +version = "0.2.16" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/jayjay-core/src/file_tree.rs b/crates/jayjay-core/src/file_tree.rs index 3705ee4..4a5d6d5 100644 --- a/crates/jayjay-core/src/file_tree.rs +++ b/crates/jayjay-core/src/file_tree.rs @@ -96,10 +96,10 @@ pub fn build_file_tree(paths: &[String]) -> Vec { // Fill in paths for file entries for entry in &mut results { - if let Some(idx) = entry.hunk_index { - if let Some(p) = paths.get(idx as usize) { - entry.path = p.clone(); - } + if let Some(idx) = entry.hunk_index + && let Some(p) = paths.get(idx as usize) + { + entry.path = p.clone(); } } diff --git a/crates/jayjay-core/src/repo/commit_ai.rs b/crates/jayjay-core/src/repo/commit_ai.rs index 938e281..f7fd968 100644 --- a/crates/jayjay-core/src/repo/commit_ai.rs +++ b/crates/jayjay-core/src/repo/commit_ai.rs @@ -16,16 +16,16 @@ Fix: resolve crash on empty diff view\n\ pub fn generate_commit_message_cli(diff_summary: &str) -> Option { let prompt = COMMIT_MESSAGE_PROMPT; - if let Some(codex) = environment::find_existing_binary("codex") { - if let Some(message) = run_ai_cli(&codex, diff_summary, prompt, AiCliMode::Codex) { - return Some(message); - } + if let Some(codex) = environment::find_existing_binary("codex") + && let Some(message) = run_ai_cli(&codex, diff_summary, prompt, AiCliMode::Codex) + { + return Some(message); } - if let Some(claude) = environment::find_existing_binary("claude") { - if let Some(message) = run_ai_cli(&claude, diff_summary, prompt, AiCliMode::Claude) { - return Some(message); - } + if let Some(claude) = environment::find_existing_binary("claude") + && let Some(message) = run_ai_cli(&claude, diff_summary, prompt, AiCliMode::Claude) + { + return Some(message); } None diff --git a/crates/jayjay-core/src/repo/diff/materialize.rs b/crates/jayjay-core/src/repo/diff/materialize.rs index 693cd31..10a920c 100644 --- a/crates/jayjay-core/src/repo/diff/materialize.rs +++ b/crates/jayjay-core/src/repo/diff/materialize.rs @@ -65,12 +65,12 @@ pub(super) fn extract_image_preview( } let cache_path = cache_dir.join(format!("{hash:016x}.{ext}")); - if !cache_path.exists() { - if let Err(err) = std::fs::write(&cache_path, &bytes) { - return Err(CoreError::Internal { - message: format!("write image cache {}: {err}", cache_path.display()), - }); - } + if !cache_path.exists() + && let Err(err) = std::fs::write(&cache_path, &bytes) + { + return Err(CoreError::Internal { + message: format!("write image cache {}: {err}", cache_path.display()), + }); } Ok(ImagePreviewResult::Image(DiffPreview::Image { diff --git a/crates/jj-diff/src/compute.rs b/crates/jj-diff/src/compute.rs index b806769..a3ad3e1 100644 --- a/crates/jj-diff/src/compute.rs +++ b/crates/jj-diff/src/compute.rs @@ -226,22 +226,19 @@ fn apply_eof_markers(lines: &mut Vec, no_eof_old: bool, no_eof_new: bo let last_old_idx = lines.iter().rposition(|l| l.old_line_no.is_some()); let last_new_idx = lines.iter().rposition(|l| l.new_line_no.is_some()); - if let (Some(oi), Some(ni)) = (last_old_idx, last_new_idx) { - if oi == ni && lines[oi].style == DiffSpanStyle::Context { - split_context_for_eof(lines, oi, no_eof_old, no_eof_new); - return; - } + if let (Some(oi), Some(ni)) = (last_old_idx, last_new_idx) + && oi == ni + && lines[oi].style == DiffSpanStyle::Context + { + split_context_for_eof(lines, oi, no_eof_old, no_eof_new); + return; } - if no_eof_old { - if let Some(idx) = last_old_idx { - lines[idx].no_eof_newline = true; - } + if no_eof_old && let Some(idx) = last_old_idx { + lines[idx].no_eof_newline = true; } - if no_eof_new { - if let Some(idx) = last_new_idx { - lines[idx].no_eof_newline = true; - } + if no_eof_new && let Some(idx) = last_new_idx { + lines[idx].no_eof_newline = true; } } diff --git a/releases/0.2.16.html b/releases/0.2.16.html new file mode 100644 index 0000000..1fb2fe9 --- /dev/null +++ b/releases/0.2.16.html @@ -0,0 +1,16 @@ +

Diff editing

+
    +
  • Added a grouped diff gutter for multi-line changes, with a dedicated group action column alongside deletion and addition line controls.
  • +
  • Change-group actions can now select or abandon a whole contiguous changed block without selecting lines one by one.
  • +
  • The group indicator uses a connected mild blue stripe and stays hidden for single-line changes.
  • +
+

Detail view

+
    +
  • Made the change description area more compact so the diff is visible sooner.
  • +
  • Added a lightweight resize handle that previews height changes during drag and applies the layout change when released.
  • +
+

Internal

+
    +
  • Split diff gutter actions into focused protocols for selection and edit behavior.
  • +
  • Cleaned up Clippy warnings in Rust core code.
  • +
diff --git a/shell/justfile b/shell/justfile index aedc99a..39c4f05 100644 --- a/shell/justfile +++ b/shell/justfile @@ -24,8 +24,8 @@ signing_identity := "Developer ID Application: Tao Xu (V28VJH6B6S)" team_id := "V28VJH6B6S" bundle_id := "dev.hewig.jayjay" app_name := "JayJay" -version := "0.2.15" -build_number := "19" +version := "0.2.16" +build_number := "20" release_dir := root / "build" / "release" default: list diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffColors.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffColors.swift index 2fe1916..49a6f60 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffColors.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffColors.swift @@ -75,6 +75,15 @@ public struct DiffColors { isDark ? NSColor(white: 0.16, alpha: 1) : NSColor(white: 0.94, alpha: 1) } + var groupStripe: NSColor { + isDark ? NSColor(calibratedRed: 0.42, green: 0.62, blue: 0.9, alpha: 0.55) : NSColor( + calibratedRed: 0.36, + green: 0.58, + blue: 0.86, + alpha: 0.42 + ) + } + /// Syntax tokens (GitHub-inspired) var keyword: NSColor { isDark ? NSColor(red: 1, green: 0.48, blue: 0.45, alpha: 1) : NSColor( diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterContextActions.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterContextActions.swift new file mode 100644 index 0000000..a32cb7c --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterContextActions.swift @@ -0,0 +1,25 @@ +public enum DiffGutterCheckboxState { + case selected + case unselected +} + +public protocol DiffGutterContextActions { + var currentSelectedLineRange: ClosedRange? { get } + + func didSelectLines(_ lineRange: ClosedRange) +} + +public protocol DiffGutterSelectionActions: DiffGutterContextActions { + func selectFile() + func selectChangeGroup(_ lineRange: ClosedRange) + func lineCheckboxState(for lineNumber: Int) -> DiffGutterCheckboxState? + func toggleLineCheckbox(_ lineNumber: Int) +} + +public protocol DiffGutterEditActions: DiffGutterContextActions { + var canOpenDiffEdit: Bool { get } + var canAbandonSelectedLines: Bool { get } + + func openDiffEdit() + func abandonSelectedLines(in lineRange: ClosedRange) +} diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterGrouping.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterGrouping.swift new file mode 100644 index 0000000..36a8128 --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterGrouping.swift @@ -0,0 +1,36 @@ +import JayJayCore + +enum DiffGutterGrouping { + static func expandedChangedRange( + in lines: [DiffLine], + containing selection: ClosedRange + ) -> ClosedRange? { + guard !lines.isEmpty else { return nil } + + let selectedChangedIndices = selection.compactMap { lineNumber -> Int? in + let index = lineNumber - 1 + guard lines.indices.contains(index), lines[index].isChangedInGutter else { return nil } + return index + } + guard let anchor = selectedChangedIndices.first else { return nil } + + var lower = anchor + while lower > 0, lines[lower - 1].isChangedInGutter { + lower -= 1 + } + + var upper = anchor + while upper + 1 < lines.count, lines[upper + 1].isChangedInGutter { + upper += 1 + } + + guard selectedChangedIndices.allSatisfy({ lower ... upper ~= $0 }) else { return nil } + return (lower + 1) ... (upper + 1) + } +} + +private extension DiffLine { + var isChangedInGutter: Bool { + style == .added || style == .removed + } +} diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift index 1f387ad..e5f1fdf 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffGutterTextView.swift @@ -26,7 +26,11 @@ public final class DiffGutterTextView: NSTextView { var pendingMenuActions: [(() -> Void)?] = [] var menuProvider: ((DiffGutterSelection) -> [DiffGutterMenuItem])? var onSelectionChanged: ((DiffGutterSelection) -> Void)? + var groupRangeProvider: ((Int) -> ClosedRange?)? + var activateGroup: ((ClosedRange) -> Void)? + var groupHitWidth: CGFloat = 0 var toggleLineCheckbox: ((Int) -> Void)? + var checkboxHitStart: CGFloat = 0 var checkboxHitWidth: CGFloat = 0 var externalSelection: ClosedRange? { didSet { applyExternalSelection() } @@ -34,8 +38,19 @@ public final class DiffGutterTextView: NSTextView { override public func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - if checkboxHitWidth > 0, - point.x <= textContainerInset.width + checkboxHitWidth, + if isInGroupColumn(point), + let lineIndex = lineIndex(at: point), + entries[safe: lineIndex]?.style.isChanged == true + { + let lineNumber = lineIndex + 1 + let range = groupRangeProvider?(lineNumber) ?? lineNumber ... lineNumber + selectionAnchorLine = range.lowerBound + selectLines(range) + activateGroup?(range) + return + } + + if isInCheckboxColumn(point), let lineIndex = lineIndex(at: point), entries[safe: lineIndex]?.style.isChanged == true { @@ -76,7 +91,15 @@ public final class DiffGutterTextView: NSTextView { } override public func menu(for event: NSEvent) -> NSMenu? { - if let lineNumber = lineNumber(for: event) { + let point = convert(event.locationInWindow, from: nil) + if isInGroupColumn(point), + let lineNumber = lineNumber(for: event), + entries[safe: lineNumber - 1]?.style.isChanged == true + { + let range = groupRangeProvider?(lineNumber) ?? lineNumber ... lineNumber + selectionAnchorLine = range.lowerBound + selectLines(range) + } else if let lineNumber = lineNumber(for: event) { let current = selectedLineRange if current == nil || !(current!.contains(lineNumber)) { selectionAnchorLine = lineNumber @@ -127,6 +150,18 @@ public final class DiffGutterTextView: NSTextView { lineIndex(for: event).map { $0 + 1 } } + private func isInGroupColumn(_ point: NSPoint) -> Bool { + guard groupHitWidth > 0 else { return false } + let x = point.x - textContainerInset.width + return x >= 0 && x <= groupHitWidth + } + + private func isInCheckboxColumn(_ point: NSPoint) -> Bool { + guard checkboxHitWidth > 0 else { return false } + let x = point.x - textContainerInset.width + return x >= checkboxHitStart && x <= checkboxHitStart + checkboxHitWidth + } + private func lineIndex(at point: NSPoint) -> Int? { guard let layoutManager, let textContainer diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift index e093575..9a12ac2 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/DiffTextSupport.swift @@ -2,6 +2,9 @@ import AppKit final class DiffLayoutManager: NSLayoutManager { var lineBgColors: [NSColor] = [] + var lineStripeColors: [NSColor] = [] + var lineStripeX: CGFloat = 0 + var lineStripeWidth: CGFloat = 0 /// Width to fill for per-line background colors. No-wrap containers have /// `containerSize.width == .greatestFiniteMagnitude`, so we use the laid-out @@ -27,18 +30,34 @@ final class DiffLayoutManager: NSLayoutManager { while charPos < fullText.length { let lineRange = fullText.lineRange(for: NSRange(location: charPos, length: 0)) let glyphRange = glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil) - if NSIntersectionRange(glyphRange, glyphsToShow).length > 0, - lineIndex < lineBgColors.count - { - let color = lineBgColors[lineIndex] - if color != .clear { - var lineRect = lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) - lineRect.origin.x = 0 - lineRect.size.width = drawWidth - lineRect.origin.x += origin.x - lineRect.origin.y += origin.y - color.setFill() - lineRect.fill() + if NSIntersectionRange(glyphRange, glyphsToShow).length > 0 { + let lineRect = lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) + if lineIndex < lineBgColors.count { + let color = lineBgColors[lineIndex] + if color != .clear { + var bgRect = lineRect + bgRect.origin.x = 0 + bgRect.size.width = drawWidth + bgRect.origin.x += origin.x + bgRect.origin.y += origin.y + color.setFill() + bgRect.fill() + } + } + + if lineStripeWidth > 0, + lineIndex < lineStripeColors.count + { + let color = lineStripeColors[lineIndex] + if color != .clear { + var stripeRect = lineRect + stripeRect.origin.x = lineStripeX + origin.x + stripeRect.origin.y += origin.y - 0.5 + stripeRect.size.width = lineStripeWidth + stripeRect.size.height += 1 + color.setFill() + stripeRect.fill() + } } } diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift new file mode 100644 index 0000000..c978c94 --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView+Gutter.swift @@ -0,0 +1,125 @@ +import AppKit +import JayJayCore + +extension NativeDiffView { + func menuProvider(selection: DiffGutterSelection) -> [DiffGutterMenuItem] { + guard let gutterActions else { return [] } + + var items: [DiffGutterMenuItem] = [] + if let selectionActions = gutterActions as? any DiffGutterSelectionActions, + let hunkRange = expandedHunkRange(containing: selection.lineRange) + { + items.append( + DiffGutterMenuItem( + title: "Select Change Group", + enabled: true, + action: { selectionActions.selectChangeGroup(hunkRange) } + ) + ) + } + if let selectionActions = gutterActions as? any DiffGutterSelectionActions { + items.append( + DiffGutterMenuItem(title: "Select File", enabled: true, action: { selectionActions.selectFile() }) + ) + } + if let editActions = gutterActions as? any DiffGutterEditActions, + editActions.canOpenDiffEdit + { + items.append( + DiffGutterMenuItem(title: "Open Diff Edit Mode", enabled: true, action: { editActions.openDiffEdit() }) + ) + } + if let editActions = gutterActions as? any DiffGutterEditActions, + editActions.canAbandonSelectedLines + { + if !items.isEmpty { + items.append(.separator) + } + let groupRange = expandedHunkRange(containing: selection.lineRange) + let isWholeGroup = groupRange == selection.lineRange && selection.changedLineCount > 1 + items.append( + DiffGutterMenuItem( + title: isWholeGroup ? "Abandon Change Group" : "Abandon Selected Lines", + enabled: selection.changedLineCount > 0, + action: selection.changedLineCount > 0 ? { + editActions.abandonSelectedLines(in: selection.lineRange) + } : nil + ) + ) + } + if !items.isEmpty, + items.last?.action == nil, + items.last?.title.isEmpty == true + { + _ = items.popLast() + } + + return items + } + + func expandedHunkRange(containing selection: ClosedRange) -> ClosedRange? { + DiffGutterGrouping.expandedChangedRange(in: diff.lines, containing: selection) + } + + func spanBackground(span: DiffSpan, theme: DiffColors) -> NSColor { + switch span.style { + case .added: theme.addedWordBg + case .removed: theme.removedWordBg + default: .clear + } + } + + func separatorGutterText(maxLineDigits: Int, showsLineCheckboxes: Bool) -> String { + let blankNumber = String(repeating: " ", count: maxLineDigits) + let checkboxColumn = showsLineCheckboxes ? " " : "" + return " \(checkboxColumn)\(blankNumber) \(blankNumber) \n" + } + + func groupText() -> String { + " " + } + + func groupStripeColor(for line: DiffLine, groupRange: ClosedRange?, theme: DiffColors) -> NSColor { + guard line.isChanged, + let groupRange, + groupRange.upperBound > groupRange.lowerBound + else { return .clear } + return theme.groupStripe + } + + func checkboxText(for lineNumber: Int, line: DiffLine) -> String { + guard line.isChanged else { return " " } + guard let state = (gutterActions as? any DiffGutterSelectionActions)?.lineCheckboxState(for: lineNumber) else { + return " " + } + switch state { + case .selected: + return "✓ " + case .unselected: + return "□ " + } + } + + func checkboxColor(for lineNumber: Int, theme: DiffColors) -> NSColor { + guard let state = (gutterActions as? any DiffGutterSelectionActions)?.lineCheckboxState(for: lineNumber) else { + return theme.gutterText + } + switch state { + case .selected: + return .controlAccentColor + case .unselected: + return theme.gutterText + } + } + + func pad(_ value: String, toWidth width: Int) -> String { + guard value.count < width else { return value } + return String(repeating: " ", count: width - value.count) + value + } +} + +private extension DiffLine { + var isChanged: Bool { + style == .added || style == .removed + } +} diff --git a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift index 9a192b8..f9ea493 100644 --- a/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift +++ b/shell/mac/Packages/JayJayDiffUI/Sources/JayJayDiffUI/NativeDiffView.swift @@ -2,51 +2,15 @@ import AppKit import JayJayCore import SwiftUI -public enum DiffGutterCheckboxState { - case selected - case unselected -} - -public struct DiffGutterContextActions { - public var openDiffEdit: (() -> Void)? - public var selectFile: (() -> Void)? - public var selectHunk: ((ClosedRange) -> Void)? - public var onLineSelectionChanged: ((ClosedRange) -> Void)? - public var selectedLineRange: ClosedRange? - public var lineCheckboxState: ((Int) -> DiffGutterCheckboxState?)? - public var toggleLineCheckbox: ((Int) -> Void)? - public var abandonSelectedLines: (() -> Void)? - - public init( - openDiffEdit: (() -> Void)? = nil, - selectFile: (() -> Void)? = nil, - selectHunk: ((ClosedRange) -> Void)? = nil, - onLineSelectionChanged: ((ClosedRange) -> Void)? = nil, - selectedLineRange: ClosedRange? = nil, - lineCheckboxState: ((Int) -> DiffGutterCheckboxState?)? = nil, - toggleLineCheckbox: ((Int) -> Void)? = nil, - abandonSelectedLines: (() -> Void)? = nil - ) { - self.openDiffEdit = openDiffEdit - self.selectFile = selectFile - self.selectHunk = selectHunk - self.onLineSelectionChanged = onLineSelectionChanged - self.selectedLineRange = selectedLineRange - self.lineCheckboxState = lineCheckboxState - self.toggleLineCheckbox = toggleLineCheckbox - self.abandonSelectedLines = abandonSelectedLines - } -} - public struct NativeDiffView: NSViewRepresentable { public let diff: FileDiff - public var gutterActions: DiffGutterContextActions? + public var gutterActions: (any DiffGutterContextActions)? @Environment(\.colorScheme) private var colorScheme @Environment(\.diffFontSize) private var fontSize @Environment(\.diffFontFamily) private var fontFamily - public init(diff: FileDiff, gutterActions: DiffGutterContextActions? = nil) { + public init(diff: FileDiff, gutterActions: (any DiffGutterContextActions)? = nil) { self.diff = diff self.gutterActions = gutterActions } @@ -136,10 +100,11 @@ public struct NativeDiffView: NSViewRepresentable { let font = NSFont(name: fontFamily, size: fontSize) ?? .monospacedSystemFont(ofSize: fontSize, weight: .regular) let isDark = colorScheme == .dark let theme = DiffColors(isDark: isDark) + let selectionActions = gutterActions as? any DiffGutterSelectionActions let gutterParagraphStyle = NSMutableParagraphStyle() - let showsLineCheckboxes = gutterActions?.lineCheckboxState != nil - gutterParagraphStyle.alignment = showsLineCheckboxes ? .left : .right + let showsLineCheckboxes = selectionActions != nil + gutterParagraphStyle.alignment = .left let gutterAttrs: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: theme.gutterText, @@ -150,16 +115,20 @@ public struct NativeDiffView: NSViewRepresentable { let gutter = NSMutableAttributedString() var gutterEntries: [DiffGutterTextView.Entry] = [] var gutterWidth: CGFloat = 0 - let markerWidth = ("+" as NSString).size(withAttributes: [.font: font]).width - let checkboxWidth = ("[x]" as NSString).size(withAttributes: [.font: font]).width + let groupWidth = (" " as NSString).size(withAttributes: [.font: font]).width + let groupStripeWidth: CGFloat = 6 + let checkboxWidth = max( + ("✓ " as NSString).size(withAttributes: [.font: font]).width, + ("□ " as NSString).size(withAttributes: [.font: font]).width + ) let gutterHorizontalInset = gutterTextView.textContainerInset.width - let gutterGap: CGFloat = 10 let gutterTrailingPadding: CGFloat = 10 let maxLineDigits = diff.lines.reduce(into: 1) { digits, line in let lineNumber = max(line.oldLineNo ?? 0, line.newLineNo ?? 0) digits = max(digits, String(lineNumber).count) } var lineBgColors: [NSColor] = [] + var groupStripeColors: [NSColor] = [] for (index, line) in diff.lines.enumerated() { if line.style == .separator { @@ -167,37 +136,52 @@ public struct NativeDiffView: NSViewRepresentable { .font: font, .foregroundColor: theme.gutterText ])) let gutterStart = gutter.length - gutter.append(NSAttributedString(string: "\n", attributes: gutterAttrs)) + gutter.append(NSAttributedString( + string: separatorGutterText( + maxLineDigits: maxLineDigits, + showsLineCheckboxes: showsLineCheckboxes + ), + attributes: gutterAttrs + )) gutterEntries.append(.init( style: line.style, range: NSRange(location: gutterStart, length: gutter.length - gutterStart) )) lineBgColors.append(theme.separatorBg) + groupStripeColors.append(.clear) continue } - let lineNumber = (line.newLineNo ?? line.oldLineNo).map(String.init) ?? "" let marker = switch line.style { case .added: "+" case .removed: "-" default: " " } - let padded = pad(lineNumber, toWidth: maxLineDigits) let markerColor = marker == "+" ? theme.addedText : marker == "-" ? theme.removedText : theme.gutterText let gutterLine = NSMutableAttributedString( - string: checkboxText(for: index + 1, line: line), - attributes: [ - .font: font, - .foregroundColor: checkboxColor(for: index + 1, theme: theme), - .paragraphStyle: gutterParagraphStyle - ] + string: groupText(), + attributes: gutterAttrs ) + if showsLineCheckboxes { + gutterLine.append(NSAttributedString( + string: checkboxText(for: index + 1, line: line), + attributes: [ + .font: font, + .foregroundColor: checkboxColor(for: index + 1, theme: theme), + .paragraphStyle: gutterParagraphStyle + ] + )) + } gutterLine.append(NSAttributedString( - string: padded, + string: pad(line.oldLineNo.map(String.init) ?? "", toWidth: maxLineDigits), attributes: gutterAttrs )) - let gap = padded.isEmpty ? "" : " " - gutterLine.append(NSAttributedString(string: gap, attributes: gutterAttrs)) + gutterLine.append(NSAttributedString(string: " ", attributes: gutterAttrs)) + gutterLine.append(NSAttributedString( + string: pad(line.newLineNo.map(String.init) ?? "", toWidth: maxLineDigits), + attributes: gutterAttrs + )) + gutterLine.append(NSAttributedString(string: " ", attributes: gutterAttrs)) gutterLine.append(NSAttributedString(string: marker, attributes: [ .font: font, .foregroundColor: markerColor @@ -210,15 +194,12 @@ public struct NativeDiffView: NSViewRepresentable { range: NSRange(location: gutterStart, length: gutter.length - gutterStart) )) - let numberWidth = (padded as NSString).size(withAttributes: gutterAttrs).width + let gutterLineWidth = gutterLine.size().width gutterWidth = max( gutterWidth, ceil( gutterHorizontalInset + - (showsLineCheckboxes ? checkboxWidth + gutterGap : 0) + - numberWidth + - gutterGap + - markerWidth + + gutterLineWidth + gutterTrailingPadding + gutterHorizontalInset ) @@ -246,6 +227,12 @@ public struct NativeDiffView: NSViewRepresentable { } result.append(NSAttributedString(string: "\n", attributes: [.font: font])) lineBgColors.append(theme.lineBg(line.style)) + let displayLine = index + 1 + groupStripeColors.append(groupStripeColor( + for: line, + groupRange: expandedHunkRange(containing: displayLine ... displayLine), + theme: theme + )) } if diff.lines.isEmpty { @@ -262,128 +249,34 @@ public struct NativeDiffView: NSViewRepresentable { } gutterLayoutManager.lineBgColors = lineBgColors + gutterLayoutManager.lineStripeColors = groupStripeColors + gutterLayoutManager.lineStripeX = 0 + gutterLayoutManager.lineStripeWidth = groupStripeWidth layoutManager.lineBgColors = lineBgColors + layoutManager.lineStripeColors = [] + layoutManager.lineStripeWidth = 0 gutterTextView.textStorage?.setAttributedString(gutter) gutterTextView.entries = gutterEntries gutterTextView.menuProvider = menuProvider(selection:) - gutterTextView.toggleLineCheckbox = gutterActions?.toggleLineCheckbox - gutterTextView.checkboxHitWidth = showsLineCheckboxes ? checkboxWidth + gutterGap : 0 - gutterTextView.onSelectionChanged = { selection in - gutterActions?.onLineSelectionChanged?(selection.lineRange) - } - gutterTextView.externalSelection = gutterActions?.selectedLineRange - textView.textStorage?.setAttributedString(result) - containerView.updateGutterWidth(max(52, gutterWidth)) - } - - private func menuProvider(selection: DiffGutterSelection) -> [DiffGutterMenuItem] { - guard let gutterActions else { return [] } - - var items: [DiffGutterMenuItem] = [] - if let selectHunk = gutterActions.selectHunk, - let hunkRange = expandedHunkRange(containing: selection.lineRange) - { - items.append( - DiffGutterMenuItem( - title: "Select Hunk", - enabled: true, - action: { selectHunk(hunkRange) } - ) - ) - } - if let selectFile = gutterActions.selectFile { - items.append(DiffGutterMenuItem(title: "Select File", enabled: true, action: selectFile)) - } - if let openDiffEdit = gutterActions.openDiffEdit { - items.append( - DiffGutterMenuItem(title: "Open Diff Edit Mode", enabled: true, action: openDiffEdit) - ) - } - if let abandonSelectedLines = gutterActions.abandonSelectedLines { - if !items.isEmpty { - items.append(.separator) - } - items.append( - DiffGutterMenuItem( - title: "Abandon Selected Lines", - enabled: selection.changedLineCount > 0, - action: selection.changedLineCount > 0 ? abandonSelectedLines : nil - ) - ) - } - if !items.isEmpty, - items.last?.action == nil, - items.last?.title.isEmpty == true - { - _ = items.popLast() - } - - return items - } - - private func expandedHunkRange(containing selection: ClosedRange) -> ClosedRange? { - guard !diff.lines.isEmpty else { return nil } - let isChanged: (DiffLine) -> Bool = { line in - line.style == .added || line.style == .removed + gutterTextView.groupRangeProvider = { lineNumber in + expandedHunkRange(containing: lineNumber ... lineNumber) } - - let anchor = selection.lowerBound - 1 - guard diff.lines.indices.contains(anchor), isChanged(diff.lines[anchor]) else { - return selection + if let selectionActions { + gutterTextView.activateGroup = { selectionActions.selectChangeGroup($0) } + } else { + gutterTextView.activateGroup = nil } - - var lower = anchor - while lower > 0, isChanged(diff.lines[lower - 1]) { - lower -= 1 - } - - var upper = anchor - while upper + 1 < diff.lines.count, isChanged(diff.lines[upper + 1]) { - upper += 1 - } - - return (lower + 1) ... (upper + 1) - } - - private func spanBackground(span: DiffSpan, theme: DiffColors) -> NSColor { - switch span.style { - case .added: theme.addedWordBg - case .removed: theme.removedWordBg - default: .clear + gutterTextView.groupHitWidth = groupWidth + gutterTextView.toggleLineCheckbox = selectionActions.map { actions in + { actions.toggleLineCheckbox($0) } } - } - - private func checkboxText(for lineNumber: Int, line: DiffLine) -> String { - guard line.isChanged else { return "" } - guard let state = gutterActions?.lineCheckboxState?(lineNumber) else { return "" } - switch state { - case .selected: - return "[x] " - case .unselected: - return "[ ] " - } - } - - private func checkboxColor(for lineNumber: Int, theme: DiffColors) -> NSColor { - guard let state = gutterActions?.lineCheckboxState?(lineNumber) else { - return theme.gutterText - } - switch state { - case .selected: - return .controlAccentColor - case .unselected: - return theme.gutterText + gutterTextView.checkboxHitStart = groupWidth + gutterTextView.checkboxHitWidth = showsLineCheckboxes ? checkboxWidth : 0 + gutterTextView.onSelectionChanged = { selection in + gutterActions?.didSelectLines(selection.lineRange) } - } - - private func pad(_ value: String, toWidth width: Int) -> String { - guard value.count < width else { return value } - return String(repeating: " ", count: width - value.count) + value - } -} - -private extension DiffLine { - var isChanged: Bool { - style == .added || style == .removed + gutterTextView.externalSelection = gutterActions?.currentSelectedLineRange + textView.textStorage?.setAttributedString(result) + containerView.updateGutterWidth(max(52, gutterWidth)) } } diff --git a/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterGroupingTests.swift b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterGroupingTests.swift new file mode 100644 index 0000000..58863c0 --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterGroupingTests.swift @@ -0,0 +1,146 @@ +import JayJayCore +@testable import JayJayDiffUI +import XCTest + +final class DiffGutterGroupingTests: XCTestCase { + func test_expandedChangedRange_groupsAdjacentAddedAndRemovedLines() { + let lines = [ + line(.context), + line(.removed), + line(.removed), + line(.added), + line(.context) + ] + + XCTAssertEqual( + DiffGutterGrouping.expandedChangedRange(in: lines, containing: 3 ... 3), + 2 ... 4 + ) + } + + func test_expandedChangedRange_returnsNilForContextSelection() { + let lines = [ + line(.context), + line(.removed), + line(.added), + line(.context) + ] + + XCTAssertNil( + DiffGutterGrouping.expandedChangedRange(in: lines, containing: 1 ... 1) + ) + } + + func test_expandedChangedRange_usesChangedLineWhenSelectionStartsOnContext() { + let lines = [ + line(.context), + line(.removed), + line(.removed), + line(.added), + line(.context) + ] + + XCTAssertEqual( + DiffGutterGrouping.expandedChangedRange(in: lines, containing: 1 ... 3), + 2 ... 4 + ) + } + + func test_menuLabelsPartialContextSelectionAsSelectedLines() { + let diff = FileDiff( + path: "file.swift", + language: "swift", + lines: [ + line(.context), + line(.removed), + line(.removed), + line(.added), + line(.context) + ], + whitespaceOnlyHidden: false + ) + let view = NativeDiffView( + diff: diff, + gutterActions: TestLineAbandoningActions() + ) + + let items = view.menuProvider(selection: DiffGutterSelection(lineRange: 1 ... 3, changedLineCount: 2)) + + XCTAssertEqual(items.last?.title, "Abandon Selected Lines") + } + + func test_menuLabelsSingleLineChangeAsSelectedLines() { + let diff = FileDiff( + path: "file.swift", + language: "swift", + lines: [ + line(.context), + line(.removed), + line(.context) + ], + whitespaceOnlyHidden: false + ) + let view = NativeDiffView( + diff: diff, + gutterActions: TestLineAbandoningActions() + ) + + let items = view.menuProvider(selection: DiffGutterSelection(lineRange: 2 ... 2, changedLineCount: 1)) + + XCTAssertEqual(items.last?.title, "Abandon Selected Lines") + } + + func test_menuLabelsMultiLineWholeChangeAsChangeGroup() { + let diff = FileDiff( + path: "file.swift", + language: "swift", + lines: [ + line(.context), + line(.removed), + line(.added), + line(.context) + ], + whitespaceOnlyHidden: false + ) + let view = NativeDiffView( + diff: diff, + gutterActions: TestLineAbandoningActions() + ) + + let items = view.menuProvider(selection: DiffGutterSelection(lineRange: 2 ... 3, changedLineCount: 2)) + + XCTAssertEqual(items.last?.title, "Abandon Change Group") + } + + func test_expandedChangedRange_returnsNilForEmptyDiff() { + XCTAssertNil(DiffGutterGrouping.expandedChangedRange(in: [], containing: 1 ... 1)) + } + + private func line(_ style: DiffSpanStyle) -> DiffLine { + DiffLine( + oldLineNo: nil, + newLineNo: nil, + style: style, + spans: [], + noEofNewline: false + ) + } +} + +private struct TestLineAbandoningActions: DiffGutterEditActions { + var currentSelectedLineRange: ClosedRange? { + nil + } + + var canOpenDiffEdit: Bool { + false + } + + var canAbandonSelectedLines: Bool { + true + } + + func didSelectLines(_ lineRange: ClosedRange) {} + func openDiffEdit() {} + func abandonSelectedLines(in lineRange: ClosedRange) {} +} diff --git a/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterRenderingTests.swift b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterRenderingTests.swift new file mode 100644 index 0000000..8ec89bc --- /dev/null +++ b/shell/mac/Packages/JayJayDiffUI/Tests/JayJayDiffUITests/DiffGutterRenderingTests.swift @@ -0,0 +1,48 @@ +import AppKit +import JayJayCore +@testable import JayJayDiffUI +import XCTest + +final class DiffGutterRenderingTests: XCTestCase { + func test_multiLineChangedGroupColumnUsesDrawnStripeNotGlyph() { + let view = NativeDiffView(diff: FileDiff( + path: "file.swift", + language: "swift", + lines: [], + whitespaceOnlyHidden: false + )) + let changedLine = DiffLine( + oldLineNo: 1, + newLineNo: nil, + style: .removed, + spans: [], + noEofNewline: false + ) + + XCTAssertEqual(view.groupText(), " ") + let stripe = view.groupStripeColor(for: changedLine, groupRange: 2 ... 3, theme: DiffColors(isDark: false)) + + XCTAssertGreaterThan(stripe.alphaComponent, 0) + XCTAssertLessThanOrEqual(stripe.alphaComponent, 0.45) + } + + func test_singleLineChangeDoesNotDrawGroupStripe() { + let view = NativeDiffView(diff: FileDiff( + path: "file.swift", + language: "swift", + lines: [], + whitespaceOnlyHidden: false + )) + let changedLine = DiffLine( + oldLineNo: 1, + newLineNo: nil, + style: .removed, + spans: [], + noEofNewline: false + ) + + let stripe = view.groupStripeColor(for: changedLine, groupRange: 2 ... 2, theme: DiffColors(isDark: false)) + + XCTAssertEqual(stripe.alphaComponent, 0) + } +} diff --git a/shell/mac/Sources/JayJay/Detail/DetailDescription.swift b/shell/mac/Sources/JayJay/Detail/DetailDescription.swift new file mode 100644 index 0000000..fb3bd3b --- /dev/null +++ b/shell/mac/Sources/JayJay/Detail/DetailDescription.swift @@ -0,0 +1,170 @@ +import SwiftUI + +extension ChangeDetailView { + var descriptionSection: some View { + DetailDescriptionSection( + description: detail.info.description, + descriptionText: $descriptionText, + editingDescription: $editingDescription, + canShowDiffEditButton: canShowDiffEditButton, + onSave: { onDescribe(detail.info.changeId, $0) }, + onOpenDiffEdit: { isDiffEditMode = true } + ) + .id("\(detail.info.changeId)|\(detail.info.commitId)") + } + + private var canShowDiffEditButton: Bool { + !isCompareMode && !detail.info.hasConflict && !detail.diff.isEmpty && !editingDescription + } +} + +private struct DetailDescriptionSection: View { + private enum Metrics { + static let compactHeight: CGFloat = 32 + static let minimumHeight: CGFloat = 24 + static let maximumHeight: CGFloat = 180 + static let editingMinimumHeight: CGFloat = 80 + } + + let description: String + @Binding var descriptionText: String + @Binding var editingDescription: Bool + let canShowDiffEditButton: Bool + let onSave: (String) -> Void + let onOpenDiffEdit: () -> Void + + @State private var descriptionHeight: CGFloat = Metrics.compactHeight + @GestureState private var resizeTranslation: CGFloat = 0 + + private var visibleDescriptionHeight: CGFloat { + let minimum = editingDescription ? Metrics.editingMinimumHeight : Metrics.minimumHeight + let baseHeight = editingDescription ? max(descriptionHeight, Metrics.editingMinimumHeight) : descriptionHeight + return clampedDescriptionHeight(baseHeight, minimum: minimum) + } + + private var previewDescriptionHeight: CGFloat { + clampedDescriptionHeight(visibleDescriptionHeight + resizeTranslation, minimum: minimumDescriptionHeight) + } + + private var minimumDescriptionHeight: CGFloat { + editingDescription ? Metrics.editingMinimumHeight : Metrics.minimumHeight + } + + private var resizePreviewOffset: CGFloat { + previewDescriptionHeight - visibleDescriptionHeight + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + descriptionHeader + descriptionBody + } + } + + private var descriptionHeader: some View { + HStack(spacing: 8) { + Text("Description") + .jayjayFont(14, weight: .semibold) + if editingDescription { + Button("Save") { + onSave(descriptionText) + editingDescription = false + } + .keyboardShortcut("s") + .controlSize(.small) + Button("Cancel") { + descriptionText = description + editingDescription = false + } + .controlSize(.small) + } else { + Button { + editingDescription = true + descriptionHeight = max(descriptionHeight, Metrics.editingMinimumHeight) + } label: { + Label("Edit", systemImage: "pencil") + .labelStyle(.titleAndIcon) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Edit message") + } + Spacer() + if canShowDiffEditButton { + Button("Edit Diff...") { onOpenDiffEdit() } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Open dedicated diff edit mode") + } + } + } + + @ViewBuilder + private var descriptionBody: some View { + if editingDescription { + VStack(spacing: 2) { + TextEditor(text: $descriptionText) + .jayjayFont(13, design: .monospaced) + .frame(height: visibleDescriptionHeight) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.1))) + descriptionResizeHandle + } + } else if !description.isEmpty { + VStack(spacing: 2) { + ScrollView { + Text(description) + .jayjayFont(13, design: .monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: visibleDescriptionHeight) + descriptionResizeHandle + } + } + } + + private var descriptionResizeHandle: some View { + ZStack { + Capsule() + .fill(Color.accentColor.opacity(resizeTranslation == 0 ? 0 : 0.45)) + .frame(height: 2) + .padding(.horizontal, 20) + .offset(y: resizePreviewOffset) + .opacity(resizeTranslation == 0 ? 0 : 1) + + Capsule() + .fill(Color.secondary.opacity(0.35)) + .frame(width: 36, height: 3) + .offset(y: resizePreviewOffset) + } + .frame(maxWidth: .infinity, minHeight: 10) + .contentShape(Rectangle()) + .gesture(descriptionResizeGesture) + .help("Resize description") + } + + private var descriptionResizeGesture: some Gesture { + DragGesture(minimumDistance: 1) + .updating($resizeTranslation) { value, state, transaction in + transaction.disablesAnimations = true + state = value.translation.height + } + .onEnded { value in + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + descriptionHeight = clampedDescriptionHeight( + visibleDescriptionHeight + value.translation.height, + minimum: minimumDescriptionHeight + ) + } + } + } + + private func clampedDescriptionHeight(_ height: CGFloat, minimum: CGFloat) -> CGFloat { + min(max(height, minimum), Metrics.maximumHeight) + } +} diff --git a/shell/mac/Sources/JayJay/Detail/DetailHeader.swift b/shell/mac/Sources/JayJay/Detail/DetailHeader.swift index a7a54b0..39fbbfa 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailHeader.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailHeader.swift @@ -48,67 +48,6 @@ extension ChangeDetailView { } } - var descriptionSection: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Description").jayjayFont(17, weight: .semibold) - if editingDescription { - HStack(spacing: 10) { - Button("Save") { - onDescribe(detail.info.changeId, descriptionText) - editingDescription = false - } - .keyboardShortcut("s") - Button("Cancel") { - descriptionText = detail.info.description - editingDescription = false - } - } - } else { - Button { - editingDescription = true - } label: { - Label("Edit", systemImage: "pencil") - .labelStyle(.titleAndIcon) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help("Edit message") - } - Spacer() - } - if editingDescription { - TextEditor(text: $descriptionText) - .jayjayFont(13, design: .monospaced) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.1))) - } else if !detail.info.description.isEmpty { - ScrollView { - Text(detail.info.description) - .jayjayFont(13, design: .monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxHeight: 160) - } - } - } - - @ViewBuilder - var detailActionsSection: some View { - if !isCompareMode, !detail.info.hasConflict, !detail.diff.isEmpty, !editingDescription { - HStack { - Spacer() - Button("Edit Diff…") { isDiffEditMode = true } - .buttonStyle(.bordered) - .help("Open dedicated diff edit mode") - } - } - } - var compareBanner: some View { HStack(spacing: 8) { Image(systemName: "arrow.left.arrow.right") diff --git a/shell/mac/Sources/JayJay/Detail/DetailView.swift b/shell/mac/Sources/JayJay/Detail/DetailView.swift index 640b5e8..d70e4db 100644 --- a/shell/mac/Sources/JayJay/Detail/DetailView.swift +++ b/shell/mac/Sources/JayJay/Detail/DetailView.swift @@ -182,7 +182,6 @@ struct ChangeDetailView: View { VStack(alignment: .leading, spacing: 16) { headerSection descriptionSection - detailActionsSection Divider() if visibleDiff.isEmpty, hiddenDiffCount > 0 { ContentUnavailableView( @@ -215,12 +214,12 @@ struct ChangeDetailView: View { if !isCompareMode { headerSection descriptionSection - detailActionsSection } } .padding(.horizontal, 18) .padding(.top, isCompareMode ? 4 : 14) .padding(.bottom, 8) + .zIndex(1) Divider() diff --git a/shell/mac/Sources/JayJay/Diff/DiffSection.swift b/shell/mac/Sources/JayJay/Diff/DiffSection.swift index 085cd0a..4ec6eab 100644 --- a/shell/mac/Sources/JayJay/Diff/DiffSection.swift +++ b/shell/mac/Sources/JayJay/Diff/DiffSection.swift @@ -2,7 +2,7 @@ import JayJayCore import JayJayDiffUI import SwiftUI -struct DiffSection: View { +struct DiffSection: View, DiffGutterEditActions { let hunk: DiffHunk let rev: String? let repo: JayJayRepo? @@ -186,12 +186,7 @@ struct DiffSection: View { } else { NativeDiffView( diff: diff, - gutterActions: DiffGutterContextActions( - openDiffEdit: onOpenDiffEdit, - onLineSelectionChanged: { selectedLineRange = $0 }, - selectedLineRange: selectedLineRange, - abandonSelectedLines: isWorkingCopy ? abandonSelectedLines : nil - ) + gutterActions: self ) .id("unified-\(hunk.path)") } @@ -305,12 +300,11 @@ struct DiffSection: View { } } - private func abandonSelectedLines() { + private func abandonSelectedLines(lineRange: ClosedRange) { guard let actions, let repo, let rev, - let fileDiff, - let selectedLineRange + let fileDiff else { return } let oldContent = loadedOldContent ?? hunk.oldContent @@ -318,7 +312,7 @@ struct DiffSection: View { let selectedKeys: Set = Set( fileDiff.lines.enumerated().compactMap { index, line in let displayLine = index + 1 - guard selectedLineRange.contains(displayLine), + guard lineRange.contains(displayLine), line.style == .added || line.style == .removed else { return nil } return diffLineKey(line) @@ -387,4 +381,28 @@ struct DiffSection: View { ranges.append(DiffEditRange(startLine: UInt32(start), endLine: UInt32(previous))) return ranges } + + var currentSelectedLineRange: ClosedRange? { + selectedLineRange + } + + var canOpenDiffEdit: Bool { + onOpenDiffEdit != nil + } + + var canAbandonSelectedLines: Bool { + isWorkingCopy + } + + func didSelectLines(_ lineRange: ClosedRange) { + selectedLineRange = lineRange + } + + func openDiffEdit() { + onOpenDiffEdit?() + } + + func abandonSelectedLines(in lineRange: ClosedRange) { + abandonSelectedLines(lineRange: lineRange) + } } diff --git a/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift b/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift index 6058664..c69a8a6 100644 --- a/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift +++ b/shell/mac/Sources/JayJay/DiffEdit/DiffEditFileSection.swift @@ -2,7 +2,7 @@ import JayJayCore import JayJayDiffUI import SwiftUI -struct DiffEditFileSection: View { +struct DiffEditFileSection: View, DiffGutterSelectionActions { let hunk: DiffHunk let rev: String let repo: JayJayRepo? @@ -93,31 +93,12 @@ struct DiffEditFileSection: View { .jayjayFont(12) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - } else if let displayDiff, let fileDiff { + } else if let displayDiff { NativeDiffView( diff: displayDiff, gutterActions: supportsDiffEdit - ? DiffGutterContextActions( - openDiffEdit: nil, - selectFile: onSelectFile, - selectHunk: { range in - let mapped = ClosedRange( - uncheckedBounds: ( - displayToFullMap[range.lowerBound] ?? range.lowerBound, - displayToFullMap[range.upperBound] ?? range.upperBound - ) - ) - onSelectHunk(mapped) - }, - lineCheckboxState: { displayLineNumber in - guard let fullLine = displayToFullMap[displayLineNumber] else { return nil } - return lineCheckboxState(fileDiff: fileDiff, lineNumber: fullLine) - }, - toggleLineCheckbox: { displayLineNumber in - guard let fullLine = displayToFullMap[displayLineNumber] else { return } - onToggleLine(fullLine) - } - ) : nil + ? self + : nil ) .frame(height: diffHeight(for: displayDiff)) } else { @@ -227,6 +208,38 @@ struct DiffEditFileSection: View { guard fileDiff.lines[lineIndex].isChanged else { return nil } return selectedChangedLines.contains(lineNumber) ? .selected : .unselected } + + var currentSelectedLineRange: ClosedRange? { + nil + } + + func didSelectLines(_ lineRange: ClosedRange) {} + + func selectFile() { + onSelectFile() + } + + func selectChangeGroup(_ lineRange: ClosedRange) { + let mapped = ClosedRange( + uncheckedBounds: ( + displayToFullMap[lineRange.lowerBound] ?? lineRange.lowerBound, + displayToFullMap[lineRange.upperBound] ?? lineRange.upperBound + ) + ) + onSelectHunk(mapped) + } + + func lineCheckboxState(for lineNumber: Int) -> DiffGutterCheckboxState? { + guard let fileDiff, + let fullLine = displayToFullMap[lineNumber] + else { return nil } + return lineCheckboxState(fileDiff: fileDiff, lineNumber: fullLine) + } + + func toggleLineCheckbox(_ lineNumber: Int) { + guard let fullLine = displayToFullMap[lineNumber] else { return } + onToggleLine(fullLine) + } } private extension DiffLine { diff --git a/shell/mac/project.yml b/shell/mac/project.yml index 550c128..c0061e3 100644 --- a/shell/mac/project.yml +++ b/shell/mac/project.yml @@ -59,8 +59,8 @@ targets: base: ASSETCATALOG_COMPILER_APPICON_NAME: app PRODUCT_BUNDLE_IDENTIFIER: dev.hewig.jayjay - MARKETING_VERSION: "0.2.15" - CURRENT_PROJECT_VERSION: 19 + MARKETING_VERSION: "0.2.16" + CURRENT_PROJECT_VERSION: 20 GENERATE_INFOPLIST_FILE: YES INFOPLIST_KEY_CFBundleDisplayName: JayJay INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools