Hypercode/alex/hypercodePublic

Code

  1. hypercode
  2. views
  3. pages
  4. repository_tree.go
repository_tree.go285 lines
package pages

import (
	"fmt"
	"net/http"
	"strings"

	"github.com/hypercodehq/hypercode/database/models"
	"github.com/hypercodehq/libhtml"
	"github.com/hypercodehq/libhtml/attr"
	"github.com/hypercodehq/hypercode/services"
	"github.com/hypercodehq/hypercode/views/components/layouts"
	"github.com/hypercodehq/hypercode/views/components/ui"
)

type RepositoryTreeData struct {
	User          *models.User
	Repository    *models.Repository
	OwnerUsername string
	CanManage     bool
	StarCount     int64
	HasStarred    bool
	Branches      []string
	CurrentBranch string
	CurrentPath   string
	Entries       []services.TreeEntry
	IsEmpty       bool
}

func RepositoryTree(r *http.Request, data *RepositoryTreeData) html.Node {
	if data == nil {
		data = &RepositoryTreeData{}
	}

	cloneURL := "https://" + r.Host + "/" + data.OwnerUsername + "/" + data.Repository.Name
	repositoryURL := cloneURL

	return layouts.Repository(r,
		"Code - "+data.OwnerUsername+"/"+data.Repository.Name,
		layouts.RepositoryLayoutOptions{
			OwnerUsername: data.OwnerUsername,
			RepoName:      data.Repository.Name,
			CurrentTab:    "tree",
			IsPublic:      data.Repository.Visibility == "public",
			ShowSettings:  data.CanManage,
			StarCount:     data.StarCount,
			HasStarred:    data.HasStarred,
			DefaultBranch: data.Repository.DefaultBranch,
			CloneURL:      cloneURL,
			RepositoryURL: repositoryURL,
		},
		html.Main(
			attr.Class("container mx-auto px-4 py-8 max-w-7xl"),
			html.H1(
				attr.Class("font-semibold text-2xl mb-6"),
				html.Text("Code"),
			),
			renderTreeContent(data),
		),
	)
}

func renderTreeContent(data *RepositoryTreeData) html.Node {
	if data.IsEmpty {
		return html.Div(
			attr.Class("border rounded-sm p-8 bg-card text-center"),
			ui.EmptyState(
				ui.EmptyStateProps{
					Icon:        ui.SVGIcon(ui.IconRepository, "size-6"),
					Title:       "This repository is empty",
					Description: "Get started by pushing code to this repository.",
				},
			),
		)
	}

	// Build branch selector
	branchSelector := renderBranchSelector(data)

	// Build breadcrumb navigation
	breadcrumb := renderPathBreadcrumb(data)

	// Build file/folder list
	fileList := renderFileList(data)

	return html.Div(
		attr.Class("space-y-4"),
		// Branch selector
		branchSelector,
		// Breadcrumb
		breadcrumb,
		// File list
		fileList,
	)
}

func renderBranchSelector(data *RepositoryTreeData) html.Node {
	selectOptions := []ui.SelectOption{}

	// Add default branch first
	defaultBranch := data.Repository.DefaultBranch
	if defaultBranch != "" {
		isSelected := data.CurrentBranch == defaultBranch
		selectOptions = append(selectOptions, ui.SelectOption{
			Value:    defaultBranch,
			Label:    defaultBranch + " (default)",
			Selected: isSelected,
			Icon:     ui.IconGitBranch,
		})
	}

	// Add other branches
	for _, branch := range data.Branches {
		if branch == defaultBranch {
			continue
		}
		isSelected := data.CurrentBranch == branch
		selectOptions = append(selectOptions, ui.SelectOption{
			Value:    branch,
			Label:    branch,
			Selected: isSelected,
			Icon:     ui.IconGitBranch,
		})
	}

	return html.Div(
		attr.Class("flex items-center gap-2"),
		ui.Select(ui.SelectProps{
			Id:      "branch-selector",
			Name:    "branch",
			Class:   "!mb-0 min-w-48",
			Options: selectOptions,
		}),
		html.Script(
			html.Text(fmt.Sprintf(`
(function() {
	const selector = document.getElementById('branch-selector');
	if (selector) {
		selector.addEventListener('change', function() {
			const branch = this.value;
			const owner = %q;
			const repo = %q;
			const path = %q;

			let url = "/" + owner + "/" + repo + "/tree/" + branch;
			if (path) {
				url += "/" + path;
			}

			window.location.href = url;
		});
	}
})();
			`, data.OwnerUsername, data.Repository.Name, data.CurrentPath)),
		),
	)
}

