Make all pages have both /branch and /tag URLs

2a54fe9bc0a1940383f0414150c8c36577508e1f

Tucker McKnight <tmcknight@instructure.com> | Sun May 17 2026

Make all pages have both /branch and /tag URLs

Make the "branches" dropdown menu also have tags, rename it to
"rel dropdown."

Also use navhelper for links in more places

Note: still has a "todo" for filling in the file contents in
      src/vcses/git/operations.ts.
frontend/main.js:1
Before
0
1
2
import branchesListItems from '../dist/js_templates/common/branchesListItems.js'

const setCheckbox = window.setCheckbox
const currentTheme = window.currentTheme
After
0
1
2
import {branchesAndTagsResults} from '../dist/js_templates/common/branchesListItems.js'

const setCheckbox = window.setCheckbox
const currentTheme = window.currentTheme
frontend/main.js:98
Before
97
98
99
100
101
102
103
104
105
106
107
108




109
110
111











112
113
114
115
116
117



118
119
120
  element.addEventListener("click", copyPull)
})

const dropdownBranchesResults = document.getElementById('dropdown-branches-results')

const searchBox = document.getElementById('dropdownBranchSearch')

searchBox?.addEventListener('input', (event) => {
  const searchTerm = event.target.value
  let branches = window.branchesWithHrefs.filter((branch) => {
    return branch.name.includes(searchTerm)
  })
⁣
⁣
⁣
⁣
  dropdownBranchesResults.innerHTML = branchesListItems(branches, window.defaultBranch, window.currentBranch, sortDirection)
})

⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
let sortDirection = document.querySelector("[name=branchSort]:checked")?.value || 'date'

const showBranchesResults = (searchTerm) => {
  let branches = window.branchesWithHrefs.filter((branch) => {
    return branch.name.includes(searchTerm)
  })
⁣
⁣
⁣
  dropdownBranchesResults.innerHTML = branchesListItems(branches, window.defaultBranch, window.currentBranch, sortDirection)
}

document.querySelectorAll('.sort-filter').forEach((element) => {
After
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
  element.addEventListener("click", copyPull)
})

const dropdownBranchesAndTagsList = document.getElementById('branches-and-tags-list')

const searchBox = document.getElementById('dropdownBranchSearch')

searchBox?.addEventListener('input', (event) => {
  const searchTerm = event.target.value
  let branches = window.branchesWithHrefs.branches.filter((branch) => {
    return branch.name.includes(searchTerm)
  })
  let tags = window.branchesWithHrefs.tags.filter((tag) => {
    return tag.name.includes(searchTerm)
  })

  dropdownBranchesAndTagsList.innerHTML = branchesAndTagsResults({branches, tags}, window.defaultBranch, window.currentBranch, sortDirection)
})

const dropdownTabs = document.querySelector('.rel-dropdown')
const dropdownTabsNavLinks = document.querySelectorAll('.rel-dropdown .nav-link')
dropdownTabsNavLinks.forEach((element) => {
  element.addEventListener('click', (event) => {
    const clickedTab = event.target.dataset.select // "branch" or "tag"
    dropdownTabs.dataset.selected = clickedTab
    dropdownTabsNavLinks.forEach(navLink => navLink.classList.remove('active'))
    document.querySelector(`.rel-dropdown .nav-link[data-select=${clickedTab}]`).classList.add('active')
  })
})

let sortDirection = document.querySelector("[name=branchSort]:checked")?.value || 'date'

const showBranchesResults = (searchTerm) => {
  let branches = window.branchesWithHrefs.branches.filter((branch) => {
    return branch.name.includes(searchTerm)
  })
  let tags = window.branchesWithHrefs.tags.filter((tag) => {
    return tag.name.includes(searchTerm)
  })
  dropdownBranchesAndTagsList.innerHTML = branchesListItems({branches, tags}, window.defaultBranch, window.currentBranch, sortDirection)
}

document.querySelectorAll('.sort-filter').forEach((element) => {
frontend/main.js:151
Before
150
151
152
153
154
155
156
  }
})

showBranchesResults(searchBox.value)

const pageLoadLastTouch = document.getElementById("showLastTouch")
if (pageLoadLastTouch) {
  setShowLastTouch(pageLoadLastTouch.checked)
After
150
151
152


153
154
  }
})

⁣
⁣
const pageLoadLastTouch = document.getElementById("showLastTouch")
if (pageLoadLastTouch) {
  setShowLastTouch(pageLoadLastTouch.checked)
js_templates/commits.ts:49
Before
48
49
50
51
52
53
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${data.patchPage.relName}/commits/page${pageObj.pageNumber}`
            }, pageObj.pageNumber)
          )
        })
After
48
49
50
51
52
53
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${nav.commits()}/page${pageObj.pageNumber}`,
            }, pageObj.pageNumber)
          )
        })
