Range-diff rd-168
- title
- feat(pgs): show dir listing when no index.html present
- description
-
Patch changed - old #1
a0c3196- new #1
fbdea17
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) + } + }) + } +}