dashboard / erock/pico / feat(pgs): show dir listing when no index.html present #91 rss

accepted · opened on 2025-12-16T01:38:45Z by erock
Help
checkout latest patchset:
ssh pr.pico.sh print pr-91 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print ps-X | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 91
add review to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add --review 91
accept PR:
ssh pr.pico.sh pr accept 91
close PR:
ssh pr.pico.sh pr close 91
Timeline Patchsets

Range-diff rd-168

title
feat(pgs): show dir listing when no index.html present
description
Patch changed
old #1
a0c3196
new #1
fbdea17
Back to top
1: a0c3196 ! 1: fbdea17 feat(pgs): show dir listing when no index.html present

old

new:pkg/apps/pgs/gen_dir_listing.go
+package pgs
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"html/template"
+	"os"
+
+	sst "github.com/picosh/pico/pkg/pobj/storage"
+)
+
+//go:embed html/*
+var dirListingFS embed.FS
+
+var dirListingTmpl = template.Must(
+	template.New("base").ParseFS(
+		dirListingFS,
+		"html/base.layout.tmpl",
+		"html/marketing-footer.partial.tmpl",
+		"html/directory_listing.page.tmpl",
+	),
+)
+
+type dirEntryDisplay struct {
+	Href    string
+	Display string
+	Size    string
+	ModTime string
+}
+
+type DirectoryListingData struct {
+	Path       string
+	ShowParent bool
+	Entries    []dirEntryDisplay
+}
+
+func formatFileSize(size int64) string {
+	const (
+		KB = 1024
+		MB = KB * 1024
+		GB = MB * 1024
+	)
+
+	switch {
+	case size >= GB:
+		return fmt.Sprintf("%.1f GB", float64(size)/float64(GB))
+	case size >= MB:
+		return fmt.Sprintf("%.1f MB", float64(size)/float64(MB))
+	case size >= KB:
+		return fmt.Sprintf("%.1f KB", float64(size)/float64(KB))
+	default:
+		return fmt.Sprintf("%d B", size)
+	}
+}
+
+func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
+	displayEntries := make([]dirEntryDisplay, 0, len(entries))
+
+	for _, entry := range entries {
+		display := dirEntryDisplay{
+			Href:    entry.Name(),
+			Display: entry.Name(),
+			Size:    formatFileSize(entry.Size()),
+			ModTime: entry.ModTime().Format("2006-01-02 15:04"),
+		}
+
+		if entry.IsDir() {
+			display.Href += "/"
+			display.Display += "/"
+			display.Size = "-"
+		}
+
+		displayEntries = append(displayEntries, display)
+	}
+
+	return displayEntries
+}
+
+func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool {
+	dirPath := projectDir + path
+	if path == "/" {
+		dirPath = projectDir + "/"
+	}
+
+	entries, err := st.ListObjects(bucket, dirPath, false)
+	if err != nil || len(entries) == 0 {
+		return false
+	}
+
+	indexPath := dirPath + "index.html"
+	_, _, err = st.GetObject(bucket, indexPath)
+	return err != nil
+}
+
+func generateDirectoryHTML(path string, entries []os.FileInfo) string {
+	data := DirectoryListingData{
+		Path:       path,
+		ShowParent: path != "/",
+		Entries:    toDisplayEntries(entries),
+	}
+
+	var buf bytes.Buffer
+	if err := dirListingTmpl.Execute(&buf, data); err != nil {
+		return fmt.Sprintf("Error rendering directory listing: %s", err)
+	}
+
+	return buf.String()
+}

new

