Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c5a07be
feat: adapt templates to Forgejo 17
marcpp97 Oct 18, 2026
801e196
perf: keep :has() off page-wide containers
marcpp97 Oct 18, 2026
16fc9c7
feat(diff): match GitHub's file header and Viewed control
marcpp97 Oct 18, 2026
0388021
feat(diff): GitHub-style toolbar above the tree and the files
marcpp97 Oct 18, 2026
fb6ea06
feat(pull): full-width files tab header and title pencil
marcpp97 Oct 18, 2026
06f7d3d
feat(diff): collapse non-diff lines, branch order and commit menu
marcpp97 Oct 18, 2026
fd07c75
fix(diff): let commit menu entries grow to two lines
marcpp97 Oct 18, 2026
85f8583
feat(issues): GitHub-style search field and sidebar order
marcpp97 Oct 18, 2026
1650c41
fix: define the colour variables Forgejo 17 expects from a theme
marcpp97 Oct 18, 2026
5fc42da
fix(actions): colour job step status icons
marcpp97 Oct 18, 2026
62ee28f
feat(repo): Code label, fj CLI clone and GitHub sidebar sections
marcpp97 Oct 18, 2026
87ee9c2
feat(repo): name the clone option Forgejo CLI and link its docs
marcpp97 Oct 18, 2026
ef5bf86
fix(dropdown): sharp dropdown menus and animated Code menu
marcpp97 Oct 18, 2026
6e433c8
fix: animate the Code menu on every open, sharp modals
marcpp97 Oct 18, 2026
f4898e3
fix(repo): start the Code menu animation without a jump
marcpp97 Oct 18, 2026
98f91db
feat(dropdown): animate every <details> menu like Fomantic dropdowns
marcpp97 Oct 18, 2026
d3db97e
feat(repo): GitHub-style branch and tag menu
marcpp97 Oct 18, 2026
8d345e2
feat(pull): GitHub-style compare page and red close button
marcpp97 Oct 18, 2026
0d21104
feat(pull): GitHub compare labels, summary and diff
marcpp97 Oct 18, 2026
b199425
fix: remove the sideways scroll on pull request files
marcpp97 Oct 18, 2026
5153e41
test: keep the getCallerInfo frames out of tail position
marcpp97 Oct 18, 2026
3bb912b
perf(diff): load the file tree of large pull requests faster
marcpp97 Oct 20, 2026
cc249a4
feat(diff): animate folding and unfolding files
marcpp97 Oct 20, 2026
a3e74a5
fix(repo): open the pull request tab on open pull requests
marcpp97 Oct 20, 2026
2bcea2b
feat(projects): GitHub-style project boards with an issue side panel
marcpp97 Oct 20, 2026
2ebe5fb
style: format with Prettier
marcpp97 Oct 20, 2026
37135c0
fix(projects): show a loader instead of a blank panel while an issue …
marcpp97 Oct 21, 2026
b929415
feat(diff): GitHub's Load diff placeholder for large files
marcpp97 Oct 21, 2026
f3539dc
perf(diff): fold and unfold large files without stutter
marcpp97 Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions components/common/dropdown/open-animation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
(() => {
// Menus built on <details> open like Fomantic's dropdowns ("Add file"), sliding down while fading in.
// A closed <details> keeps its content hidden with content-visibility, so a CSS animation would only play on the
// first open, and the toggle event fires a frame after the menu shows (it would jump). Start from the summary
// click instead, in the frame that first paints the open menu.
const menus = "details.dropdown, details.theme-menu, details.theme-dropdown";
const keyframes = [
{ opacity: 0, transform: "translateY(-8px)" },
{ opacity: 1, transform: "translateY(0)" },
];
document.addEventListener("click", event => {
const summary = event.target.closest("summary");
const menu = summary?.parentElement;
if (!menu?.matches(menus) || menu.open || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const panel = [...menu.children].find(child => child !== summary);
if (!panel) return;
requestAnimationFrame(() => {
if (menu.open) panel.animate(keyframes, { duration: 200, easing: "cubic-bezier(0.33, 1, 0.68, 1)" });
});
});
})();
145 changes: 142 additions & 3 deletions components/common/search/search.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
}