js_templates/commits.ts:63
Before
62
63
64
65
66
67
        commit.isMerge ? m('span', {class: 'badge rounded-pill bg-primary me-1'}, 'merge') : null,
        m('a', {
          class: "fs-5",
          href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${slugify(data.patchPage.relName)}/commits/${commit.hash}`,
        }, commit.message.split('\n').slice(0, 1)),
        m('br'),
        m('span', date(commit.date)),
After
62
63
64
65
66
67
        commit.isMerge ? m('span', {class: 'badge rounded-pill bg-primary me-1'}, 'merge') : null,
        m('a', {
          class: "fs-5",
          href: nav.commit(commit.hash),
        }, commit.message.split('\n').slice(0, 1)),
        m('br'),
        m('span', date(commit.date)),
js_templates/commits.ts:83
Before
82
83
84
85
86
87
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${data.patchPage.relName}/commits/page${pageObj.pageNumber}`
            }, pageObj.pageNumber)
          )
        })
After
82
83
84
85
86
87
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${nav.commits()}/page${pageObj.pageNumber}`,
            }, pageObj.pageNumber)
          )
        })
js_templates/common/branchesListItems.ts:1
Before
0

1


2
3

4
5
6
7
8
export default (
⁣
  branches: Array<{name: string, href: string, date: string}>,
⁣
⁣
  defaultBranch: string,
  currentBranch: string,
⁣
  sortBy: 'name' | 'date',
): string => {
  return branches.sort((a, b) => {
    let comparison = 0
    if (a[sortBy] < b[sortBy]) { comparison = -1 }
    if (a[sortBy] > b[sortBy]) { comparison = 1 }
After
0
1
2
3
4
5
6
7
8
9
10
11
12
export const branchesAndTagsResults = (
  rels: {
    branches: Array<{name: string, href: string, date: string}>,
    tags: Array<{name: string, href: string, date: string}>,
  },
  defaultBranch: string,
  currentRef: string,
  currentRefType: "branch" | "tag",
  sortBy: 'name' | 'date',
): string => {
  return `<div class="branch">` + rels.branches.sort((a, b) => {
    let comparison = 0
    if (a[sortBy] < b[sortBy]) { comparison = -1 }
    if (a[sortBy] > b[sortBy]) { comparison = 1 }
js_templates/common/branchesListItems.ts:13
Before
12
13
14
15
16
17

    return comparison
  }).map((branch) => {
    const currentBadge = currentBranch === branch.name
      ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>'
      : ''
After
12
13
14
15
16
17

    return comparison
  }).map((branch) => {
    const currentBadge = currentRef === branch.name && currentRefType === "branch"
      ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>'
      : ''
js_templates/common/branchesListItems.ts:31
Before
30
31
32
33


34














































        </span>
      </a>
    `
  }).join('')
