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
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub fn build(b: *std.Build) void {
"mouse",
"accessibility",
"wasm_app",
"diff_view",
"code_view",
"sortable_table",
"text_overflow",
Expand Down
107 changes: 107 additions & 0 deletions examples/diff_view.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! ZigZag Diff View Example
//! Demonstrates unified and side-by-side diff display.

const std = @import("std");
const zz = @import("zigzag");

const old_text =
\\fn greet(name: []const u8) void {
\\ std.debug.print("Hello, {s}!\n", .{name});
\\}
\\
\\pub fn main() !void {
\\ greet("World");
\\ greet("Zig");
\\}
;

const new_text =
\\fn greet(name: []const u8, excited: bool) void {
\\ if (excited) {
\\ std.debug.print("Hello, {s}!!!\n", .{name});
\\ } else {
\\ std.debug.print("Hello, {s}.\n", .{name});
\\ }
\\}
\\
\\pub fn main() !void {
\\ greet("World", true);
\\ greet("Zig", false);
\\ greet("ZigZag", true);
\\}
;

const Model = struct {
side_by_side: bool,

pub const Msg = union(enum) {
key: zz.KeyEvent,
};

pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) {
self.* = .{ .side_by_side = false };
return .none;
}

pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) {
switch (msg) {
.key => |k| switch (k.key) {
.char => |c| switch (c) {
'q' => return .quit,
'm' => self.side_by_side = !self.side_by_side,
else => {},
},
.escape => return .quit,
else => {},
},
}
return .none;
}

pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 {
const alloc = ctx.allocator;

var title_s = zz.Style{};
title_s = title_s.bold(true);
title_s = title_s.fg(zz.Color.cyan());
title_s = title_s.inline_style(true);

var dv = zz.components.diff_view.DiffView{};
dv.old_text = old_text;
dv.new_text = new_text;
dv.old_label = "greet.zig (before)";
dv.new_label = "greet.zig (after)";
dv.mode = if (self.side_by_side) .side_by_side else .unified;

var box_s = zz.Style{};
box_s = box_s.borderAll(zz.Border.rounded);
box_s = box_s.borderForeground(zz.Color.gray(10));
box_s = box_s.paddingAll(1);

const diff_output = dv.view(alloc);
const boxed = box_s.render(alloc, diff_output) catch diff_output;

const mode_label: []const u8 = if (self.side_by_side) "side-by-side" else "unified";

var help_s = zz.Style{};
help_s = help_s.fg(zz.Color.gray(10));
help_s = help_s.inline_style(true);

return std.fmt.allocPrint(alloc, "{s} [{s}]\n\n{s}\n\n{s}", .{
title_s.render(alloc, "Diff Viewer") catch "Diff Viewer",
mode_label,
boxed,
help_s.render(alloc, "m: toggle mode q: quit") catch "",
}) catch "Error";
}
};

pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

var program = try zz.Program(Model).init(gpa.allocator());
defer program.deinit();

try program.run();
}
272 changes: 272 additions & 0 deletions src/components/diff_view.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
//! Diff viewer component.
//! Displays unified or side-by-side text diffs with syntax coloring.

const std = @import("std");
const style_mod = @import("../style/style.zig");
const Color = @import("../style/color.zig").Color;

pub const DiffView = struct {
old_text: []const u8 = "",
new_text: []const u8 = "",
old_label: []const u8 = "old",
new_label: []const u8 = "new",
mode: Mode = .unified,
show_line_numbers: bool = true,
context_lines: usize = 3,

// Styles
add_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.green());
s = s.inline_style(true);
break :blk s;
},
remove_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.red());
s = s.inline_style(true);
break :blk s;
},
context_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.gray(12));
s = s.inline_style(true);
break :blk s;
},
header_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.cyan());
s = s.bold(true);
s = s.inline_style(true);
break :blk s;
},
line_num_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.gray(8));
s = s.inline_style(true);
break :blk s;
},
separator_style: style_mod.Style = blk: {
var s = style_mod.Style{};
s = s.fg(Color.gray(6));
s = s.inline_style(true);
break :blk s;
},

pub const Mode = enum {
unified,
side_by_side,
};

pub fn view(self: *const DiffView, allocator: std.mem.Allocator) []const u8 {
return switch (self.mode) {
.unified => self.renderUnified(allocator),
.side_by_side => self.renderSideBySide(allocator),
};
}

