Commit 95ae423

mo khan <mo@mokhan.ca>
2026-01-31 02:21:07
refactor: clean up some duplication
1 parent 8547138
Changed files (7)
internal/generator/blob.go
@@ -2,25 +2,30 @@ package generator
 
 import (
 	"bytes"
-	"context"
 	"fmt"
 	"html/template"
 	"os"
 	"path/filepath"
-	"runtime"
 	"strings"
-	"sync"
 
+	"github.com/alecthomas/chroma/v2"
 	"github.com/alecthomas/chroma/v2/formatters/html"
 	"github.com/alecthomas/chroma/v2/lexers"
 	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/yuin/goldmark"
 
 	"mokhan.ca/antonmedv/gitmal/internal/git"
 	"mokhan.ca/antonmedv/gitmal/internal/links"
-	"mokhan.ca/antonmedv/gitmal/internal/progress_bar"
+	"mokhan.ca/antonmedv/gitmal/internal/pool"
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
+type blobWorker struct {
+	md        goldmark.Markdown
+	formatter *html.Formatter
+	style     *chroma.Style
+}
+
 func GenerateBlobs(files []git.Blob, params Params) error {
 	formatterOptions := []html.Option{
 		html.WithLineNumbers(true),
@@ -30,220 +35,134 @@ func GenerateBlobs(files []git.Blob, params Params) error {
 	}
 
 	css := cssSyntax(formatterOptions)
-
 	dirsSet := links.BuildDirSet(files)
 	filesSet := links.BuildFileSet(files)
 
-	// Bounded worker pool
-	workers := runtime.NumCPU()
-	if workers < 1 {
-		workers = 1
-	}
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	jobs := make(chan git.Blob)
-	errCh := make(chan error, 1)
-	var wg sync.WaitGroup
-
-	p := progress_bar.NewProgressBar("blobs for "+params.Ref.String(), len(files))
-
-	workerFn := func() {
-		defer wg.Done()
-
-		md := createMarkdown()
-		formatter := html.New(formatterOptions...)
-		style := styles.Get("github")
-
-		check := func(err error) bool {
+	return pool.RunWithInit(files,
+		func() *blobWorker {
+			return &blobWorker{
+				md:        createMarkdown(),
+				formatter: html.New(formatterOptions...),
+				style:     styles.Get("github"),
+			}
+		},
+		func(w *blobWorker, blob git.Blob) error {
+			var content string
+			data, isBin, err := git.BlobContent(params.Ref, blob.Path, params.RepoDir)
 			if err != nil {
-				select {
-				case errCh <- err:
-					cancel()
-				default:
-				}
-				return true
+				return err
 			}
-			return false
-		}
-
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case blob, ok := <-jobs:
-				if !ok {
-					return
-				}
-				func() {
-					var content string
-					data, isBin, err := git.BlobContent(params.Ref, blob.Path, params.RepoDir)
-					if check(err) {
-						return
-					}
-
-					isImg := isImage(blob.Path)
-					if !isBin {
-						content = string(data)
-					}
-
-					outPath := filepath.Join(params.OutputDir, "blob", params.Ref.DirName(), blob.Path) + ".html"
-					if err := os.MkdirAll(filepath.Dir(outPath), 0o755); check(err) {
-						return
-					}
-
-					f, err := os.Create(outPath)
-					if check(err) {
-						return
-					}
-					defer func() {
-						_ = f.Close()
-					}()
-
-					depth := 0
-					if strings.Contains(blob.Path, "/") {
-						depth = len(strings.Split(blob.Path, "/")) - 1
-					}
-					rootHref := strings.Repeat("../", depth+2)
-
-					if isMarkdown(blob.Path) {
-						var b bytes.Buffer
-						if err := md.Convert([]byte(content), &b); check(err) {
-							return
-						}
-
-						contentHTML := links.Resolve(
-							b.String(),
-							blob.Path,
-							rootHref,
-							params.Ref.DirName(),
-							dirsSet,
-							filesSet,
-						)
-
-						err = templates.MarkdownTemplate.ExecuteTemplate(f, "layout.gohtml", templates.MarkdownParams{
-							LayoutParams: templates.LayoutParams{
-								Title:         fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
-								CSSMarkdown:   cssMarkdown(),
-								Name:          params.Name,
-								RootHref:      rootHref,
-								CurrentRefDir: params.Ref.DirName(),
-								Selected:      "code",
-							},
-							HeaderParams: templates.HeaderParams{
-								Ref:         params.Ref,
-								Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
-							},
-							Blob:    blob,
-							Content: template.HTML(contentHTML),
-						})
-						if check(err) {
-							return
-						}
 
-					} else {
-
-						var contentHTML template.HTML
-						if !isBin {
-							var b bytes.Buffer
-							lx := lexers.Match(blob.Path)
-							if lx == nil {
-								lx = lexers.Fallback
-							}
-							iterator, _ := lx.Tokenise(nil, content)
-							if err := formatter.Format(&b, style, iterator); check(err) {
-								return
-							}
-							contentHTML = template.HTML(b.String())
-
-						} else if isImg {
-
-							rawPath := filepath.Join(params.OutputDir, "raw", params.Ref.DirName(), blob.Path)
-							if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); check(err) {
-								return
-							}
+			isImg := isImage(blob.Path)
+			if !isBin {
+				content = string(data)
+			}
 
-							rf, err := os.Create(rawPath)
-							if check(err) {
-								return
-							}
-							defer func() {
-								_ = rf.Close()
-							}()
+			outPath := filepath.Join(params.OutputDir, "blob", params.Ref.DirName(), blob.Path) + ".html"
+			if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
+				return err
+			}
 
-							if _, err := rf.Write(data); check(err) {
-								return
-							}
+			f, err := os.Create(outPath)
+			if err != nil {
+				return err
+			}
+			defer f.Close()
 
-							relativeRawPath := filepath.Join(rootHref, "raw", params.Ref.DirName(), blob.Path)
-							contentHTML = template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" />`, relativeRawPath, blob.FileName))
-						}
+			depth := 0
+			if strings.Contains(blob.Path, "/") {
+				depth = len(strings.Split(blob.Path, "/")) - 1
+			}
+			rootHref := strings.Repeat("../", depth+2)
 
-						err = templates.BlobTemplate.ExecuteTemplate(f, "layout.gohtml", templates.BlobParams{
-							LayoutParams: templates.LayoutParams{
-								Title:         fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
-								Name:          params.Name,
-								RootHref:      rootHref,
-								CurrentRefDir: params.Ref.DirName(),
-								Selected:      "code",
-							},
-							HeaderParams: templates.HeaderParams{
-								Ref:         params.Ref,
-								Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
-							},
-							CSS:      css,
-							Blob:     blob,
-							IsBinary: isBin,
-							IsImage:  isImg,
-							Content:  contentHTML,
-						})
-						if check(err) {
-							return
-						}
-					}
-				}()
+			if isMarkdown(blob.Path) {
+				var b bytes.Buffer
+				if err := w.md.Convert([]byte(content), &b); err != nil {
+					return err
+				}
 
-				p.Inc()
+				contentHTML := links.Resolve(
+					b.String(),
+					blob.Path,
+					rootHref,
+					params.Ref.DirName(),
+					dirsSet,
+					filesSet,
+				)
+
+				return templates.MarkdownTemplate.ExecuteTemplate(f, "layout.gohtml", templates.MarkdownParams{
+					LayoutParams: templates.LayoutParams{
+						Title:         fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
+						CSSMarkdown:   cssMarkdown(),
+						Name:          params.Name,
+						RootHref:      rootHref,
+						CurrentRefDir: params.Ref.DirName(),
+						Selected:      "code",
+					},
+					HeaderParams: templates.HeaderParams{
+						Ref:         params.Ref,
+						Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
+					},
+					Blob:    blob,
+					Content: template.HTML(contentHTML),
+				})
 			}
-		}
-	}
 
-	// Start workers
-	wg.Add(workers)
-	for i := 0; i < workers; i++ {
-		go workerFn()
-	}
+			var contentHTML template.HTML
+			if !isBin {
+				var b bytes.Buffer
+				lx := lexers.Match(blob.Path)
+				if lx == nil {
+					lx = lexers.Fallback
+				}
+				iterator, _ := lx.Tokenise(nil, content)
+				if err := w.formatter.Format(&b, w.style, iterator); err != nil {
+					return err
+				}
+				contentHTML = template.HTML(b.String())
+			} else if isImg {
+				rawPath := filepath.Join(params.OutputDir, "raw", params.Ref.DirName(), blob.Path)
+				if err := os.MkdirAll(filepath.Dir(rawPath), 0o755); err != nil {
+					return err
+				}
 
-	// Feed jobs
-	go func() {
-		defer close(jobs)
-		for _, b := range files {
-			select {
-			case <-ctx.Done():
-				return
-			case jobs <- b:
-			}
-		}
-	}()
+				rf, err := os.Create(rawPath)
+				if err != nil {
+					return err
+				}
 
-	// Wait for workers
-	doneCh := make(chan struct{})
-	go func() {
-		wg.Wait()
-		close(doneCh)
-	}()
+				if _, err := rf.Write(data); err != nil {
+					rf.Close()
+					return err
+				}
+				if err := rf.Close(); err != nil {
+					return err
+				}
 
-	var runErr error
-	select {
-	case runErr = <-errCh:
-		// error occurred, wait workers to finish
-		<-doneCh
-	case <-doneCh:
-	}
+				relativeRawPath := filepath.Join(rootHref, "raw", params.Ref.DirName(), blob.Path)
+				contentHTML = template.HTML(fmt.Sprintf(`<img src="%s" alt="%s" />`, relativeRawPath, blob.FileName))
+			}
 
-	p.Done()
-	return runErr
+			return templates.BlobTemplate.ExecuteTemplate(f, "layout.gohtml", templates.BlobParams{
+				LayoutParams: templates.LayoutParams{
+					Title:         fmt.Sprintf("%s/%s at %s", params.Name, blob.Path, params.Ref),
+					Name:          params.Name,
+					RootHref:      rootHref,
+					CurrentRefDir: params.Ref.DirName(),
+					Selected:      "code",
+				},
+				HeaderParams: templates.HeaderParams{
+					Ref:         params.Ref,
+					Breadcrumbs: breadcrumbs(params.Name, blob.Path, true),
+				},
+				CSS:      css,
+				Blob:     blob,
+				IsBinary: isBin,
+				IsImage:  isImg,
+				Content:  contentHTML,
+			})
+		},
+	)
 }
 
 func cssSyntax(opts []html.Option) template.CSS {
internal/generator/commit.go
@@ -2,15 +2,12 @@ package generator
 
 import (
 	"bytes"
-	"context"
 	"fmt"
 	"html/template"
 	"os"
 	"path/filepath"
-	"runtime"
 	"sort"
 	"strings"
-	"sync"
 
 	"github.com/alecthomas/chroma/v2"
 	"github.com/alecthomas/chroma/v2/formatters/html"
@@ -19,7 +16,7 @@ import (
 
 	"mokhan.ca/antonmedv/gitmal/internal/git"
 	"mokhan.ca/antonmedv/gitmal/internal/gitdiff"
-	"mokhan.ca/antonmedv/gitmal/internal/progress_bar"
+	"mokhan.ca/antonmedv/gitmal/internal/pool"
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
@@ -34,75 +31,9 @@ func GenerateCommits(commits map[string]git.Commit, params Params) error {
 		list = append(list, c)
 	}
 
-	workers := runtime.NumCPU()
-	if workers < 1 {
-		workers = 1
-	}
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	jobs := make(chan git.Commit)
-	errCh := make(chan error, 1)
-	var wg sync.WaitGroup
-
-	p := progress_bar.NewProgressBar("commits", len(list))
-
-	workerFn := func() {
-		defer wg.Done()
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case c, ok := <-jobs:
-				if !ok {
-					return
-				}
-				if err := generateCommitPage(c, params); err != nil {
-					select {
-					case errCh <- err:
-						cancel()
-					default:
-					}
-					return
-				}
-				p.Inc()
-			}
-		}
-	}
-
-	wg.Add(workers)
-	for i := 0; i < workers; i++ {
-		go workerFn()
-	}
-
-	go func() {
-		defer close(jobs)
-		for _, c := range list {
-			select {
-			case <-ctx.Done():
-				return
-			case jobs <- c:
-			}
-		}
-	}()
-
-	done := make(chan struct{})
-	go func() {
-		wg.Wait()
-		close(done)
-	}()
-
-	var err error
-	select {
-	case err = <-errCh:
-		cancel()
-		<-done
-	case <-done:
-	}
-
-	p.Done()
-	return err
+	return pool.Run(list, func(c git.Commit) error {
+		return generateCommitPage(c, params)
+	})
 }
 
 func generateCommitPage(commit git.Commit, params Params) error {
internal/generator/commits_list.go
@@ -7,7 +7,6 @@ import (
 	"slices"
 
 	"mokhan.ca/antonmedv/gitmal/internal/git"
-	"mokhan.ca/antonmedv/gitmal/internal/progress_bar"
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
@@ -24,8 +23,6 @@ func GenerateLogForBranch(allCommits []git.Commit, params Params) error {
 		return err
 	}
 
-	p := progress_bar.NewProgressBar("commits for "+params.Ref.String(), totalPages)
-
 	page := 1
 	for pageCommits := range slices.Chunk(allCommits, commitsPerPage) {
 		for i := range pageCommits {
@@ -91,10 +88,7 @@ func GenerateLogForBranch(allCommits []git.Commit, params Params) error {
 		}
 
 		page++
-		p.Inc()
 	}
 
-	p.Done()
-
 	return nil
 }
internal/generator/list.go
@@ -1,19 +1,16 @@
 package generator
 
 import (
-	"context"
 	"fmt"
 	"html/template"
 	"os"
 	"path/filepath"
-	"runtime"
 	"sort"
 	"strings"
-	"sync"
 
 	"mokhan.ca/antonmedv/gitmal/internal/git"
 	"mokhan.ca/antonmedv/gitmal/internal/links"
-	"mokhan.ca/antonmedv/gitmal/internal/progress_bar"
+	"mokhan.ca/antonmedv/gitmal/internal/pool"
 	"mokhan.ca/antonmedv/gitmal/internal/templates"
 )
 
@@ -56,7 +53,6 @@ func GenerateLists(files []git.Blob, params Params) error {
 		ensureDir(cur).files = append(ensureDir(cur).files, b)
 	}
 
-	// Prepare jobs slice to have stable iteration order (optional)
 	type job struct {
 		dirPath string
 		di      *dirInfo
@@ -65,184 +61,95 @@ func GenerateLists(files []git.Blob, params Params) error {
 	for dp, di := range dirs {
 		jobsSlice = append(jobsSlice, job{dirPath: dp, di: di})
 	}
-	// Sort by dirPath for determinism
 	sort.Slice(jobsSlice, func(i, j int) bool { return jobsSlice[i].dirPath < jobsSlice[j].dirPath })
 
-	// Worker pool similar to generateBlobs
-	workers := runtime.NumCPU()
-	if workers < 1 {
-		workers = 1
-	}
+	return pool.Run(jobsSlice, func(jb job) error {
+		dirPath := jb.dirPath
+		di := jb.di
 
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
+		outDir := filepath.Join(params.OutputDir, "blob", params.Ref.DirName())
+		if dirPath != "" {
+			outDir = filepath.Join(outDir, filepath.FromSlash(dirPath))
+		}
+		if err := os.MkdirAll(outDir, 0o755); err != nil {
+			return err
+		}
 
-	jobCh := make(chan job)
-	errCh := make(chan error, 1)
-	var wg sync.WaitGroup
+		dirNames := make([]string, 0, len(di.subdirs))
+		for name := range di.subdirs {
+			dirNames = append(dirNames, name)
+		}
 
-	p := progress_bar.NewProgressBar("lists for "+params.Ref.String(), len(jobsSlice))
+		sort.Strings(dirNames)
+		sort.Slice(di.files, func(i, j int) bool {
+			return di.files[i].FileName < di.files[j].FileName
+		})
+
+		subdirEntries := make([]templates.ListEntry, 0, len(dirNames))
+		for _, name := range dirNames {
+			subdirEntries = append(subdirEntries, templates.ListEntry{
+				Name:  name + "/",
+				Href:  name + "/index.html",
+				IsDir: true,
+			})
+		}
 
-	check := func(err error) bool {
-		if err != nil {
-			select {
-			case errCh <- err:
-				cancel()
-			default:
-			}
-			return true
+		fileEntries := make([]templates.ListEntry, 0, len(di.files))
+		for _, b := range di.files {
+			fileEntries = append(fileEntries, templates.ListEntry{
+				Name: b.FileName + "",
+				Href: b.FileName + ".html",
+				Mode: b.Mode,
+				Size: humanizeSize(b.Size),
+			})
 		}
-		return false
-	}
 
-	workerFn := func() {
-		defer wg.Done()
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case jb, ok := <-jobCh:
-				if !ok {
-					return
-				}
-				func() {
-					dirPath := jb.dirPath
-					di := jb.di
-
-					outDir := filepath.Join(params.OutputDir, "blob", params.Ref.DirName())
-					if dirPath != "" {
-						// convert forward slash path into OS path
-						outDir = filepath.Join(outDir, filepath.FromSlash(dirPath))
-					}
-					if err := os.MkdirAll(outDir, 0o755); check(err) {
-						return
-					}
-
-					// Build entries
-					dirNames := make([]string, 0, len(di.subdirs))
-					for name := range di.subdirs {
-						dirNames = append(dirNames, name)
-					}
-
-					// Sort for stable output
-					sort.Strings(dirNames)
-					sort.Slice(di.files, func(i, j int) bool {
-						return di.files[i].FileName < di.files[j].FileName
-					})
-
-					subdirEntries := make([]templates.ListEntry, 0, len(dirNames))
-					for _, name := range dirNames {
-						subdirEntries = append(subdirEntries, templates.ListEntry{
-							Name:  name + "/",
-							Href:  name + "/index.html",
-							IsDir: true,
-						})
-					}
-
-					fileEntries := make([]templates.ListEntry, 0, len(di.files))
-					for _, b := range di.files {
-						fileEntries = append(fileEntries, templates.ListEntry{
-							Name: b.FileName + "",
-							Href: b.FileName + ".html",
-							Mode: b.Mode,
-							Size: humanizeSize(b.Size),
-						})
-					}
-
-					// Title and current path label
-					title := fmt.Sprintf("%s/%s at %s", params.Name, dirPath, params.Ref)
-					if dirPath == "" {
-						title = fmt.Sprintf("%s at %s", params.Name, params.Ref)
-					}
-
-					f, err := os.Create(filepath.Join(outDir, "index.html"))
-					if check(err) {
-						return
-					}
-					defer func() {
-						_ = f.Close()
-					}()
-
-					// parent link is not shown for root
-					parent := "../index.html"
-					if dirPath == "" {
-						parent = ""
-					}
-
-					depth := 0
-					if dirPath != "" {
-						depth = len(strings.Split(dirPath, "/"))
-					}
-					rootHref := strings.Repeat("../", depth+2)
-
-					readmeHTML := readme(di.files, dirsSet, filesSet, params, rootHref)
-					var CSSMarkdown template.CSS
-					if readmeHTML != "" {
-						CSSMarkdown = cssMarkdown()
-					}
-
-					err = templates.ListTemplate.ExecuteTemplate(f, "layout.gohtml", templates.ListParams{
-						LayoutParams: templates.LayoutParams{
-							Title:         title,
-							Name:          params.Name,
-							CSSMarkdown:   CSSMarkdown,
-							RootHref:      rootHref,
-							CurrentRefDir: params.Ref.DirName(),
-							Selected:      "code",
-						},
-						HeaderParams: templates.HeaderParams{
-							Ref:         params.Ref,
-							Breadcrumbs: breadcrumbs(params.Name, dirPath, false),
-						},
-						Ref:        params.Ref,
-						ParentHref: parent,
-						Dirs:       subdirEntries,
-						Files:      fileEntries,
-						Readme:     readmeHTML,
-					})
-					if check(err) {
-						return
-					}
-				}()
-
-				p.Inc()
-			}
+		title := fmt.Sprintf("%s/%s at %s", params.Name, dirPath, params.Ref)
+		if dirPath == "" {
+			title = fmt.Sprintf("%s at %s", params.Name, params.Ref)
 		}
-	}
 
-	// Start workers
-	wg.Add(workers)
-	for i := 0; i < workers; i++ {
-		go workerFn()
-	}
+		f, err := os.Create(filepath.Join(outDir, "index.html"))
+		if err != nil {
+			return err
+		}
+		defer f.Close()
 
-	// Feed jobs
-	go func() {
-		defer close(jobCh)
-		for _, jb := range jobsSlice {
-			select {
-			case <-ctx.Done():
-				return
-			case jobCh <- jb:
-			}
+		parent := "../index.html"
+		if dirPath == "" {
+			parent = ""
 		}
-	}()
-
-	// Wait for workers or first error
-	doneCh := make(chan struct{})
-	go func() {
-		wg.Wait()
-		close(doneCh)
-	}()
-
-	var runErr error
-	select {
-	case runErr = <-errCh:
-		<-doneCh
-	case <-doneCh:
-	}
 
-	p.Done()
+		depth := 0
+		if dirPath != "" {
+			depth = len(strings.Split(dirPath, "/"))
+		}
+		rootHref := strings.Repeat("../", depth+2)
+
+		readmeHTML := readme(di.files, dirsSet, filesSet, params, rootHref)
+		var CSSMarkdown template.CSS
+		if readmeHTML != "" {
+			CSSMarkdown = cssMarkdown()
+		}
 
-	return runErr
+		return templates.ListTemplate.ExecuteTemplate(f, "layout.gohtml", templates.ListParams{
+			LayoutParams: templates.LayoutParams{
+				Title:         title,
+				Name:          params.Name,
+				CSSMarkdown:   CSSMarkdown,
+				RootHref:      rootHref,
+				CurrentRefDir: params.Ref.DirName(),
+				Selected:      "code",
+			},
+			HeaderParams: templates.HeaderParams{
+				Ref:         params.Ref,
+				Breadcrumbs: breadcrumbs(params.Name, dirPath, false),
+			},
+			Ref:        params.Ref,
+			ParentHref: parent,
+			Dirs:       subdirEntries,
+			Files:      fileEntries,
+			Readme:     readmeHTML,
+		})
+	})
 }
internal/pool/pool.go
@@ -0,0 +1,83 @@
+package pool
+
+import (
+	"context"
+	"runtime"
+	"sync"
+)
+
+func Run[T any](items []T, fn func(T) error) error {
+	return RunWithInit(items, func() struct{} { return struct{}{} }, func(_ struct{}, item T) error {
+		return fn(item)
+	})
+}
+
+func RunWithInit[T, W any](items []T, init func() W, fn func(W, T) error) error {
+	if len(items) == 0 {
+		return nil
+	}
+
+	workers := runtime.NumCPU()
+	if workers < 1 {
+		workers = 1
+	}
+
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	jobs := make(chan T)
+	errCh := make(chan error, 1)
+	var wg sync.WaitGroup
+
+	wg.Add(workers)
+	for i := 0; i < workers; i++ {
+		go func() {
+			defer wg.Done()
+			w := init()
+			for {
+				select {
+				case <-ctx.Done():
+					return
+				case item, ok := <-jobs:
+					if !ok {
+						return
+					}
+					if err := fn(w, item); err != nil {
+						select {
+						case errCh <- err:
+							cancel()
+						default:
+						}
+						return
+					}
+				}
+			}
+		}()
+	}
+
+	go func() {
+		defer close(jobs)
+		for _, item := range items {
+			select {
+			case <-ctx.Done():
+				return
+			case jobs <- item:
+			}
+		}
+	}()
+
+	done := make(chan struct{})
+	go func() {
+		wg.Wait()
+		close(done)
+	}()
+
+	var err error
+	select {
+	case err = <-errCh:
+		<-done
+	case <-done:
+	}
+
+	return err
+}
internal/progress_bar/progress_bar.go
@@ -1,84 +0,0 @@
-package progress_bar
-
-import (
-	"fmt"
-	"os"
-	"strings"
-	"sync"
-	"sync/atomic"
-	"time"
-)
-
-type ProgressBar struct {
-	label   string
-	total   int64
-	current int64
-	stop    chan struct{}
-	wg      sync.WaitGroup
-}
-
-func NewProgressBar(label string, total int) *ProgressBar {
-	p := &ProgressBar{label: label, total: int64(total), stop: make(chan struct{})}
-	p.wg.Add(1)
-	go func() {
-		defer p.wg.Done()
-		ticker := time.NewTicker(100 * time.Millisecond)
-		defer ticker.Stop()
-		// initial draw
-		p.draw(atomic.LoadInt64(&p.current))
-		for {
-			select {
-			case <-p.stop:
-				return
-			case <-ticker.C:
-				cur := atomic.LoadInt64(&p.current)
-				if cur > p.total {
-					cur = p.total
-				}
-				p.draw(cur)
-			}
-		}
-	}()
-	return p
-}
-
-func (p *ProgressBar) Inc() {
-	for {
-		cur := atomic.LoadInt64(&p.current)
-		if cur >= p.total {
-			return
-		}
-		if atomic.CompareAndSwapInt64(&p.current, cur, cur+1) {
-			return
-		}
-		// retry on race
-	}
-}
-
-func (p *ProgressBar) Done() {
-	atomic.StoreInt64(&p.current, p.total)
-	close(p.stop)
-	p.wg.Wait()
-	p.draw(p.total)
-	_, _ = fmt.Fprintln(os.Stderr)
-}
-
-func (p *ProgressBar) draw(current int64) {
-	if p.total <= 0 {
-		return
-	}
-	percent := 0
-	if p.total > 0 {
-		percent = int(current * 100 / p.total)
-	}
-	barLen := 24
-	filled := 0
-	if p.total > 0 {
-		filled = int(current * int64(barLen) / p.total)
-	}
-	if filled > barLen {
-		filled = barLen
-	}
-	bar := strings.Repeat("#", filled) + strings.Repeat(" ", barLen-filled)
-	_, _ = fmt.Fprintf(os.Stderr, "\r[%s] %4d/%-4d (%3d%%) %s", bar, current, p.total, percent, p.label)
-}
internal/templates/templates.go
@@ -195,4 +195,3 @@ type FileView struct {
 	HasChanges bool
 	HTML       HTML // pre-rendered HTML for diff of this file
 }
-