Tucker McKnight <tmcknight@instructure.com> | Sun Aug 02 2026
[WIP] implement some functionality for commit language graph Adds "added" and "removed" fields to a commit, which has the number of lines added and deleted in that commit hunk. This is different from the length of the beforeText and afterText fields, since those have newlines added to them to balance them out in the side-by-side view. So the beforeText and afterText always have the same number of lines in them. added and removed says what the original diff sizes were. This is still a WIP and I need to figure out how to have shared language colors between this and the repo home graph, as well as implement the "other" length. And also do something for the average size of a commit -- a moving standard deviation?
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
)
),
m('div', {class: "container"}, data.patchPage.commits.map((commit) => {
return [
m('div', {class: "commit row py-2 my-2"}, [
m('div', {class: "col-lg-auto col-12 commit-date"}, [
m('span', {class: "date mb-1 me-2 font-monospace d-inline-block text-nowrap"}, date(commit.date).split(' ').slice(1).join(' ')),
commit.isMerge ? m('span', {class: 'badge rounded-pill bg-warning me-1'}, 'merge') : null,
// TODO: if this commit has any tags, list them here
// m('span', {class: "bg-danger mb-1 badge rounded-pill d-inline-block"}, 'v1.2')
]),
m('div', {class: "col-12 col-md"}, [
m('div', {class: "row mb-1"},
m('a', {href: nav.commit(commit.hash), class: "commit-msg"}, commit.message.split('\n').slice(0, 1))
),
m('div', {class: "row mb-3 mb-lg-0"}, [
m('div', {class: "col d-flex flex-direction-row align-items-center flex-wrap"}, [
m('span', {class: "author me-2"}, commit.author),
// TODO: if the user has a verified website, put it here
// m('span', {class: "bg-primary badge rounded-pill me-2"}, 'tuckerm.us'),
m('span', {class: "copy-sha"}, [
m('span', {class: "sha font-monospace me-1"}, commit.hash.slice(0, 6)),
m('a', {href: "#"}, 'Copy')
])
])
])
]),
m('div', {class: "col col-lg-4"}, [
m('div', {class: "row"}, [
m('div', {class: "col-auto language-names pe-1"}, [
m('div', 'js'),
m('div', 'css'),
m('div', 'md'),
m('div', 'other'),
]),
m('div', {class: "col ps-0"}, [
m('div', {class: "flex-grow-1 d-flex"}, [
m('div', {class: "lang-diff plus-1"}, '+510'),
m('div', {class: "lang-diff minus-1"}, '-245')
]),
m('div', {class: "flex-grow-1 d-flex"}, [
m('div', {class: "lang-diff plus-2 text-white"}, '+314'),
m('div', {class: "lang-diff minus-2 text-white"}, '-275')
]),
m('div', {class: "flex-grow-1 d-flex"}, [
m('div', {class: "lang-diff plus-3 text-white"}, '+45'),
m('div', {class: "lang-diff minus-3 text-white"}, '-20')
]),
m('div', {class: "flex-grow-1 d-flex"}, [
m('div', {class: "lang-diff plus-4 text-white"}, '+890'),
m('div', {class: "lang-diff minus-4 text-white"}, '-789')
])
])
])
])
]),
]
})),55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
)
),
m('div', {class: "container"}, data.patchPage.commits.map((commit) => {
// Map of fileExtension => [lines_deleted, lines_added]
const languageCounts = new Map<string, [number, number]>()
commit.diffs.forEach((diff) => {
const fileParts = diff.fileName.split(".")
const fileExtension = fileParts[fileParts.length - 1]
const currentCounts = languageCounts.get(fileExtension) || [0, 0]
currentCounts[0] = currentCounts[0] + diff.removed
currentCounts[1] = currentCounts[1] + diff.added
languageCounts.set(fileExtension, currentCounts)
})
let totalDiffSizePerExtension = []
// see which languages have the largest change
// totalDiffSizePerExtension = [{extension: 'html', total: '50'}]
for (const entry of languageCounts) {
totalDiffSizePerExtension.push({ extension: entry[0], total: entry[1][0] + entry[1][1] })
}
totalDiffSizePerExtension = totalDiffSizePerExtension.toSorted((a, b) => {
return b.total - a.total
})
const numOfTopLanguages = Math.min(3, totalDiffSizePerExtension.filter(a => a.total).length)
const topLanguagePercentages = totalDiffSizePerExtension.slice(0, numOfTopLanguages)
const otherLanguageDiffs = totalDiffSizePerExtension.slice(numOfTopLanguages).reduce((totals, current) => {
totals[0] += languageCounts.get(current.extension)[0]
totals[1] += languageCounts.get(current.extension)[1]
return totals
}, [0, 0])
return [
m('div', {class: "commit row py-2 my-2"}, [
m('div', {class: "col-md-auto col-12 commit-date"}, [
m('span', {class: "date mb-1 me-2 font-monospace d-inline-block text-nowrap"}, date(commit.date).split(' ').slice(1).join(' ')),
commit.isMerge ? m('span', {class: 'badge rounded-pill bg-warning me-1'}, 'merge') : null,
// TODO: if this commit has any tags, list them here
// m('span', {class: "bg-danger mb-1 badge rounded-pill d-inline-block"}, 'v1.2')
]),
m('div', { class: 'col' }, [
m('div', { class: 'row' }, [
m('div', {class: "col-12 col-md-8"}, [
m('div', {class: "row mb-1"},
m('a', {href: nav.commit(commit.hash), class: "commit-msg"}, commit.message.split('\n').slice(0, 1))
),
m('div', {class: "row mb-3 mb-lg-0"}, [
m('div', {class: "col d-flex flex-direction-row align-items-center flex-wrap"}, [
m('span', {class: "author me-2"}, commit.author),
// TODO: if the user has a verified website, put it here
// m('span', {class: "bg-primary badge rounded-pill me-2"}, 'tuckerm.us'),
])
])
]),
m('div', {class: "col-12 col-md-4 d-flex"}, [
m('div', { class: "d-flex flex-column" }, topLanguagePercentages.map((topLangPercent) => {
return m('div', {
class: 'font-monospace small',
style: 'height: 1rem;'
}, topLangPercent.extension)
}).concat(otherLanguageDiffs[0] > 0 || otherLanguageDiffs[1] > 0
? m('div', {
class: 'font-monospace small',
style: 'height: 1rem;'
}, `other ${otherLanguageDiffs[0]} ${otherLanguageDiffs[1]}`)
: null,
)),
m('div', {
class: "d-flex flex-column flex-grow-1"
}, topLanguagePercentages.map((topLangPercent) => {
const langDiffs = languageCounts.get(topLangPercent.extension)
// TODO: make the average commit size adjustable
const adjustedLength = Math.min(langDiffs[0] + langDiffs[1], 100)
return m('div', {
style: `height: 1rem; width: ${adjustedLength}%; background: black;`
})
}))
]),
]),
]),
m('div', {class: 'col-12 col-md-auto'}, [
m('div', {class: "input-group mb-2 flex-nowrap"}, [
m('span', {class: "font-monospace input-group-text border-info text-white text-bg-dark overflow-scroll"}, commit.hash.slice(0, 6)),
m('button', {'data-copy-text': commit.hash, class: "btn btn-sm btn-info shadow-none copy-button"}, 'Copy')
])
]),
]),
]
})),17 18 19 20 21
lineNumber: number,
beforeText: string,
afterText: string,
}>
}
17 18 19 20 21 22 23
lineNumber: number,
beforeText: string,
afterText: string,
added: number,
removed: number,
}>
}
50 51 52 53 54 55 56 57 58 59
if (str.startsWith(' ')) { return str.replace(" ", "") }
}).join("\n")
const changeObject = (lastHunkBefore.length > 1000 || lastHunkAfter.length > 1000)
? []
: Diff.diffWordsWithSpace(lastHunkBefore, lastHunkAfter)
let beforeText = ""
let afterText = ""
changeObject.forEach((obj) => {
const numNewLines = obj.value.split('').filter(char => char === '\n').length50 51 52 53 54 55 56 57 58 59 60 61 62
if (str.startsWith(' ')) { return str.replace(" ", "") }
}).join("\n")
// TODO: don't split on \n again here, do this earlier? More efficiently?
const changeObject = (lastHunkBefore.split('\n').length > 1000 || lastHunkAfter.split('\n').length > 1000)
? []
: Diff.diffWordsWithSpace(lastHunkBefore, lastHunkAfter)
let beforeText = ""
let afterText = ""
let added = 0
let removed = 0
changeObject.forEach((obj) => {
const numNewLines = obj.value.split('').filter(char => char === '\n').length62 63 64 65 66 67 68 69
if (!obj.added && !obj.removed) {
beforeText = beforeText + escape(obj.value)
afterText = afterText + escape(obj.value)
}
if (obj.added) {
afterText = afterText + "<mark>" + escape(obj.value) + "</mark>"
if (numNewLines > 0) {
let insertAt = beforeText.lastIndexOf("\n")
insertAt = insertAt === -1 ? 0 : insertAt62 63 64 65 66 67 68 69 70 71 72
if (!obj.added && !obj.removed) {
beforeText = beforeText + escape(obj.value)
afterText = afterText + escape(obj.value)
added += obj.value.split('\n').length
removed += obj.value.split('\n').length
}
if (obj.added) {
afterText = afterText + "<mark>" + escape(obj.value) + "</mark>"
added += obj.value.split('\n').length
if (numNewLines > 0) {
let insertAt = beforeText.lastIndexOf("\n")
insertAt = insertAt === -1 ? 0 : insertAt74 75 76 77 78
}
if (obj.removed) {
beforeText = beforeText + "<mark>" + escape(obj.value) + "</mark>"
if (numNewLines > 0) {
let insertAt = afterText.lastIndexOf("\n")
insertAt = insertAt === -1 ? 0 : insertAt74 75 76 77 78 79
}
if (obj.removed) {
beforeText = beforeText + "<mark>" + escape(obj.value) + "</mark>"
removed += obj.value.split('\n').length
if (numNewLines > 0) {
let insertAt = afterText.lastIndexOf("\n")
insertAt = insertAt === -1 ? 0 : insertAt88 89 90 91 92
lineNumber: parseInt(lines[previousHunk].match(lineNumberRegex)[1]),
beforeText,
afterText,
})
previousFilename = ''
previousHunk = index88 89 90 91 92 93 94
lineNumber: parseInt(lines[previousHunk].match(lineNumberRegex)[1]),
beforeText,
afterText,
added,
removed,
})
previousFilename = ''
previousHunk = index