fn renderUnified(self: *const DiffView, allocator: std.mem.Allocator) []const u8 {
var result = std.array_list.Managed(u8).init(allocator);
const writer = result.writer();

// Header
const hdr = std.fmt.allocPrint(allocator, "--- {s}\n+++ {s}", .{ self.old_label, self.new_label }) catch "";
writer.writeAll(self.header_style.render(allocator, hdr) catch hdr) catch {};
writer.writeByte('\n') catch {};

// Compute diff using LCS-based approach
const old_lines = splitLines(allocator, self.old_text);
const new_lines = splitLines(allocator, self.new_text);
const ops = computeDiff(allocator, old_lines, new_lines);

var old_num: usize = 1;
var new_num: usize = 1;

for (ops) |op| {
switch (op) {
.equal => |line| {
if (self.show_line_numbers) {
const nums = std.fmt.allocPrint(allocator, "{d:>4} {d:>4} ", .{ old_num, new_num }) catch "";
writer.writeAll(self.line_num_style.render(allocator, nums) catch nums) catch {};
}
writer.writeAll(self.context_style.render(allocator, " ") catch " ") catch {};
writer.writeAll(self.context_style.render(allocator, line) catch line) catch {};
writer.writeByte('\n') catch {};
old_num += 1;
new_num += 1;
},
.delete => |line| {
if (self.show_line_numbers) {
const nums = std.fmt.allocPrint(allocator, "{d:>4} ", .{old_num}) catch "";
writer.writeAll(self.line_num_style.render(allocator, nums) catch nums) catch {};
}
const prefixed = std.fmt.allocPrint(allocator, "-{s}", .{line}) catch line;
writer.writeAll(self.remove_style.render(allocator, prefixed) catch prefixed) catch {};
writer.writeByte('\n') catch {};
old_num += 1;
},
.insert => |line| {
if (self.show_line_numbers) {
const nums = std.fmt.allocPrint(allocator, " {d:>4} ", .{new_num}) catch "";
writer.writeAll(self.line_num_style.render(allocator, nums) catch nums) catch {};
}
const prefixed = std.fmt.allocPrint(allocator, "+{s}", .{line}) catch line;
writer.writeAll(self.add_style.render(allocator, prefixed) catch prefixed) catch {};
writer.writeByte('\n') catch {};
new_num += 1;
},
}
}

// Trim trailing newline
if (result.items.len > 0 and result.items[result.items.len - 1] == '\n') {
_ = result.pop();
}

return result.items;
}

fn renderSideBySide(self: *const DiffView, allocator: std.mem.Allocator) []const u8 {
var result = std.array_list.Managed(u8).init(allocator);
const writer = result.writer();

const old_lines = splitLines(allocator, self.old_text);
const new_lines = splitLines(allocator, self.new_text);
const ops = computeDiff(allocator, old_lines, new_lines);

const half_width: usize = 38;

// Header
const old_hdr = padRight(allocator, self.old_label, half_width);
const new_hdr = self.new_label;
writer.writeAll(self.header_style.render(allocator, old_hdr) catch old_hdr) catch {};
writer.writeAll(self.separator_style.render(allocator, " \xe2\x94\x82 ") catch " | ") catch {};
writer.writeAll(self.header_style.render(allocator, new_hdr) catch new_hdr) catch {};
writer.writeByte('\n') catch {};

// Separator line
for (0..half_width) |_| writer.writeAll("\xe2\x94\x80") catch {};
writer.writeAll("\xe2\x94\xbc") catch {};
for (0..half_width + 2) |_| writer.writeAll("\xe2\x94\x80") catch {};
writer.writeByte('\n') catch {};

for (ops) |op| {
switch (op) {
.equal => |line| {
const padded = padRight(allocator, line, half_width);
writer.writeAll(self.context_style.render(allocator, padded) catch padded) catch {};
writer.writeAll(self.separator_style.render(allocator, " \xe2\x94\x82 ") catch " | ") catch {};
writer.writeAll(self.context_style.render(allocator, line) catch line) catch {};
writer.writeByte('\n') catch {};
},
.delete => |line| {
const padded = padRight(allocator, line, half_width);
writer.writeAll(self.remove_style.render(allocator, padded) catch padded) catch {};
writer.writeAll(self.separator_style.render(allocator, " \xe2\x94\x82 ") catch " | ") catch {};
writer.writeByte('\n') catch {};
},
.insert => |line| {
const blank = padRight(allocator, "", half_width);
writer.writeAll(blank) catch {};
writer.writeAll(self.separator_style.render(allocator, " \xe2\x94\x82 ") catch " | ") catch {};
writer.writeAll(self.add_style.render(allocator, line) catch line) catch {};
writer.writeByte('\n') catch {};
},
}
}

if (result.items.len > 0 and result.items[result.items.len - 1] == '\n') {
_ = result.pop();
}

return result.items;
}