/* :is() keeps one specificity for fields inside and outside Forgejo's .ui.form. */
:is(.ui.form, body) .theme-search-field > input[type="search"] {
:is(.ui.form, body) .theme-search-field input[type="search"] {
flex: 1;
width: 100%;
min-width: 0;
Expand All @@ -33,7 +33,7 @@
outline: none;
}

:is(.ui.form, body) .theme-search-field > input[type="search"]::placeholder {
:is(.ui.form, body) .theme-search-field input[type="search"]::placeholder {
color: var(--color-text-light-1);
opacity: 1;
}
Expand All @@ -50,7 +50,7 @@
margin-right: 9px;
}

:is(.ui.form, body) .theme-search-field-icon > input[type="search"] {
:is(.ui.form, body) .theme-search-field-icon input[type="search"] {
padding-left: 0;
}

Expand All @@ -72,6 +72,145 @@
background: var(--color-hover);
}

/* The browser's own clear button has no pointer and only empties the field; search.js handles this one. */
:is(.ui.form, body) .theme-search-field input[type="search"]::-webkit-search-cancel-button {
display: none;
}

.theme-search-field > .theme-search-clear {
flex: none;
display: grid;
place-items: center;
width: 28px;
padding: 0;
border: 0;
background: transparent;
color: var(--color-text-light-1);
cursor: pointer;
}

.theme-search-field > .theme-search-clear:hover {
color: var(--color-text);
}

.theme-search-field:has(> input:placeholder-shown, > .theme-search-input-wrap > input:placeholder-shown)
> .theme-search-clear {
display: none;
}

/* Syntax fields: a mirror behind the transparent input text colours qualifier values (author:<value>). */
.theme-search-syntax-control {
position: relative;
width: 100%;
min-width: 0;
}

.theme-search-input-wrap {
position: relative;
display: flex;
flex: 1;
min-width: 0;
}

.theme-search-highlight {
position: absolute;
inset: 0;
padding: 5px 12px;
overflow: hidden;
color: var(--color-text);
font: 14px/20px var(--fonts-proportional);
white-space: pre;
pointer-events: none;
}

.theme-search-field-icon .theme-search-highlight {
padding-left: 0;
}

:is(.ui.form, body) .theme-search-input-wrap > input[type="search"] {
color: transparent;
caret-color: var(--color-text);
}

.theme-search-qualifier-value {
color: var(--github-fgColor-accent);
}

/* GitHub's qualifier list under the field. */
.theme-search-syntax {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
display: flex;
flex-direction: column;
width: min(256px, 100%);
max-height: 340px;
overflow-y: auto;
padding: 8px 0 0;
border: 1px solid var(--color-light-border);
border-radius: 12px;
background: var(--color-menu);
box-shadow: var(--github-shadow-floating-small, 0 8px 24px rgb(0 0 0 / 0.2));
}

.theme-search-syntax[hidden] {
display: none;
}

.theme-search-syntax-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
margin: 0 8px;
padding: 6px 8px;
border: 0;
border-radius: var(--border-radius);
background: transparent;
color: var(--color-text);
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
}

.theme-search-syntax-item[hidden] {
display: none;
}

.theme-search-syntax-item > svg {
flex: none;
color: var(--color-text-light-1);
}

.theme-search-syntax-item > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.theme-search-syntax-item:hover,
.theme-search-syntax-item.active {
background: var(--color-hover);
}

.theme-search-syntax-docs {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 16px;
border-top: 1px solid var(--color-light-border);
color: var(--color-text-light-1);
font-size: 12px;
}

.theme-search-syntax-docs:hover {
color: var(--github-fgColor-accent);
}

