diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 8e85cc3278..80148ecdf9 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -35,6 +35,7 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" @@ -179,6 +180,18 @@ func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input i return escaped, output, err } +func renderSidebarTocHTML(rctx *markup.RenderContext) template.HTML { + if rctx.SidebarTocNode == nil { + return "" + } + sb := &strings.Builder{} + if err := markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode); err != nil { + log.Error("Failed to render sidebar TOC: %v", err) + return "" + } + return templates.SanitizeHTML(sb.String()) +} + func checkHomeCodeViewable(ctx *context.Context) { if ctx.Repo.HasUnits() { if ctx.Repo.Repository.IsBeingCreated() { diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 167cd5f927..d505ce982b 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -92,6 +92,8 @@ func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedTy ctx.ServerError("Render", err) return true } + + ctx.Data["FileTocHTML"] = renderSidebarTocHTML(rctx) return true } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index f1fa5732f0..8999339bab 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -206,6 +206,8 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) delete(ctx.Data, "IsMarkup") } + + ctx.Data["ReadmeTocHTML"] = renderSidebarTocHTML(rctx) } if ctx.Data["IsMarkup"] != true { diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 809b1e9677..f15b932b2b 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -35,6 +35,11 @@ {{end}}
+ {{if or .ReadmeTocHTML .FileTocHTML}} + + {{end}} {{/* this componment is also controlled by frontend plugin renders */}}
{{if .IsRepresentableAsText}} @@ -93,50 +98,57 @@ {{if not .IsMarkup}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{end}} -
- {{if .IsFileTooLarge}} - {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} - {{else if not .FileSize}} - {{template "shared/fileisempty"}} - {{else if .IsMarkup}} - {{.FileContent}} - {{else if .IsPlainText}} -
{{if .FileContent}}{{.FileContent}}{{end}}
- {{else if .FileContent}} - - - {{range $idx, $code := .FileContent}} - {{$line := Eval $idx "+" 1}} - - - {{if $.EscapeStatus.Escaped}} - - {{end}} - - - {{end}} - -
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
- {{else}} -
- {{if .IsImageFile}} - {{$.RawFileLink}} - {{else if .IsVideoFile}} - - {{else if .IsAudioFile}} - - {{else}} -
-
- {{ctx.Locale.Tr "repo.file_view_raw"}} +
+
+ {{if .IsFileTooLarge}} + {{template "shared/filetoolarge" dict "RawFileLink" .RawFileLink}} + {{else if not .FileSize}} + {{template "shared/fileisempty"}} + {{else if .IsMarkup}} + {{.FileContent}} + {{else if .IsPlainText}} +
{{if .FileContent}}{{.FileContent}}{{end}}
+ {{else if .FileContent}} + + + {{range $idx, $code := .FileContent}} + {{$line := Eval $idx "+" 1}} + + + {{if $.EscapeStatus.Escaped}} + + {{end}} + + + {{end}} + +
{{if (index $.LineEscapeStatus $idx).Escaped}}{{end}}{{$code}}
+ {{else}} +
+ {{if .IsImageFile}} + {{$.RawFileLink}} + {{else if .IsVideoFile}} + + {{else if .IsAudioFile}} + + {{else}} + -
- {{end}} -
+ {{end}} +
+ {{end}} +
+ {{if or .ReadmeTocHTML .FileTocHTML}} +
+ {{or .ReadmeTocHTML .FileTocHTML}} +
{{end}}
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 60bf1f17f9..c43afe826c 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -105,3 +105,92 @@ padding: 0 0.5em; /* make the UI look better for narrow (mobile) view */ text-decoration: none; } + +/* File view with TOC sidebar */ +.file-view-container { + position: relative; +} + +.file-view-container .file-view { + width: 100%; +} + +/* TOC as a floating sidebar panel */ +.file-view-toc { + position: fixed; + top: 60px; + right: 0; + width: 280px; + max-height: calc(100vh - 80px); + overflow-y: auto; + padding: 1rem; + background: var(--color-body); + border: 1px solid var(--color-secondary); + border-right: none; + border-radius: var(--border-radius) 0 0 var(--border-radius); + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); + z-index: 100; + transform: translateX(0); + transition: transform 0.25s ease-in-out; +} + +/* Hidden state - using custom class to avoid Tailwind conflicts */ +.file-view-toc.toc-panel-hidden { + transform: translateX(100%); +} + +.file-view-toc details { + font-size: 13px; +} + +.file-view-toc summary { + font-weight: 600; + cursor: pointer; + padding: 8px 0; + color: var(--color-text); + border-bottom: 1px solid var(--color-secondary); + margin-bottom: 8px; +} + +.file-view-toc ul { + margin: 0; + list-style: none; + padding: 5px 0 5px 1em; +} + +.file-view-toc ul ul { + border-left: 1px dashed var(--color-secondary); +} + +.file-view-toc a { + display: block; + padding: 6px 10px; + color: var(--color-text); + text-decoration: none; + font-size: 13px; + line-height: 1.4; + border-radius: var(--border-radius); +} + +.file-view-toc a:hover { + color: var(--color-primary); + background: var(--color-hover); +} + +/* TOC toggle button active state - when TOC is visible */ +#toggle-toc-btn.active { + color: var(--color-primary); + background: var(--color-hover); +} + +/* Hide TOC sidebar on small screens */ +@media (max-width: 768px) { + .file-view-toc { + width: 100%; + max-width: 300px; + } + + #toggle-toc-btn { + display: none; + } +} diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa26..74e3d042b2 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -59,9 +59,47 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str } } +function initTocToggle(elFileView: HTMLElement): void { + const toggleBtn = elFileView.querySelector('#toggle-toc-btn'); + const tocSidebar = elFileView.querySelector('.file-view-toc'); + if (!toggleBtn || !tocSidebar) return; + + // Restore saved state from localStorage (default to hidden) + const savedState = localStorage.getItem('file-view-toc-visible'); + const isVisible = savedState === 'true'; // default to hidden + + // Apply initial state + if (isVisible) { + tocSidebar.classList.remove('toc-panel-hidden'); + toggleBtn.classList.add('active'); + } else { + tocSidebar.classList.add('toc-panel-hidden'); + toggleBtn.classList.remove('active'); + } + + toggleBtn.addEventListener('click', () => { + const isCurrentlyVisible = !tocSidebar.classList.contains('toc-panel-hidden'); + if (isCurrentlyVisible) { + // Hide TOC + tocSidebar.classList.add('toc-panel-hidden'); + toggleBtn.classList.remove('active'); + localStorage.setItem('file-view-toc-visible', 'false'); + } else { + // Show TOC + tocSidebar.classList.remove('toc-panel-hidden'); + toggleBtn.classList.add('active'); + localStorage.setItem('file-view-toc-visible', 'true'); + } + }); +} + export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { initPluginsOnce(); + + // Initialize TOC toggle functionality + initTocToggle(elFileView); + const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not