src/html.cpp

Sat, 28 Jun 2025 11:32:08 +0200

author
Mike Becker <universe@uap-core.de>
date
Sat, 28 Jun 2025 11:32:08 +0200
changeset 54
586dcd606e47
parent 52
e9edc3bd0301
permissions
-rw-r--r--

add popups with commit summaries - resolves #644

/* Copyright 2025 Mike Becker. All rights reserved.
*
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 * notice, this list of conditions and the following disclaimer in the
 * documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

#include "html.h"

#include <ranges>
#include <cstdio>
#include <cassert>

using namespace std::chrono;

namespace html {
    static constexpr const char* weekdays[] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};

    static unsigned indentation;
    static const char *tabs = "                                                                ";
    static void indent(int change = 0) {
        indentation += change;
        assert(indentation <= max_indentation);
        fwrite(tabs, 4, indentation, stdout);
    }

    static std::string encode(const std::string &data) {
        std::string buffer;
        buffer.reserve(data.size()+16);
        for (const char &pos: data) {
            switch (pos) {
                case '&':
                    buffer.append("&amp;");
                    break;
                case '\"':
                    buffer.append("&quot;");
                    break;
                case '\'':
                    buffer.append("&apos;");
                    break;
                case '<':
                    buffer.append("&lt;");
                    break;
                case '>':
                    buffer.append("&gt;");
                    break;
                case '#':
                    buffer.append("&#35;");
                    break;
                default:
                    buffer.append(&pos, 1);
                    break;
            }
        }
        return buffer;
    }
}

void html::open(bool fragment, unsigned char fragment_indent) {
    if (fragment) {
        indent(fragment_indent);
        puts("<div class=\"heatmap-content\">");
        indentation++;
    } else {
        puts(R"(<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <style>
            table.heatmap {
                table-layout: fixed;
                border-collapse: collapse;
                font-family: sans-serif;
            }

            table.heatmap td, table.heatmap th {
                text-align: center;
                border: solid 1px lightgray;
                height: 1.5em;
            }

            table.heatmap td {
                border: solid 1px lightgray;
                width: 1.5em;
                height: 1.5em;
            }

            table.heatmap td:hover, table.heatmap td.popup-open {
                filter: hue-rotate(90deg);
            }

            table.heatmap td.out-of-range {
                background-color: gray;
            }

            table.heatmap td.zero-commits {
                background-color: white;
            }

            table.heatmap td.one-commit {
                background-color: #80E7A0;
            }

            table.heatmap td.up-to-5-commits {
                background-color: #30D350;
            }

            table.heatmap td.up-to-10-commits {
                background-color: #00BF00;
            }

            table.heatmap td.up-to-20-commits {
                background-color: #00A300;
            }

            table.heatmap td.commit-spam {
                background-color: #008000;
            }

            /* Popup styles */
            .commit-popup {
                position: absolute;
                background-color: #fff;
                border: 1px solid #ccc;
                border-radius: 4px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.2);
                padding: .2rem;
                z-index: 1000;
                width: 40ch;
                font-family: sans-serif;
                font-size: smaller;
                display: none;
            }

            .commit-popup h3 {
                margin-top: 0;
                border-bottom: 1px solid #eee;
                padding-bottom: 5px;
            }

            .commit-popup ul {
                margin: 0;
                padding-left: 20px;
            }

            .commit-popup li {
                margin-bottom: 5px;
            }

            .commit-popup .close-btn {
                position: absolute;
                top: 5px;
                right: 8px;
                cursor: pointer;
                font-weight: bold;
            }
        </style>
        <script>
            document.addEventListener('DOMContentLoaded', function() {
                // Create popup element
                const popup = document.createElement('div');
                popup.className = 'commit-popup';
                document.body.appendChild(popup);

                // Add click event listeners to all commit cells
                const cells = document.querySelectorAll('table.heatmap td:not(.out-of-range, .zero-commits)');
                cells.forEach(cell => {
                    cell.addEventListener('click', function(e) {
                        const date = this.dataset.date;
                        const summaries = JSON.parse(this.dataset.summaries);

                        // Create popup content
                        let content = `<span class="close-btn">×</span>
                                      <h3>${date}: ${summaries.length} commit${summaries.length !== '1' ? 's' : ''}</h3>`;
                        content += '<ul>';
                        summaries.forEach(summary => {
                            content += `<li>${summary}</li>`;
                        });
                        content += '</ul>';
                        popup.innerHTML = content;

                        // Position popup near the cell
                        const rect = this.getBoundingClientRect();
                        popup.style.left = rect.left + window.scrollX + 'px';
                        popup.style.top = (rect.bottom + window.scrollY + 5) + 'px';
                        popup.style.display = 'block';

                        // Highlight the cell to which the popup belongs
                        document.querySelectorAll('table.heatmap td.popup-open').forEach(old_cell => {
                            old_cell.classList.toggle("popup-open");
                        });
                        cell.classList.toggle("popup-open");

                        // Add close button handler
                        const closeBtn = popup.querySelector('.close-btn');
                        if (closeBtn) {
                            closeBtn.addEventListener('click', function() {
                                popup.style.display = 'none';
                                cell.classList.toggle("popup-open");
                            });
                        }

                        e.stopPropagation();
                    });
                });

                // Close popup when clicking elsewhere
                document.addEventListener('click', function(e) {
                    if (!popup.contains(e.target)) {
                        popup.style.display = 'none';
                        document.querySelectorAll('table.heatmap td.popup-open').forEach(cell => {
                            cell.classList.toggle("popup-open");
                        });
                    }
                });
            });
        </script>
    </head>
    <body>
        <div class="heatmap-content">)");
        indentation = 3;
    }
}