/* Forgejo's native query/mode/submit group (shared/search/combo_multi), opted in on its form. */
.ui.form.theme-search-control > .ui.action.input {
display: flex;
Expand Down
121 changes: 121 additions & 0 deletions components/common/search/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
(() => {
// The clear button empties the field; a submitted search that is currently applied reloads without it.
document.addEventListener("click", event => {
const clear = event.target.closest(".theme-search-clear");
if (!clear) return;
event.preventDefault();
const input = clear.closest(".theme-search-field")?.querySelector('input[type="search"]');
if (!input) return;
const applied = Boolean(input.name) && input.defaultValue !== "";
input.value = "";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.focus();
if (applied && input.form) input.form.requestSubmit();
});

// Qualifiers Forgejo's issue search understands; their values are highlighted like GitHub's.
const qualifiers = new Set(["is", "author", "assignee", "review", "mentions", "sort", "modified"]);

function setup(input) {
const control = input.closest(".theme-search-syntax-control");
const highlight = control?.querySelector(".theme-search-highlight");
const popup = control?.querySelector(".theme-search-syntax");
if (!highlight || !popup) return;
const items = [...popup.querySelectorAll(".theme-search-syntax-item")];
let active = -1;

function render() {
highlight.replaceChildren();
for (const part of input.value.split(/(\s+)/)) {
const match = /^(-?)([a-z]+):(.*)$/i.exec(part);
if (!match || !qualifiers.has(match[2].toLowerCase())) {
highlight.append(part);
continue;
}
const value = document.createElement("span");
value.className = "theme-search-qualifier-value";
value.textContent = match[3];
highlight.append(`${match[1]}${match[2]}:`, value);
}
highlight.scrollLeft = input.scrollLeft;
}

// The word under the caret decides which qualifiers are offered.
function currentToken() {
const end = input.selectionStart ?? input.value.length;
const start = input.value.lastIndexOf(" ", end - 1) + 1;
return { start, end, text: input.value.slice(start, end) };
}
const visibleItems = () => items.filter(item => !item.hidden);
function setActive(index) {
active = index;
const target = visibleItems()[active];
items.forEach(item => item.classList.toggle("active", item === target));
target?.scrollIntoView({ block: "nearest" });
}
function update() {
const text = currentToken().text.toLowerCase();
let any = false;
for (const item of items) {
const name = item.textContent.trim().toLowerCase();
item.hidden = !(item.dataset.insert.toLowerCase().startsWith(text) || name.startsWith(text));
any ||= !item.hidden;
}
popup.hidden = document.activeElement !== input || text.includes(":") || !any;
input.setAttribute("aria-expanded", String(!popup.hidden));
setActive(-1);
}
function close() {
popup.hidden = true;
input.setAttribute("aria-expanded", "false");
}
function insert(item) {
const { start, end } = currentToken();
const text = item.dataset.insert;
input.value = input.value.slice(0, start) + text + input.value.slice(end);
const caret = start + text.length;
input.setSelectionRange(caret, caret);
input.focus();
render();
update();
}

input.addEventListener("input", () => {
render();
update();
});
input.addEventListener("scroll", () => (highlight.scrollLeft = input.scrollLeft));
input.addEventListener("focus", update);
input.addEventListener("click", update);
input.addEventListener("keyup", event => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) update();
highlight.scrollLeft = input.scrollLeft;
});
input.addEventListener("blur", close);
input.addEventListener("keydown", event => {
if (popup.hidden) return;
const visible = visibleItems();
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const step = event.key === "ArrowDown" ? 1 : -1;
setActive((active + step + visible.length) % visible.length);
} else if (event.key === "Enter" && active >= 0) {
event.preventDefault();
insert(visible[active]);
} else if (event.key === "Escape") {
close();
}
});
// Keep the focus in the field while picking a qualifier.
popup.addEventListener("mousedown", event => {
if (!event.target.closest(".theme-search-syntax-docs")) event.preventDefault();
});
popup.addEventListener("click", event => {
const item = event.target.closest(".theme-search-syntax-item");
if (item) insert(item);
});
render();
}