fn padRight(allocator: std.mem.Allocator, text: []const u8, target: usize) []const u8 {
if (text.len >= target) return text[0..target];
var buf = std.array_list.Managed(u8).init(allocator);
buf.appendSlice(text) catch {};
for (0..target - text.len) |_| buf.append(' ') catch {};
return buf.items;
}

fn splitLines(allocator: std.mem.Allocator, text: []const u8) []const []const u8 {
var lines = std.array_list.Managed([]const u8).init(allocator);
var iter = std.mem.splitScalar(u8, text, '\n');
while (iter.next()) |line| {
lines.append(line) catch {};
}
return lines.items;
}
};

/// Diff operation.
const DiffOp = union(enum) {
equal: []const u8,
delete: []const u8,
insert: []const u8,
};

/// Simple Myers-like diff: compute edit operations between old and new line arrays.
fn computeDiff(allocator: std.mem.Allocator, old: []const []const u8, new: []const []const u8) []const DiffOp {
var ops = std.array_list.Managed(DiffOp).init(allocator);

// Simple O(n*m) LCS-based diff
const m = old.len;
const n = new.len;

if (m == 0) {
for (new) |line| ops.append(.{ .insert = line }) catch {};
return ops.items;
}
if (n == 0) {
for (old) |line| ops.append(.{ .delete = line }) catch {};
return ops.items;
}

// Build LCS table
const table = allocator.alloc(usize, (m + 1) * (n + 1)) catch {
// Fallback: show all as delete + insert
for (old) |line| ops.append(.{ .delete = line }) catch {};
for (new) |line| ops.append(.{ .insert = line }) catch {};
return ops.items;
};
for (0..m + 1) |i| {
for (0..n + 1) |j| {
if (i == 0 or j == 0) {
table[i * (n + 1) + j] = 0;
} else if (std.mem.eql(u8, old[i - 1], new[j - 1])) {
table[i * (n + 1) + j] = table[(i - 1) * (n + 1) + (j - 1)] + 1;
} else {
table[i * (n + 1) + j] = @max(table[(i - 1) * (n + 1) + j], table[i * (n + 1) + (j - 1)]);
}
}
}

// Backtrack to produce ops
var rev_ops = std.array_list.Managed(DiffOp).init(allocator);
var i: usize = m;
var j: usize = n;
while (i > 0 or j > 0) {
if (i > 0 and j > 0 and std.mem.eql(u8, old[i - 1], new[j - 1])) {
rev_ops.append(.{ .equal = old[i - 1] }) catch {};
i -= 1;
j -= 1;
} else if (j > 0 and (i == 0 or table[i * (n + 1) + (j - 1)] >= table[(i - 1) * (n + 1) + j])) {
rev_ops.append(.{ .insert = new[j - 1] }) catch {};
j -= 1;
} else if (i > 0) {
rev_ops.append(.{ .delete = old[i - 1] }) catch {};
i -= 1;
}
}

// Reverse
var idx: usize = rev_ops.items.len;
while (idx > 0) {
idx -= 1;
ops.append(rev_ops.items[idx]) catch {};
}

return ops.items;
}
2 changes: 2 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ pub const components = struct {
pub const ContextMenu = @import("components/context_menu.zig").ContextMenu;
pub const Form = @import("components/form.zig").Form;
pub const Markdown = @import("components/markdown.zig").Markdown;
pub const diff_view = @import("components/diff_view.zig");
pub const DiffView = diff_view.DiffView;
pub const code_view = @import("components/code_view.zig");
pub const CodeView = code_view.CodeView;
pub const sortable_table = @import("components/sortable_table.zig");
Expand Down
Loading