semdiff-differ-text 0.4.2

Text diff calculator and reporters for semdiff.
Documentation
<style>
    .text-detail {
        width: 100%;
        font-size: 1rem;
        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
        display: grid;
        column-gap: 0;
        row-gap: 0;
        {% if detail.is_multicolumn() %}
        grid-template-columns: max-content minmax(0, 1fr) max-content minmax(0, 1fr);
        {% else %}
        grid-template-columns: max-content minmax(0, 1fr);
        {% endif %}
    }

    .text-detail-row {
        display: grid;
        grid-template-columns: subgrid;
        grid-column: 1 / -1;
    }

    .text-detail-cell {
        border-left: 1px solid var(--status-neutral-border);
        border-right: 1px solid var(--status-neutral-border);
        padding: 0 0.5rem;
        vertical-align: top;
        line-height: 1.6;
        user-select: none;
        box-sizing: border-box;
        grid-row: 1;
    }

    .text-detail-header .text-detail-cell {
        background: var(--status-neutral-bg);
        text-align: left;
        font-weight: 600;
        border-bottom: 1px solid var(--status-neutral-border);
    }

    .text-detail-cell.line-no {
        text-align: right;
        color: var(--status-neutral-text-subtle);
        background: var(--status-neutral-bg);
    }

    .text-detail-cell.same {
        background: var(--status-neutral-bg);
    }

    .text-detail-cell.added {
        background: var(--status-added-bg);
    }

    .text-detail-cell.deleted {
        background: var(--status-deleted-bg);
    }

    .text-detail-cell.empty {
        background: var(--status-neutral-surface);
    }

    .text-detail-cell .cell-text {
        display: block;
        white-space: pre-wrap;
        word-break: break-word;
        user-select: text;
    }

    .text-detail.select-left .cell-right .cell-text,
    .text-detail.select-right .cell-left .cell-text {
        user-select: none;
    }

    {% if detail.is_multicolumn() %}
    @media (max-width: 1024px) {
        .text-detail {
            grid-template-columns: max-content max-content minmax(0, 1fr);
        }

        .text-detail .text-detail-cell.line-no.expected {
            grid-column: 1;
        }

        .text-detail .text-detail-cell.line-no.actual {
            grid-column: 2;
        }

        .text-detail .text-detail-header .text-detail-cell.header-label {
            grid-column: 3;
            color: transparent;
        }

        .text-detail .cell-left,
        .text-detail .cell-right {
            grid-column: 3;
            min-width: 0;
        }

        .text-detail .cell-right.same {
            display: none;
        }

        .text-detail .empty {
            display: none;
        }
    }
    {% endif %}
</style>
<div class="text-detail">
    <div class="text-detail-row text-detail-header">
        {% match detail %}
        {% when TextDetailBody::Diff with { .. } %}
        <div class="text-detail-cell line-no">Line</div>
        <div class="text-detail-cell header-label">expected</div>
        <div class="text-detail-cell line-no">Line</div>
        <div class="text-detail-cell header-label">actual</div>
        {% when TextDetailBody::Single with { label, .. } %}
        <div class="text-detail-cell line-no">Line</div>
        <div class="text-detail-cell">{{ label }}</div>
        {% endmatch %}
    </div>
    {% match detail %}
    {% when TextDetailBody::Diff with { lines } %}
    {% let mut expected_index = 1usize.. %}
    {% let mut actual_index = 1usize.. %}
    {% for change in lines.iter_all_changes() %}
    <div class="text-detail-row">
        {% match change.tag() %}
        {% when similar::ChangeTag::Equal %}
        <div class="text-detail-cell line-no expected">{{ expected_index.next().unwrap() }}</div>
        <div class="text-detail-cell cell-left same"><span class="cell-text">{{ change.to_string_lossy() }}</span>
        </div>
        <div class="text-detail-cell line-no actual">{{ actual_index.next().unwrap() }}</div>
        <div class="text-detail-cell cell-right same"><span class="cell-text">{{ change.to_string_lossy() }}</span>
        </div>
        {% when similar::ChangeTag::Delete %}
        <div class="text-detail-cell line-no expected">{{ expected_index.next().unwrap() }}</div>
        <div class="text-detail-cell cell-left deleted"><span
                class="cell-text">{{ change.to_string_lossy() }}</span></div>
        <div class="text-detail-cell line-no actual"></div>
        <div class="text-detail-cell cell-right empty"><span class="cell-text"></span></div>
        {% when similar::ChangeTag::Insert %}
        <div class="text-detail-cell line-no expected"></div>
        <div class="text-detail-cell cell-left empty"><span class="cell-text"></span></div>
        <div class="text-detail-cell line-no actual">{{ actual_index.next().unwrap() }}</div>
        <div class="text-detail-cell cell-right added"><span class="cell-text">{{ change.to_string_lossy() }}</span>
        </div>
        {% endmatch %}
    </div>
    {% endfor %}
    {% when TextDetailBody::Single with { label, body } %}
    {% for (i, line) in body.lines().enumerate() %}
    <div class="text-detail-row">
        <div class="text-detail-cell line-no">{{ i + 1 }}</div>
        <div class="text-detail-cell cell-left {{ label }}"><span class="cell-text">{{ line }}</span></div>
    </div>
    {% endfor %}
    {% endmatch %}
</div>
<script>
    (() => {
        const detail = document.querySelector(".text-detail");
        if (!detail) {
            return;
        }
        const clearSelectionMode = () => {
            detail.classList.remove("select-left", "select-right");
        };
        const clearSelectionRanges = () => {
            const selection = window.getSelection();
            if (selection) {
                selection.removeAllRanges();
            }
        };
        let pointerDown = false;
        let dragged = false;
        let downSide = null;

        document.addEventListener(
            "mousedown",
            (event) => {
                if (!event.target.closest(".text-detail")) {
                    clearSelectionMode();
                }
            },
            true
        );
        detail.addEventListener("mousedown", (event) => {
            const cell = event.target.closest(".text-detail-cell");
            if (!cell) {
                clearSelectionMode();
                clearSelectionRanges();
                return;
            }
            pointerDown = true;
            dragged = false;
            if (cell.classList.contains("cell-left")) {
                downSide = "left";
            } else if (cell.classList.contains("cell-right")) {
                downSide = "right";
            } else {
                downSide = null;
            }
            clearSelectionMode();
            clearSelectionRanges();
            if (downSide === "left") {
                detail.classList.add("select-left");
            } else if (downSide === "right") {
                detail.classList.add("select-right");
            }
        });
        detail.addEventListener("mousemove", (event) => {
            if (!pointerDown || dragged) {
                return;
            }
            if (event.buttons !== 0) {
                dragged = true;
            }
        });
        document.addEventListener(
            "mouseup",
            () => {
                if (!pointerDown) {
                    return;
                }
                pointerDown = false;
                if (!dragged && downSide) {
                    clearSelectionRanges();
                }
                dragged = false;
                downSide = null;
            },
            true
        );
    })();
</script>