func renderPathBreadcrumb(data *RepositoryTreeData) html.Node {
	if data.CurrentPath == "" {
		return html.Div()
	}

	parts := strings.Split(data.CurrentPath, "/")
	breadcrumbItems := []html.Node{}

	// Root
	breadcrumbItems = append(breadcrumbItems,
		html.Element("li",
			attr.Class("inline-flex items-center gap-1.5"),
			html.Element("a",
				attr.Href(fmt.Sprintf("/%s/%s/tree/%s", data.OwnerUsername, data.Repository.Name, data.CurrentBranch)),
				attr.Class("hover:text-foreground transition-colors"),
				html.Text(data.Repository.Name),
			),
		),
	)

	// Path parts
	currentPath := ""
	for i, part := range parts {
		if currentPath != "" {
			currentPath += "/"
		}
		currentPath += part

		// Add separator
		breadcrumbItems = append(breadcrumbItems,
			html.Element("li",
				ui.SVGIcon(ui.IconChevronRight, "size-3.5"),
			),
		)

		if i == len(parts)-1 {
			// Last part - not a link
			breadcrumbItems = append(breadcrumbItems, html.Element("li",
				attr.Class("inline-flex items-center gap-1.5"),
				html.Element("span",
					attr.Class("text-foreground font-normal"),
					html.Text(part),
				),
			))
		} else {
			// Intermediate part - link
			breadcrumbItems = append(breadcrumbItems,
				html.Element("li",
					attr.Class("inline-flex items-center gap-1.5"),
					html.Element("a",
						attr.Href(fmt.Sprintf("/%s/%s/tree/%s/%s", data.OwnerUsername, data.Repository.Name, data.CurrentBranch, currentPath)),
						attr.Class("hover:text-foreground transition-colors"),
						html.Text(part),
					),
				),
			)
		}
	}

	return html.Element("ol",
		attr.Class("text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5"),
		html.Group(breadcrumbItems...),
	)
}

func renderFileList(data *RepositoryTreeData) html.Node {
	if len(data.Entries) == 0 {
		return html.Div(
			attr.Class("border rounded-sm p-8 bg-card text-center"),
			html.P(
				attr.Class("text-muted-foreground"),
				html.Text("This directory is empty."),
			),
		)
	}

	rows := []html.Node{}

	for _, entry := range data.Entries {
		rows = append(rows, renderFileListItem(data, entry))
	}

	return html.Div(
		attr.Class("border rounded-sm bg-card overflow-hidden"),
		html.Table(
			attr.Class("w-full"),
			html.Tbody(
				rows...,
			),
		),
	)
}

func renderFileListItem(data *RepositoryTreeData, entry services.TreeEntry) html.Node {
	var icon ui.Icon
	var entryURL string
	isFolder := entry.Type == "tree"

	if isFolder {
		icon = ui.IconFolder
		entryURL = fmt.Sprintf("/%s/%s/tree/%s/%s", data.OwnerUsername, data.Repository.Name, data.CurrentBranch, entry.Path)
	} else {
		icon = ui.IconFile
		// For now, files also link to tree (in future, they should show file content)
		entryURL = fmt.Sprintf("/%s/%s/tree/%s/%s", data.OwnerUsername, data.Repository.Name, data.CurrentBranch, entry.Path)
	}

	return html.Tr(
		attr.Class("border-b last:border-b-0 hover:bg-muted/50 transition-colors"),
		html.Td(
			attr.Class("p-3"),
			html.Element("a",
				attr.Href(entryURL),
				attr.Class("flex items-center gap-3 text-foreground hover:text-primary transition-colors"),
				html.Div(
					attr.Class("flex-shrink-0 text-muted-foreground"),
					ui.SVGIcon(icon, "size-4"),
				),
				html.Span(
					attr.Class("text-sm"),
					html.Text(entry.Name),
				),
			),
		),
	)
}