void html::close(bool fragment) {
    if (fragment) {
        indent(-1);
        puts("</div>");
    } else {
        puts("        </div>\n    </body>\n</html>");
    }
}

void html::chart_begin(const std::string& repo, const std::string& author) {
    indent();
    printf("<div class=\"chart\" data-repo=\"%s\" data-author=\"%s\">\n",
        encode(repo).c_str(), encode(author).c_str());
    indentation++;
}

void html::chart_end() {
    indent(-1);
    puts("</div>");
}

void html::heading_repo(const std::string& repo) {
    indent();
    printf("<h1 data-repo=\"%1$s\">%1$s</h1>\n", encode(repo).c_str());
}

void html::heading_author(const std::string& author, unsigned int total_commits) {
    indent();
    printf("<h2 title=\"Total commits: %u\">%s</h2>\n",
        total_commits,
        encode(author).c_str());
}

void html::table_begin(year y, const std::array<unsigned int, 12> &commits_per_month) {
    static constexpr const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
    // compute the column spans, first
    unsigned colspans[12] = {};
    {
        unsigned total_cols = 0;
        sys_days day{year_month_day{y, January, 1d}};
        for (unsigned col = 0; col < 12; ++col) {
            while (total_cols < html::columns && year_month_day{day}.month() <= month{col + 1}) {
                ++total_cols;
                ++colspans[col];
                day += days{7};
            }
        }
    }

    // now render the table heading
    indent();
    puts("<table class=\"heatmap\">");
    indent(1);
    puts("<tr>");
    indent(1);
    puts("<th></th>");
    for (unsigned i = 0 ; i < 12 ; i++) {
        indent();
        printf("<th scope=\"col\" title=\"Total commits: %u\" colspan=\"%d\">%s</th>\n",
            commits_per_month[i], colspans[i], months[i]);
    }
    indent(-1);
    puts("</tr>");
}

void html::table_end() {
    indent(-1);
    puts("</table>");
}

void html::row_begin(unsigned int row) {
    indent();
    puts("<tr>");
    indent(1);
    printf("<th scope=\"row\">%s</th>\n", weekdays[row]);
}

void html::row_end() {
    indent(-1);
    puts("</tr>");
}

void html::cell_out_of_range() {
    indent();
    puts("<td class=\"out-of-range\"></td>");
}

void html::cell(year_month_day ymd, const fm::commits &commits) {
    const char *color_class;
    if (commits.count() == 0) {
        color_class = "zero-commits";
    } else if (commits.count() == 1) {
        color_class = "one-commit";
    } else if (commits.count() <= 5) {
        color_class = "up-to-5-commits";
    } else if (commits.count() <= 10) {
        color_class = "up-to-10-commits";
    } else if (commits.count() <= 20) {
        color_class = "up-to-20-commits";
    } else {
        color_class = "commit-spam";
    }


    // Format date for display
    char date_str[32];
    sprintf(date_str, "%s, %d-%02u-%02u",
        weekdays[weekday(ymd).iso_encoding() - 1],
        static_cast<int>(ymd.year()),
        static_cast<unsigned>(ymd.month()),
        static_cast<unsigned>(ymd.day()));

    // Build a JSON array of commit summaries
    // We have to iterate in reverse order to sort the summaries chronologically
    std::string summaries_json = "[";
    for (const auto &summary :  commits.summaries | std::views::reverse) {
        // Escape quotes in JSON
        size_t pos = summary.find('\"');
        if (pos == std::string::npos) {
            summaries_json += "\"" + summary + "\",";
        } else {
            std::string escaped = summary;
            do {
                escaped.replace(pos, 1, "\\\"");
                pos += 2;
            } while ((pos = escaped.find('\"', pos)) != std::string::npos);
            summaries_json += "\"" + escaped + "\",";
        }
    }
    summaries_json.pop_back();
    summaries_json += "]";

    indent();
    printf("<td class=\"%s\" title=\"%s: %u %s\" data-date=\"%s\" data-summaries=\"%s\"></td>\n",
        color_class,
        date_str,
        commits.count(),
        commits.count() == 1 ? "commit" : "commits",
        date_str,
        encode(summaries_json).c_str()
    );
}

mercurial