@@ -9070,6 +9070,333 @@ handle_court_command() {
90709070MD_ASSETS_VERSION= " 1"
90719071MD_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> ' +
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+
90739400md_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