new:pkg/apps/pgs/gen_dir_listing.go
+package pgs
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"html/template"
+	"os"
+	"sort"
+
+	sst "github.com/picosh/pico/pkg/pobj/storage"
+)
+
+//go:embed html/*
+var dirListingFS embed.FS
+
+var dirListingTmpl = template.Must(
+	template.New("base").ParseFS(
+		dirListingFS,
+		"html/base.layout.tmpl",
+		"html/marketing-footer.partial.tmpl",
+		"html/directory_listing.page.tmpl",
+	),
+)
+
+type dirEntryDisplay struct {
+	Href    string
+	Display string
+	Size    string
+	ModTime string
+}
+
+type DirectoryListingData struct {
+	Path       string
+	ShowParent bool
+	Entries    []dirEntryDisplay
+}
+
+func formatFileSize(size int64) string {
+	const (
+		KB = 1024
+		MB = KB * 1024
+		GB = MB * 1024
+	)
+
+	switch {
+	case size >= GB:
+		return fmt.Sprintf("%.1f GB", float64(size)/float64(GB))
+	case size >= MB:
+		return fmt.Sprintf("%.1f MB", float64(size)/float64(MB))
+	case size >= KB:
+		return fmt.Sprintf("%.1f KB", float64(size)/float64(KB))
+	default:
+		return fmt.Sprintf("%d B", size)
+	}
+}
+
+func sortEntries(entries []os.FileInfo) {
+	sort.Slice(entries, func(i, j int) bool {
+		if entries[i].IsDir() != entries[j].IsDir() {
+			return entries[i].IsDir()
+		}
+		return entries[i].Name() < entries[j].Name()
+	})
+}
+
+func toDisplayEntries(entries []os.FileInfo) []dirEntryDisplay {
+	sortEntries(entries)
+	displayEntries := make([]dirEntryDisplay, 0, len(entries))
+
+	for _, entry := range entries {
+		display := dirEntryDisplay{
+			Href:    entry.Name(),
+			Display: entry.Name(),
+			Size:    formatFileSize(entry.Size()),
+			ModTime: entry.ModTime().Format("2006-01-02 15:04"),
+		}
+
+		if entry.IsDir() {
+			display.Href += "/"
+			display.Display += "/"
+			display.Size = "-"
+		}
+
+		displayEntries = append(displayEntries, display)
+	}
+
+	return displayEntries
+}
+
+func shouldGenerateListing(st sst.ObjectStorage, bucket sst.Bucket, projectDir string, path string) bool {
+	dirPath := projectDir + path
+	if path == "/" {
+		dirPath = projectDir + "/"
+	}
+
+	entries, err := st.ListObjects(bucket, dirPath, false)
+	if err != nil || len(entries) == 0 {
+		return false
+	}
+
+	indexPath := dirPath + "index.html"
+	_, _, err = st.GetObject(bucket, indexPath)
+	return err != nil
+}
+
+func generateDirectoryHTML(path string, entries []os.FileInfo) string {
+	data := DirectoryListingData{
+		Path:       path,
+		ShowParent: path != "/",
+		Entries:    toDisplayEntries(entries),
+	}
+
+	var buf bytes.Buffer
+	if err := dirListingTmpl.Execute(&buf, data); err != nil {
+		return fmt.Sprintf("Error rendering directory listing: %s", err)
+	}
+
+	return buf.String()
+}

old