for (const input of document.querySelectorAll("input[data-search-syntax]")) setup(input);
})();
9 changes: 7 additions & 2 deletions components/common/search/search.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{{/* Kind: submit (default, trailing search button), icon (leading search icon), or plain. Local marks a client-side filter that is never submitted. Class adds page layout hooks. */}}
{{/* Kind: submit (default, trailing search button), icon (leading search icon), or plain. Local marks a client-side filter that is never submitted. Class adds page layout hooks. Syntax adds GitHub's qualifier popup and value highlighting (issue and pull request searches). */}}
{{$icon := eq .Kind "icon"}}
{{if .Syntax}}<div class="theme-search-syntax-control">{{end}}
{{if $icon}}<label class="theme-search-field theme-search-field-icon{{if .Class}} {{.Class}}{{end}}">{{svg "octicon-search" 16}}{{else}}<div class="theme-search-field{{if .Class}} {{.Class}}{{end}}">{{end}}
<input type="search"{{if .ID}} id="{{.ID}}"{{end}}{{if .Local}} autocomplete="off"{{else}} name="{{or .Name "q"}}" value="{{.Value}}" maxlength="255"{{end}} placeholder="{{.Placeholder}}" aria-label="{{or .Label .Placeholder}}" spellcheck="false" dir="auto">
{{if .Syntax}}<span class="theme-search-input-wrap"><span class="theme-search-highlight" aria-hidden="true"></span>{{end}}
<input type="search"{{if .ID}} id="{{.ID}}"{{end}}{{if .Local}} autocomplete="off"{{else}} name="{{or .Name "q"}}" value="{{.Value}}" maxlength="255"{{end}}{{if .Syntax}} autocomplete="off" data-search-syntax role="combobox" aria-autocomplete="list" aria-expanded="false"{{end}} placeholder="{{.Placeholder}}" aria-label="{{or .Label .Placeholder}}" spellcheck="false" dir="auto">
{{if .Syntax}}</span>{{end}}
<button type="button" class="theme-search-clear" aria-label="Clear search" tabindex="-1">{{svg "octicon-x-circle-fill" 16}}</button>
{{if not (or $icon (eq .Kind "plain"))}}<button type="submit" class="theme-search-submit" aria-label="{{ctx.Locale.Tr "search.search"}}">{{svg "octicon-search" 16}}</button>{{end}}
{{if $icon}}</label>{{else}}</div>{{end}}
{{if .Syntax}}{{template "components/common/search/syntax"}}</div>{{end}}
16 changes: 16 additions & 0 deletions components/common/search/syntax.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{{/* GitHub lists the search qualifiers by name when the field gets focus; these are the ones Forgejo's issue search
understands (the entries of Forgejo's shared/search/issue/syntax dialog), with its hint as the tooltip.
search.js inserts data-insert at the caret and matches typing against the name or the qualifier. */}}
<div class="theme-search-syntax" role="listbox" aria-label="{{ctx.Locale.Tr "search.syntax"}}" hidden>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="is:open " title="is:open">{{svg "octicon-issue-opened" 16}}<span>{{ctx.Locale.Tr "repo.issues.open_title"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="is:closed " title="is:closed">{{svg "octicon-issue-closed" 16}}<span>{{ctx.Locale.Tr "repo.issues.closed_title"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="is:all " title="is:all">{{svg "octicon-tasklist" 16}}<span>{{ctx.Locale.Tr "repo.issues.all_title"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="author:" title="{{ctx.Locale.Tr "repo.issues.filter_poster.hint"}}">{{svg "octicon-person" 16}}<span>{{ctx.Locale.Tr "repo.issues.filter_poster"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="assignee:" title="{{ctx.Locale.Tr "repo.issues.filter_assignee.hint"}}">{{svg "octicon-people" 16}}<span>{{ctx.Locale.Tr "repo.issues.filter_assignee"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="review:" title="{{ctx.Locale.Tr "repo.issues.filter_reviewers.hint"}}">{{svg "octicon-eye" 16}}<span>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="mentions:" title="{{ctx.Locale.Tr "repo.issues.filter_mention.hint"}}">{{svg "octicon-mention" 16}}<span>Mentions</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="sort:" title="{{ctx.Locale.Tr "repo.issues.filter_sort.hint_with_placeholder" "created|comments|updated|deadline"}}">{{svg "octicon-sort-desc" 16}}<span>{{ctx.Locale.Tr "repo.issues.filter_sort"}}</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="modified:" title="{{ctx.Locale.Tr "repo.issues.filter_modified.hint"}}">{{svg "octicon-calendar" 16}}<span>Modified</span></button>
<button type="button" class="theme-search-syntax-item" role="option" data-insert="&quot;" title="{{ctx.Locale.Tr "search.match_inside_quotes"}}">{{svg "octicon-quote" 16}}<span>Exact phrase</span></button>
<a class="theme-search-syntax-docs" href="{{AppDocsURL "user/search/issue-search/"}}" target="_blank" rel="noopener noreferrer">{{svg "octicon-question" 16}}{{ctx.Locale.Tr "search.syntax"}}</a>
</div>
2 changes: 1 addition & 1 deletion components/repository/results/list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
{{if .Owner.Visibility.IsPrivate}}
<span class="ui label">{{ctx.Locale.Tr "repo.desc.internal"}}</span>
{{else}}
<span class="ui label">{{ctx.Locale.Tr "settings.visibility.public"}}</span>
<span class="ui label">{{ctx.Locale.Tr "user.visibility.public.title"}}</span>
{{end}}
{{end}}
{{if .IsTemplate}}
Expand Down
16 changes: 8 additions & 8 deletions components/repository/sort/items.tmpl
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "newest" "Checked" (eq .SortType "newest") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.latest")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "oldest" "Checked" (eq .SortType "oldest") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.oldest")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "newest" "Checked" (eq .SortType "newest") "Label" (ctx.Locale.Tr "issues.search.sort.latest")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "oldest" "Checked" (eq .SortType "oldest") "Label" (ctx.Locale.Tr "issues.search.sort.oldest")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "alphabetically" "Checked" (eq .SortType "alphabetically") "Label" (ctx.Locale.Tr "repo.issues.label.filter_sort.alphabetically")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "reversealphabetically" "Checked" (eq .SortType "reversealphabetically") "Label" (ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_alphabetically")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "recentupdate" "Checked" (eq .SortType "recentupdate") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.recentupdate")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "leastupdate" "Checked" (eq .SortType "leastupdate") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.leastupdate")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "recentupdate" "Checked" (eq .SortType "recentupdate") "Label" (ctx.Locale.Tr "issues.search.sort.recentupdate")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "leastupdate" "Checked" (eq .SortType "leastupdate") "Label" (ctx.Locale.Tr "issues.search.sort.leastupdate")}}
{{if not .DisableStars}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "moststars" "Checked" (eq .SortType "moststars") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.moststars")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "feweststars" "Checked" (eq .SortType "feweststars") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.feweststars")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "moststars" "Checked" (eq .SortType "moststars") "Label" (ctx.Locale.Tr "search.sort.moststars")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "feweststars" "Checked" (eq .SortType "feweststars") "Label" (ctx.Locale.Tr "search.sort.feweststars")}}
{{end}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "mostforks" "Checked" (eq .SortType "mostforks") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.mostforks")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "fewestforks" "Checked" (eq .SortType "fewestforks") "Label" (ctx.Locale.Tr "repo.issues.filter_sort.fewestforks")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "mostforks" "Checked" (eq .SortType "mostforks") "Label" (ctx.Locale.Tr "search.sort.mostforks")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "fewestforks" "Checked" (eq .SortType "fewestforks") "Label" (ctx.Locale.Tr "search.sort.fewestforks")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "size" "Checked" (eq .SortType "size") "Label" (ctx.Locale.Tr "repo.issues.label.filter_sort.by_size")}}
{{template "components/common/dropdown/option" dict "Type" "radio" "Name" "sort" "Value" "reversesize" "Checked" (eq .SortType "reversesize") "Label" (ctx.Locale.Tr "repo.issues.label.filter_sort.reverse_by_size")}}
Loading
Loading