From 19fa68afd1fe67ece9f2d19f9e27633f65890d05 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 26 Dec 2025 23:16:00 +0800 Subject: [PATCH 01/19] feat: add TOC sidebar for README and Markdown files - Add floating TOC panel that displays table of contents for README and markdown files - Implement toggle button to show/hide TOC panel - Add localStorage persistence for TOC panel visibility state - Support both README in repo root and individual markdown file views - Add responsive design for mobile screens --- routers/web/repo/view.go | 13 +++++ routers/web/repo/view_file.go | 2 + routers/web/repo/view_readme.go | 2 + templates/repo/view_file.tmpl | 98 ++++++++++++++++++-------------- web_src/css/repo/home.css | 89 +++++++++++++++++++++++++++++ web_src/js/features/file-view.ts | 38 +++++++++++++ 6 files changed, 199 insertions(+), 43 deletions(-) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 8e85cc3278..80148ecdf9 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -35,6 +35,7 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" @@ -179,6 +180,18 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i return escaped, output, err } +func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML { + if rctx.SidebarTocNode == nil { + return "" + } + sb := &strings.Builder{} + if err := markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode); err != nil { + log.Error("Failed to render sidebar TOC: %v", err) + return "" + } + return templates.SanitizeHTML(sb.String()) +} + func checkHomeCodeViewable(ctx *context.Context) { if ctx.Repo.HasUnits() { if ctx.Repo.Repository.IsBeingCreated() { diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 167cd5f927..d505ce982b 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -92,6 +92,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy ctx.ServerError("Render", err) return true } + + ctx.Data["FileTocHTML"] = renderSidebarTocHTML(rctx) return true } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index f1fa5732f0..8999339bab 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -206,6 +206,8 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) delete(ctx.Data, "IsMarkup") } + + ctx.Data["ReadmeTocHTML"] = renderSidebarTocHTML(rctx) } if ctx.Data["IsMarkup"] != true { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 809b1e9677..f15b932b2b 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -35,6 +35,11 @@ {{end}}
+ {{if or .ReadmeTocHTML .FileTocHTML}} + + {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
{{if .IsRepresentableAsText}} @@ -93,50 +98,57 @@ {{if not .IsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
- {{if .IsFileTooLarge}} - {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} - {{else if not .FileSize}} - {{template "shared/fileisempty"}} - {{else if .IsMarkup}} - {{.FileContent}} - {{else if .IsPlainText}} -
{{if .FileContent}}{{.FileContent}}{{end}}
- {{else if .FileContent}} - - - {{range $idx, $code := .FileContent}} - {{$line := Eval $idx "+" 1}} - - - {{if $.EscapeStatus.Escaped}} - - {{end}} - - - {{end}} - -
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
- {{else}} -
- {{if .IsImageFile}} - {{$.RawFileLink}} - {{else if .IsVideoFile}} - - {{else if .IsAudioFile}} - - {{else}} -
-
- {{ctx.Locale.Tr "repo.file_view_raw"}} +
+
+ {{if .IsFileTooLarge}} + {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} + {{else if not .FileSize}} + {{template "shared/fileisempty"}} + {{else if .IsMarkup}} + {{.FileContent}} + {{else if .IsPlainText}} +
{{if .FileContent}}{{.FileContent}}{{end}}
+ {{else if .FileContent}} + + + {{range $idx, $code := .FileContent}} + {{$line := Eval $idx "+" 1}} + + + {{if $.EscapeStatus.Escaped}} + + {{end}} + + + {{end}} + +
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
+ {{else}} +
+ {{if .IsImageFile}} + {{$.RawFileLink}} + {{else if .IsVideoFile}} + + {{else if .IsAudioFile}} + + {{else}} + -
- {{end}} -
+ {{end}} +
+ {{end}} +
+ {{if or .ReadmeTocHTML .FileTocHTML}} +
+ {{or .ReadmeTocHTML .FileTocHTML}} +
{{end}}
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 60bf1f17f9..c43afe826c 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -105,3 +105,92 @@ padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */ text-decoration: none; } + +/* File view with TOC sidebar */ +.file-view-container { + position: relative; +} + +.file-view-container .file-view { + width: 100%; +} + +/* TOC as a floating sidebar panel */ +.file-view-toc { + position: fixed; + top: 60px; + right: 0; + width: 280px; + max-height: calc(100vh - 80px); + overflow-y: auto; + padding: 1rem; + background: var(--color-body); + border: 1px solid var(--color-secondary); + border-right: none; + border-radius: var(--border-radius) 0 0 var(--border-radius); + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); + z-index: 100; + transform: translateX(0); + transition: transform 0.25s ease-in-out; +} + +/* Hidden state - using custom class to avoid Tailwind conflicts */ +.file-view-toc.toc-panel-hidden { + transform: translateX(100%); +} + +.file-view-toc details { + font-size: 13px; +} + +.file-view-toc summary { + font-weight: 600; + cursor: pointer; + padding: 8px 0; + color: var(--color-text); + border-bottom: 1px solid var(--color-secondary); + margin-bottom: 8px; +} + +.file-view-toc ul { + margin: 0; + list-style: none; + padding: 5px 0 5px 1em; +} + +.file-view-toc ul ul { + border-left: 1px dashed var(--color-secondary); +} + +.file-view-toc a { + display: block; + padding: 6px 10px; + color: var(--color-text); + text-decoration: none; + font-size: 13px; + line-height: 1.4; + border-radius: var(--border-radius); +} + +.file-view-toc a:hover { + color: var(--color-primary); + background: var(--color-hover); +} + +/* TOC toggle button active state - when TOC is visible */ +#toggle-toc-btn.active { + color: var(--color-primary); + background: var(--color-hover); +} + +/* Hide TOC sidebar on small screens */ +@media (max-width: 768px) { + .file-view-toc { + width: 100%; + max-width: 300px; + } + + #toggle-toc-btn { + display: none; + } +} diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa26..74e3d042b2 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -59,9 +59,47 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str } } +function initTocToggle(elFileView: HTMLElement): void { + const toggleBtn = elFileView.querySelector('#toggle-toc-btn'); + const tocSidebar = elFileView.querySelector('.file-view-toc'); + if (!toggleBtn || !tocSidebar) return; + + // Restore saved state from localStorage (default to hidden) + const savedState = localStorage.getItem('file-view-toc-visible'); + const isVisible = savedState === 'true'; // default to hidden + + // Apply initial state + if (isVisible) { + tocSidebar.classList.remove('toc-panel-hidden'); + toggleBtn.classList.add('active'); + } else { + tocSidebar.classList.add('toc-panel-hidden'); + toggleBtn.classList.remove('active'); + } + + toggleBtn.addEventListener('click', () => { + const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden'); + if (isCurrentlyVisible) { + // Hide TOC + tocSidebar.classList.add('toc-panel-hidden'); + toggleBtn.classList.remove('active'); + localStorage.setItem('file-view-toc-visible', 'false'); + } else { + // Show TOC + tocSidebar.classList.remove('toc-panel-hidden'); + toggleBtn.classList.add('active'); + localStorage.setItem('file-view-toc-visible', 'true'); + } + }); +} + export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { initPluginsOnce(); + + // Initialize TOC toggle functionality + initTocToggle(elFileView); + const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not From 098321f7343bda05ce973a7e152f38171589a34d Mon Sep 17 00:00:00 2001 From: hamki Date: Sat, 27 Dec 2025 00:01:19 +0800 Subject: [PATCH 02/19] fix: use CSS variable for font-weight in TOC sidebar --- web_src/css/repo/home.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index c43afe826c..f05408f00e 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -144,7 +144,7 @@ } .file-view-toc summary { - font-weight: 600; + font-weight: var(--font-weight-semibold); cursor: pointer; padding: 8px 0; color: var(--color-text); From 2d7b11e3650f53b5a950bca0c0c96ab3b4646acb Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 28 Dec 2025 15:17:23 +0800 Subject: [PATCH 03/19] feat: add TOC sidebar for README and Markdown files --- templates/repo/view_file.tmpl | 98 ++++++++++++++++---------------- web_src/css/repo/home.css | 57 ++++++++++--------- web_src/js/features/file-view.ts | 86 ++++++++++++++++++++++++---- 3 files changed, 153 insertions(+), 88 deletions(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index f15b932b2b..f638e73f15 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -98,59 +98,57 @@ {{if not .IsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
-
- {{if .IsFileTooLarge}} - {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} - {{else if not .FileSize}} - {{template "shared/fileisempty"}} - {{else if .IsMarkup}} - {{.FileContent}} - {{else if .IsPlainText}} -
{{if .FileContent}}{{.FileContent}}{{end}}
- {{else if .FileContent}} - - - {{range $idx, $code := .FileContent}} - {{$line := Eval $idx "+" 1}} - - - {{if $.EscapeStatus.Escaped}} - - {{end}} - - - {{end}} - -
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
- {{else}} -
- {{if .IsImageFile}} - {{$.RawFileLink}} - {{else if .IsVideoFile}} - - {{else if .IsAudioFile}} - - {{else}} -
- +
+ {{if .IsFileTooLarge}} + {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} + {{else if not .FileSize}} + {{template "shared/fileisempty"}} + {{else if .IsMarkup}} + {{.FileContent}} + {{else if .IsPlainText}} +
{{if .FileContent}}{{.FileContent}}{{end}}
+ {{else if .FileContent}} + + + {{range $idx, $code := .FileContent}} + {{$line := Eval $idx "+" 1}} + + + {{if $.EscapeStatus.Escaped}} + + {{end}} + + + {{end}} + +
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
+ {{else}} +
+ {{if .IsImageFile}} + {{$.RawFileLink}} + {{else if .IsVideoFile}} + + {{else if .IsAudioFile}} + + {{else}} + - {{end}} -
- {{if or .ReadmeTocHTML .FileTocHTML}} -
- {{or .ReadmeTocHTML .FileTocHTML}} -
+
+ {{end}} +
{{end}}
+ {{if or .ReadmeTocHTML .FileTocHTML}} +
+ {{or .ReadmeTocHTML .FileTocHTML}} +
+ {{end}}
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeIssues}} diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index f05408f00e..110f8f7389 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -76,6 +76,12 @@ .repo-view-content { flex: 1; min-width: 0; + transition: margin-right 0.2s ease; +} + +/* When TOC is visible, reserve space on the right (only for file view, not home page) */ +.repo-view-content.toc-visible { + margin-right: 270px; } .language-stats { @@ -106,37 +112,31 @@ text-decoration: none; } -/* File view with TOC sidebar */ -.file-view-container { - position: relative; -} - -.file-view-container .file-view { - width: 100%; -} - -/* TOC as a floating sidebar panel */ +/* TOC as a fixed sidebar panel */ .file-view-toc { position: fixed; - top: 60px; - right: 0; - width: 280px; - max-height: calc(100vh - 80px); + top: 120px; /* Will be adjusted by JS to align with file content */ + right: 0.5rem; + width: 260px; + max-height: calc(100vh - 140px); overflow-y: auto; - padding: 1rem; - background: var(--color-body); + padding: 0.75rem; + background: var(--color-box-body); border: 1px solid var(--color-secondary); - border-right: none; - border-radius: var(--border-radius) 0 0 var(--border-radius); - box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); - z-index: 100; - transform: translateX(0); - transition: transform 0.25s ease-in-out; + border-radius: var(--border-radius); + z-index: 50; + opacity: 0; /* Hidden until JS positions it */ + transition: opacity 0.15s ease; +} + +/* Show TOC after JS has positioned it */ +.file-view-toc.toc-positioned { + opacity: 1; } /* Hidden state - using custom class to avoid Tailwind conflicts */ .file-view-toc.toc-panel-hidden { - transform: translateX(100%); + display: none; } .file-view-toc details { @@ -180,17 +180,20 @@ /* TOC toggle button active state - when TOC is visible */ #toggle-toc-btn.active { color: var(--color-primary); - background: var(--color-hover); } /* Hide TOC sidebar on small screens */ -@media (max-width: 768px) { +@media (max-width: 1400px) { .file-view-toc { - width: 100%; - max-width: 300px; + display: none !important; } #toggle-toc-btn { display: none; } + + /* Don't reserve space for TOC on small screens */ + .repo-view-content.toc-visible { + margin-right: 0; + } } diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 74e3d042b2..08f5ce35c9 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -59,35 +59,99 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str } } +function updateTocPosition(elFileView: HTMLElement, tocSidebar: HTMLElement): void { + const fileHeader = elFileView.querySelector('.file-header'); + if (!fileHeader) return; + + const headerRect = fileHeader.getBoundingClientRect(); + // Align TOC top with the file header top, with a minimum of 12px from viewport top + const topPos = Math.max(12, headerRect.top); + tocSidebar.style.top = `${topPos}px`; + + // Position TOC right next to the content (works for both file view and home page) + const segment = elFileView.querySelector('.ui.segment'); + if (segment) { + const segmentRect = segment.getBoundingClientRect(); + const leftPos = segmentRect.right + 8; // 8px gap from content + tocSidebar.style.left = `${leftPos}px`; + tocSidebar.style.right = 'auto'; + } + + // Mark as positioned to show the TOC (prevents flicker) + tocSidebar.classList.add('toc-positioned'); +} + function initTocToggle(elFileView: HTMLElement): void { const toggleBtn = elFileView.querySelector('#toggle-toc-btn'); - const tocSidebar = elFileView.querySelector('.file-view-toc'); + const tocSidebar = elFileView.querySelector('.file-view-toc'); if (!toggleBtn || !tocSidebar) return; + // Check if we're in file view (not home page) - only file view needs margin adjustment + const repoViewContent = elFileView.closest('.repo-view-content'); + const isFileView = Boolean(repoViewContent); + + // Helper to update position + const updatePosition = () => { + if (!tocSidebar.classList.contains('toc-panel-hidden')) { + updateTocPosition(elFileView, tocSidebar); + } + }; + + // Helper to show TOC with proper positioning + const showToc = () => { + toggleBtn.classList.add('active'); + + // Wait for margin to take effect before showing and positioning TOC + const showAfterLayout = () => { + tocSidebar.classList.remove('toc-panel-hidden'); + requestAnimationFrame(() => { + updateTocPosition(elFileView, tocSidebar); + }); + }; + + // For file view, first add margin, wait for layout, then show TOC + if (isFileView) { + repoViewContent.classList.add('toc-visible'); + // Wait for CSS transition to complete (200ms) before calculating position + setTimeout(showAfterLayout, 220); + } else { + // For home page (README), no margin needed, show with small delay + setTimeout(showAfterLayout, 10); + } + }; + + // Helper to hide TOC + const hideToc = () => { + tocSidebar.classList.add('toc-panel-hidden'); + tocSidebar.classList.remove('toc-positioned'); + toggleBtn.classList.remove('active'); + if (isFileView) { + repoViewContent.classList.remove('toc-visible'); + } + }; + // Restore saved state from localStorage (default to hidden) const savedState = localStorage.getItem('file-view-toc-visible'); const isVisible = savedState === 'true'; // default to hidden // Apply initial state if (isVisible) { - tocSidebar.classList.remove('toc-panel-hidden'); - toggleBtn.classList.add('active'); + showToc(); } else { - tocSidebar.classList.add('toc-panel-hidden'); - toggleBtn.classList.remove('active'); + hideToc(); } + // Update TOC position on resize/scroll to keep aligned with file content + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden'); if (isCurrentlyVisible) { - // Hide TOC - tocSidebar.classList.add('toc-panel-hidden'); - toggleBtn.classList.remove('active'); + hideToc(); localStorage.setItem('file-view-toc-visible', 'false'); } else { - // Show TOC - tocSidebar.classList.remove('toc-panel-hidden'); - toggleBtn.classList.add('active'); + showToc(); localStorage.setItem('file-view-toc-visible', 'true'); } }); From e7fb9af3cbe649a1fc7e0722655461cda01ca4ed Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 28 Dec 2025 15:48:23 +0800 Subject: [PATCH 04/19] refactor: enhance TOC position updates with ResizeObserver and IntersectionObserver - Replace window resize and scroll event listeners with a ResizeObserver to monitor changes in the document body. - Implement an IntersectionObserver for the file header to trigger TOC position updates based on its visibility. - This improves performance and responsiveness of the TOC sidebar in file views. --- web_src/js/features/file-view.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 08f5ce35c9..430a6d5ef8 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -142,8 +142,22 @@ function initTocToggle(elFileView: HTMLElement): void { } // Update TOC position on resize/scroll to keep aligned with file content - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition); + const resizeObserver = new ResizeObserver(() => { + updatePosition(); + }); + resizeObserver.observe(document.body); + + const fileHeader = elFileView.querySelector('.file-header'); + if (fileHeader) { + const intersectionObserver = new IntersectionObserver(() => { + updatePosition(); + }, { + root: null, + rootMargin: '0px', + threshold: [0, 0.25, 0.5, 0.75, 1.0], + }); + intersectionObserver.observe(fileHeader); + } toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden'); From 7386cc0aba368a5ed4ba0bb71c58c578e7d1109e Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 08:28:16 +0800 Subject: [PATCH 05/19] refactor: rename TOC to sidebar and update related functionality - Change references from TOC to sidebar in the file view and README rendering functions. - Update CSS classes and JavaScript functions to reflect the new sidebar implementation. - Ensure proper visibility and positioning of the sidebar in the file view context. --- routers/web/repo/view_file.go | 2 +- routers/web/repo/view_readme.go | 2 +- templates/repo/view_file.tmpl | 16 ++++--- web_src/css/repo/home.css | 40 ++++++++-------- web_src/js/features/file-view.ts | 78 ++++++++++++++++---------------- 5 files changed, 70 insertions(+), 68 deletions(-) diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index d505ce982b..75ea42d333 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -93,7 +93,7 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy return true } - ctx.Data["FileTocHTML"] = renderSidebarTocHTML(rctx) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) return true } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 8999339bab..3cc35c35ec 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -207,7 +207,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil delete(ctx.Data, "IsMarkup") } - ctx.Data["ReadmeTocHTML"] = renderSidebarTocHTML(rctx) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) } if ctx.Data["IsMarkup"] != true { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index f638e73f15..7010e9d690 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -35,11 +35,13 @@ {{end}}
- {{if or .ReadmeTocHTML .FileTocHTML}} - - {{end}} +
+ {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
{{if .IsRepresentableAsText}} @@ -144,9 +146,9 @@
{{end}}
- {{if or .ReadmeTocHTML .FileTocHTML}} -
- {{or .ReadmeTocHTML .FileTocHTML}} + {{if .FileSidebarHTML}} + {{end}} diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 110f8f7389..f686171375 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -79,8 +79,8 @@ transition: margin-right 0.2s ease; } -/* When TOC is visible, reserve space on the right (only for file view, not home page) */ -.repo-view-content.toc-visible { +/* When sidebar is visible, reserve space on the right (only for file view, not home page) */ +.repo-view-content.sidebar-visible { margin-right: 270px; } @@ -112,8 +112,8 @@ text-decoration: none; } -/* TOC as a fixed sidebar panel */ -.file-view-toc { +/* File view sidebar panel (e.g., TOC for markdown files) */ +.file-view-sidebar { position: fixed; top: 120px; /* Will be adjusted by JS to align with file content */ right: 0.5rem; @@ -129,21 +129,21 @@ transition: opacity 0.15s ease; } -/* Show TOC after JS has positioned it */ -.file-view-toc.toc-positioned { +/* Show sidebar after JS has positioned it */ +.file-view-sidebar.sidebar-positioned { opacity: 1; } /* Hidden state - using custom class to avoid Tailwind conflicts */ -.file-view-toc.toc-panel-hidden { +.file-view-sidebar.sidebar-panel-hidden { display: none; } -.file-view-toc details { +.file-view-sidebar details { font-size: 13px; } -.file-view-toc summary { +.file-view-sidebar summary { font-weight: var(--font-weight-semibold); cursor: pointer; padding: 8px 0; @@ -152,17 +152,17 @@ margin-bottom: 8px; } -.file-view-toc ul { +.file-view-sidebar ul { margin: 0; list-style: none; padding: 5px 0 5px 1em; } -.file-view-toc ul ul { +.file-view-sidebar ul ul { border-left: 1px dashed var(--color-secondary); } -.file-view-toc a { +.file-view-sidebar a { display: block; padding: 6px 10px; color: var(--color-text); @@ -172,28 +172,28 @@ border-radius: var(--border-radius); } -.file-view-toc a:hover { +.file-view-sidebar a:hover { color: var(--color-primary); background: var(--color-hover); } -/* TOC toggle button active state - when TOC is visible */ -#toggle-toc-btn.active { +/* Sidebar toggle button active state - when sidebar is visible */ +#toggle-sidebar-btn.active { color: var(--color-primary); } -/* Hide TOC sidebar on small screens */ +/* Hide sidebar on small screens */ @media (max-width: 1400px) { - .file-view-toc { + .file-view-sidebar { display: none !important; } - #toggle-toc-btn { + #toggle-sidebar-btn { display: none; } - /* Don't reserve space for TOC on small screens */ - .repo-view-content.toc-visible { + /* Don't reserve space for sidebar on small screens */ + .repo-view-content.sidebar-visible { margin-right: 0; } } diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 430a6d5ef8..caa4a036db 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -59,32 +59,32 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str } } -function updateTocPosition(elFileView: HTMLElement, tocSidebar: HTMLElement): void { +function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): void { const fileHeader = elFileView.querySelector('.file-header'); if (!fileHeader) return; const headerRect = fileHeader.getBoundingClientRect(); - // Align TOC top with the file header top, with a minimum of 12px from viewport top + // Align sidebar top with the file header top, with a minimum of 12px from viewport top const topPos = Math.max(12, headerRect.top); - tocSidebar.style.top = `${topPos}px`; + sidebar.style.top = `${topPos}px`; - // Position TOC right next to the content (works for both file view and home page) + // Position sidebar right next to the content (works for both file view and home page) const segment = elFileView.querySelector('.ui.segment'); if (segment) { const segmentRect = segment.getBoundingClientRect(); const leftPos = segmentRect.right + 8; // 8px gap from content - tocSidebar.style.left = `${leftPos}px`; - tocSidebar.style.right = 'auto'; + sidebar.style.left = `${leftPos}px`; + sidebar.style.right = 'auto'; } - // Mark as positioned to show the TOC (prevents flicker) - tocSidebar.classList.add('toc-positioned'); + // Mark as positioned to show the sidebar (prevents flicker) + sidebar.classList.add('sidebar-positioned'); } -function initTocToggle(elFileView: HTMLElement): void { - const toggleBtn = elFileView.querySelector('#toggle-toc-btn'); - const tocSidebar = elFileView.querySelector('.file-view-toc'); - if (!toggleBtn || !tocSidebar) return; +function initSidebarToggle(elFileView: HTMLElement): void { + const toggleBtn = elFileView.querySelector('#toggle-sidebar-btn'); + const sidebar = elFileView.querySelector('.file-view-sidebar'); + if (!toggleBtn || !sidebar) return; // Check if we're in file view (not home page) - only file view needs margin adjustment const repoViewContent = elFileView.closest('.repo-view-content'); @@ -92,26 +92,26 @@ function initTocToggle(elFileView: HTMLElement): void { // Helper to update position const updatePosition = () => { - if (!tocSidebar.classList.contains('toc-panel-hidden')) { - updateTocPosition(elFileView, tocSidebar); + if (!sidebar.classList.contains('sidebar-panel-hidden')) { + updateSidebarPosition(elFileView, sidebar); } }; - // Helper to show TOC with proper positioning - const showToc = () => { + // Helper to show sidebar with proper positioning + const showSidebar = () => { toggleBtn.classList.add('active'); - // Wait for margin to take effect before showing and positioning TOC + // Wait for margin to take effect before showing and positioning sidebar const showAfterLayout = () => { - tocSidebar.classList.remove('toc-panel-hidden'); + sidebar.classList.remove('sidebar-panel-hidden'); requestAnimationFrame(() => { - updateTocPosition(elFileView, tocSidebar); + updateSidebarPosition(elFileView, sidebar); }); }; - // For file view, first add margin, wait for layout, then show TOC - if (isFileView) { - repoViewContent.classList.add('toc-visible'); + // For file view, first add margin, wait for layout, then show sidebar + if (isFileView && repoViewContent) { + repoViewContent.classList.add('sidebar-visible'); // Wait for CSS transition to complete (200ms) before calculating position setTimeout(showAfterLayout, 220); } else { @@ -120,28 +120,28 @@ function initTocToggle(elFileView: HTMLElement): void { } }; - // Helper to hide TOC - const hideToc = () => { - tocSidebar.classList.add('toc-panel-hidden'); - tocSidebar.classList.remove('toc-positioned'); + // Helper to hide sidebar + const hideSidebar = () => { + sidebar.classList.add('sidebar-panel-hidden'); + sidebar.classList.remove('sidebar-positioned'); toggleBtn.classList.remove('active'); - if (isFileView) { - repoViewContent.classList.remove('toc-visible'); + if (isFileView && repoViewContent) { + repoViewContent.classList.remove('sidebar-visible'); } }; // Restore saved state from localStorage (default to hidden) - const savedState = localStorage.getItem('file-view-toc-visible'); + const savedState = localStorage.getItem('file-view-sidebar-visible'); const isVisible = savedState === 'true'; // default to hidden // Apply initial state if (isVisible) { - showToc(); + showSidebar(); } else { - hideToc(); + hideSidebar(); } - // Update TOC position on resize/scroll to keep aligned with file content + // Update sidebar position on resize/scroll to keep aligned with file content const resizeObserver = new ResizeObserver(() => { updatePosition(); }); @@ -160,13 +160,13 @@ function initTocToggle(elFileView: HTMLElement): void { } toggleBtn.addEventListener('click', () => { - const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden'); + const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); if (isCurrentlyVisible) { - hideToc(); - localStorage.setItem('file-view-toc-visible', 'false'); + hideSidebar(); + localStorage.setItem('file-view-sidebar-visible', 'false'); } else { - showToc(); - localStorage.setItem('file-view-toc-visible', 'true'); + showSidebar(); + localStorage.setItem('file-view-sidebar-visible', 'true'); } }); } @@ -175,8 +175,8 @@ export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { initPluginsOnce(); - // Initialize TOC toggle functionality - initTocToggle(elFileView); + // Initialize sidebar toggle functionality (e.g., TOC for markdown files) + initSidebarToggle(elFileView); const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet From 694d5510bf4613dd11038b122095231f3e2f0406 Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 08:37:27 +0800 Subject: [PATCH 06/19] refactor: improve sidebar positioning and visibility logic - Enhance sidebar positioning to ensure it aligns with the file header and does not exceed the content's bottom. - Implement logic to hide the sidebar when the file content is scrolled out of view. - Replace IntersectionObserver with a scroll event listener for updating sidebar position, improving performance and responsiveness. --- web_src/js/features/file-view.ts | 42 +++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index caa4a036db..bb076a2386 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -61,17 +61,30 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): void { const fileHeader = elFileView.querySelector('.file-header'); - if (!fileHeader) return; + const segment = elFileView.querySelector('.ui.segment'); + if (!fileHeader || !segment) return; const headerRect = fileHeader.getBoundingClientRect(); - // Align sidebar top with the file header top, with a minimum of 12px from viewport top - const topPos = Math.max(12, headerRect.top); - sidebar.style.top = `${topPos}px`; + const segmentRect = segment.getBoundingClientRect(); + const sidebarHeight = sidebar.offsetHeight; - // Position sidebar right next to the content (works for both file view and home page) - const segment = elFileView.querySelector('.ui.segment'); - if (segment) { - const segmentRect = segment.getBoundingClientRect(); + // Calculate the maximum top position so sidebar doesn't exceed file content bottom + const maxTopPos = segmentRect.bottom - sidebarHeight; + + // Align sidebar top with the file header top, with constraints: + // - minimum of 12px from viewport top + // - maximum so sidebar bottom doesn't exceed segment bottom + let topPos = Math.max(12, headerRect.top); + topPos = Math.min(topPos, maxTopPos); + + // Hide sidebar if file content is scrolled out of view + if (segmentRect.bottom < 50 || segmentRect.top > window.innerHeight) { + sidebar.style.opacity = '0'; + } else { + sidebar.style.opacity = ''; + sidebar.style.top = `${topPos}px`; + + // Position sidebar right next to the content const leftPos = segmentRect.right + 8; // 8px gap from content sidebar.style.left = `${leftPos}px`; sidebar.style.right = 'auto'; @@ -147,17 +160,8 @@ function initSidebarToggle(elFileView: HTMLElement): void { }); resizeObserver.observe(document.body); - const fileHeader = elFileView.querySelector('.file-header'); - if (fileHeader) { - const intersectionObserver = new IntersectionObserver(() => { - updatePosition(); - }, { - root: null, - rootMargin: '0px', - threshold: [0, 0.25, 0.5, 0.75, 1.0], - }); - intersectionObserver.observe(fileHeader); - } + // Update position on scroll + window.addEventListener('scroll', updatePosition, {passive: true}); toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); From ed9eb57fc7f672b4d69c8e19de3d7df76d0262ec Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 09:10:15 +0800 Subject: [PATCH 07/19] feat: introduce SidebarTocHeaders for improved sidebar TOC generation - Add a new Header type to encapsulate header data for generating the sidebar TOC. - Update the rendering logic to utilize SidebarTocHeaders, providing a more flexible structure for TOC generation. - Implement extraction of headers from orgmode documents to populate SidebarTocHeaders. - Ensure backward compatibility by maintaining the legacy SidebarTocNode for existing functionality. --- modules/markup/markdown/goldmark.go | 5 ++ modules/markup/orgmode/orgmode.go | 42 +++++++++++++++- modules/markup/render.go | 10 +++- modules/markup/sidebar_toc.go | 78 +++++++++++++++++++++++++++++ routers/web/repo/view.go | 7 ++- routers/web/repo/view_file.go | 2 +- routers/web/repo/view_readme.go | 2 +- routers/web/repo/wiki.go | 5 +- 8 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 modules/markup/sidebar_toc.go diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index b28fa9824e..1bb1601488 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -88,6 +88,11 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa } else { tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"}) ctx.SidebarTocNode = tocNode + // Also set the generic SidebarTocHeaders for the new abstraction + ctx.SidebarTocHeaders = make([]markup.Header, len(tocList)) + for i, h := range tocList { + ctx.SidebarTocHeaders[i] = markup.Header{Level: h.Level, Text: h.Text, ID: h.ID} + } } } diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index 93c335d244..c015d52636 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -96,7 +96,16 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter} htmlWriter.ExtendingWriter = w - res, err := org.New().Silent().Parse(input, "").Write(w) + // Parse the document first to extract outline for TOC + doc := org.New().Silent().Parse(input, "") + if doc.Error != nil { + return fmt.Errorf("orgmode.Parse failed: %w", doc.Error) + } + + // Extract headers from the document outline for sidebar TOC + ctx.SidebarTocHeaders = extractHeadersFromOutline(doc.Outline) + + res, err := doc.Write(w) if err != nil { return fmt.Errorf("orgmode.Render failed: %w", err) } @@ -104,6 +113,37 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error return err } +// extractHeadersFromOutline recursively extracts headers from org document outline +func extractHeadersFromOutline(outline org.Outline) []markup.Header { + var headers []markup.Header + collectHeaders(outline.Section, &headers) + return headers +} + +// collectHeaders recursively collects headers from sections +func collectHeaders(section *org.Section, headers *[]markup.Header) { + if section == nil { + return + } + + // Process current section's headline + if section.Headline != nil { + h := section.Headline + // Convert headline title nodes to plain text + titleText := org.String(h.Title...) + *headers = append(*headers, markup.Header{ + Level: h.Lvl, + Text: titleText, + ID: h.ID(), + }) + } + + // Process child sections + for _, child := range section.Children { + collectHeaders(child, headers) + } +} + // RenderString renders orgmode string to HTML string func RenderString(ctx *markup.RenderContext, content string) (string, error) { var buf strings.Builder diff --git a/modules/markup/render.go b/modules/markup/render.go index c645749065..7f456d0991 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -36,6 +36,13 @@ var RenderBehaviorForTesting struct { DisableAdditionalAttributes bool } +// Header holds the data about a header for generating TOC +type Header struct { + Level int + Text string + ID string +} + type RenderOptions struct { UseAbsoluteLink bool @@ -63,7 +70,8 @@ type RenderContext struct { // the context might be used by the "render" function, but it might also be used by "postProcess" function usedByRender bool - SidebarTocNode ast.Node + SidebarTocNode ast.Node // Deprecated: use SidebarTocHeaders instead, keep for compatibility + SidebarTocHeaders []Header // Headers for generating sidebar TOC RenderHelper RenderHelper RenderOptions RenderOptions diff --git a/modules/markup/sidebar_toc.go b/modules/markup/sidebar_toc.go new file mode 100644 index 0000000000..f2e4a59cc4 --- /dev/null +++ b/modules/markup/sidebar_toc.go @@ -0,0 +1,78 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markup + +import ( + "html" + "html/template" + "net/url" + "strings" + + "code.gitea.io/gitea/modules/translation" +) + +// RenderSidebarTocHTML renders a list of headers into HTML for sidebar TOC display. +// It generates a
element with nested
    lists representing the header hierarchy. +func RenderSidebarTocHTML(headers []Header, lang string) template.HTML { + if len(headers) == 0 { + return "" + } + + var sb strings.Builder + + // Start with
    + sb.WriteString(`
    `) + sb.WriteString(``) + sb.WriteString(html.EscapeString(translation.NewLocale(lang).TrString("toc"))) + sb.WriteString(``) + + // Find the minimum level to start with + minLevel := 6 + for _, header := range headers { + if header.Level < minLevel { + minLevel = header.Level + } + } + + // Build nested list structure + currentLevel := minLevel + sb.WriteString(`
      `) + openLists := 1 + + for _, header := range headers { + // Close lists if we need to go up levels + for currentLevel > header.Level { + sb.WriteString(`
    `) + openLists-- + currentLevel-- + } + + // Open new lists if we need to go down levels + for currentLevel < header.Level { + sb.WriteString(``) + openLists-- + } + + sb.WriteString(`
    `) + + return template.HTML(sb.String()) +} + diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 80148ecdf9..2cb8008315 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -180,7 +180,12 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i return escaped, output, err } -func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML { +func renderSidebarTocHTML(rctx *markup.RenderContext, lang string) template.HTML { + // Prefer the new generic SidebarTocHeaders + if len(rctx.SidebarTocHeaders) > 0 { + return markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, lang) + } + // Fallback to legacy SidebarTocNode for backward compatibility if rctx.SidebarTocNode == nil { return "" } diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 75ea42d333..4d96fb19c0 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -93,7 +93,7 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy return true } - ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language()) return true } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 3cc35c35ec..ac44b7f59b 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -207,7 +207,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil delete(ctx.Data, "IsMarkup") } - ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language()) } if ctx.Data["IsMarkup"] != true { diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index 921e17fb6a..b475e3ed21 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -277,7 +277,10 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { return nil, nil } - if rctx.SidebarTocNode != nil { + // Render sidebar TOC - prefer generic headers, fallback to legacy node + if len(rctx.SidebarTocHeaders) > 0 { + ctx.Data["WikiSidebarTocHTML"] = markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, ctx.Locale.Language()) + } else if rctx.SidebarTocNode != nil { sb := strings.Builder{} if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil { log.Error("Failed to render wiki sidebar TOC: %v", err) From b92be9c33422a0956de7449ca8a4893d7b549cf7 Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 09:50:26 +0800 Subject: [PATCH 08/19] refactor: streamline sidebar positioning logic - Simplify the calculation of the sidebar's top position to ensure it aligns with the file header or sticks to the top of the viewport when necessary. - Remove redundant opacity handling and improve clarity in the sidebar's visibility logic. - Maintain the sidebar's position next to the content with a consistent gap. --- web_src/js/features/file-view.ts | 33 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index bb076a2386..9e6348aedc 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -68,27 +68,22 @@ function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): v const segmentRect = segment.getBoundingClientRect(); const sidebarHeight = sidebar.offsetHeight; - // Calculate the maximum top position so sidebar doesn't exceed file content bottom - const maxTopPos = segmentRect.bottom - sidebarHeight; + // Calculate top position: + // - When file header is visible: align with file header top + // - When file header scrolls above viewport: stick to top (12px) + // - Limit so sidebar bottom doesn't go below segment bottom + const minTop = 12; + const maxTop = Math.max(minTop, segmentRect.bottom - sidebarHeight); + let topPos = Math.max(minTop, headerRect.top); + topPos = Math.min(topPos, maxTop); - // Align sidebar top with the file header top, with constraints: - // - minimum of 12px from viewport top - // - maximum so sidebar bottom doesn't exceed segment bottom - let topPos = Math.max(12, headerRect.top); - topPos = Math.min(topPos, maxTopPos); + sidebar.style.opacity = ''; + sidebar.style.top = `${topPos}px`; - // Hide sidebar if file content is scrolled out of view - if (segmentRect.bottom < 50 || segmentRect.top > window.innerHeight) { - sidebar.style.opacity = '0'; - } else { - sidebar.style.opacity = ''; - sidebar.style.top = `${topPos}px`; - - // Position sidebar right next to the content - const leftPos = segmentRect.right + 8; // 8px gap from content - sidebar.style.left = `${leftPos}px`; - sidebar.style.right = 'auto'; - } + // Position sidebar right next to the content + const leftPos = segmentRect.right + 8; // 8px gap from content + sidebar.style.left = `${leftPos}px`; + sidebar.style.right = 'auto'; // Mark as positioned to show the sidebar (prevents flicker) sidebar.classList.add('sidebar-positioned'); From 1aa08e2b5a5605405befe9746dfe451599a3d662 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:07:29 +0800 Subject: [PATCH 09/19] refactor: enhance sidebar height and visibility logic - Dynamically calculate the maximum height of the sidebar to prevent it from extending below the segment bottom. - Implement logic to hide the sidebar when the available height is insufficient, improving user experience. - Ensure the sidebar's top position does not exceed the segment's top during scrolling. --- web_src/js/features/file-view.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 9e6348aedc..44a4fe049e 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -66,17 +66,27 @@ function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): v const headerRect = fileHeader.getBoundingClientRect(); const segmentRect = segment.getBoundingClientRect(); - const sidebarHeight = sidebar.offsetHeight; // Calculate top position: // - When file header is visible: align with file header top // - When file header scrolls above viewport: stick to top (12px) - // - Limit so sidebar bottom doesn't go below segment bottom + // - Ensure sidebar top doesn't go above segment top (when scrolling up) const minTop = 12; - const maxTop = Math.max(minTop, segmentRect.bottom - sidebarHeight); let topPos = Math.max(minTop, headerRect.top); - topPos = Math.min(topPos, maxTop); + topPos = Math.max(topPos, segmentRect.top); // Don't go above segment top + // Dynamically calculate max-height so sidebar doesn't extend below segment bottom + const availableHeight = Math.max(0, segmentRect.bottom - topPos); + const cssMaxHeight = window.innerHeight - 140; // Match CSS calc(100vh - 140px) + const maxHeight = Math.min(availableHeight, cssMaxHeight); + + // Hide sidebar if available height is too small + if (maxHeight < 50) { + sidebar.style.opacity = '0'; + return; + } + + sidebar.style.maxHeight = `${maxHeight}px`; sidebar.style.opacity = ''; sidebar.style.top = `${topPos}px`; From 468780323ada6cc0a0aa25cbd159e771671a6a1c Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:20:34 +0800 Subject: [PATCH 10/19] fix: use requestAnimationFrame for smooth sidebar scroll tracking --- web_src/js/features/file-view.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 44a4fe049e..853c4a76cc 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -165,8 +165,15 @@ function initSidebarToggle(elFileView: HTMLElement): void { }); resizeObserver.observe(document.body); - // Update position on scroll - window.addEventListener('scroll', updatePosition, {passive: true}); + // Update position on scroll - use requestAnimationFrame for smooth updates + let scrollRafId: number | null = null; + window.addEventListener('scroll', () => { + if (scrollRafId !== null) return; // Already scheduled + scrollRafId = requestAnimationFrame(() => { + updatePosition(); + scrollRafId = null; + }); + }, {passive: true}); toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); From 55e2cd39b9d46a4015baa0d32d59986a7197f897 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:31:51 +0800 Subject: [PATCH 11/19] fix: use correct segment selector for TOC sidebar positioning --- web_src/js/features/file-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 853c4a76cc..dd1f8e3cde 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -61,7 +61,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): void { const fileHeader = elFileView.querySelector('.file-header'); - const segment = elFileView.querySelector('.ui.segment'); + const segment = elFileView.querySelector('.ui.bottom.segment'); if (!fileHeader || !segment) return; const headerRect = fileHeader.getBoundingClientRect(); From cbbbf093a4fa7afe5ccc2081d40f71e8e569d36b Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:41:29 +0800 Subject: [PATCH 12/19] fix: lower TOC sidebar breakpoint to 768px for tablet support --- web_src/css/repo/home.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index f686171375..17a9c6e65a 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -182,8 +182,8 @@ color: var(--color-primary); } -/* Hide sidebar on small screens */ -@media (max-width: 1400px) { +/* Hide sidebar on small screens (phones) */ +@media (max-width: 768px) { .file-view-sidebar { display: none !important; } From ba42ce0c9616995fce953aafcb13f36a199ad0b4 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:44:13 +0800 Subject: [PATCH 13/19] fix: use btn-octicon style for TOC toggle button --- templates/repo/view_file.tmpl | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 7010e9d690..721c795617 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -36,11 +36,7 @@
{{if .FileSidebarHTML}} -
- -
+ {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
From 548e7ba5e47a47832b888da26acda4bf025022a7 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:50:08 +0800 Subject: [PATCH 14/19] fix: remove explicit icon size for TOC button to match other btn-octicon --- templates/repo/view_file.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 721c795617..a3703f37d2 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -36,7 +36,7 @@
{{if .FileSidebarHTML}} - + {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
From 4267f69a8f7cee89d43edc33b0c08959d61f3e0a Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:51:47 +0800 Subject: [PATCH 15/19] fix: reset button default styles for btn-octicon --- templates/repo/view_file.tmpl | 2 +- web_src/css/repo.css | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index a3703f37d2..721c795617 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -36,7 +36,7 @@
{{if .FileSidebarHTML}} - + {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 0bf37ca083..970075ab60 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -167,6 +167,9 @@ td .commit-summary { padding: 8px; vertical-align: middle; color: var(--color-text); + background: none; + border: none; + cursor: pointer; } .non-diff-file-content .header .file-actions .btn-octicon:hover { From 3c5621c53492ac515ca66364da0c1b982416daee Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:15:29 +0800 Subject: [PATCH 16/19] refactor: replace scroll event with IntersectionObserver for sidebar position updates - Utilize IntersectionObserver to enhance performance and avoid issues associated with scroll events. - Implement fine-grained position updates using multiple thresholds for better responsiveness during scrolling. --- web_src/js/features/file-view.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index dd1f8e3cde..de1d00e49c 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -165,15 +165,19 @@ function initSidebarToggle(elFileView: HTMLElement): void { }); resizeObserver.observe(document.body); - // Update position on scroll - use requestAnimationFrame for smooth updates - let scrollRafId: number | null = null; - window.addEventListener('scroll', () => { - if (scrollRafId !== null) return; // Already scheduled - scrollRafId = requestAnimationFrame(() => { + // Update position using IntersectionObserver instead of scroll event + // This provides better performance and avoids scroll event issues + const fileHeader = elFileView.querySelector('.file-header'); + const segment = elFileView.querySelector('.ui.bottom.segment'); + if (fileHeader && segment) { + // Use many thresholds to get fine-grained position updates during scroll + const thresholds = Array.from({length: 101}, (_, i) => i / 100); + const positionObserver = new IntersectionObserver(() => { updatePosition(); - scrollRafId = null; - }); - }, {passive: true}); + }, {threshold: thresholds}); + positionObserver.observe(segment); + positionObserver.observe(fileHeader); + } toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); From fd754aa36765c14c62119ca1f1120b97ae68e864 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:21:45 +0800 Subject: [PATCH 17/19] refactor: clean up sidebar TOC structure comments - Update comments in RenderContext to clarify the deprecation of SidebarTocNode in favor of SidebarTocHeaders. - Remove unnecessary blank line in RenderSidebarTocHTML function for improved code readability. --- modules/markup/render.go | 4 ++-- modules/markup/sidebar_toc.go | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/markup/render.go b/modules/markup/render.go index 7f456d0991..c30527cb78 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -70,8 +70,8 @@ type RenderContext struct { // the context might be used by the "render" function, but it might also be used by "postProcess" function usedByRender bool - SidebarTocNode ast.Node // Deprecated: use SidebarTocHeaders instead, keep for compatibility - SidebarTocHeaders []Header // Headers for generating sidebar TOC + SidebarTocNode ast.Node // Deprecated: use SidebarTocHeaders instead, keep for compatibility + SidebarTocHeaders []Header // Headers for generating sidebar TOC RenderHelper RenderHelper RenderOptions RenderOptions diff --git a/modules/markup/sidebar_toc.go b/modules/markup/sidebar_toc.go index f2e4a59cc4..441e540d6a 100644 --- a/modules/markup/sidebar_toc.go +++ b/modules/markup/sidebar_toc.go @@ -75,4 +75,3 @@ func RenderSidebarTocHTML(headers []Header, lang string) template.HTML { return template.HTML(sb.String()) } - From dc873a7e707d0f2149c101823662ad7e79b2b0ed Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:45:48 +0800 Subject: [PATCH 18/19] style: enhance sidebar toggle button appearance - Add border styling to the sidebar toggle button in file view to match other buttons. - Update hover state to change border color for improved visual feedback. --- web_src/css/repo/home.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 17a9c6e65a..3f3bbd1d6a 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -177,9 +177,20 @@ background: var(--color-hover); } +/* Sidebar toggle button styling for file view (not readme) - add border to match other buttons */ +.file-header-right #toggle-sidebar-btn { + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); +} + +.file-header-right #toggle-sidebar-btn:hover { + border-color: var(--color-primary); +} + /* Sidebar toggle button active state - when sidebar is visible */ #toggle-sidebar-btn.active { color: var(--color-primary); + border-color: var(--color-primary); } /* Hide sidebar on small screens (phones) */ From c5a65b9d455c6cbec2b2ff20a8fe30934221388c Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 18 Jan 2026 14:58:37 +0800 Subject: [PATCH 19/19] refactor: remove deprecated SidebarTocNode and clean up sidebar TOC rendering - Eliminate the deprecated SidebarTocNode from RenderContext and related functions. - Update sidebar TOC rendering logic to exclusively use SidebarTocHeaders for improved clarity and maintainability. - Remove fallback logic for legacy TOC rendering to streamline the codebase. --- modules/markup/markdown/goldmark.go | 3 --- modules/markup/render.go | 2 -- routers/web/repo/view.go | 13 +------------ routers/web/repo/wiki.go | 8 +------- 4 files changed, 2 insertions(+), 24 deletions(-) diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 1bb1601488..35ecc77b5a 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -86,9 +86,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa tocNode := createTOCNode(tocList, rc.Lang, nil) node.InsertBefore(node, firstChild, tocNode) } else { - tocNode := createTOCNode(tocList, rc.Lang, map[string]string{"open": "open"}) - ctx.SidebarTocNode = tocNode - // Also set the generic SidebarTocHeaders for the new abstraction ctx.SidebarTocHeaders = make([]markup.Header, len(tocList)) for i, h := range tocList { ctx.SidebarTocHeaders[i] = markup.Header{Level: h.Level, Text: h.Text, ID: h.ID} diff --git a/modules/markup/render.go b/modules/markup/render.go index c30527cb78..06aec9bc30 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -18,7 +18,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - "github.com/yuin/goldmark/ast" "golang.org/x/sync/errgroup" ) @@ -70,7 +69,6 @@ type RenderContext struct { // the context might be used by the "render" function, but it might also be used by "postProcess" function usedByRender bool - SidebarTocNode ast.Node // Deprecated: use SidebarTocHeaders instead, keep for compatibility SidebarTocHeaders []Header // Headers for generating sidebar TOC RenderHelper RenderHelper diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2cb8008315..4bbad1a02e 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -35,7 +35,6 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" @@ -181,20 +180,10 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i } func renderSidebarTocHTML(rctx *markup.RenderContext, lang string) template.HTML { - // Prefer the new generic SidebarTocHeaders if len(rctx.SidebarTocHeaders) > 0 { return markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, lang) } - // Fallback to legacy SidebarTocNode for backward compatibility - if rctx.SidebarTocNode == nil { - return "" - } - sb := &strings.Builder{} - if err := markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode); err != nil { - log.Error("Failed to render sidebar TOC: %v", err) - return "" - } - return templates.SanitizeHTML(sb.String()) + return "" } func checkHomeCodeViewable(ctx *context.Context) { diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index b475e3ed21..165eb54d00 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -277,15 +277,9 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { return nil, nil } - // Render sidebar TOC - prefer generic headers, fallback to legacy node + // Render sidebar TOC if len(rctx.SidebarTocHeaders) > 0 { ctx.Data["WikiSidebarTocHTML"] = markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, ctx.Locale.Language()) - } else if rctx.SidebarTocNode != nil { - sb := strings.Builder{} - if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil { - log.Error("Failed to render wiki sidebar TOC: %v", err) - } - ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String()) } if !isSideBar {