From 3b4f74cbf1fe8181b9a567773d2dc8765d190665 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 26 Dec 2025 23:16:00 +0800 Subject: [PATCH 01/36] 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 8aeb1a0af8..42a217d111 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" @@ -174,6 +175,18 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r 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 44bc8543b0..9759ed093c 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -86,6 +86,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re 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 830709422e..05b2a27e0b 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -202,6 +202,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 050633284874f8ff9f567d0e513c488c5505ce1f Mon Sep 17 00:00:00 2001 From: hamki Date: Sat, 27 Dec 2025 00:01:19 +0800 Subject: [PATCH 02/36] 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 a29d0c029b47387d4abf29c3bdcc748c6bc6840c Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 28 Dec 2025 15:17:23 +0800 Subject: [PATCH 03/36] 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 ffcb4bfda0c542fe560d3393c06493ebfd09d909 Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 28 Dec 2025 15:48:23 +0800 Subject: [PATCH 04/36] 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 111bbcd7c0316d0996409745a64e797aada00263 Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 08:28:16 +0800 Subject: [PATCH 05/36] 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 9759ed093c..dedff1d563 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -87,7 +87,7 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re 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 05b2a27e0b..6641edd834 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -203,7 +203,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 f8868e5dab14a13b333314c3dabb20289adc118a Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 08:37:27 +0800 Subject: [PATCH 06/36] 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 679cf25fb2806abd0321cf59135edf8dd8a7e8b8 Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 09:10:15 +0800 Subject: [PATCH 07/36] 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 | 9 ++++ 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 +- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 modules/markup/sidebar_toc.go diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 555a171685..ab731ac0ed 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -84,6 +84,11 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa ctx.TocShowInSection = markup.TocShowInMain case showTocInSidebar: ctx.TocShowInSection = markup.TocShowInSidebar + // Also populate SidebarTocHeaders for README/file view (not used by Wiki) + 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 17d994734a..f3811fb71f 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -70,7 +70,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) } @@ -78,6 +87,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 5785dc5ad5..87c9d5fe48 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -37,6 +37,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 @@ -84,6 +91,8 @@ type RenderContext struct { TocShowInSection TocShowInSectionType TocHeadingItems []*TocHeadingItem + SidebarTocHeaders []Header // Headers for generating sidebar TOC (used by README/file view) + RenderHelper RenderHelper RenderOptions RenderOptions RenderInternal internal.RenderInternal 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 42a217d111..14e2ad222d 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -175,7 +175,12 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r 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 dedff1d563..67b35e21c8 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -87,7 +87,7 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re 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 6641edd834..77b7286451 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -203,7 +203,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 { From ce92f7a32a85ace51c3d1e651a1f3b504519df48 Mon Sep 17 00:00:00 2001 From: hamki Date: Thu, 1 Jan 2026 09:50:26 +0800 Subject: [PATCH 08/36] 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 37afd142086ecbcf09281c31caf6c4aa08f6db27 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:07:29 +0800 Subject: [PATCH 09/36] 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 b3063c75da5e3370f32663124bf5715f12d2ff76 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:20:34 +0800 Subject: [PATCH 10/36] 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 7878f7e5acd8f8331aa2e80d61d778f430f85aff Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:31:51 +0800 Subject: [PATCH 11/36] 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 47e11c61dbc4bbe530a15b7f215fef16e3ad9bf1 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:41:29 +0800 Subject: [PATCH 12/36] 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 84b344ff613d7c5ca1af67dce6c09db06052fce3 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:44:13 +0800 Subject: [PATCH 13/36] 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 dcbad5b917f04e632615075d7fbd00ccb5bc7b50 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:50:08 +0800 Subject: [PATCH 14/36] 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 e45bc328cca25a56eb5887f0fb9970418f4636b0 Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 04:51:47 +0800 Subject: [PATCH 15/36] 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 16fbfaec4a..52af7c03a4 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -169,6 +169,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 01554e98cd60736f0a0866e8c2995f52184ffb1b Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:15:29 +0800 Subject: [PATCH 16/36] 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 b9ab482b75daf2741dc22ef02cfb17a36e42972c Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:21:45 +0800 Subject: [PATCH 17/36] 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/sidebar_toc.go | 1 - 1 file changed, 1 deletion(-) 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 4f3d5f2968d99780e7b1353adc267360edf6d10d Mon Sep 17 00:00:00 2001 From: hamki Date: Fri, 16 Jan 2026 05:45:48 +0800 Subject: [PATCH 18/36] 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 a9d4df6bb36a64a1ea92285e616bb2677880edb5 Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 18 Jan 2026 14:58:37 +0800 Subject: [PATCH 19/36] 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. --- routers/web/repo/view.go | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 14e2ad222d..6c149529b4 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" @@ -176,20 +175,10 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r } 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) { From 512d4a93835873d45bc43dc852c11f942f245172 Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 19 Jan 2026 09:23:59 +0800 Subject: [PATCH 20/36] Update modules/markup/sidebar_toc.go Co-authored-by: Lunny Xiao Signed-off-by: hamkido --- modules/markup/sidebar_toc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/markup/sidebar_toc.go b/modules/markup/sidebar_toc.go index 441e540d6a..7ce9b3535d 100644 --- a/modules/markup/sidebar_toc.go +++ b/modules/markup/sidebar_toc.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package markup From 0497d4fb893d8156dfca80067632df64b5b3f619 Mon Sep 17 00:00:00 2001 From: hamki Date: Sun, 25 Jan 2026 14:39:54 +0800 Subject: [PATCH 21/36] style: update TOC button styling in README view - Remove border from the TOC button in the README view for a cleaner appearance. - Ensure hover state maintains no border for consistency with the overall design. --- templates/repo/view_file.tmpl | 2 +- web_src/css/repo/home.css | 9 +++++++++ 2 files changed, 10 insertions(+), 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 */}}
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 3f3bbd1d6a..103df1644f 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -187,6 +187,15 @@ border-color: var(--color-primary); } +/* README view: TOC button should not have border */ +#readme .file-header-right #toggle-sidebar-btn { + border: none; +} + +#readme .file-header-right #toggle-sidebar-btn:hover { + border: none; +} + /* Sidebar toggle button active state - when sidebar is visible */ #toggle-sidebar-btn.active { color: var(--color-primary); From 7a5c352d25df24bc1dda0957a04652a6819545aa Mon Sep 17 00:00:00 2001 From: hamki Date: Mon, 26 Jan 2026 17:03:21 +0800 Subject: [PATCH 22/36] refactor: unify TOC rendering to use upstream TocHeadingItems for all views --- modules/markup/markdown/goldmark.go | 5 -- modules/markup/orgmode/orgmode.go | 29 ++++++----- modules/markup/render.go | 2 - modules/markup/sidebar_toc.go | 77 ----------------------------- routers/web/repo/view.go | 8 +-- routers/web/repo/view_file.go | 2 +- routers/web/repo/view_readme.go | 2 +- 7 files changed, 23 insertions(+), 102 deletions(-) delete mode 100644 modules/markup/sidebar_toc.go diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index ab731ac0ed..555a171685 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -84,11 +84,6 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa ctx.TocShowInSection = markup.TocShowInMain case showTocInSidebar: ctx.TocShowInSection = markup.TocShowInSidebar - // Also populate SidebarTocHeaders for README/file view (not used by Wiki) - 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 f3811fb71f..d84c0fb861 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -77,7 +77,10 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error } // Extract headers from the document outline for sidebar TOC - ctx.SidebarTocHeaders = extractHeadersFromOutline(doc.Outline) + ctx.TocHeadingItems = extractTocHeadingItems(doc.Outline) + if len(ctx.TocHeadingItems) > 0 { + ctx.TocShowInSection = markup.TocShowInSidebar + } res, err := doc.Write(w) if err != nil { @@ -87,15 +90,15 @@ 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 +// extractTocHeadingItems recursively extracts headers from org document outline +func extractTocHeadingItems(outline org.Outline) []*markup.TocHeadingItem { + var items []*markup.TocHeadingItem + collectTocHeadingItems(outline.Section, &items) + return items } -// collectHeaders recursively collects headers from sections -func collectHeaders(section *org.Section, headers *[]markup.Header) { +// collectTocHeadingItems recursively collects headers from sections +func collectTocHeadingItems(section *org.Section, items *[]*markup.TocHeadingItem) { if section == nil { return } @@ -105,16 +108,16 @@ func collectHeaders(section *org.Section, headers *[]markup.Header) { 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(), + *items = append(*items, &markup.TocHeadingItem{ + HeadingLevel: h.Lvl, + InnerText: titleText, + AnchorID: h.ID(), }) } // Process child sections for _, child := range section.Children { - collectHeaders(child, headers) + collectTocHeadingItems(child, items) } } diff --git a/modules/markup/render.go b/modules/markup/render.go index 87c9d5fe48..2f6d8f054b 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -91,8 +91,6 @@ type RenderContext struct { TocShowInSection TocShowInSectionType TocHeadingItems []*TocHeadingItem - SidebarTocHeaders []Header // Headers for generating sidebar TOC (used by README/file view) - RenderHelper RenderHelper RenderOptions RenderOptions RenderInternal internal.RenderInternal diff --git a/modules/markup/sidebar_toc.go b/modules/markup/sidebar_toc.go deleted file mode 100644 index 7ce9b3535d..0000000000 --- a/modules/markup/sidebar_toc.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2026 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 6c149529b4..6003b2aeb8 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -174,9 +174,11 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r return escaped, output, err } -func renderSidebarTocHTML(rctx *markup.RenderContext, lang string) template.HTML { - if len(rctx.SidebarTocHeaders) > 0 { - return markup.RenderSidebarTocHTML(rctx.SidebarTocHeaders, lang) +func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML { + if rctx.TocShowInSection == markup.TocShowInSidebar && len(rctx.TocHeadingItems) > 0 { + sb := strings.Builder{} + markup.RenderTocHeadingItems(rctx, map[string]string{"open": ""}, &sb) + return template.HTML(sb.String()) } return "" } diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 67b35e21c8..dedff1d563 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -87,7 +87,7 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re return true } - ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language()) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) return true } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 77b7286451..6641edd834 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -203,7 +203,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil delete(ctx.Data, "IsMarkup") } - ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx, ctx.Locale.Language()) + ctx.Data["FileSidebarHTML"] = renderSidebarTocHTML(rctx) } if ctx.Data["IsMarkup"] != true { From 82a91140bf43dfb4949f79070064f41954093c0e Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:16:36 +0800 Subject: [PATCH 23/36] Update templates/repo/view_file.tmpl Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- 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 a3703f37d2..da9ad863a7 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -38,7 +38,7 @@ {{if .FileSidebarHTML}} {{end}} - {{/* this componment is also controlled by frontend plugin renders */}} + {{/* this component is also controlled by frontend plugin renders */}}
    {{if .IsRepresentableAsText}} {{svg "octicon-code" 15}} From 6a5ea3b2f371c8c3270966f6571d5e8e1cc9ab1f Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:17:15 +0800 Subject: [PATCH 24/36] Update web_src/js/features/file-view.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- web_src/js/features/file-view.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index de1d00e49c..9072465303 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -170,8 +170,8 @@ function initSidebarToggle(elFileView: HTMLElement): void { 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); + // Use a small set of thresholds to get periodic position updates during scroll + const thresholds = [0, 0.25, 0.5, 0.75, 1]; const positionObserver = new IntersectionObserver(() => { updatePosition(); }, {threshold: thresholds}); From 745aceef9b280cdbad3cae94c1daa7acb769496c Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:17:30 +0800 Subject: [PATCH 25/36] Update web_src/js/features/file-view.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- web_src/js/features/file-view.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 9072465303..91699301fa 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -183,10 +183,18 @@ function initSidebarToggle(elFileView: HTMLElement): void { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); if (isCurrentlyVisible) { hideSidebar(); - localStorage.setItem('file-view-sidebar-visible', 'false'); + try { + localStorage.setItem('file-view-sidebar-visible', 'false'); + } catch { + // Ignore storage errors (e.g., disabled or full localStorage) + } } else { showSidebar(); - localStorage.setItem('file-view-sidebar-visible', 'true'); + try { + localStorage.setItem('file-view-sidebar-visible', 'true'); + } catch { + // Ignore storage errors (e.g., disabled or full localStorage) + } } }); } From 860afc51d3bd7f6767fc9b29e8bc01b71b9f5e0b Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:18:10 +0800 Subject: [PATCH 26/36] Update web_src/css/repo/home.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- web_src/css/repo/home.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 103df1644f..086a6fcf3b 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -217,3 +217,16 @@ margin-right: 0; } } + +/* Adjust sidebar layout on tablet-sized screens to prevent layout issues */ +@media (min-width: 769px) and (max-width: 1024px) { + /* Slightly reduce sidebar width in the grid layout */ + .repo-grid-filelist-sidebar { + grid-template-columns: auto 220px; + } + + /* Match reserved content margin to the narrower sidebar */ + .repo-view-content.sidebar-visible { + margin-right: 220px; + } +} From 5896a80421c93c685b2f9e9ecbde67c1ba4485f4 Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:18:37 +0800 Subject: [PATCH 27/36] Update web_src/js/features/file-view.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- web_src/js/features/file-view.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 91699301fa..70db3c1db1 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -77,7 +77,9 @@ function updateSidebarPosition(elFileView: HTMLElement, sidebar: HTMLElement): v // 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) + // 140px accounts for fixed layout chrome (header, spacing, etc.) and must match CSS: calc(100vh - 140px) + const cssMaxHeightOffset = 140; + const cssMaxHeight = window.innerHeight - cssMaxHeightOffset; const maxHeight = Math.min(availableHeight, cssMaxHeight); // Hide sidebar if available height is too small From 24f253fe44864f1946174684773e57395c7b16c4 Mon Sep 17 00:00:00 2001 From: hamkido Date: Mon, 26 Jan 2026 17:19:07 +0800 Subject: [PATCH 28/36] Update web_src/js/features/file-view.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: hamkido --- web_src/js/features/file-view.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 70db3c1db1..064d8b4852 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -132,8 +132,13 @@ function initSidebarToggle(elFileView: HTMLElement): void { // 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); + // Wait for CSS transition to actually complete before calculating position + const onTransitionEnd = (event: TransitionEvent) => { + if (event.target !== repoViewContent) return; + repoViewContent.removeEventListener('transitionend', onTransitionEnd); + showAfterLayout(); + }; + repoViewContent.addEventListener('transitionend', onTransitionEnd); } else { // For home page (README), no margin needed, show with small delay setTimeout(showAfterLayout, 10); From a8a1873b27636539198394e71a9b99ed96fca6eb Mon Sep 17 00:00:00 2001 From: hamki Date: Mon, 26 Jan 2026 17:27:10 +0800 Subject: [PATCH 29/36] fix: correct HTML nesting structure in TOC rendering Fix invalid HTML structure where
      was directly nested inside
        . According to HTML specification, nested
          elements must be wrapped in
        • elements. Before:
              ...
          After:
            • ...
          --- modules/markup/html.go | 19 ++++++++++++++----- modules/markup/html_toc_test.go | 16 ++++++++-------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/modules/markup/html.go b/modules/markup/html.go index 1c2ae6918d..c2339838e5 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -254,26 +254,35 @@ func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]strin currentLevel := baseLevel indent := []byte{' ', ' '} _, _ = htmlutil.HTMLPrint(out, "
            \n") - for _, header := range ctx.TocHeadingItems { + for i, header := range ctx.TocHeadingItems { + // Go deeper: open nested
              elements (wrapped in
            • for valid HTML) for currentLevel < header.HeadingLevel { _, _ = out.Write(indent) - _, _ = htmlutil.HTMLPrint(out, "
                \n") + _, _ = htmlutil.HTMLPrint(out, "
                • \n") indent = append(indent, ' ', ' ') currentLevel++ } + // Go shallower: close nested
              • elements for currentLevel > header.HeadingLevel { indent = indent[:len(indent)-2] _, _ = out.Write(indent) - _, _ = htmlutil.HTMLPrint(out, "
              \n") + _, _ = htmlutil.HTMLPrint(out, "
            \n") currentLevel-- } _, _ = out.Write(indent) - _, _ = htmlutil.HTMLPrintf(out, "
          • %s
          • \n", header.AnchorID, header.InnerText) + _, _ = htmlutil.HTMLPrintf(out, "
          • %s", header.AnchorID, header.InnerText) + // Check if next item is at a deeper level - if so, don't close the
          • yet + nextIsDeeper := i+1 < len(ctx.TocHeadingItems) && ctx.TocHeadingItems[i+1].HeadingLevel > header.HeadingLevel + if !nextIsDeeper { + _, _ = htmlutil.HTMLPrint(out, "
          • ") + } + _, _ = htmlutil.HTMLPrint(out, "\n") } + // Close any remaining nested levels for currentLevel > baseLevel { indent = indent[:len(indent)-2] _, _ = out.Write(indent) - _, _ = htmlutil.HTMLPrint(out, "
          \n") + _, _ = htmlutil.HTMLPrint(out, "
        \n") currentLevel-- } _, _ = htmlutil.HTMLPrint(out, "
      \n
    \n") diff --git a/modules/markup/html_toc_test.go b/modules/markup/html_toc_test.go index e93cfc9346..ab363878a4 100644 --- a/modules/markup/html_toc_test.go +++ b/modules/markup/html_toc_test.go @@ -38,16 +38,16 @@ include_toc: true result = re.ReplaceAllString(result, "\n") expected := `
    toc
    From f049ed52cbdbffad072fafa72f43b4db7276b42d Mon Sep 17 00:00:00 2001 From: hamki Date: Mon, 26 Jan 2026 17:27:24 +0800 Subject: [PATCH 30/36] a11y: add ARIA attributes to TOC sidebar for accessibility Add role="navigation" and aria-label to the file view sidebar panel to improve accessibility for screen readers and assistive technologies. --- 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 da9ad863a7..8b1e632433 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -143,7 +143,7 @@ {{end}}
{{if .FileSidebarHTML}} - `) } + +func TestRender_TocHeaderExtraction(t *testing.T) { + // Test single level headers + t.Run("SingleLevel", func(t *testing.T) { + input := `* Header 1 +* Header 2 +* Header 3 +` + ctx := markup.NewTestRenderContext() + _, err := orgmode.RenderString(ctx, input) + assert.NoError(t, err) + assert.Len(t, ctx.TocHeadingItems, 3) + assert.Equal(t, "Header 1", ctx.TocHeadingItems[0].InnerText) + assert.Equal(t, "Header 2", ctx.TocHeadingItems[1].InnerText) + assert.Equal(t, "Header 3", ctx.TocHeadingItems[2].InnerText) + for _, item := range ctx.TocHeadingItems { + assert.Equal(t, 1, item.HeadingLevel) + } + }) + + // Test nested headers + t.Run("NestedHeaders", func(t *testing.T) { + input := `* Level 1 +** Level 2 +*** Level 3 +** Another Level 2 +` + ctx := markup.NewTestRenderContext() + _, err := orgmode.RenderString(ctx, input) + assert.NoError(t, err) + assert.Len(t, ctx.TocHeadingItems, 4) + assert.Equal(t, 1, ctx.TocHeadingItems[0].HeadingLevel) + assert.Equal(t, 2, ctx.TocHeadingItems[1].HeadingLevel) + assert.Equal(t, 3, ctx.TocHeadingItems[2].HeadingLevel) + assert.Equal(t, 2, ctx.TocHeadingItems[3].HeadingLevel) + }) + + // Test headers with special characters + t.Run("SpecialCharacters", func(t *testing.T) { + input := `* Header with & "characters" +* Another header +` + ctx := markup.NewTestRenderContext() + _, err := orgmode.RenderString(ctx, input) + assert.NoError(t, err) + assert.Len(t, ctx.TocHeadingItems, 2) + assert.Equal(t, `Header with & "characters"`, ctx.TocHeadingItems[0].InnerText) + }) + + // Test empty document + t.Run("EmptyDocument", func(t *testing.T) { + input := `Just some text without headers.` + ctx := markup.NewTestRenderContext() + _, err := orgmode.RenderString(ctx, input) + assert.NoError(t, err) + assert.Empty(t, ctx.TocHeadingItems) + }) + + // Test that TocShowInSection is set correctly + t.Run("TocShowInSection", func(t *testing.T) { + input := `* Header 1` + ctx := markup.NewTestRenderContext() + _, err := orgmode.RenderString(ctx, input) + assert.NoError(t, err) + assert.Equal(t, markup.TocShowInSidebar, ctx.TocShowInSection) + }) +} From 8a6a348771ab35caa1166174b7cfc3089d7fe792 Mon Sep 17 00:00:00 2001 From: hamki Date: Mon, 26 Jan 2026 17:28:20 +0800 Subject: [PATCH 32/36] perf: optimize ResizeObserver to only observe necessary element Change ResizeObserver from observing document.body (too broad) to only observing the segment element. This reduces unnecessary position update callbacks when unrelated parts of the page resize. --- web_src/js/features/file-view.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 064d8b4852..0747bc429d 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -166,11 +166,14 @@ function initSidebarToggle(elFileView: HTMLElement): void { hideSidebar(); } - // Update sidebar position on resize/scroll to keep aligned with file content + // Update sidebar position on resize to keep aligned with file content + // Only observe the segment element to avoid unnecessary updates from unrelated page changes const resizeObserver = new ResizeObserver(() => { updatePosition(); }); - resizeObserver.observe(document.body); + if (segment) { + resizeObserver.observe(segment); + } // Update position using IntersectionObserver instead of scroll event // This provides better performance and avoids scroll event issues From 1004c2250f102e4d5542f018a4748fa9638cdd3d Mon Sep 17 00:00:00 2001 From: hamki Date: Tue, 3 Feb 2026 06:24:21 +0800 Subject: [PATCH 33/36] Refactor TOC rendering and improve HTML structure - Updated the rendering of table of contents (TOC) items to ensure proper closing of list items in HTML. - Removed redundant header extraction logic from orgmode, enabling TOC extraction during post-processing. - Simplified sidebar visibility state management by utilizing user settings instead of localStorage. This enhances the overall structure and maintainability of the markup rendering process. --- modules/markup/html.go | 10 +--- modules/markup/html_toc_test.go | 4 +- modules/markup/orgmode/orgmode.go | 38 ++------------- modules/markup/orgmode/orgmode_test.go | 66 -------------------------- web_src/js/features/file-view.ts | 23 ++++----- 5 files changed, 15 insertions(+), 126 deletions(-) diff --git a/modules/markup/html.go b/modules/markup/html.go index c2339838e5..acd2f18471 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -254,7 +254,7 @@ func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]strin currentLevel := baseLevel indent := []byte{' ', ' '} _, _ = htmlutil.HTMLPrint(out, "
    \n") - for i, header := range ctx.TocHeadingItems { + for _, header := range ctx.TocHeadingItems { // Go deeper: open nested
      elements (wrapped in
    • for valid HTML) for currentLevel < header.HeadingLevel { _, _ = out.Write(indent) @@ -270,13 +270,7 @@ func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]strin currentLevel-- } _, _ = out.Write(indent) - _, _ = htmlutil.HTMLPrintf(out, "
    • %s", header.AnchorID, header.InnerText) - // Check if next item is at a deeper level - if so, don't close the
    • yet - nextIsDeeper := i+1 < len(ctx.TocHeadingItems) && ctx.TocHeadingItems[i+1].HeadingLevel > header.HeadingLevel - if !nextIsDeeper { - _, _ = htmlutil.HTMLPrint(out, "
    • ") - } - _, _ = htmlutil.HTMLPrint(out, "\n") + _, _ = htmlutil.HTMLPrintf(out, "
    • %s
    • \n", header.AnchorID, header.InnerText) } // Close any remaining nested levels for currentLevel > baseLevel { diff --git a/modules/markup/html_toc_test.go b/modules/markup/html_toc_test.go index ab363878a4..1493d50e86 100644 --- a/modules/markup/html_toc_test.go +++ b/modules/markup/html_toc_test.go @@ -38,9 +38,9 @@ include_toc: true result = re.ReplaceAllString(result, "\n") expected := `
      toc
        -
      • tag link and Bold +
      • tag link and Bold
        • -
        • code block <a> +
        • code block <a>
            • markdown bold
            • diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index d84c0fb861..2873045595 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -70,15 +70,14 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter} htmlWriter.ExtendingWriter = 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.TocHeadingItems = extractTocHeadingItems(doc.Outline) - if len(ctx.TocHeadingItems) > 0 { + // Enable TOC extraction in post-process step for orgmode files + // The actual TOC items will be extracted from HTML headings during post-processing + if ctx.RenderOptions.EnableHeadingIDGeneration { ctx.TocShowInSection = markup.TocShowInSidebar } @@ -90,37 +89,6 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error return err } -// extractTocHeadingItems recursively extracts headers from org document outline -func extractTocHeadingItems(outline org.Outline) []*markup.TocHeadingItem { - var items []*markup.TocHeadingItem - collectTocHeadingItems(outline.Section, &items) - return items -} - -// collectTocHeadingItems recursively collects headers from sections -func collectTocHeadingItems(section *org.Section, items *[]*markup.TocHeadingItem) { - 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...) - *items = append(*items, &markup.TocHeadingItem{ - HeadingLevel: h.Lvl, - InnerText: titleText, - AnchorID: h.ID(), - }) - } - - // Process child sections - for _, child := range section.Children { - collectTocHeadingItems(child, items) - } -} - // 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/orgmode/orgmode_test.go b/modules/markup/orgmode/orgmode_test.go index 1b66ac4d24..e97804fdf2 100644 --- a/modules/markup/orgmode/orgmode_test.go +++ b/modules/markup/orgmode/orgmode_test.go @@ -105,69 +105,3 @@ int a;
`) } -func TestRender_TocHeaderExtraction(t *testing.T) { - // Test single level headers - t.Run("SingleLevel", func(t *testing.T) { - input := `* Header 1 -* Header 2 -* Header 3 -` - ctx := markup.NewTestRenderContext() - _, err := orgmode.RenderString(ctx, input) - assert.NoError(t, err) - assert.Len(t, ctx.TocHeadingItems, 3) - assert.Equal(t, "Header 1", ctx.TocHeadingItems[0].InnerText) - assert.Equal(t, "Header 2", ctx.TocHeadingItems[1].InnerText) - assert.Equal(t, "Header 3", ctx.TocHeadingItems[2].InnerText) - for _, item := range ctx.TocHeadingItems { - assert.Equal(t, 1, item.HeadingLevel) - } - }) - - // Test nested headers - t.Run("NestedHeaders", func(t *testing.T) { - input := `* Level 1 -** Level 2 -*** Level 3 -** Another Level 2 -` - ctx := markup.NewTestRenderContext() - _, err := orgmode.RenderString(ctx, input) - assert.NoError(t, err) - assert.Len(t, ctx.TocHeadingItems, 4) - assert.Equal(t, 1, ctx.TocHeadingItems[0].HeadingLevel) - assert.Equal(t, 2, ctx.TocHeadingItems[1].HeadingLevel) - assert.Equal(t, 3, ctx.TocHeadingItems[2].HeadingLevel) - assert.Equal(t, 2, ctx.TocHeadingItems[3].HeadingLevel) - }) - - // Test headers with special characters - t.Run("SpecialCharacters", func(t *testing.T) { - input := `* Header with & "characters" -* Another header -` - ctx := markup.NewTestRenderContext() - _, err := orgmode.RenderString(ctx, input) - assert.NoError(t, err) - assert.Len(t, ctx.TocHeadingItems, 2) - assert.Equal(t, `Header with & "characters"`, ctx.TocHeadingItems[0].InnerText) - }) - - // Test empty document - t.Run("EmptyDocument", func(t *testing.T) { - input := `Just some text without headers.` - ctx := markup.NewTestRenderContext() - _, err := orgmode.RenderString(ctx, input) - assert.NoError(t, err) - assert.Empty(t, ctx.TocHeadingItems) - }) - - // Test that TocShowInSection is set correctly - t.Run("TocShowInSection", func(t *testing.T) { - input := `* Header 1` - ctx := markup.NewTestRenderContext() - _, err := orgmode.RenderString(ctx, input) - assert.NoError(t, err) - assert.Equal(t, markup.TocShowInSidebar, ctx.TocShowInSection) - }) -} diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 0747bc429d..9e41da791a 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -2,6 +2,7 @@ import type {FileRenderPlugin} from '../render/plugin.ts'; import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; +import {localUserSettings} from '../modules/user-settings.ts'; import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; import {basename} from '../utils.ts'; @@ -155,9 +156,8 @@ function initSidebarToggle(elFileView: HTMLElement): void { } }; - // Restore saved state from localStorage (default to hidden) - const savedState = localStorage.getItem('file-view-sidebar-visible'); - const isVisible = savedState === 'true'; // default to hidden + // Restore saved state (default to hidden) + const isVisible = localUserSettings.getBoolean('file-view-sidebar-visible'); // Apply initial state if (isVisible) { @@ -168,6 +168,9 @@ function initSidebarToggle(elFileView: HTMLElement): void { // Update sidebar position on resize to keep aligned with file content // Only observe the segment element to avoid unnecessary updates from unrelated page changes + const fileHeader = elFileView.querySelector('.file-header'); + const segment = elFileView.querySelector('.ui.bottom.segment'); + const resizeObserver = new ResizeObserver(() => { updatePosition(); }); @@ -177,8 +180,6 @@ function initSidebarToggle(elFileView: HTMLElement): void { // 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 a small set of thresholds to get periodic position updates during scroll const thresholds = [0, 0.25, 0.5, 0.75, 1]; @@ -193,18 +194,10 @@ function initSidebarToggle(elFileView: HTMLElement): void { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); if (isCurrentlyVisible) { hideSidebar(); - try { - localStorage.setItem('file-view-sidebar-visible', 'false'); - } catch { - // Ignore storage errors (e.g., disabled or full localStorage) - } + localUserSettings.setBoolean('file-view-sidebar-visible', false); } else { showSidebar(); - try { - localStorage.setItem('file-view-sidebar-visible', 'true'); - } catch { - // Ignore storage errors (e.g., disabled or full localStorage) - } + localUserSettings.setBoolean('file-view-sidebar-visible', true); } }); } From 2998a816633c6e0f52e083f2a4e263050259b286 Mon Sep 17 00:00:00 2001 From: hamki Date: Tue, 3 Feb 2026 06:58:46 +0800 Subject: [PATCH 34/36] fix(file-view): use scroll event for smooth sidebar tracking and prevent memory leak --- web_src/js/features/file-view.ts | 33 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 9e41da791a..1f5e378663 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -168,7 +168,6 @@ function initSidebarToggle(elFileView: HTMLElement): void { // Update sidebar position on resize to keep aligned with file content // Only observe the segment element to avoid unnecessary updates from unrelated page changes - const fileHeader = elFileView.querySelector('.file-header'); const segment = elFileView.querySelector('.ui.bottom.segment'); const resizeObserver = new ResizeObserver(() => { @@ -178,17 +177,29 @@ function initSidebarToggle(elFileView: HTMLElement): void { resizeObserver.observe(segment); } - // Update position using IntersectionObserver instead of scroll event - // This provides better performance and avoids scroll event issues - if (fileHeader && segment) { - // Use a small set of thresholds to get periodic position updates during scroll - const thresholds = [0, 0.25, 0.5, 0.75, 1]; - const positionObserver = new IntersectionObserver(() => { + // Update position on scroll using requestAnimationFrame for smooth updates + // Note: IntersectionObserver was tried but it only triggers at threshold crossings, + // not continuously during scroll, causing the sidebar to "jump" or get stuck + // when scrolling between thresholds. scroll event + rAF provides smooth tracking. + let scrollRafId: number | null = null; + const scrollHandler = () => { + // Auto-cleanup: if element is removed from DOM, remove the listener and disconnect observer + if (!document.contains(elFileView)) { + window.removeEventListener('scroll', scrollHandler); + resizeObserver.disconnect(); + if (scrollRafId !== null) { + cancelAnimationFrame(scrollRafId); + scrollRafId = null; + } + return; + } + if (scrollRafId !== null) return; // Already scheduled + scrollRafId = requestAnimationFrame(() => { updatePosition(); - }, {threshold: thresholds}); - positionObserver.observe(segment); - positionObserver.observe(fileHeader); - } + scrollRafId = null; + }); + }; + window.addEventListener('scroll', scrollHandler, {passive: true}); toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); From 61933a96684855c94c87f102e412d4a44ce6df66 Mon Sep 17 00:00:00 2001 From: hamki Date: Tue, 3 Feb 2026 07:12:57 +0800 Subject: [PATCH 35/36] orgmode_test.go: Remove trailing empty line at end of file to pass make fmt check. file-view.ts: Revert to IntersectionObserver with 101 thresholds (0%-100%) instead of scroll event to satisfy github/prefer-observers lint rule, while maintaining fine-grained position updates. --- modules/markup/orgmode/orgmode_test.go | 1 - web_src/js/features/file-view.ts | 35 ++++++++++---------------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/modules/markup/orgmode/orgmode_test.go b/modules/markup/orgmode/orgmode_test.go index e97804fdf2..ebda2271f2 100644 --- a/modules/markup/orgmode/orgmode_test.go +++ b/modules/markup/orgmode/orgmode_test.go @@ -104,4 +104,3 @@ int a;
int a;
`) } - diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 1f5e378663..a01a4b2245 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -168,6 +168,7 @@ function initSidebarToggle(elFileView: HTMLElement): void { // Update sidebar position on resize to keep aligned with file content // Only observe the segment element to avoid unnecessary updates from unrelated page changes + const fileHeader = elFileView.querySelector('.file-header'); const segment = elFileView.querySelector('.ui.bottom.segment'); const resizeObserver = new ResizeObserver(() => { @@ -177,29 +178,19 @@ function initSidebarToggle(elFileView: HTMLElement): void { resizeObserver.observe(segment); } - // Update position on scroll using requestAnimationFrame for smooth updates - // Note: IntersectionObserver was tried but it only triggers at threshold crossings, - // not continuously during scroll, causing the sidebar to "jump" or get stuck - // when scrolling between thresholds. scroll event + rAF provides smooth tracking. - let scrollRafId: number | null = null; - const scrollHandler = () => { - // Auto-cleanup: if element is removed from DOM, remove the listener and disconnect observer - if (!document.contains(elFileView)) { - window.removeEventListener('scroll', scrollHandler); - resizeObserver.disconnect(); - if (scrollRafId !== null) { - cancelAnimationFrame(scrollRafId); - scrollRafId = null; + // Update position using IntersectionObserver for scroll tracking + // Use 101 thresholds (0%, 1%, 2%, ..., 100%) for fine-grained position updates + if (fileHeader && segment) { + const thresholds = Array.from({length: 101}, (_, i) => i / 100); + const positionObserver = new IntersectionObserver((entries) => { + // Only update if any entry is intersecting (visible) + if (entries.some((e) => e.isIntersecting)) { + updatePosition(); } - return; - } - if (scrollRafId !== null) return; // Already scheduled - scrollRafId = requestAnimationFrame(() => { - updatePosition(); - scrollRafId = null; - }); - }; - window.addEventListener('scroll', scrollHandler, {passive: true}); + }, {threshold: thresholds}); + positionObserver.observe(segment); + positionObserver.observe(fileHeader); + } toggleBtn.addEventListener('click', () => { const isCurrentlyVisible = !sidebar.classList.contains('sidebar-panel-hidden'); From 81f930e6715f94b3687682337f7b4edf750ea59b Mon Sep 17 00:00:00 2001 From: hamki Date: Tue, 3 Feb 2026 07:17:14 +0800 Subject: [PATCH 36/36] fix(file-view): specify HTMLElement type in closest method for improved type safety --- 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 a01a4b2245..b654104d9f 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -108,7 +108,7 @@ function initSidebarToggle(elFileView: HTMLElement): void { 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'); + const repoViewContent = elFileView.closest('.repo-view-content'); const isFileView = Boolean(repoViewContent); // Helper to update position