new:pkg/apps/pgs/gen_dir_listing_test.go
+package pgs
+
+import (
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	sst "github.com/picosh/pico/pkg/pobj/storage"
+	"github.com/picosh/pico/pkg/send/utils"
+)
+
+func TestGenerateDirectoryHTML(t *testing.T) {
+	fixtures := []struct {
+		Name     string
+		Path     string
+		Entries  []os.FileInfo
+		Contains []string
+	}{
+		{
+			Name:    "empty-directory",
+			Path:    "/",
+			Entries: []os.FileInfo{},
+			Contains: []string{
+				"<title>Index of /</title>",
+				"Index of /",
+			},
+		},
+		{
+			Name: "single-file",
+			Path: "/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "hello.txt", FSize: 1024, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /</title>",
+				`href="hello.txt"`,
+				"hello.txt",
+				"1.0 KB",
+			},
+		},
+		{
+			Name: "single-folder",
+			Path: "/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "docs", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				`href="docs/"`,
+				"docs/",
+			},
+		},
+		{
+			Name: "mixed-entries",
+			Path: "/assets/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "images", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+				&utils.VirtualFile{FName: "style.css", FSize: 2048, FIsDir: false, FModTime: time.Date(2025, 1, 14, 8, 0, 0, 0, time.UTC)},
+				&utils.VirtualFile{FName: "app.js", FSize: 512, FIsDir: false, FModTime: time.Date(2025, 1, 13, 12, 0, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /assets/</title>",
+				`href="images/"`,
+				`href="style.css"`,
+				`href="app.js"`,
+				"images/",
+				"2.0 KB",
+			},
+		},
+		{
+			Name: "subdirectory-with-parent-link",
+			Path: "/docs/api/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "readme.md", FSize: 256, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /docs/api/</title>",
+				`href="../"`,
+				"../",
+			},
+		},
+	}
+
+	for _, fixture := range fixtures {
+		t.Run(fixture.Name, func(t *testing.T) {
+			html := generateDirectoryHTML(fixture.Path, fixture.Entries)
+
+			for _, expected := range fixture.Contains {
+				if !strings.Contains(html, expected) {
+					t.Errorf("expected HTML to contain %q, got:\n%s", expected, html)
+				}
+			}
+		})
+	}
+}
+
+func TestShouldGenerateListing(t *testing.T) {
+	fixtures := []struct {
+		Name     string
+		Path     string
+		Storage  map[string]map[string]string
+		Expected bool
+	}{
+		{
+			Name: "directory-with-index-html",
+			Path: "/docs/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/docs/index.html": "<html>hello</html>",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "directory-without-index-html",
+			Path: "/docs/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/docs/readme.md": "# Readme",
+					"/project/docs/guide.md":  "# Guide",
+				},
+			},
+			Expected: true,
+		},
+		{
+			Name: "empty-directory",
+			Path: "/empty/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/other/file.txt": "content",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "root-directory-without-index",
+			Path: "/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/style.css": "body {}",
+					"/project/app.js":    "console.log('hi')",
+				},
+			},
+			Expected: true,
+		},
+		{
+			Name: "root-directory-with-index",
+			Path: "/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/index.html": "<html>home</html>",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "nested-directory-without-index",
+			Path: "/assets/images/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/assets/images/logo.png":   "png data",
+					"/project/assets/images/banner.jpg": "jpg data",
+				},
+			},
+			Expected: true,
+		},
+	}
+
+	for _, fixture := range fixtures {
+		t.Run(fixture.Name, func(t *testing.T) {
+			st, _ := sst.NewStorageMemory(fixture.Storage)
+			bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"}
+
+			result := shouldGenerateListing(st, bucket, "project", fixture.Path)
+
+			if result != fixture.Expected {
+				t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
+			}
+		})
+	}
+}

new

