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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/jayjay-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 4 additions & 4 deletions crates/jayjay-core/src/file_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ pub fn build_file_tree(paths: &[String]) -> Vec<FileTreeEntry> {

// 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();
}
}

Expand Down
16 changes: 8 additions & 8 deletions crates/jayjay-core/src/repo/commit_ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ Fix: resolve crash on empty diff view\n\
pub fn generate_commit_message_cli(diff_summary: &str) -> Option<String> {
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
Expand Down
12 changes: 6 additions & 6 deletions crates/jayjay-core/src/repo/diff/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 10 additions & 13 deletions crates/jj-diff/src/compute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,22 +226,19 @@ fn apply_eof_markers(lines: &mut Vec<DiffLine>, 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;
}
}

Expand Down
16 changes: 16 additions & 0 deletions releases/0.2.16.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<h3>Diff editing</h3>
<ul>
<li>Added a grouped diff gutter for multi-line changes, with a dedicated group action column alongside deletion and addition line controls.</li>
<li>Change-group actions can now select or abandon a whole contiguous changed block without selecting lines one by one.</li>
<li>The group indicator uses a connected mild blue stripe and stays hidden for single-line changes.</li>
</ul>
<h3>Detail view</h3>
<ul>
<li>Made the change description area more compact so the diff is visible sooner.</li>
<li>Added a lightweight resize handle that previews height changes during drag and applies the layout change when released.</li>
</ul>
<h3>Internal</h3>
<ul>
<li>Split diff gutter actions into focused protocols for selection and edit behavior.</li>
<li>Cleaned up Clippy warnings in Rust core code.</li>
</ul>
4 changes: 2 additions & 2 deletions shell/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
public enum DiffGutterCheckboxState {
case selected
case unselected
}

public protocol DiffGutterContextActions {
var currentSelectedLineRange: ClosedRange<Int>? { get }

func didSelectLines(_ lineRange: ClosedRange<Int>)
}

public protocol DiffGutterSelectionActions: DiffGutterContextActions {
func selectFile()
func selectChangeGroup(_ lineRange: ClosedRange<Int>)
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<Int>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import JayJayCore

enum DiffGutterGrouping {
static func expandedChangedRange(
in lines: [DiffLine],
containing selection: ClosedRange<Int>
) -> ClosedRange<Int>? {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,31 @@ public final class DiffGutterTextView: NSTextView {
var pendingMenuActions: [(() -> Void)?] = []
var menuProvider: ((DiffGutterSelection) -> [DiffGutterMenuItem])?
var onSelectionChanged: ((DiffGutterSelection) -> Void)?
var groupRangeProvider: ((Int) -> ClosedRange<Int>?)?
var activateGroup: ((ClosedRange<Int>) -> Void)?
var groupHitWidth: CGFloat = 0
var toggleLineCheckbox: ((Int) -> Void)?
var checkboxHitStart: CGFloat = 0
var checkboxHitWidth: CGFloat = 0
var externalSelection: ClosedRange<Int>? {
didSet { applyExternalSelection() }
}

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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
}

Expand Down
Loading
Loading