⁣
⁣
}
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
After
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
        </span>
      </a>
    `
  }).join('') + `</div>
  <div class="tag">` + rels.tags.sort((a, b) => {
    let comparison = 0
    if (a[sortBy] < b[sortBy]) { comparison = -1 }
    if (a[sortBy] > b[sortBy]) { comparison = 1 }
    // we want reverse order if sorting by date (making newest first)
    if (sortBy === 'date') { comparison = comparison * -1 }

    return comparison
  }).map((tag) => {
    const currentBadge = currentRef === tag.name && currentRefType === "tag"
      ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>'
      : ''

    return `
      <a href='${tag.href}' class='dropdown-item my-1'>
        <span class="branch-dropdown-branch-name me-1">
          ${tag.name}
        </span>${currentBadge}
        <span class="text-body d-block ms-2">
          updated ${new Date(tag.date).toDateString()}
        </span>
      </a>
    `
  }).join('') + `</div>`
}

export default (
  rels: {
    branches: Array<{name: string, href: string, date: string}>,
    tags: Array<{name: string, href: string, date: string}>,
  },
  defaultBranch: string,
  currentRef: string,
  currentRefType: "branch" | "tag",
  sortBy: 'name' | 'date',
): string => {
  return `<div class="rel-dropdown" data-selected="${currentRefType}">
    <ul class="nav nav-tabs">
      <li class="nav-item">
        <a class="nav-link ${currentRefType === 'branch' ? 'active' : ''}" data-select="branch" href="#">Branches</a>
      </li>
      <li class="nav-item">
        <a class="nav-link ${currentRefType === 'tag' ? 'active' : ''}" data-select="tag" href="#">Tags</a>
      </li>
    </ul>
    <div id="branches-and-tags-list">
      ${branchesAndTagsResults(rels, defaultBranch, currentRef, currentRefType, sortBy)}
    </div>
  </div>`
}
js_templates/common/htmlPage.ts:2
Before
1
2
3
4
5
6
import { type ReposConfiguration } from '../../src/configTypes.ts'
import { type Repository } from '../../src/dataTypes.ts'
import { NavHelper } from '../helpers/nav.ts'
import branchesListItems from './branchesListItems.ts'

export default async (reposConfig: ReposConfiguration, eleventyConfig: any, data: any, pageContent: any) => {
  // this still necessary?
After
1
2
3
4
5
6
import { type ReposConfiguration } from '../../src/configTypes.ts'
import { type Repository } from '../../src/dataTypes.ts'
import { NavHelper } from '../helpers/nav.ts'
import relDropDown from './relDropDown.ts'

export default async (reposConfig: ReposConfiguration, eleventyConfig: any, data: any, pageContent: any) => {
  // this still necessary?
js_templates/common/htmlPage.ts:22
Before
21
22
23

24
25
26
27
28
29

30
31
32
33
34
35
36
37

38
    currentRefType: data.currentRefType,
  })

⁣
  const branchesWithHrefs = repo.branches.map((branch) => {
    return {
      name: branch.name,
      href: nav.refHome({refName: branch.name, refType: 'branch'}) + '/' + data.nav.path,
      date: repo.commits.get(branch.sha).date.toISOString(),
    }
⁣
  }).concat(repo.tags.map((tag) => {
    return {
      name: tag.name,
      href: nav.refHome({refName: tag.name, refType: 'tag'}) + '/' + data.nav.path,
      date: repo.commits.get(tag.sha).date.toISOString(),
    }
  }))

⁣
  return {
    rootPath: nav.rootPath(),
After
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    currentRefType: data.currentRefType,
  })

  const branchesWithHrefs = {
    branches: repo.branches.map((branch) => {
      return {
        name: branch.name,
        href: nav.refHome({refName: branch.name, refType: 'branch'}) + '/' + data.nav.path,
        date: repo.commits.get(branch.sha).date.toISOString(),
      }
    }),
    tags: (repo.tags.map((tag) => {
      return {
        name: tag.name,
        href: nav.refHome({refName: tag.name, refType: 'tag'}) + '/' + data.nav.path,
        date: repo.commits.get(tag.sha).date.toISOString(),
      }
    }))
  }

  return {
    rootPath: nav.rootPath(),
js_templates/common/htmlPage.ts:68
Before
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
          }, repo.name)
        ),
        m('li', {class: "nav-item"}, [
          m('span', {class: "nav-link d-inline-block"}, 'Branch:'),
          m('div', {class: "branch-selector dropdown-center d-inline-block"}, [
            m('button', {
              class: "branches nav-link d-inline-block btn btn-bg dropdown-toggle",
              'data-bs-toggle': "dropdown",
              'data-bs-auto-close': "outside",
              'aria-expanded': "false"
            }, ref.name),
            m('div', {class: "dropdown-menu"}, [
              m('form', {class: "mx-3 my-1"}, [
                m('input', {type: "text", class: "form-control", id: "dropdownBranchSearch", placeholder: "Search branches..."}),
                m('div', [
                  m('div', {class: "row mt-3"},
                    m('div', {class: "col"},
                      m('label', {class: "form-label"}, 'Sort by:')
                    )
                  ),
                  m('div', {class: "sortRadioButtons"}, [
                    m('div', {class: "sortRadioButton pe-1"}, [
                      m('input', {class: "form-check-input sort-filter", type: "radio", name: "branchSort", value: 'date', id: "branchSortByDate", checked: true}),
                      m('label', {class: "form-check-label", for: "branchSortByDate"}, 'Last commit')
                    ]),
                    m('div', {class: "sortRadioButton ps-1"}, [
                      m('input', {class: "form-check-input sort-filter", type: "radio", name: "branchSort", value: 'name', id: "branchSortByName"}),
                      m('label', {class: "form-check-label", for: "branchSortByName"}, 'Name')
                    ])
                  ])
                ])
              ]),
              m('div', {class: "dropdown-divider"}),
              m('div', {id: "dropdown-branches-results", class: "dropdown-branches"},
                branchesListItems(branchesWithHrefs, repo.defaultBranch, ref.name, 'date')
              )
            ])
          ])
        ])
      ])
    ]),
After
67
68
69
70









71
























72
73
          }, repo.name)
        ),
        m('li', {class: "nav-item"}, [
          m('span', {class: "nav-link d-inline-block"}, 'ref:'),
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
          relDropDown(ref, data.currentRefType, repo, branchesWithHrefs),
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
        ])
      ])
    ]),
js_templates/common/relDropDown.ts:0
Before







































⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
After
-1
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import m from 'mithril'
import { type Repository } from '../../src/dataTypes.ts'
import branchesListItems from './branchesListItems.ts'

type RefType = Repository['branches'][0]

export default (ref: RefType, refType: "branch" | "tag", repo: Repository, branchesWithHrefs) => {
  return m('div', {class: "branch-selector dropdown-center d-inline-block"}, [
    m('button', {
      class: "branches nav-link d-inline-block btn btn-bg dropdown-toggle",
      'data-bs-toggle': "dropdown",
      'data-bs-auto-close': "outside",
      'aria-expanded': "false"
    }, ref.name),
    m('div', {class: "dropdown-menu"}, [
      m('form', {class: "mx-3 my-1"}, [
        m('input', {type: "text", class: "form-control", id: "dropdownBranchSearch", placeholder: "Search branches..."}),
        m('div', [
          m('div', {class: "row mt-3"},
            m('div', {class: "col"},
              m('label', {class: "form-label"}, 'Sort by:')
            )
          ),
          m('div', {class: "sortRadioButtons"}, [
            m('div', {class: "sortRadioButton pe-1"}, [
              m('input', {class: "form-check-input sort-filter", type: "radio", name: "branchSort", value: 'date', id: "branchSortByDate", checked: true}),
              m('label', {class: "form-check-label", for: "branchSortByDate"}, 'Last commit')
            ]),
            m('div', {class: "sortRadioButton ps-1"}, [
              m('input', {class: "form-check-input sort-filter", type: "radio", name: "branchSort", value: 'name', id: "branchSortByName"}),
              m('label', {class: "form-check-label", for: "branchSortByName"}, 'Name')
            ])
          ])
        ])
      ]),
      m('div', {class: "dropdown-divider"}),
      m('div', {id: "dropdown-branches-results", class: "dropdown-branches"},
        m.trust(branchesListItems(branchesWithHrefs, repo.defaultBranch, ref.name, refType, 'date'))
      )
    ])
  ])
}
js_templates/file.ts:23
Before
22
23
24
25
26
27
      m('div', {class: "col"},
        m('p', [
          'Files snapshot from ',
          m('span', {class: "font-monospace"}, fileInfo.branchName)
        ])
      )
    ),
After
22
23
24
25
26
27
      m('div', {class: "col"},
        m('p', [
          'Files snapshot from ',
          m('span', {class: "font-monospace"}, fileInfo.refName)
        ])
      )
    ),
js_templates/file.ts:31
Before
30
31
32
33
34
35
      m('div', {class: "col"},
        m('h3', [
          m('span', {class: "bezel-gray p-1 my-1 d-inline-block"},
            m('a', {href: `${data.reposPath}/${fileInfo.repoName}/branch/${fileInfo.branchName}/files`}, './')
          ),
          fileInfo.file.split('/').map((dir, index, arr) => {
            if (index === arr.length - 1) {
After
30
31
32
33
34
35
      m('div', {class: "col"},
        m('h3', [
          m('span', {class: "bezel-gray p-1 my-1 d-inline-block"},
            m('a', {href: `${data.reposPath}/${fileInfo.repoName}/${fileInfo.type}/${fileInfo.refName}/files`}, './')
          ),
          fileInfo.file.split('/').map((dir, index, arr) => {
            if (index === arr.length - 1) {
js_templates/file.ts:40
Before
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
            else {
              return m('span', {class: "bezel-gray p-1 my-1 d-inline-block"}, [
                m('a', {
                  href: `${data.reposPath}/${fileInfo.repoName}/branch/${fileInfo.branchName}/files/${arr.slice(0, index + 1).map((part) => slugify(part)).join('/')}.html`}, `${dir}/`)
              ])
            }
          })
        ])
      )
    ),
    (isDirectory(fileInfo.file, fileInfo.repoName, fileInfo.branchName) ?
      m('div', {class: "row"},
        m('div', {class: "col"},
          m('ul', {class: "list-group"},
            topLevelFilesOnly(getDirectoryContents(fileInfo.repoName, fileInfo.branchName, fileInfo.file), fileInfo.file + '/').map((dir) => {
              return m('li', {class: 'list-group-item'}, [
                dir.isDirectory ? m('span', m.trust('&#x1F4C1;&nbsp;')) : null,
                m('a', {
                  href: `${data.reposPath}/${slugify(fileInfo.repoName)}/branch/${slugify(fileInfo.branchName)}/files/${dir.fullPath.split('/').map((pathPart) => {
                    return pathPart.split('.').map((subPart) => {
                      return slugify(subPart)
                    }).join('.')
After
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
            else {
              return m('span', {class: "bezel-gray p-1 my-1 d-inline-block"}, [
                m('a', {
                  href: `${data.reposPath}/${fileInfo.repoName}/${fileInfo.type}/${fileInfo.refName}/files/${arr.slice(0, index + 1).map((part) => slugify(part)).join('/')}.html`}, `${dir}/`)
              ])
            }
          })
        ])
      )
    ),
    (isDirectory(fileInfo.file, fileInfo.repoName, fileInfo.refName, fileInfo.type) ?
      m('div', {class: "row"},
        m('div', {class: "col"},
          m('ul', {class: "list-group"},
            topLevelFilesOnly(getDirectoryContents(fileInfo.repoName, fileInfo.refName, fileInfo.type, fileInfo.file), fileInfo.file + '/').map((dir) => {
              return m('li', {class: 'list-group-item'}, [
                dir.isDirectory ? m('span', m.trust('&#x1F4C1;&nbsp;')) : null,
                m('a', {
                  href: `${data.reposPath}/${slugify(fileInfo.repoName)}/${fileInfo.type}/${slugify(fileInfo.refName)}/files/${dir.fullPath.split('/').map((pathPart) => {
                    return pathPart.split('.').map((subPart) => {
                      return slugify(subPart)
                    }).join('.')
js_templates/file.ts:71
Before
70
71
72
73
74
75
        m('div', {class: "row my-3"},
          m('div', {class: "col"},
            m('span', m('a', {
              href: `${data.reposPath}/${slugify(fileInfo.repoName)}/branch/${slugify(fileInfo.branchName)}/raw/${fileInfo.file.split('.').map(filePart => slugify(filePart)).join('.')}`}, 'View raw file'))
          )
        ),
        (fileInfo.file.endsWith(".md") ?
After
70
71
72
73
74
75
        m('div', {class: "row my-3"},
          m('div', {class: "col"},
            m('span', m('a', {
              href: `${data.reposPath}/${slugify(fileInfo.repoName)}/${fileInfo.type}/${slugify(fileInfo.refName)}/raw/${fileInfo.file.split('.').map(filePart => slugify(filePart)).join('.')}`}, 'View raw file'))
          )
        ),
        (fileInfo.file.endsWith(".md") ?
js_templates/file.ts:126
Before
125
126
127
128
129
130
                m('code', {style: "white-space: pre;"}, [
                  m('pre', {class: "language-text"}, m.trust(
                    currentRef.fileList.get(fileInfo.file).fileInfo.blameLines.map((annotation) => {
                      return `<a href="${data.reposPath}/${slugify(fileInfo.repoName)}/branch/${slugify(fileInfo.branchName)}/commits/${annotation.sha}">${annotation.sha.substr(0, 6)}</a> ${annotation.author}`
                    }).join('\n')))
                ])
              ),
After
125
126
127
128
129
130
                m('code', {style: "white-space: pre;"}, [
                  m('pre', {class: "language-text"}, m.trust(
                    currentRef.fileList.get(fileInfo.file).fileInfo.blameLines.map((annotation) => {
                      return `<a href="${data.reposPath}/${slugify(fileInfo.repoName)}/${fileInfo.type}/${slugify(fileInfo.refName)}/commits/${annotation.sha}">${annotation.sha.substr(0, 6)}</a> ${annotation.author}`
                    }).join('\n')))
                ])
              ),
js_templates/files.ts:1
Before
0
1
2
3

4
5
6
7
8








9
10
import m from 'mithril'
import { type SortedFileList, type Repository } from "../src/dataTypes.ts"
import htmlPage from './common/htmlPage.ts'

⁣
export default async (reposConfig: any, eleventyConfig: any, data: any) => {
  const branch: Repository['branches'][0] = data.currentRef
  const topLevelFilesOnly = eleventyConfig.getFilter("topLevelFilesOnly")
  const slugify = eleventyConfig.getFilter("slugify")

⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
  const files: SortedFileList = topLevelFilesOnly(Array.from(branch.fileList.keys()), '')

  const pageContent = [
After
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import m from 'mithril'
import { type SortedFileList, type Repository } from "../src/dataTypes.ts"
import htmlPage from './common/htmlPage.ts'
import {NavHelper} from './helpers/nav.ts'

export default async (reposConfig: any, eleventyConfig: any, data: any) => {
  const branch: Repository['branches'][0] = data.currentRef
  const topLevelFilesOnly = eleventyConfig.getFilter("topLevelFilesOnly")
  const slugify = eleventyConfig.getFilter("slugify")

  const nav = NavHelper({
    reposConfig,
    slugify,
    currentRepoName: data.currentRepo.name,
    currentRefName: data.currentRef.name,
    currentRefType: data.currentRefType,
  })

  const files: SortedFileList = topLevelFilesOnly(Array.from(branch.fileList.keys()), '')

  const pageContent = [
js_templates/files.ts:27
Before
26
27
28
29
30
31
32
33
34
35
36
37
      return m('li', {class: 'list-group-item'}, [
        file.isDirectory ? m.trust('<span>&#x1F4C1;&nbsp;</span>') : null,
        m('a', {
          href: `${data.reposPath}/${slugify(data.flatRef.repoName)}/branch/${slugify(data.flatRef.relName)}/files/${
            file.fullPath.split('/')
            .map((pathPart) => {
              return pathPart.split('.').map((subPart) => {
                return slugify(subPart)
              }).join('.')
            }).join('/')}.html`
        }, file.name)
      ])
    }))
After
26
27
28

29





30
31
      return m('li', {class: 'list-group-item'}, [
        file.isDirectory ? m.trust('<span>&#x1F4C1;&nbsp;</span>') : null,
        m('a', {
⁣
          href: nav.file(file)
⁣
⁣
⁣
⁣
⁣
        }, file.name)
      ])
    }))
js_templates/helpers/nav.ts:1
Before
0
1
i⁣
mport { type ReposConfiguration } from "../../src/configTypes.ts"

/**
After
0
1
2
import { type SortedFileList } from '../../src/dataTypes.ts'
import { type ReposConfiguration } from "../../src/configTypes.ts"

/**
js_templates/helpers/nav.ts:50
Before
49
50
51











52
53
               ? repoBasePath
               : refPath(refName, refType)
    },
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
    files: (newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType
After
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
               ? repoBasePath
               : refPath(refName, refType)
    },
    file: (file: SortedFileList[0], newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType

      return `${refPath(refName, refType)}/files/${file.fullPath.split('/')
        .map((pathPart) => {
          return pathPart.split('.').map((subPart) => {
            return args.slugify(subPart)
          }).join('.')
        }).join('/')}.html`
    },
    files: (newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType
js_templates/index.ts:16
Before
15
16
17
18
19
20
21
22
23
24
25
26
27
          return (
            m('div', {class: "m-2 card bezel-gray flex-grow-1", style: "flex-basis: 20rem;"},
              m('div', {class: "card-header"},
                m('a', {class: "card-title fs-5", href: `${data.reposPath}/${slugify(repo.name)}/branch/${repo.defaultBranch}`}, repo.name)
              ),
              m('div', {class: "card-body"},
                repo.description ? m('p', {class: "card-text"}, repo.description) : null
              ),
              m('div', {class: "card-footer"}, [
                m('a', {
                  href: `${data.reposPath}/${slugify(repo.name)}/branch/${repo.defaultBranch}`,
                  class: "ms-0 me-2 my-2 btn btn-primary text-white"
                }, 'Go to site'),
                m('button', {
After
15
16
17
18
19
20
21
22
23
24
25
26
27
          return (
            m('div', {class: "m-2 card bezel-gray flex-grow-1", style: "flex-basis: 20rem;"},
              m('div', {class: "card-header"},
                m('a', {class: "card-title fs-5", href: `${data.reposPath}/${slugify(repo.name)}`}, repo.name)
              ),
              m('div', {class: "card-body"},
                repo.description ? m('p', {class: "card-text"}, repo.description) : null
              ),
              m('div', {class: "card-footer"}, [
                m('a', {
                  href: `${data.reposPath}/${slugify(repo.name)}`,
                  class: "ms-0 me-2 my-2 btn btn-primary text-white"
                }, 'Go to site'),
                m('button', {
js_templates/repo.ts:18
Before
17
18
19
20
21
22
    reposConfig,
    slugify,
    currentRepoName: repo.name,
    currentRefName: data.flatRef.name,
    currentRefType: data.flatRef.type,
  })
After
17
18
19
20
21
22
    reposConfig,
    slugify,
    currentRepoName: repo.name,
    currentRefName: data.currentRef.name,
    currentRefType: data.flatRef.type,
  })
main.ts:149
Before
148
149
150
151

152
153
154
155
156
157
    }
  )

  eleventyConfig.addFilter("getDirectoryContents", (repo: string, branch: string, dirPath: string) => {
⁣
    const fileList = reposData.find(
      current => current.name === repo
    ).branches.find(
      current => current.name === branch
    ).fileList

    return Array.from(fileList.keys()).filter(
After
148
149
150
151
152
153
154
155
156
157
158
    }
  )

  eleventyConfig.addFilter("getDirectoryContents", (repo: string, refName: string, refType: 'branch' | 'tag', dirPath: string) => {
    const refKey = refType === 'branch' ? 'branches' : 'tags'
    const fileList = reposData.find(
      current => current.name === repo
    )[refKey].find(
      current => current.name === refName
    ).fileList

    return Array.from(fileList.keys()).filter(
main.ts:245
Before
244
245
246
247
248

249
250
251
    return sortedByDirectory
  })

  eleventyConfig.addFilter("isDirectory", (filename: string, repoName: string, branchName: string) => {
    const repo = reposData.find(repo => repo.name === repoName)
⁣
    const files = repo.branches.find(branch => branch.name === branchName).fileList
    const isDirectory = Array.from(files.keys()).some((testFile) => {
        return testFile.startsWith(filename + '/') && (testFile !== filename)
      })
After
244
245
246
247
248
249
250
251
252
    return sortedByDirectory
  })

  eleventyConfig.addFilter("isDirectory", (filename: string, repoName: string, refName: string, refType: 'branch' | 'tag') => {
    const repo = reposData.find(repo => repo.name === repoName)
    const refKey = refType === 'branch' ? 'branches' : 'tags'
    const files = repo[refKey].find(ref => ref.name === refName).fileList
    const isDirectory = Array.from(files.keys()).some((testFile) => {
        return testFile.startsWith(filename + '/') && (testFile !== filename)
      })
main.ts:384
Before
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398



399
400
401
402

403
404
405
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const branchName = data.fileInfo.branchName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(branchName)}/files/${data.fileInfo.file.split('/').map((filePart) => filePart.split('.').map((subPart) => eleventyConfig.getFilter("slugify")(subPart)).join('.')).join('/')}.html`
      },
      eleventyComputed: {
        nav: {
          repoName: (data) => data.fileInfo.repoName,
          branchName: (data) => data.fileInfo.branchName,
          path: 'files',
        },
        currentRepo: (data) => reposData.find(repo => {
          return repo.name === data.fileInfo.repoName
        }),
⁣
⁣
⁣
        currentRef: (data) => reposData.find(repo => {
          return repo.name === data.fileInfo.repoName
        }).branches.find(branch => {
          return branch.name === data.fileInfo.branchName        }),
        currentRefType: (data) => data.fileInfo.refType,
      },
      navTab: "files",
After
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const refName = data.fileInfo.refName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(refName)}/files/${data.fileInfo.file.split('/').map((filePart) => filePart.split('.').map((subPart) => eleventyConfig.getFilter("slugify")(subPart)).join('.')).join('/')}.html`
      },
      eleventyComputed: {
        nav: {
          repoName: (data) => data.fileInfo.repoName,
          refName: (data) => data.fileInfo.refName,
          path: 'files',
        },
        currentRepo: (data) => reposData.find(repo => {
          return repo.name === data.fileInfo.repoName
        }),
        currentRef: (data) => {
          const refType = data.fileInfo.type === "branch" ? "branches" : "tags"

          return reposData.find(repo => {
            return repo.name === data.fileInfo.repoName
          })[refType].find(ref => {
            return ref.name === data.fileInfo.refName
          })
        },
        currentRefType: (data) => data.fileInfo.refType,
      },
      navTab: "files",
main.ts:422
Before
421
422
423
424
425
426
427
428



429
430
431
432

433
434
435
436
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const branchName = data.fileInfo.branchName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(branchName)}/raw/${data.fileInfo.file.split('.').map(filePart => eleventyConfig.getFilter("slugify")(filePart)).join('.')}`
      },
      eleventyComputed: {
⁣
⁣
⁣
        currentRef: (data) => reposData.find(repo => {
          return repo.name === data.fileInfo.repoName
        }).branches.find(branch => {
          return branch.name === data.fileInfo.branchName        }),
        currentRefType: (data) => data.fileInfo.refType,
      }
    }
  )
After
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const refName = data.fileInfo.refName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(refName)}/raw/${data.fileInfo.file.split('.').map(filePart => eleventyConfig.getFilter("slugify")(filePart)).join('.')}`
      },
      eleventyComputed: {
        currentRef: (data) => {
          const refKey = data.fileInfo.type === 'branch' ? 'branches' : 'tags'

          return reposData.find(repo => {
            return repo.name === data.fileInfo.repoName
          })[refKey].find(ref => {
            return ref.name === data.fileInfo.refName
          })
        },
        currentRefType: (data) => data.fileInfo.type,
      }
    }
  )
scss/design-board.scss:175
Before
174
175
176







177
178
  font-size: 1rem;
}

⁣
⁣
⁣
⁣
⁣
⁣
⁣
.dropdown-branches {
  list-style: none;
  padding: 0;
After
174
175
176
177
178
179
180
181
182
183
184
185
  font-size: 1rem;
}

.rel-dropdown[data-selected=branch] .tag {
  display: none;
}
.rel-dropdown[data-selected=tag] .branch {
  display: none;
}

.dropdown-branches {
  list-style: none;
  padding: 0;
src/flatFiles.ts:3
Before
2
3
4
5
6

7
8
9
10
11
12
13
14
15
16
17
18

19
20
21
22
23













24

let cachedFlatFiles: Array<FlatFileEntry> | null = null
export type FlatFileEntry = {
  file: string,
  branchName: string,
  repoName: string,
⁣
}

export default (repos: Array<Repository>) : Array<FlatFileEntry> => {
  if (cachedFlatFiles !== null) { return cachedFlatFiles }

  cachedFlatFiles = repos.flatMap((repo) => {
    return repo.branches.flatMap((branch) => {
      return Array.from(branch.fileList.keys()).map((file) => {
        return {
          file,
          branchName: branch.name,
          repoName: repo.name,
⁣
        }
      })
    })
  })

⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
  return cachedFlatFiles
⁣
⁣
}
After
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
let cachedFlatFiles: Array<FlatFileEntry> | null = null
export type FlatFileEntry = {
  file: string,
  refName: string,
  repoName: string,
  type: 'branch' | 'tag',
}

export default (repos: Array<Repository>) : Array<FlatFileEntry> => {
  if (cachedFlatFiles !== null) { return cachedFlatFiles }

  const branches: FlatFileEntry[] = repos.flatMap((repo) => {
    return repo.branches.flatMap((branch) => {
      return Array.from(branch.fileList.keys()).map((file) => {
        return {
          file,
          refName: branch.name,
          repoName: repo.name,
          type: 'branch',
        }
      })
    })
  })

  const tags: FlatFileEntry[] = repos.flatMap((repo) => {
    return repo.tags.flatMap((tag) => {
      return Array.from(tag.fileList.keys()).map((file) => {
        return {
          file,
          refName: tag.name,
          repoName: repo.name,
          type: 'tag',
        }
      })
    })
  })

  cachedFlatFiles = [...branches, ...tags]

  return cachedFlatFiles
}
src/vcses/git/operations.ts:37
Before
36
37
38
39
40
41
      filesMap.set(path, {
        get fileInfo() {
          return {
            contents: "",
            lastModified: new Date(),
            blameLines: []
          }
After
36
37
38
39
40
41
      filesMap.set(path, {
        get fileInfo() {
          return {
            contents: "", // TODO: implement this part
            lastModified: new Date(),
            blameLines: []
          }
src/vcses/git/operations.ts:52
Before
51
52
53








54



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
  const totalPatchesCountRes = await exec(`git -C ${repoLocation} rev-list --count ${branchName}`)
  const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
  let previousHash: null | string = null
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
  for (let i = 0; i < totalPatchesCount; i = i + 10) {
⁣
⁣
⁣
    const gitLogSubsetRes = await exec(`git -C ${repoLocation} log ${branchName} -p -n 10 --skip ${i}`)
⁣
⁣
⁣
    let gitLogSubset = gitLogSubsetRes.stdout.split("\n")
⁣
    do {
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
      const nextPatchStart = gitLogSubset.findIndex((line, index) => {
        return (index > 0 && line.startsWith("commit ")) || index === gitLogSubset.length - 1
      })
      const isEndOfFile = nextPatchStart === gitLogSubset.length - 1
      const currentPatch = gitLogSubset.slice(0, isEndOfFile ? nextPatchStart : nextPatchStart - 1)
      gitLogSubset = gitLogSubset.slice(nextPatchStart)

      const hash = currentPatch[0].replace("commit ", "").trim()

      // set the parent hash of the previous commit, unless we're on the first commit
      if (previousHash !== null) {
        commits.get(previousHash)['parent'] = hash
      }
      previousHash = hash

      // exit early if the commits map already includes this hash
      if (commits.has(hash)) {
        return
      }

      let author: string, date: string, isMerge: boolean
      [1, 2, 3].forEach((lineNumber) => {
        if (currentPatch[lineNumber].startsWith("Author")) {
          author = currentPatch[lineNumber].replace("Author: ", "").trim()
        }
        else if (currentPatch[lineNumber].startsWith("Date")) {
          date = currentPatch[lineNumber].replace("Date: ", "").trim()
        }
        else if (currentPatch[lineNumber].startsWith("Merge")) {
          isMerge = true
        }
      })
      let diffStart = currentPatch.findIndex((line) => {
        return line.startsWith("diff ")
      })
      // If no line starts with "diff", this
      // is probably a mege commit. Use the last
      // line of the patch + 1, in that case, to just get the full
      // text of the commit
      let messageStart = 4
      if (diffStart === -1) {
        messageStart = 5
        diffStart = currentPatch.length
      }
      // Git log is indent four spaces by default -- remove those.
      const commitMessage = currentPatch.slice(messageStart, diffStart).map(str => str.replace("    ", "")).filter((line) => {
        // git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
        return !line.startsWith('Ignore-this: ')
      }).join("\n").trim()

      const diffs = getGitDiffsFromPatchText(currentPatch.slice(diffStart).join("\n"))
      commits.set(hash, {
        hash,
        message: commitMessage,
        isMerge: isMerge || false,
        author,
        date: new Date(date),
        diffs,
        parent: null,
        cachedFiles: new Map(),
      })
    } while (gitLogSubset.length > 1)
⁣
  }
}

export type BlameInfo = {
After
51
52
53
54
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
145
146
147
148
149
150
151
152
  const totalPatchesCountRes = await exec(`git -C ${repoLocation} rev-list --count ${branchName}`)
  const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
  let previousHash: null | string = null
  let gitLogSubsetCmd = null
  let gitLogSubset = []
  return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount)
}

export const addBranchToCommitsMapWithSkip = async(branchName: string, repoLocation: string, commits: Repository['commits'], totalPatchesCount: number, skip = 0, previousHash = null): Promise<void> => {
  console.log(`addBranchToCommitsMap, skip: ${skip}, total: ${totalPatchesCount}`)
  return new Promise((resolve) => {
    if (skip >= totalPatchesCount) {
      return resolve()
    }

    // const gitLogSubsetRes = await exec(`git -C ${repoLocation} log ${branchName} -p -n 10 --skip ${i}`)
    const gitLogSubsetCmd = childProcess.spawn('git', ['-C', repoLocation, 'log', branchName, '-p', '-n', '10', '--skip', skip.toString()], {
      stdio: [0, "pipe", "inherit"],
    })
    // let gitLogSubset = gitLogSubsetRes.stdout.split("\n")
    let gitLogSubset = []
    gitLogSubsetCmd.stdout.on('data', (data) => {
      gitLogSubset.push(data)
    })

    gitLogSubsetCmd.on('close', () => {
      previousHash = addGitLogSubsetToMap(gitLogSubset.join('').split('\n'), commits, previousHash)
      return addBranchToCommitsMapWithSkip(branchName, repoLocation, commits, totalPatchesCount, skip + 10, previousHash).then(() => {
        return resolve()
      })
    })
  })
}

const addGitLogSubsetToMap = (gitLogSubset: string[], commits: Repository['commits'], previousHash: string | null) => {
  do {
    const nextPatchStart = gitLogSubset.findIndex((line, index) => {
      return (index > 0 && line.startsWith("commit ")) || index === gitLogSubset.length - 1
    })
    const isEndOfFile = nextPatchStart === gitLogSubset.length - 1
    const currentPatch = gitLogSubset.slice(0, isEndOfFile ? nextPatchStart : nextPatchStart - 1)
    gitLogSubset = gitLogSubset.slice(nextPatchStart)

    const hash = currentPatch[0].replace("commit ", "").trim()
    console.log(`doing ${hash}`)
    // set the parent hash of the previous commit, unless we're on the first commit
    if (previousHash !== null) {
      commits.get(previousHash)['parent'] = hash
    }
    previousHash = hash

    // exit early if the commits map already includes this hash
    if (commits.has(hash)) {
      return
    }

    let author: string, date: string, isMerge: boolean
    [1, 2, 3].forEach((lineNumber) => {
      if (currentPatch[lineNumber].startsWith("Author")) {
        author = currentPatch[lineNumber].replace("Author: ", "").trim()
      }
      else if (currentPatch[lineNumber].startsWith("Date")) {
        date = currentPatch[lineNumber].replace("Date: ", "").trim()
      }
      else if (currentPatch[lineNumber].startsWith("Merge")) {
        isMerge = true
      }
    })
    let diffStart = currentPatch.findIndex((line) => {
      return line.startsWith("diff ")
    })
    // If no line starts with "diff", this
    // is probably a mege commit. Use the last
    // line of the patch + 1, in that case, to just get the full
    // text of the commit
    let messageStart = 4
    if (diffStart === -1) {
      messageStart = 5
      diffStart = currentPatch.length
    }
    // Git log is indent four spaces by default -- remove those.
    const commitMessage = currentPatch.slice(messageStart, diffStart).map(str => str.replace("    ", "")).filter((line) => {
      // git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
      return !line.startsWith('Ignore-this: ')
    }).join("\n").trim()

    const diffs = getGitDiffsFromPatchText(currentPatch.slice(diffStart).join("\n"))
    commits.set(hash, {
      hash,
      message: commitMessage,
      isMerge: isMerge || false,
      author,
      date: new Date(date),
      diffs,
      parent: null,
      cachedFiles: new Map(),
    })
  } while (gitLogSubset.length > 1)

  return previousHash
}

export type BlameInfo = {