new:pkg/apps/pgs/gen_dir_listing_test.go
+package pgs
+
+import (
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	sst "github.com/picosh/pico/pkg/pobj/storage"
+	"github.com/picosh/pico/pkg/send/utils"
+)
+
+func TestGenerateDirectoryHTML(t *testing.T) {
+	fixtures := []struct {
+		Name     string
+		Path     string
+		Entries  []os.FileInfo
+		Contains []string
+	}{
+		{
+			Name:    "empty-directory",
+			Path:    "/",
+			Entries: []os.FileInfo{},
+			Contains: []string{
+				"<title>Index of /</title>",
+				"Index of /",
+			},
+		},
+		{
+			Name: "single-file",
+			Path: "/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "hello.txt", FSize: 1024, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /</title>",
+				`href="hello.txt"`,
+				"hello.txt",
+				"1.0 KB",
+			},
+		},
+		{
+			Name: "single-folder",
+			Path: "/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "docs", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				`href="docs/"`,
+				"docs/",
+			},
+		},
+		{
+			Name: "mixed-entries",
+			Path: "/assets/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "images", FSize: 0, FIsDir: true, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+				&utils.VirtualFile{FName: "style.css", FSize: 2048, FIsDir: false, FModTime: time.Date(2025, 1, 14, 8, 0, 0, 0, time.UTC)},
+				&utils.VirtualFile{FName: "app.js", FSize: 512, FIsDir: false, FModTime: time.Date(2025, 1, 13, 12, 0, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /assets/</title>",
+				`href="images/"`,
+				`href="style.css"`,
+				`href="app.js"`,
+				"images/",
+				"2.0 KB",
+			},
+		},
+		{
+			Name: "subdirectory-with-parent-link",
+			Path: "/docs/api/",
+			Entries: []os.FileInfo{
+				&utils.VirtualFile{FName: "readme.md", FSize: 256, FIsDir: false, FModTime: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)},
+			},
+			Contains: []string{
+				"<title>Index of /docs/api/</title>",
+				`href="../"`,
+				"../",
+			},
+		},
+	}
+
+	for _, fixture := range fixtures {
+		t.Run(fixture.Name, func(t *testing.T) {
+			html := generateDirectoryHTML(fixture.Path, fixture.Entries)
+
+			for _, expected := range fixture.Contains {
+				if !strings.Contains(html, expected) {
+					t.Errorf("expected HTML to contain %q, got:\n%s", expected, html)
+				}
+			}
+		})
+	}
+}
+
+func TestSortEntries(t *testing.T) {
+	entries := []os.FileInfo{
+		&utils.VirtualFile{FName: "zebra.txt", FIsDir: false},
+		&utils.VirtualFile{FName: "alpha", FIsDir: true},
+		&utils.VirtualFile{FName: "beta.md", FIsDir: false},
+		&utils.VirtualFile{FName: "zulu", FIsDir: true},
+		&utils.VirtualFile{FName: "apple.js", FIsDir: false},
+	}
+
+	sortEntries(entries)
+
+	expected := []string{"alpha", "zulu", "apple.js", "beta.md", "zebra.txt"}
+	for i, entry := range entries {
+		if entry.Name() != expected[i] {
+			t.Errorf("position %d: expected %q, got %q", i, expected[i], entry.Name())
+		}
+	}
+}
+
+func TestShouldGenerateListing(t *testing.T) {
+	fixtures := []struct {
+		Name     string
+		Path     string
+		Storage  map[string]map[string]string
+		Expected bool
+	}{
+		{
+			Name: "directory-with-index-html",
+			Path: "/docs/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/docs/index.html": "<html>hello</html>",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "directory-without-index-html",
+			Path: "/docs/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/docs/readme.md": "# Readme",
+					"/project/docs/guide.md":  "# Guide",
+				},
+			},
+			Expected: true,
+		},
+		{
+			Name: "empty-directory",
+			Path: "/empty/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/other/file.txt": "content",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "root-directory-without-index",
+			Path: "/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/style.css": "body {}",
+					"/project/app.js":    "console.log('hi')",
+				},
+			},
+			Expected: true,
+		},
+		{
+			Name: "root-directory-with-index",
+			Path: "/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/index.html": "<html>home</html>",
+				},
+			},
+			Expected: false,
+		},
+		{
+			Name: "nested-directory-without-index",
+			Path: "/assets/images/",
+			Storage: map[string]map[string]string{
+				"testbucket": {
+					"/project/assets/images/logo.png":   "png data",
+					"/project/assets/images/banner.jpg": "jpg data",
+				},
+			},
+			Expected: true,
+		},
+	}
+
+	for _, fixture := range fixtures {
+		t.Run(fixture.Name, func(t *testing.T) {
+			st, _ := sst.NewStorageMemory(fixture.Storage)
+			bucket := sst.Bucket{Name: "testbucket", Path: "testbucket"}
+
+			result := shouldGenerateListing(st, bucket, "project", fixture.Path)
+
+			if result != fixture.Expected {
+				t.Errorf("shouldGenerateListing(%q) = %v, want %v", fixture.Path, result, fixture.Expected)
+			}
+		})
+	}
+}