1package main
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"html/template"
  7	"net/http"
  8	"strings"
  9
 10	"github.com/alecthomas/chroma/v2"
 11	"github.com/alecthomas/chroma/v2/formatters/html"
 12	"github.com/alecthomas/chroma/v2/lexers"
 13	"github.com/alecthomas/chroma/v2/styles"
 14	"github.com/go-git/go-git/v5"
 15	"github.com/go-git/go-git/v5/plumbing"
 16	"github.com/go-git/go-git/v5/plumbing/filemode"
 17	"github.com/go-git/go-git/v5/plumbing/object"
 18	"github.com/yuin/goldmark"
 19	"github.com/yuin/goldmark/extension"
 20	"github.com/yuin/goldmark/parser"
 21)
 22
 23func highlight(filename, content string) template.HTML {
 24	lexer := lexers.Get(filename)
 25	if lexer == nil {
 26		lexer = lexers.Analyse(content)
 27	}
 28	if lexer == nil {
 29		lexer = lexers.Fallback
 30	}
 31	lexer = chroma.Coalesce(lexer)
 32
 33	style := styles.Get("vs")
 34	if style == nil {
 35		style = styles.Fallback
 36	}
 37
 38	formatter := html.New(html.WithLineNumbers(true), html.WithLinkableLineNumbers(true, "L"))
 39
 40	iterator, err := lexer.Tokenise(nil, content)
 41	if err != nil {
 42		return template.HTML(template.HTMLEscapeString(content))
 43	}
 44
 45	var sb strings.Builder
 46	err = formatter.Format(&sb, style, iterator)
 47	if err != nil {
 48		return template.HTML(template.HTMLEscapeString(content))
 49	}
 50
 51	return template.HTML(sb.String())
 52}
 53
 54func scanMarkers(ctx *RepoContext) ([]Marker, error) {
 55	commit, err := ctx.GitRepo.CommitObject(ctx.Hash)
 56	if err != nil {
 57		return nil, err
 58	}
 59
 60	tree, err := commit.Tree()
 61	if err != nil {
 62		return nil, err
 63	}
 64
 65	var markers []Marker
 66	err = tree.Files().ForEach(func(f *object.File) error {
 67		// Skip vendor directory and binary files
 68		if strings.HasPrefix(f.Name, "vendor/") || strings.Contains(f.Name, "/vendor/") {
 69			return nil
 70		}
 71		if f.Mode.IsFile() && f.Size > 1024*1024 { // Skip files > 1MB
 72			return nil
 73		}
 74
 75		isSource := false
 76		exts := []string{".go", ".js", ".ts", ".py", ".c", ".h", ".cpp", ".hpp", ".css", ".html", ".md", ".txt", ".yaml", ".yml", ".sh"}
 77		for _, ext := range exts {
 78			if strings.HasSuffix(strings.ToLower(f.Name), ext) {
 79				isSource = true
 80				break
 81			}
 82		}
 83
 84		if !isSource {
 85			return nil
 86		}
 87
 88		content, err := f.Contents()
 89		if err != nil {
 90			return nil
 91		}
 92
 93		lines := strings.Split(content, "\n")
 94		for i, line := range lines {
 95			trimmed := strings.TrimSpace(line)
 96			markerType := ""
 97			if strings.Contains(trimmed, "TODO:") {
 98				markerType = "TODO"
 99			} else if strings.Contains(trimmed, "FIXME:") {
100				markerType = "FIXME"
101			}
102
103			if markerType != "" {
104				// Extract content after the marker
105				idx := strings.Index(trimmed, markerType+":")
106				content := strings.TrimSpace(trimmed[idx+len(markerType)+1:])
107				markers = append(markers, Marker{
108					Type:     markerType,
109					FilePath: f.Name,
110					Line:     i + 1,
111					Content:  content,
112				})
113			}
114		}
115		return nil
116	})
117
118	return markers, err
119}
120
121func getRepoContext(w http.ResponseWriter, r *http.Request) (*RepoContext, error) {
122	name := r.PathValue("name")
123	config := GlobalConfig
124
125	var repo *Repository
126	for _, repoItem := range config.Repositories {
127		if repoItem.Name == name {
128			repo = &repoItem
129			break
130		}
131	}
132
133	if repo == nil {
134		return nil, fmt.Errorf("repository not found")
135	}
136
137	gitRepo, err := git.PlainOpen(repo.Path)
138	if err != nil {
139		return nil, fmt.Errorf("error opening repository: %w", err)
140	}
141
142	refName := r.URL.Query().Get("ref")
143	var hash plumbing.Hash
144
145	if refName == "" {
146		head, err := gitRepo.Head()
147		if err != nil {
148			return nil, fmt.Errorf("error getting HEAD: %w", err)
149		}
150		hash = head.Hash()
151		refName = head.Name().Short()
152	} else {
153		h, err := gitRepo.ResolveRevision(plumbing.Revision(refName))
154		if err != nil {
155			return nil, fmt.Errorf("error resolving revision: %w", err)
156		}
157		hash = *h
158	}
159
160	metadataKey := name + ":" + hash.String()
161
162	if val, ok := repoMetadataCache.Load(metadataKey); ok {
163		meta := val.(RepoMetadata)
164		if meta.Version >= CurrentMetadataVersion {
165			return &RepoContext{
166				Repo:        repo,
167				GitRepo:     gitRepo,
168				Config:      config,
169				CurrentRef:  refName,
170				Hash:        hash,
171				Branches:    meta.Branches,
172				Tags:        meta.Tags,
173				ReadmeName:  meta.ReadmeName,
174				LicenseName: meta.LicenseName,
175			}, nil
176		}
177	}
178
179	// Get all branch-like references (local and remote)
180	var branches []string
181	rIter, err := gitRepo.References()
182	seenBranches := make(map[string]bool)
183	if err == nil {
184		rIter.ForEach(func(r *plumbing.Reference) error {
185			if r.Name().IsBranch() || r.Name().IsRemote() {
186				name := r.Name().Short()
187				if name == "origin/HEAD" || seenBranches[name] {
188					return nil
189				}
190				branches = append(branches, name)
191				seenBranches[name] = true
192			}
193			return nil
194		})
195	}
196
197	// Get tags
198	var tags []string
199	tIter, err := gitRepo.Tags()
200	if err == nil {
201		tIter.ForEach(func(r *plumbing.Reference) error {
202			tags = append(tags, r.Name().Short())
203			return nil
204		})
205	}
206
207	// Detect README and LICENSE
208	readmeName := ""
209	licenseName := ""
210	commit, err := gitRepo.CommitObject(hash)
211	if err == nil {
212		tree, err := commit.Tree()
213		if err == nil {
214			for _, entry := range tree.Entries {
215				nameLower := strings.ToLower(entry.Name)
216				if readmeName == "" && (nameLower == "readme.md" || nameLower == "readme.markdown" || nameLower == "readme") {
217					readmeName = entry.Name
218				}
219				if licenseName == "" && (nameLower == "license" || nameLower == "license.md" || nameLower == "license.txt" || nameLower == "licence" || nameLower == "licence.md" || nameLower == "licence.txt" || nameLower == "copying" || nameLower == "copying.md" || nameLower == "copying.txt") {
220					licenseName = entry.Name
221				}
222				if readmeName != "" && licenseName != "" {
223					break
224				}
225			}
226		}
227	}
228
229	// Note: totalCommits will be updated in repoHandler if needed,
230	// for now we store what we have.
231	repoMetadataCache.Store(metadataKey, RepoMetadata{
232		Branches:    branches,
233		Tags:        tags,
234		ReadmeName:  readmeName,
235		LicenseName: licenseName,
236		Version:     CurrentMetadataVersion,
237	})
238	NotifySave()
239
240	return &RepoContext{
241		Repo:        repo,
242		GitRepo:     gitRepo,
243		Config:      config,
244		CurrentRef:  refName,
245		Hash:        hash,
246		Branches:    branches,
247		Tags:        tags,
248		ReadmeName:  readmeName,
249		LicenseName: licenseName,
250	}, nil
251}
252
253func formatMode(m filemode.FileMode) string {
254	if m == filemode.Empty {
255		return "----------"
256	}
257	s := m.String()
258	switch m {
259	case filemode.Regular:
260		return "-rw-r--r--"
261	case filemode.Executable:
262		return "-rwxr-xr-x"
263	case filemode.Dir:
264		return "drwxr-xr-x"
265	case filemode.Symlink:
266		return "lrwxrwxrwx"
267	default:
268		return s
269	}
270}
271
272func renderMarkdown(content string) template.HTML {
273	md := goldmark.New(
274		goldmark.WithExtensions(
275			extension.GFM,
276			extension.Linkify,
277			extension.Table,
278			AlertExtension,
279		),
280		goldmark.WithParserOptions(
281			parser.WithAutoHeadingID(),
282		),
283	)
284	var buf bytes.Buffer
285	if err := md.Convert([]byte(content), &buf); err != nil {
286		return template.HTML(content)
287	}
288	return template.HTML(buf.String())
289}