Skip to content

Commit ca26e5d

Browse files
MrFlounderclaude
andcommitted
feat: add crab compare command for browser-based file diff
Adds a new built-in command that takes two file paths, generates a self-contained HTML page with side-by-side and unified diff views using diff2html, and opens it in the browser. Includes eye-friendly dark/light color themes with low-saturation tints. Usage: crab compare <file1> <file2> Alias: crab diff Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aa57c65 commit ca26e5d

File tree

1 file changed

+332
-1
lines changed

1 file changed

+332
-1
lines changed

src/crabcode

Lines changed: 332 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9070,6 +9070,333 @@ handle_court_command() {
90709070
MD_ASSETS_VERSION="1"
90719071
MD_ASSETS_DIR="$CONFIG_DIR/assets/md"
90729072

9073+
COMPARE_ASSETS_VERSION="1"
9074+
COMPARE_ASSETS_DIR="$CONFIG_DIR/assets/compare"
9075+
9076+
compare_show_help() {
9077+
echo -e "${CYAN}File Compare:${NC}"
9078+
echo ""
9079+
echo "Usage:"
9080+
echo " crab compare <file1> <file2> Compare two files in browser"
9081+
echo " crab compare --update-assets Re-download rendering libraries"
9082+
echo ""
9083+
echo "Opens a beautiful side-by-side diff view in your default browser."
9084+
echo "Supports syntax highlighting and both unified and side-by-side modes."
9085+
echo ""
9086+
echo -e "${GRAY}Assets stored in: $COMPARE_ASSETS_DIR${NC}"
9087+
}
9088+
9089+
compare_ensure_assets() {
9090+
local version_file="$COMPARE_ASSETS_DIR/.version"
9091+
9092+
if [ -f "$version_file" ] && [ "$(cat "$version_file")" = "$COMPARE_ASSETS_VERSION" ]; then
9093+
return 0
9094+
fi
9095+
9096+
echo -e "${CYAN}Downloading compare rendering assets (one-time setup)...${NC}"
9097+
mkdir -p "$COMPARE_ASSETS_DIR"
9098+
9099+
local base="https://cdn.jsdelivr.net/npm"
9100+
local failed=0
9101+
9102+
curl -fsSL "$base/diff2html@3/bundles/css/diff2html.min.css" -o "$COMPARE_ASSETS_DIR/diff2html.min.css" || failed=1
9103+
curl -fsSL "$base/diff2html@3/bundles/js/diff2html-ui.min.js" -o "$COMPARE_ASSETS_DIR/diff2html-ui.min.js" || failed=1
9104+
9105+
if [ "$failed" -eq 1 ]; then
9106+
error "Failed to download assets. Check your internet connection."
9107+
rm -rf "$COMPARE_ASSETS_DIR"
9108+
return 1
9109+
fi
9110+
9111+
echo "$COMPARE_ASSETS_VERSION" > "$version_file"
9112+
echo -e "${GREEN}Assets downloaded${NC}"
9113+
}
9114+
9115+
cmd_compare() {
9116+
local file1="${1:-}"
9117+
local file2="${2:-}"
9118+
9119+
case "$file1" in
9120+
""|"-h"|"--help"|"help")
9121+
compare_show_help
9122+
return 0
9123+
;;
9124+
"--update-assets"|"--update")
9125+
rm -rf "$COMPARE_ASSETS_DIR"
9126+
compare_ensure_assets
9127+
return $?
9128+
;;
9129+
esac
9130+
9131+
if [ -z "$file2" ]; then
9132+
error "Two file paths required."
9133+
echo ""
9134+
compare_show_help
9135+
return 1
9136+
fi
9137+
9138+
# Resolve to absolute paths
9139+
if [[ "$file1" != /* ]]; then
9140+
file1="$(pwd)/$file1"
9141+
fi
9142+
if [[ "$file2" != /* ]]; then
9143+
file2="$(pwd)/$file2"
9144+
fi
9145+
9146+
if [ ! -f "$file1" ]; then
9147+
error "File not found: $file1"
9148+
return 1
9149+
fi
9150+
if [ ! -f "$file2" ]; then
9151+
error "File not found: $file2"
9152+
return 1
9153+
fi
9154+
9155+
compare_ensure_assets || return 1
9156+
9157+
local name1 name2
9158+
name1=$(basename "$file1")
9159+
name2=$(basename "$file2")
9160+
local tmp_html="/tmp/crab-compare-${name1%.*}-${name2%.*}-$$.html"
9161+
9162+
# Generate unified diff (don't fail on differences)
9163+
local diff_output
9164+
diff_output=$(diff -u "$file1" "$file2" 2>/dev/null || true)
9165+
9166+
# If files are identical
9167+
if [ -z "$diff_output" ]; then
9168+
echo -e "${GREEN}Files are identical.${NC}"
9169+
return 0
9170+
fi
9171+
9172+
# Base64-encode the diff for safe embedding
9173+
local diff_b64
9174+
diff_b64=$(printf '%s' "$diff_output" | base64)
9175+
9176+
# Generate self-contained HTML
9177+
{
9178+
cat << 'CMPHEAD'
9179+
<!DOCTYPE html>
9180+
<html lang="en">
9181+
<head>
9182+
<meta charset="UTF-8">
9183+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
9184+
CMPHEAD
9185+
echo " <title>Compare: ${name1}${name2}</title>"
9186+
echo " <style>"
9187+
cat "$COMPARE_ASSETS_DIR/diff2html.min.css"
9188+
cat << 'CMPCSS'
9189+
9190+
* { box-sizing: border-box; margin: 0; padding: 0; }
9191+
body {
9192+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
9193+
background: #ffffff;
9194+
color: #24292f;
9195+
}
9196+
#cmp-toolbar {
9197+
position: sticky; top: 0; z-index: 100;
9198+
display: flex; justify-content: space-between; align-items: center;
9199+
padding: 10px 20px;
9200+
background: #f6f8fa; border-bottom: 1px solid #d0d7de;
9201+
font-size: 14px;
9202+
}
9203+
#cmp-title {
9204+
font-weight: 600; color: #24292f; font-size: 15px;
9205+
}
9206+
#cmp-title .arrow { color: #57606a; margin: 0 6px; }
9207+
#cmp-toolbar .controls { display: flex; gap: 6px; align-items: center; }
9208+
#cmp-toolbar button {
9209+
padding: 5px 16px; font-size: 13px; border-radius: 6px;
9210+
border: 1px solid rgba(27,31,36,0.15); cursor: pointer;
9211+
font-weight: 500; background: #f6f8fa; color: #24292f;
9212+
transition: background 0.15s;
9213+
}
9214+
#cmp-toolbar button:hover { background: #e1e4e8; }
9215+
#cmp-toolbar button.active {
9216+
background: #0969da; color: #fff; border-color: #0969da;
9217+
}
9218+
#cmp-toolbar button.active:hover { background: #0860ca; }
9219+
.d2h-wrapper { padding: 0; }
9220+
.d2h-file-header { display: none; }
9221+
#cmp-stats {
9222+
font-size: 12px; color: #57606a; padding: 8px 20px;
9223+
background: #f6f8fa; border-bottom: 1px solid #d0d7de;
9224+
}
9225+
#cmp-stats .added { color: #1a7f37; font-weight: 600; }
9226+
#cmp-stats .removed { color: #cf222e; font-weight: 600; }
9227+
9228+
/*
9229+
* Eye-friendly diff palette — override diff2html CSS variables.
9230+
* Low saturation (~15%), minimal luminance shift for line backgrounds,
9231+
* gentle word highlights. Based on Solarized perceptual uniformity
9232+
* and WCAG guidelines for reducing visual fatigue.
9233+
*/
9234+
:root {
9235+
/* Light mode: pastel tints, barely-there backgrounds */
9236+
--d2h-ins-bg-color: #eef6ee;
9237+
--d2h-ins-border-color: #d8e8d8;
9238+
--d2h-ins-highlight-bg-color: #d0e4d0;
9239+
--d2h-ins-label-color: #5a7a5a;
9240+
--d2h-del-bg-color: #f6eeee;
9241+
--d2h-del-border-color: #e8d8d8;
9242+
--d2h-del-highlight-bg-color: #e4d0d0;
9243+
--d2h-del-label-color: #7a5a5a;
9244+
--d2h-change-ins-color: #e8f0e8;
9245+
--d2h-change-del-color: #f0e8e4;
9246+
--d2h-info-bg-color: #eef2f8;
9247+
--d2h-info-border-color: #dce2ed;
9248+
--d2h-line-border-color: #e8eaed;
9249+
9250+
/* Dark mode */
9251+
--d2h-dark-bg-color: #1c2028;
9252+
--d2h-dark-color: #c8cdd5;
9253+
--d2h-dark-border-color: #2c3040;
9254+
--d2h-dark-dim-color: #5a6270;
9255+
--d2h-dark-line-border-color: #262a35;
9256+
--d2h-dark-file-header-bg-color: #20242e;
9257+
--d2h-dark-file-header-border-color: #2c3040;
9258+
--d2h-dark-empty-placeholder-bg-color: rgba(100,110,130,0.08);
9259+
--d2h-dark-empty-placeholder-border-color: #2c3040;
9260+
--d2h-dark-selected-color: rgba(80,130,190,0.10);
9261+
/* Additions: clearly green, not harsh */
9262+
--d2h-dark-ins-bg-color: rgba(50,200,80,0.18);
9263+
--d2h-dark-ins-border-color: rgba(50,200,80,0.25);
9264+
--d2h-dark-ins-highlight-bg-color: rgba(50,210,90,0.38);
9265+
--d2h-dark-ins-label-color: #4cc764;
9266+
/* Deletions: clearly red, not harsh */
9267+
--d2h-dark-del-bg-color: rgba(230,70,60,0.16);
9268+
--d2h-dark-del-border-color: rgba(230,70,60,0.25);
9269+
--d2h-dark-del-highlight-bg-color: rgba(235,80,70,0.35);
9270+
--d2h-dark-del-label-color: #e06060;
9271+
--d2h-dark-change-ins-color: rgba(50,200,80,0.20);
9272+
--d2h-dark-change-del-color: rgba(220,150,50,0.16);
9273+
--d2h-dark-info-bg-color: rgba(70,130,200,0.08);
9274+
--d2h-dark-info-border-color: rgba(70,130,200,0.15);
9275+
--d2h-dark-change-label-color: #a08a50;
9276+
--d2h-dark-moved-label-color: #5a8ab0;
9277+
}
9278+
9279+
/* Force dark scheme class based on system preference */
9280+
.d2h-file-header { display: none; }
9281+
.d2h-wrapper { padding: 0; }
9282+
9283+
#cmp-stats {
9284+
font-size: 12px; color: #57606a; padding: 8px 20px;
9285+
background: #f6f8fa; border-bottom: 1px solid #d0d7de;
9286+
}
9287+
#cmp-stats .added { color: #5a7a5a; font-weight: 600; }
9288+
#cmp-stats .removed { color: #7a5a5a; font-weight: 600; }
9289+
9290+
@media (prefers-color-scheme: dark) {
9291+
body { background: #1c2028; color: #c8cdd5; }
9292+
#cmp-toolbar { background: #20242e; border-bottom-color: #2c3040; }
9293+
#cmp-title { color: #c8cdd5; }
9294+
#cmp-title .arrow { color: #5a6270; }
9295+
#cmp-toolbar button { background: #282d38; color: #a0a8b5; border-color: #2c3040; }
9296+
#cmp-toolbar button:hover { background: #303848; }
9297+
#cmp-toolbar button.active { background: #3a5580; border-color: #3a5580; color: #d0d8e0; }
9298+
#cmp-stats { background: #20242e; border-bottom-color: #2c3040; color: #5a6270; }
9299+
#cmp-stats .added { color: #4cc764; }
9300+
#cmp-stats .removed { color: #e06060; }
9301+
}
9302+
</style>
9303+
</head>
9304+
<body>
9305+
<div id="cmp-toolbar">
9306+
<span id="cmp-title"></span>
9307+
<div class="controls">
9308+
<button id="btn-side" class="active" title="Side-by-side view">Side by Side</button>
9309+
<button id="btn-line" title="Unified line view">Unified</button>
9310+
</div>
9311+
</div>
9312+
<div id="cmp-stats"></div>
9313+
<div id="cmp-diff"></div>
9314+
<script>
9315+
CMPCSS
9316+
cat "$COMPARE_ASSETS_DIR/diff2html-ui.min.js"
9317+
cat << CMPSCRIPT
9318+
</script>
9319+
<script>
9320+
var bin = atob('${diff_b64}');
9321+
var bytes = new Uint8Array(bin.length);
9322+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
9323+
var diffString = new TextDecoder('utf-8').decode(bytes);
9324+
9325+
var name1 = '$(printf '%s' "$name1" | sed "s/'/\\\\'/g")';
9326+
var name2 = '$(printf '%s' "$name2" | sed "s/'/\\\\'/g")';
9327+
9328+
document.getElementById('cmp-title').innerHTML =
9329+
'<span>' + name1 + '</span><span class="arrow">↔</span><span>' + name2 + '</span>';
9330+
9331+
// Count additions/removals
9332+
var lines = diffString.split('\\n');
9333+
var added = 0, removed = 0;
9334+
for (var i = 0; i < lines.length; i++) {
9335+
if (lines[i].charAt(0) === '+' && !lines[i].startsWith('+++')) added++;
9336+
else if (lines[i].charAt(0) === '-' && !lines[i].startsWith('---')) removed++;
9337+
}
9338+
document.getElementById('cmp-stats').innerHTML =
9339+
'<span class="added">+' + added + ' additions</span>&nbsp;&nbsp;' +
9340+
'<span class="removed">-' + removed + ' removals</span>';
9341+
9342+
function renderDiff(outputFormat) {
9343+
var targetElement = document.getElementById('cmp-diff');
9344+
var configuration = {
9345+
drawFileList: false,
9346+
matching: 'lines',
9347+
outputFormat: outputFormat,
9348+
highlight: true,
9349+
colorScheme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
9350+
renderNothingWhenEmpty: false
9351+
};
9352+
var diff2htmlUi = new Diff2HtmlUI(targetElement, diffString, configuration);
9353+
diff2htmlUi.draw();
9354+
diff2htmlUi.highlightCode();
9355+
}
9356+
9357+
// Re-render on system theme change
9358+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
9359+
var active = document.querySelector('#btn-side.active') ? 'side-by-side' : 'line-by-line';
9360+
renderDiff(active);
9361+
});
9362+
9363+
renderDiff('side-by-side');
9364+
9365+
var btnSide = document.getElementById('btn-side');
9366+
var btnLine = document.getElementById('btn-line');
9367+
9368+
btnSide.addEventListener('click', function() {
9369+
btnSide.classList.add('active');
9370+
btnLine.classList.remove('active');
9371+
renderDiff('side-by-side');
9372+
});
9373+
btnLine.addEventListener('click', function() {
9374+
btnLine.classList.add('active');
9375+
btnSide.classList.remove('active');
9376+
renderDiff('line-by-line');
9377+
});
9378+
</script>
9379+
</body>
9380+
</html>
9381+
CMPSCRIPT
9382+
} > "$tmp_html"
9383+
9384+
# Open in browser
9385+
if [[ "$OSTYPE" == "darwin"* ]]; then
9386+
open "$tmp_html"
9387+
elif command_exists xdg-open; then
9388+
xdg-open "$tmp_html"
9389+
elif command_exists wslview; then
9390+
wslview "$tmp_html"
9391+
else
9392+
echo -e "Open in browser: ${BOLD}file://$tmp_html${NC}"
9393+
return 0
9394+
fi
9395+
9396+
echo -e "${GREEN}Comparing:${NC} $name1$name2"
9397+
echo -e "${GRAY}$tmp_html${NC}"
9398+
}
9399+
90739400
md_show_help() {
90749401
echo -e "${CYAN}Markdown Renderer:${NC}"
90759402
echo ""
@@ -9560,7 +9887,7 @@ main() {
95609887

95619888
# For project-aware commands, resolve project (cwd-first, then default)
95629889
case "${1:-}" in
9563-
""|"ws"|"workspace"|"restart"|"reset"|"refresh"|"continue"|"resume"|"cleanup"|"clean"|"destroy"|"rm"|"remove"|"new"|"create"|"wip"|"save"|"config"|"doctor"|"ports"|"shared"|"status"|"snapshot"|"receive"|"handoff"|"rewind"|"timetravel"|"tt"|"pair"|"join"|"spectate"|"watch"|"mood"|"mobile"|"msg"|"message"|"slack"|"tk"|"toolkit"|"pf"|"promptfoo"|"draw"|"session"|"review"|"court"|"ticket"|"tkt")
9890+
""|"ws"|"workspace"|"restart"|"reset"|"refresh"|"continue"|"resume"|"cleanup"|"clean"|"destroy"|"rm"|"remove"|"new"|"create"|"wip"|"save"|"config"|"doctor"|"ports"|"shared"|"status"|"snapshot"|"receive"|"handoff"|"rewind"|"timetravel"|"tt"|"pair"|"join"|"spectate"|"watch"|"mood"|"mobile"|"msg"|"message"|"slack"|"tk"|"toolkit"|"pf"|"promptfoo"|"draw"|"compare"|"diff"|"session"|"review"|"court"|"ticket"|"tkt")
95649891
if [ -z "$PROJECT_ALIAS" ]; then
95659892
# Legacy migration check
95669893
if is_legacy_config; then
@@ -9625,6 +9952,10 @@ main() {
96259952
# Render markdown file in browser
96269953
cmd_md "${@:2}"
96279954
;;
9955+
"compare"|"diff")
9956+
# Compare two files in browser
9957+
cmd_compare "${@:2}"
9958+
;;
96289959
"session"|"sessions")
96299960
# Session management
96309961
handle_session_command "${@:2}"

0 commit comments

Comments
 (0)