### Overview This is the implementation of Code Frequency page. This feature was mentioned on these issues: #18262, #7392. It adds another tab to Activity page called Code Frequency. Code Frequency tab shows additions and deletions over time since the repository existed. Before: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/2603504f-aee7-4929-a8c4-fb3412a7a0f6"> After: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/58c03721-729f-4536-a663-9f337f240963"> --- #### Features - See additions deletions over time since repository existed - Click on "Additions" or "Deletions" legend to show only one type of contribution - Use the same cache from Contributors page so that the loading of data will be fast once it is cached by visiting either one of the pages --------- Co-authored-by: Giteabot <teabot@gitea.io>tags/v1.22.0-rc0
@@ -1919,6 +1919,7 @@ wiki.original_git_entry_tooltip = View original Git file instead of using friend | |||
activity = Activity | |||
activity.navbar.pulse = Pulse | |||
activity.navbar.contributors = Contributors | |||
activity.navbar.code_frequency = Code Frequency | |||
activity.period.filter_label = Period: | |||
activity.period.daily = 1 day | |||
activity.period.halfweekly = 3 days | |||
@@ -2597,6 +2598,7 @@ component_loading = Loading %s... | |||
component_loading_failed = Could not load %s | |||
component_loading_info = This might take a bit… | |||
component_failed_to_load = An unexpected error happened. | |||
code_frequency.what = code frequency | |||
contributors.what = contributions | |||
[org] |
@@ -0,0 +1,41 @@ | |||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package repo | |||
import ( | |||
"errors" | |||
"net/http" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
contributors_service "code.gitea.io/gitea/services/repository" | |||
) | |||
const ( | |||
tplCodeFrequency base.TplName = "repo/activity" | |||
) | |||
// CodeFrequency renders the page to show repository code frequency | |||
func CodeFrequency(ctx *context.Context) { | |||
ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency") | |||
ctx.Data["PageIsActivity"] = true | |||
ctx.Data["PageIsCodeFrequency"] = true | |||
ctx.PageData["repoLink"] = ctx.Repo.RepoLink | |||
ctx.HTML(http.StatusOK, tplCodeFrequency) | |||
} | |||
// CodeFrequencyData returns JSON of code frequency data | |||
func CodeFrequencyData(ctx *context.Context) { | |||
if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { | |||
if errors.Is(err, contributors_service.ErrAwaitGeneration) { | |||
ctx.Status(http.StatusAccepted) | |||
return | |||
} | |||
ctx.ServerError("GetCodeFrequencyData", err) | |||
} else { | |||
ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) | |||
} | |||
} |
@@ -1403,6 +1403,10 @@ func registerRoutes(m *web.Route) { | |||
m.Get("", repo.Contributors) | |||
m.Get("/data", repo.ContributorsData) | |||
}) | |||
m.Group("/code-frequency", func() { | |||
m.Get("", repo.CodeFrequency) | |||
m.Get("/data", repo.CodeFrequencyData) | |||
}) | |||
}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) | |||
m.Group("/activity_author_data", func() { |
@@ -143,7 +143,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int | |||
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { | |||
_ = stdoutWriter.Close() | |||
scanner := bufio.NewScanner(stdoutReader) | |||
scanner.Split(bufio.ScanLines) | |||
for scanner.Scan() { | |||
line := strings.TrimSpace(scanner.Text()) | |||
@@ -180,7 +179,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int | |||
} | |||
} | |||
commitStats.Total = commitStats.Additions + commitStats.Deletions | |||
scanner.Scan() | |||
scanner.Text() // empty line at the end | |||
res := &ExtendedCommitStats{ |
@@ -8,6 +8,7 @@ | |||
<div class="flex-container-main"> | |||
{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}} | |||
{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}} | |||
{{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}} | |||
</div> | |||
</div> | |||
</div> |
@@ -0,0 +1,9 @@ | |||
{{if .Permission.CanRead $.UnitTypeCode}} | |||
<div id="repo-code-frequency-chart" | |||
data-locale-loading-title="{{ctx.Locale.Tr "graphs.component_loading" (ctx.Locale.Tr "graphs.code_frequency.what")}}" | |||
data-locale-loading-title-failed="{{ctx.Locale.Tr "graphs.component_loading_failed" (ctx.Locale.Tr "graphs.code_frequency.what")}}" | |||
data-locale-loading-info="{{ctx.Locale.Tr "graphs.component_loading_info"}}" | |||
data-locale-component-failed-to-load="{{ctx.Locale.Tr "graphs.component_failed_to_load"}}" | |||
> | |||
</div> | |||
{{end}} |
@@ -5,4 +5,7 @@ | |||
<a class="{{if .PageIsContributors}}active {{end}}item" href="{{.RepoLink}}/activity/contributors"> | |||
{{ctx.Locale.Tr "repo.activity.navbar.contributors"}} | |||
</a> | |||
<a class="{{if .PageIsCodeFrequency}}active{{end}} item" href="{{.RepoLink}}/activity/code-frequency"> | |||
{{ctx.Locale.Tr "repo.activity.navbar.code_frequency"}} | |||
</a> | |||
</div> |
@@ -0,0 +1,172 @@ | |||
<script> | |||
import {SvgIcon} from '../svg.js'; | |||
import { | |||
Chart, | |||
Legend, | |||
LinearScale, | |||
TimeScale, | |||
PointElement, | |||
LineElement, | |||
Filler, | |||
} from 'chart.js'; | |||
import {GET} from '../modules/fetch.js'; | |||
import {Line as ChartLine} from 'vue-chartjs'; | |||
import { | |||
startDaysBetween, | |||
firstStartDateAfterDate, | |||
fillEmptyStartDaysWithZeroes, | |||
} from '../utils/time.js'; | |||
import {chartJsColors} from '../utils/color.js'; | |||
import {sleep} from '../utils.js'; | |||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; | |||
const {pageData} = window.config; | |||
Chart.defaults.color = chartJsColors.text; | |||
Chart.defaults.borderColor = chartJsColors.border; | |||
Chart.register( | |||
TimeScale, | |||
LinearScale, | |||
Legend, | |||
PointElement, | |||
LineElement, | |||
Filler, | |||
); | |||
export default { | |||
components: {ChartLine, SvgIcon}, | |||
props: { | |||
locale: { | |||
type: Object, | |||
required: true | |||
}, | |||
}, | |||
data: () => ({ | |||
isLoading: false, | |||
errorText: '', | |||
repoLink: pageData.repoLink || [], | |||
data: [], | |||
}), | |||
mounted() { | |||
this.fetchGraphData(); | |||
}, | |||
methods: { | |||
async fetchGraphData() { | |||
this.isLoading = true; | |||
try { | |||
let response; | |||
do { | |||
response = await GET(`${this.repoLink}/activity/code-frequency/data`); | |||
if (response.status === 202) { | |||
await sleep(1000); // wait for 1 second before retrying | |||
} | |||
} while (response.status === 202); | |||
if (response.ok) { | |||
this.data = await response.json(); | |||
const weekValues = Object.values(this.data); | |||
const start = weekValues[0].week; | |||
const end = firstStartDateAfterDate(new Date()); | |||
const startDays = startDaysBetween(new Date(start), new Date(end)); | |||
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data); | |||
this.errorText = ''; | |||
} else { | |||
this.errorText = response.statusText; | |||
} | |||
} catch (err) { | |||
this.errorText = err.message; | |||
} finally { | |||
this.isLoading = false; | |||
} | |||
}, | |||
toGraphData(data) { | |||
return { | |||
datasets: [ | |||
{ | |||
data: data.map((i) => ({x: i.week, y: i.additions})), | |||
pointRadius: 0, | |||
pointHitRadius: 0, | |||
fill: true, | |||
label: 'Additions', | |||
backgroundColor: chartJsColors['additions'], | |||
borderWidth: 0, | |||
tension: 0.3, | |||
}, | |||
{ | |||
data: data.map((i) => ({x: i.week, y: -i.deletions})), | |||
pointRadius: 0, | |||
pointHitRadius: 0, | |||
fill: true, | |||
label: 'Deletions', | |||
backgroundColor: chartJsColors['deletions'], | |||
borderWidth: 0, | |||
tension: 0.3, | |||
}, | |||
], | |||
}; | |||
}, | |||
getOptions() { | |||
return { | |||
responsive: true, | |||
maintainAspectRatio: false, | |||
animation: true, | |||
plugins: { | |||
legend: { | |||
display: true, | |||
}, | |||
}, | |||
scales: { | |||
x: { | |||
type: 'time', | |||
grid: { | |||
display: false, | |||
}, | |||
time: { | |||
minUnit: 'month', | |||
}, | |||
ticks: { | |||
maxRotation: 0, | |||
maxTicksLimit: 12 | |||
}, | |||
}, | |||
y: { | |||
ticks: { | |||
maxTicksLimit: 6 | |||
}, | |||
}, | |||
}, | |||
}; | |||
}, | |||
}, | |||
}; | |||
</script> | |||
<template> | |||
<div> | |||
<div class="ui header gt-df gt-ac gt-sb"> | |||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }} | |||
</div> | |||
<div class="gt-df ui segment main-graph"> | |||
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto"> | |||
<div v-if="isLoading"> | |||
<SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/> | |||
{{ locale.loadingInfo }} | |||
</div> | |||
<div v-else class="text red"> | |||
<SvgIcon name="octicon-x-circle-fill"/> | |||
{{ errorText }} | |||
</div> | |||
</div> | |||
<ChartLine | |||
v-memo="data" v-if="data.length !== 0" | |||
:data="toGraphData(data)" :options="getOptions()" | |||
/> | |||
</div> | |||
</div> | |||
</template> | |||
<style scoped> | |||
.main-graph { | |||
height: 440px; | |||
} | |||
</style> |
@@ -3,10 +3,7 @@ import {SvgIcon} from '../svg.js'; | |||
import { | |||
Chart, | |||
Title, | |||
Tooltip, | |||
Legend, | |||
BarElement, | |||
CategoryScale, | |||
LinearScale, | |||
TimeScale, | |||
PointElement, | |||
@@ -21,27 +18,13 @@ import { | |||
firstStartDateAfterDate, | |||
fillEmptyStartDaysWithZeroes, | |||
} from '../utils/time.js'; | |||
import {chartJsColors} from '../utils/color.js'; | |||
import {sleep} from '../utils.js'; | |||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; | |||
import $ from 'jquery'; | |||
const {pageData} = window.config; | |||
const colors = { | |||
text: '--color-text', | |||
border: '--color-secondary-alpha-60', | |||
commits: '--color-primary-alpha-60', | |||
additions: '--color-green', | |||
deletions: '--color-red', | |||
title: '--color-secondary-dark-4', | |||
}; | |||
const styles = window.getComputedStyle(document.documentElement); | |||
const getColor = (name) => styles.getPropertyValue(name).trim(); | |||
for (const [key, value] of Object.entries(colors)) { | |||
colors[key] = getColor(value); | |||
} | |||
const customEventListener = { | |||
id: 'customEventListener', | |||
afterEvent: (chart, args, opts) => { | |||
@@ -54,17 +37,14 @@ const customEventListener = { | |||
} | |||
}; | |||
Chart.defaults.color = colors.text; | |||
Chart.defaults.borderColor = colors.border; | |||
Chart.defaults.color = chartJsColors.text; | |||
Chart.defaults.borderColor = chartJsColors.border; | |||
Chart.register( | |||
TimeScale, | |||
CategoryScale, | |||
LinearScale, | |||
BarElement, | |||
Title, | |||
Tooltip, | |||
Legend, | |||
PointElement, | |||
LineElement, | |||
Filler, | |||
@@ -122,7 +102,7 @@ export default { | |||
do { | |||
response = await GET(`${this.repoLink}/activity/contributors/data`); | |||
if (response.status === 202) { | |||
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying | |||
await sleep(1000); // wait for 1 second before retrying | |||
} | |||
} while (response.status === 202); | |||
if (response.ok) { | |||
@@ -222,7 +202,7 @@ export default { | |||
pointRadius: 0, | |||
pointHitRadius: 0, | |||
fill: 'start', | |||
backgroundColor: colors[this.type], | |||
backgroundColor: chartJsColors[this.type], | |||
borderWidth: 0, | |||
tension: 0.3, | |||
}, | |||
@@ -254,7 +234,6 @@ export default { | |||
title: { | |||
display: type === 'main', | |||
text: 'drag: zoom, shift+drag: pan, double click: reset zoom', | |||
color: colors.title, | |||
position: 'top', | |||
align: 'center', | |||
}, | |||
@@ -262,9 +241,6 @@ export default { | |||
chartType: type, | |||
instance: this, | |||
}, | |||
legend: { | |||
display: false, | |||
}, | |||
zoom: { | |||
pan: { | |||
enabled: true, |
@@ -0,0 +1,21 @@ | |||
import {createApp} from 'vue'; | |||
export async function initRepoCodeFrequency() { | |||
const el = document.getElementById('repo-code-frequency-chart'); | |||
if (!el) return; | |||
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue'); | |||
try { | |||
const View = createApp(RepoCodeFrequency, { | |||
locale: { | |||
loadingTitle: el.getAttribute('data-locale-loading-title'), | |||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), | |||
loadingInfo: el.getAttribute('data-locale-loading-info'), | |||
} | |||
}); | |||
View.mount(el); | |||
} catch (err) { | |||
console.error('RepoCodeFrequency failed to load', err); | |||
el.textContent = el.getAttribute('data-locale-component-failed-to-load'); | |||
} | |||
} |
@@ -87,6 +87,7 @@ import {onDomReady} from './utils/dom.js'; | |||
import {initRepoIssueList} from './features/repo-issue-list.js'; | |||
import {initCommonIssueListQuickGoto} from './features/common-issue-list.js'; | |||
import {initRepoContributors} from './features/contributors.js'; | |||
import {initRepoCodeFrequency} from './features/code-frequency.js'; | |||
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js'; | |||
import {initDirAuto} from './modules/dirauto.js'; | |||
@@ -177,6 +178,7 @@ onDomReady(() => { | |||
initRepository(); | |||
initRepositoryActionView(); | |||
initRepoContributors(); | |||
initRepoCodeFrequency(); | |||
initCommitStatuses(); | |||
initCaptcha(); |
@@ -139,3 +139,5 @@ export function parseDom(text, contentType) { | |||
export function serializeXml(node) { | |||
return xmlSerializer.serializeToString(node); | |||
} | |||
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
@@ -19,3 +19,17 @@ function getLuminance(r, g, b) { | |||
export function useLightTextOnBackground(r, g, b) { | |||
return getLuminance(r, g, b) < 0.453; | |||
} | |||
function resolveColors(obj) { | |||
const styles = window.getComputedStyle(document.documentElement); | |||
const getColor = (name) => styles.getPropertyValue(name).trim(); | |||
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)])); | |||
} | |||
export const chartJsColors = resolveColors({ | |||
text: '--color-text', | |||
border: '--color-secondary-alpha-60', | |||
commits: '--color-primary-alpha-60', | |||
additions: '--color-green', | |||
deletions: '--color-red', | |||
}); |