Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/web/web.go

Zum Verzeichnis
package web

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"html/template"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"stackyard/internal/app"
	"stackyard/internal/archive"
	"stackyard/internal/config"
	"stackyard/internal/git"
	"stackyard/internal/launcher"
	"stackyard/internal/operations"
	"stackyard/internal/project"
	"stackyard/internal/storage"
	"stackyard/internal/todo"
)

type Server struct {
	cfg       config.Config
	projects  *app.Service
	gitClient git.Client
	store     *storage.Store
	processes *operations.ProcessManager
}

type fileTreeNode struct {
	Name     string
	Path     string
	IsDir    bool
	Children []fileTreeNode
}

type flashMessage struct {
	Kind string
	Text string
}

const maxEditorFileSize = 2 << 20
const maxFileTreeEntries = 2000
const maxFileTreeDepth = 6

type editorView struct {
	Project      project.Project
	Files        []fileTreeNode
	SelectedPath string
	Content      string
	Flash        flashMessage
	Protected    bool
}

type dashboardProject struct {
	project.Project
	CollectionID      int64
	CollectionOptions []project.Collection
}

type dashboardCollection struct {
	ID        int64
	Name      string
	Color     string
	SortOrder int
	Projects  []dashboardProject
}

type dashboardView struct {
	Projects      []dashboardProject
	Collections   []dashboardCollection
	Unassigned    []dashboardProject
	Flash         flashMessage
	Query         string
	StateFilter   string
	FavoritesOnly bool
	Sort          string
	View          string
	Page          int
	PrevPage      int
	NextPage      int
	HasPrev       bool
	HasNext       bool
	TotalProjects int
	RepoCount     int
	DirtyCount    int
	WarningCount  int
	SnapshotName  string
	Warnings      []dashboardWarning
}

type dashboardWarning struct {
	ProjectID   string
	ProjectName string
	Message     string
	Level       string
	Priority    int
}

type operationsView struct {
	Projects             []project.Project
	Ports                []operations.PortStatus
	Todos                []operations.TodoItem
	Snapshots            []project.Snapshot
	ScannerRules         []project.ScannerRule
	Dependencies         []project.Dependency
	Events               []project.Event
	Manifests            map[string]operations.Manifest
	ProjectNames         map[string]string
	Flash                flashMessage
	HealthRefreshSeconds int
}

type backupPayload struct {
	Version      int                   `json:"version"`
	Projects     []project.Project     `json:"projects"`
	Collections  []project.Collection  `json:"collections"`
	Snapshots    []project.Snapshot    `json:"snapshots"`
	ScannerRules []project.ScannerRule `json:"scanner_rules"`
	Dependencies []project.Dependency  `json:"dependencies"`
}

func NewServer(cfg config.Config, store *storage.Store) *Server {
	if cfg.Monitoring.RefreshSeconds <= 0 {
		cfg.Monitoring.RefreshSeconds = 10
	}
	return &Server{
		cfg:       cfg,
		projects:  app.NewService(cfg, store),
		gitClient: git.NewClient(),
		store:     store,
		processes: operations.NewProcessManager(),
	}
}

func (s *Server) Close() {
	if s != nil {
		s.processes.StopAll()
	}
}

func (s *Server) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/", s.handleDashboard)
	mux.HandleFunc("/projects", s.handleProjects)
	mux.HandleFunc("/projects/rescan", s.handleRescan)
	mux.HandleFunc("/projects/favorite", s.handleFavorite)
	mux.HandleFunc("/projects/state", s.handleState)
	mux.HandleFunc("/collections/create", s.handleCreateCollection)
	mux.HandleFunc("/collections/delete", s.handleDeleteCollection)
	mux.HandleFunc("/collections/assign", s.handleAssignCollection)
	mux.HandleFunc("/collections/update", s.handleUpdateCollection)
	mux.HandleFunc("/collections/move", s.handleMoveCollection)
	mux.HandleFunc("/projects/metadata", s.handleProjectMetadata)
	mux.HandleFunc("/operations", s.handleOperations)
	mux.HandleFunc("/operations/health", s.handleOperationsHealth)
	mux.HandleFunc("/operations/snapshots/create", s.handleCreateSnapshot)
	mux.HandleFunc("/operations/snapshots/delete", s.handleDeleteSnapshot)
	mux.HandleFunc("/operations/scanner/set", s.handleSetScannerRule)
	mux.HandleFunc("/operations/scanner/delete", s.handleDeleteScannerRule)
	mux.HandleFunc("/operations/scanner/preview", s.handleScannerPreview)
	mux.HandleFunc("/operations/dependencies/set", s.handleSetDependency)
	mux.HandleFunc("/operations/dependencies/delete", s.handleDeleteDependency)
	mux.HandleFunc("/operations/actions/run", s.handleRunAction)
	mux.HandleFunc("/operations/actions/start", s.handleStartAction)
	mux.HandleFunc("/operations/actions/stop", s.handleStopAction)
	mux.HandleFunc("/operations/actions/status", s.handleActionStatus)
	mux.HandleFunc("/operations/backup", s.handleBackup)
	mux.HandleFunc("/operations/backup/import", s.handleBackupImport)
	mux.HandleFunc("/projects/", s.handleProject)
	return csrfGuard(mux)
}

func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/operations" || r.Method != http.MethodGet {
		http.NotFound(w, r)
		return
	}
	projects, err := s.projects.List(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	snapshots, err := s.store.ListSnapshots(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	rules, err := s.store.ListScannerRules(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	dependencies, err := s.store.ListDependencies(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	events, err := s.store.ListEvents(r.Context(), 50)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	manifests := make(map[string]operations.Manifest, len(projects))
	projectNames := make(map[string]string, len(projects))
	for _, current := range projects {
		manifests[current.ID] = operations.LoadManifest(current.Path)
		projectNames[current.ID] = current.Name
	}
	view := operationsView{Projects: projects, Ports: operations.CollectPorts(r.Context(), projects), Todos: operations.CollectTodos(projects), Snapshots: snapshots, ScannerRules: rules, Dependencies: dependencies, Events: events, Manifests: manifests, ProjectNames: projectNames, Flash: readFlash(r), HealthRefreshSeconds: s.cfg.Monitoring.RefreshSeconds}
	if err := operationsTemplate.Execute(w, view); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleOperationsHealth(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	projects, err := s.projects.List(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := healthPanelTemplate.Execute(w, map[string]any{
		"Ports":          operations.CollectPorts(r.Context(), projects),
		"RefreshSeconds": s.cfg.Monitoring.RefreshSeconds,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	projectIDs := r.Form["project_id"]
	if len(projectIDs) == 0 {
		projects, err := s.projects.List(r.Context())
		if err != nil {
			redirectWithFlash(w, r, "/operations", "error", err.Error())
			return
		}
		for _, current := range projects {
			projectIDs = append(projectIDs, current.ID)
		}
	}
	if err := s.store.CreateSnapshot(r.Context(), r.FormValue("name"), projectIDs); err != nil {
		redirectWithFlash(w, r, "/operations", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/operations", "success", "Workspace-Snapshot gespeichert")
}

func (s *Server) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
	if r.Method != http.MethodPost || err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}
	_ = s.store.DeleteSnapshot(r.Context(), id)
	http.Redirect(w, r, "/operations", http.StatusSeeOther)
}

func (s *Server) handleSetScannerRule(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if err := s.store.SetScannerRule(r.Context(), r.FormValue("path"), r.FormValue("kind")); err != nil {
		redirectWithFlash(w, r, "/operations", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/operations", "success", "Scanner-Regel gespeichert; beim nächsten Rescan aktiv")
}

func (s *Server) handleDeleteScannerRule(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	_ = s.store.DeleteScannerRule(r.Context(), r.FormValue("path"))
	http.Redirect(w, r, "/operations", http.StatusSeeOther)
}

func (s *Server) handleScannerPreview(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	candidates, err := s.projects.PreviewScan(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, `<!doctype html><html lang="de"><meta charset="utf-8"><body style="font-family:system-ui;background:#080d18;color:#e7eef9;padding:2rem"><h1>Scan-Vorschau</h1><p>Diese Vorschau verändert die Datenbank nicht.</p><table style="width:100%;border-collapse:collapse"><tr><th>Name</th><th>Root</th><th>Typ</th><th>Pfad</th></tr>`)
	for _, candidate := range candidates {
		fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td>%s</td><td><code>%s</code></td></tr>`, template.HTMLEscapeString(candidate.Name), template.HTMLEscapeString(candidate.RootName), template.HTMLEscapeString(candidate.PrimaryType), template.HTMLEscapeString(candidate.Path))
	}
	fmt.Fprint(w, `</table><p><a style="color:#7dd3fc" href="/operations">Zurück</a></p></body></html>`)
}

func (s *Server) handleSetDependency(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if err := s.store.SetDependency(r.Context(), r.FormValue("project_id"), r.FormValue("depends_on")); err != nil {
		redirectWithFlash(w, r, "/operations", "error", err.Error())
		return
	}
	http.Redirect(w, r, "/operations", http.StatusSeeOther)
}

func (s *Server) handleDeleteDependency(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	_ = s.store.DeleteDependency(r.Context(), r.FormValue("project_id"), r.FormValue("depends_on"))
	http.Redirect(w, r, "/operations", http.StatusSeeOther)
}

func (s *Server) handleRunAction(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	current, ok, err := s.projects.Get(r.Context(), r.FormValue("project_id"))
	returnTo := backTo(r, "/operations")
	if err != nil || !ok {
		redirectWithFlash(w, r, returnTo, "error", "Projekt nicht gefunden")
		return
	}
	kind := r.FormValue("kind")
	if operations.IsLongRunningAction(kind) {
		redirectWithFlash(w, r, returnTo, "error", "Dauerhafte Aktion bitte mit Start ausführen")
		return
	}
	manifest := operations.LoadManifest(current.Path)
	if !manifestDeclaresAction(manifest, kind) || operations.ValidateAction(kind) != nil {
		redirectWithFlash(w, r, returnTo, "error", "Aktion ist nicht sicher deklariert")
		return
	}
	_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestartet", current.Name, kind))
	go func() {
		output, runErr := operations.RunAction(context.Background(), current.Path, kind)
		level := "info"
		message := fmt.Sprintf("%s: Aktion %s abgeschlossen", current.Name, kind)
		if runErr != nil {
			level = "error"
			message = fmt.Sprintf("%s: %s fehlgeschlagen: %v\n%s", current.Name, kind, runErr, output)
		}
		_ = s.store.AddEvent(context.Background(), level, message)
	}()
	redirectWithFlash(w, r, returnTo, "success", "Aktion wurde gestartet")
}

func (s *Server) handleStartAction(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	current, ok, err := s.projects.Get(r.Context(), r.FormValue("project_id"))
	returnTo := backTo(r, "/operations")
	if err != nil || !ok {
		redirectWithFlash(w, r, returnTo, "error", "Projekt nicht gefunden")
		return
	}
	kind := r.FormValue("kind")
	manifest := operations.LoadManifest(current.Path)
	if !manifestDeclaresAction(manifest, kind) || !operations.IsLongRunningAction(kind) {
		redirectWithFlash(w, r, returnTo, "error", "Aktion ist nicht als sicherer Dienst deklariert")
		return
	}
	if err := s.processes.Start(current.ID, current.Path, kind); err != nil {
		redirectWithFlash(w, r, returnTo, "error", err.Error())
		return
	}
	_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestartet", current.Name, kind))
	redirectWithFlash(w, r, returnTo, "success", "Dienst wurde gestartet")
}

func (s *Server) handleStopAction(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	projectID := strings.TrimSpace(r.FormValue("project_id"))
	kind := strings.TrimSpace(r.FormValue("kind"))
	returnTo := backTo(r, "/operations")
	current, ok, err := s.projects.Get(r.Context(), projectID)
	if err != nil || !ok || !manifestDeclaresAction(operations.LoadManifest(current.Path), kind) {
		redirectWithFlash(w, r, returnTo, "error", "Deklarierte Aktion nicht gefunden")
		return
	}
	if err := s.processes.Stop(projectID, kind); err != nil {
		redirectWithFlash(w, r, returnTo, "error", err.Error())
		return
	}
	_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestoppt", current.Name, kind))
	redirectWithFlash(w, r, returnTo, "success", "Dienst wird gestoppt")
}

func (s *Server) handleActionStatus(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
	kind := strings.TrimSpace(r.URL.Query().Get("kind"))
	current, ok, err := s.projects.Get(r.Context(), projectID)
	if err != nil || !ok || !manifestDeclaresAction(operations.LoadManifest(current.Path), kind) {
		http.NotFound(w, r)
		return
	}
	if err := actionStatusTemplate.Execute(w, s.processes.Status(projectID, kind)); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func manifestDeclaresAction(manifest operations.Manifest, kind string) bool {
	for _, action := range manifest.Actions {
		if action.Kind == kind {
			return true
		}
	}
	return false
}

func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	projects, _ := s.projects.List(r.Context())
	collections, _ := s.store.ListCollections(r.Context())
	snapshots, _ := s.store.ListSnapshots(r.Context())
	rules, _ := s.store.ListScannerRules(r.Context())
	dependencies, _ := s.store.ListDependencies(r.Context())
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Disposition", `attachment; filename="stackyard-backup.json"`)
	_ = json.NewEncoder(w).Encode(map[string]any{"version": 1, "exported_at": time.Now().UTC(), "projects": projects, "collections": collections, "snapshots": snapshots, "scanner_rules": rules, "dependencies": dependencies})
}

func (s *Server) handleBackupImport(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	var data []byte
	if encoded := r.FormValue("payload"); encoded != "" {
		decoded, err := base64.StdEncoding.DecodeString(encoded)
		if err != nil {
			http.Error(w, "invalid backup payload", http.StatusBadRequest)
			return
		}
		data = decoded
	} else {
		if err := r.ParseMultipartForm(6 << 20); err != nil {
			http.Error(w, "invalid upload", http.StatusBadRequest)
			return
		}
		file, _, err := r.FormFile("backup")
		if err != nil {
			http.Error(w, "backup file missing", http.StatusBadRequest)
			return
		}
		defer file.Close()
		data, err = io.ReadAll(io.LimitReader(file, 5<<20))
		if err != nil {
			http.Error(w, "read backup", http.StatusBadRequest)
			return
		}
	}
	var payload backupPayload
	if err := json.Unmarshal(data, &payload); err != nil || payload.Version != 1 {
		http.Error(w, "unsupported or invalid backup", http.StatusBadRequest)
		return
	}
	if r.FormValue("mode") != "apply" {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprintf(w, `<!doctype html><html lang="de"><meta charset="utf-8"><title>Import-Vorschau</title><body style="font-family:system-ui;background:#080d18;color:#e7eef9;padding:2rem"><h1>Import-Vorschau</h1><ul><li>%d Projekte mit Metadaten</li><li>%d Projektmappen</li><li>%d Snapshots</li><li>%d Scanner-Regeln</li><li>%d Abhängigkeiten</li></ul><p>Projektdateien und Repository-Inhalte werden nicht verändert.</p><form method="post" action="/operations/backup/import"><input type="hidden" name="mode" value="apply"><input type="hidden" name="payload" value="%s"><button style="padding:.7rem">Import anwenden</button> <a style="color:#7dd3fc" href="/operations">Abbrechen</a></form></body></html>`, len(payload.Projects), len(payload.Collections), len(payload.Snapshots), len(payload.ScannerRules), len(payload.Dependencies), template.HTMLEscapeString(base64.StdEncoding.EncodeToString(data)))
		return
	}
	for _, saved := range payload.Projects {
		if _, ok, _ := s.store.GetProject(r.Context(), saved.ID); ok {
			_ = s.store.SetProjectMetadata(r.Context(), saved.ID, saved.Description, saved.Notes, strings.Join(saved.Labels, ","))
		}
	}
	for _, saved := range payload.Collections {
		_ = s.store.CreateCollection(r.Context(), saved.Name)
		collections, _ := s.store.ListCollections(r.Context())
		for _, created := range collections {
			if strings.EqualFold(created.Name, saved.Name) {
				_ = s.store.UpdateCollection(r.Context(), created.ID, created.Name, saved.Color)
				for _, projectID := range saved.ProjectIDs {
					_ = s.store.AssignProjectCollection(r.Context(), projectID, created.ID)
				}
				break
			}
		}
	}
	for _, saved := range payload.Snapshots {
		_ = s.store.CreateSnapshot(r.Context(), saved.Name, saved.ProjectIDs)
	}
	for _, rule := range payload.ScannerRules {
		_ = s.store.SetScannerRule(r.Context(), rule.Path, rule.Kind)
	}
	for _, dependency := range payload.Dependencies {
		_ = s.store.SetDependency(r.Context(), dependency.ProjectID, dependency.DependsOnProjectID)
	}
	_ = s.store.AddEvent(r.Context(), "info", "Stackyard-Backup importiert")
	redirectWithFlash(w, r, "/operations", "success", "Backup importiert")
}

func csrfGuard(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost {
			source := r.Header.Get("Origin")
			if source == "" {
				source = r.Header.Get("Referer")
			}
			if source != "" {
				parsed, err := url.Parse(source)
				if err != nil || !strings.EqualFold(parsed.Host, r.Host) {
					http.Error(w, "cross-site request rejected", http.StatusForbidden)
					return
				}
			}
		}
		next.ServeHTTP(w, r)
	})
}

func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/projects" {
		http.NotFound(w, r)
		return
	}
	s.handleDashboard(w, r)
}

func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}

	projects, err := s.projects.List(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var snapshotName string
	if snapshotID, parseErr := strconv.ParseInt(r.URL.Query().Get("snapshot"), 10, 64); parseErr == nil && snapshotID > 0 {
		if snapshot, ok, snapshotErr := s.store.GetSnapshot(r.Context(), snapshotID); snapshotErr == nil && ok {
			allowed := make(map[string]struct{}, len(snapshot.ProjectIDs))
			for _, id := range snapshot.ProjectIDs {
				allowed[id] = struct{}{}
			}
			filtered := projects[:0]
			for _, current := range projects {
				if _, include := allowed[current.ID]; include {
					filtered = append(filtered, current)
				}
			}
			projects = filtered
			snapshotName = snapshot.Name
		}
	}
	query := strings.TrimSpace(r.URL.Query().Get("q"))
	stateFilter := strings.TrimSpace(r.URL.Query().Get("state"))
	favoritesOnly := r.URL.Query().Get("favorites") == "1"
	sortMode := strings.TrimSpace(r.URL.Query().Get("sort"))
	viewMode := strings.TrimSpace(r.URL.Query().Get("view"))
	if viewMode != "list" {
		viewMode = "cards"
	}
	if query != "" {
		projects = filterProjects(projects, query)
	}
	if stateFilter == "" {
		projects = filterWithoutArchived(projects)
	} else if stateFilter != "all" {
		projects = filterByState(projects, stateFilter)
	}
	if favoritesOnly {
		projects = filterFavorites(projects)
	}
	sortProjects(projects, sortMode)
	statsProjects := append([]project.Project(nil), projects...)
	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
	if page < 1 {
		page = 1
	}
	const pageSize = 24
	total := len(projects)
	start := (page - 1) * pageSize
	if start > total {
		start = total
	}
	end := min(start+pageSize, total)
	projects = projects[start:end]

	collections, err := s.store.ListCollections(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	view := buildDashboardView(projects, collections)
	view.Flash = readFlash(r)
	view.Query = query
	view.StateFilter = stateFilter
	view.FavoritesOnly = favoritesOnly
	view.Sort = sortMode
	view.View = viewMode
	view.Page = page
	view.HasPrev = page > 1
	view.PrevPage = page - 1
	view.HasNext = end < total
	view.NextPage = page + 1
	view.SnapshotName = snapshotName
	view.TotalProjects = len(statsProjects)
	for _, current := range statsProjects {
		if current.Git.IsRepo {
			view.RepoCount++
		}
		if current.Git.Dirty {
			view.DirtyCount++
		}
		warnings := dashboardWarnings(current)
		if len(warnings) > 0 {
			view.WarningCount++
		}
		view.Warnings = append(view.Warnings, warnings...)
	}
	slices.SortFunc(view.Warnings, func(a, b dashboardWarning) int {
		if a.Priority != b.Priority {
			return a.Priority - b.Priority
		}
		return strings.Compare(a.ProjectName, b.ProjectName)
	})
	if len(view.Warnings) > 8 {
		view.Warnings = view.Warnings[:8]
	}
	if err := dashboardTemplate.Execute(w, view); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func dashboardWarnings(current project.Project) []dashboardWarning {
	if _, err := os.Stat(current.Path); err != nil {
		return []dashboardWarning{{ProjectID: current.ID, ProjectName: current.Name, Message: "Projektpfad nicht erreichbar", Level: "critical", Priority: 0}}
	}
	var warnings []dashboardWarning
	if current.Git.IsRepo && current.Git.Dirty {
		warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "Nicht gespeicherte Git-Änderungen", Level: "warning", Priority: 1})
	}
	if current.ReadmePath == "" {
		warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "README fehlt", Level: "notice", Priority: 2})
	}
	if current.TodoPath == "" {
		warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "TODO fehlt", Level: "notice", Priority: 3})
	}
	return warnings
}

func sortProjects(projects []project.Project, mode string) {
	slices.SortStableFunc(projects, func(a, b project.Project) int {
		switch mode {
		case "commit":
			return b.Git.LastCommitAt.Compare(a.Git.LastCommitAt)
		case "opened":
			return b.LastOpenedAt.Compare(a.LastOpenedAt)
		case "state":
			if cmp := strings.Compare(string(a.State), string(b.State)); cmp != 0 {
				return cmp
			}
		}
		return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
	})
}

func buildDashboardView(projects []project.Project, collections []project.Collection) dashboardView {
	assignment := make(map[string]int64)
	groups := make([]dashboardCollection, len(collections))
	groupIndex := make(map[int64]int, len(collections))
	for i, collection := range collections {
		groups[i] = dashboardCollection{ID: collection.ID, Name: collection.Name, Color: collection.Color, SortOrder: collection.SortOrder}
		groupIndex[collection.ID] = i
		for _, projectID := range collection.ProjectIDs {
			assignment[projectID] = collection.ID
		}
	}
	view := dashboardView{Collections: groups, Projects: make([]dashboardProject, 0, len(projects))}
	for _, current := range projects {
		item := dashboardProject{Project: current, CollectionID: assignment[current.ID], CollectionOptions: collections}
		view.Projects = append(view.Projects, item)
		if index, ok := groupIndex[item.CollectionID]; ok {
			view.Collections[index].Projects = append(view.Collections[index].Projects, item)
		} else {
			view.Unassigned = append(view.Unassigned, item)
		}
	}
	return view
}

func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if err := s.store.CreateCollection(r.Context(), r.FormValue("name")); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/", "success", "Projektmappe angelegt")
}

func (s *Server) handleDeleteCollection(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
	if err != nil || id <= 0 {
		redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
		return
	}
	if err := s.store.DeleteCollection(r.Context(), id); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/", "success", "Projektmappe entfernt; Projekte bleiben erhalten")
}

func (s *Server) handleAssignCollection(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	collectionID, err := strconv.ParseInt(r.FormValue("collection_id"), 10, 64)
	if err != nil || collectionID < 0 {
		redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
		return
	}
	if err := s.store.AssignProjectCollection(r.Context(), strings.TrimSpace(r.FormValue("project_id")), collectionID); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/", "success", "Projektzuordnung aktualisiert")
}

func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
	if err != nil || id <= 0 {
		redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
		return
	}
	if err := s.store.UpdateCollection(r.Context(), id, r.FormValue("name"), r.FormValue("color")); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/", "success", "Projektmappe aktualisiert")
}

func (s *Server) handleMoveCollection(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
	direction, directionErr := strconv.Atoi(r.FormValue("direction"))
	if err != nil || id <= 0 || directionErr != nil || (direction != -1 && direction != 1) {
		redirectWithFlash(w, r, "/", "error", "ungueltige Sortierung")
		return
	}
	if err := s.store.MoveCollection(r.Context(), id, direction); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) handleProjectMetadata(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	id := strings.TrimSpace(r.FormValue("id"))
	if err := s.store.SetProjectMetadata(r.Context(), id, r.FormValue("description"), r.FormValue("notes"), r.FormValue("labels")); err != nil {
		redirectWithFlash(w, r, "/projects/"+id, "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/projects/"+id, "success", "Projektinformationen gespeichert")
}

func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	if _, err := s.projects.Refresh(r.Context()); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	id := strings.TrimSpace(r.FormValue("id"))
	favorite := r.FormValue("favorite") == "1"
	if err := s.projects.SetFavorite(r.Context(), id, favorite); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, backTo(r, "/"), "success", "Favorit aktualisiert")
}

func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	id := strings.TrimSpace(r.FormValue("id"))
	state := project.State(strings.TrimSpace(r.FormValue("state")))
	if err := s.projects.SetState(r.Context(), id, state); err != nil {
		redirectWithFlash(w, r, "/", "error", err.Error())
		return
	}
	redirectWithFlash(w, r, backTo(r, "/"), "success", "Projektstatus aktualisiert")
}

func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
	trimmed := strings.Trim(strings.TrimPrefix(r.URL.Path, "/projects/"), "/")
	if trimmed == "" {
		http.NotFound(w, r)
		return
	}
	parts := strings.Split(trimmed, "/")
	projectKey := parts[0]
	action := ""
	if len(parts) == 2 {
		action = parts[1]
	}

	current, ok, err := s.projects.Get(r.Context(), projectKey)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if !ok {
		http.NotFound(w, r)
		return
	}

	if r.Method == http.MethodPost {
		switch action {
		case "todo":
			s.handleSaveTodo(w, r, current.ID, current.Path)
			return
		case "git", "git-branch":
			s.handleBranchAction(w, r, current.ID)
			return
		case "open":
			s.handleOpenProject(w, r, current.ID)
			return
		case "editor":
			if r.FormValue("mode") == "create" {
				s.handleProjectEditorCreate(w, r, current)
			} else {
				s.handleProjectEditorSave(w, r, current)
			}
			return
		}
	}

	if r.Method == http.MethodGet && action != "" {
		switch action {
		case "git":
			s.handleProjectGit(w, r, current)
			return
		case "todo":
			s.handleProjectTodo(w, r, current)
			return
		case "files":
			s.handleProjectFiles(w, r, current)
			return
		case "editor":
			s.handleProjectEditor(w, r, current)
			return
		case "download":
			s.handleProjectDownload(w, r, current)
			return
		}
	}
	if action != "" {
		http.NotFound(w, r)
		return
	}

	readmeBody := loadReadme(current.Path)
	readmeHTML := renderMarkdown(readmeBody)
	manifest := operations.LoadManifest(current.Path)

	if err := projectTemplate.Execute(w, map[string]any{
		"Project":    current,
		"Flash":      readFlash(r),
		"ReadmeBody": readmeBody,
		"ReadmeHTML": template.HTML(readmeHTML),
		"LabelsText": strings.Join(current.Labels, ", "),
		"Manifest":   manifest,
		"Ports":      operations.CollectPorts(r.Context(), []project.Project{current}),
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request, current project.Project) {
	format := archive.Format(r.URL.Query().Get("format"))
	extension := string(format)
	contentType := "application/gzip"
	if format == archive.FormatZIP {
		contentType = "application/zip"
	} else if format != archive.FormatTarGZ {
		http.Error(w, "unsupported archive format", http.StatusBadRequest)
		return
	}
	name := strings.Map(func(r rune) rune {
		if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
			return r
		}
		return '-'
	}, current.Name)
	w.Header().Set("Content-Type", contentType)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, name, extension))
	w.Header().Set("X-Content-Type-Options", "nosniff")
	if err := archive.Write(w, current.Path, format, archive.Options{IgnoreDirs: s.cfg.Scanner.IgnoreDirs}); err != nil {
		fmt.Fprintf(os.Stderr, "stackyard: archive %s: %v\n", current.Path, err)
	}
}

func (s *Server) handleProjectEditor(w http.ResponseWriter, r *http.Request, current project.Project) {
	selected := strings.TrimSpace(r.URL.Query().Get("path"))
	content := ""
	flash := readFlash(r)
	if selected != "" {
		var err error
		content, err = readEditorFile(current.Path, selected)
		if err != nil {
			flash = flashMessage{Kind: "error", Text: err.Error()}
			selected = ""
		}
	}
	if err := editorTemplate.Execute(w, editorView{
		Project: current, Files: loadFileTree(current.Path, s.cfg.Scanner.IgnoreDirs),
		SelectedPath: selected, Content: content, Flash: flash, Protected: isProtectedEditorPath(selected),
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleProjectEditorSave(w http.ResponseWriter, r *http.Request, current project.Project) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	relPath := strings.TrimSpace(r.FormValue("path"))
	if err := writeEditorFile(current.Path, relPath, r.FormValue("content")); err != nil {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor?path="+urlQueryEscape(filepath.ToSlash(relPath)), "error", err.Error())
		return
	}
	target := "/projects/" + current.ID + "/editor?path=" + urlQueryEscape(filepath.ToSlash(relPath))
	redirectWithFlash(w, r, target, "success", "Datei gespeichert")
}

func resolveEditorFile(projectDir, relativePath string) (string, error) {
	relativePath = strings.TrimSpace(relativePath)
	if relativePath == "" || filepath.IsAbs(relativePath) {
		return "", errors.New("ungueltiger Dateipfad")
	}
	clean := filepath.Clean(filepath.FromSlash(relativePath))
	if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", errors.New("Datei liegt ausserhalb des Projekts")
	}
	root, err := filepath.EvalSymlinks(projectDir)
	if err != nil {
		return "", fmt.Errorf("Projektpfad aufloesen: %w", err)
	}
	target := filepath.Join(root, clean)
	resolved, err := filepath.EvalSymlinks(target)
	if err != nil {
		return "", fmt.Errorf("Datei aufloesen: %w", err)
	}
	rel, err := filepath.Rel(root, resolved)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return "", errors.New("Datei liegt ausserhalb des Projekts")
	}
	info, err := os.Stat(resolved)
	if err != nil {
		return "", fmt.Errorf("Datei pruefen: %w", err)
	}
	if !info.Mode().IsRegular() {
		return "", errors.New("nur regulaere Dateien koennen bearbeitet werden")
	}
	if info.Size() > maxEditorFileSize {
		return "", errors.New("Datei ist groesser als 2 MiB")
	}
	return resolved, nil
}

func readEditorFile(projectDir, relativePath string) (string, error) {
	path, err := resolveEditorFile(projectDir, relativePath)
	if err != nil {
		return "", err
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return "", fmt.Errorf("Datei lesen: %w", err)
	}
	if !utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0 {
		return "", errors.New("Binaerdateien koennen nicht bearbeitet werden")
	}
	return string(data), nil
}

func writeEditorFile(projectDir, relativePath, content string) error {
	if isProtectedEditorPath(relativePath) {
		return errors.New("diese sensible oder generierte Datei ist in der WebIDE schreibgeschuetzt")
	}
	path, err := resolveEditorFile(projectDir, relativePath)
	if err != nil {
		return err
	}
	if len(content) > maxEditorFileSize {
		return errors.New("Inhalt ist groesser als 2 MiB")
	}
	info, err := os.Stat(path)
	if err != nil {
		return fmt.Errorf("Datei pruefen: %w", err)
	}
	if err := os.WriteFile(path, []byte(content), info.Mode().Perm()); err != nil {
		return fmt.Errorf("Datei speichern: %w", err)
	}
	return nil
}

func isProtectedEditorPath(relativePath string) bool {
	name := strings.ToLower(filepath.Base(filepath.Clean(relativePath)))
	if name == ".env" || strings.HasPrefix(name, ".env.") || name == "credentials" || name == "credentials.json" {
		return true
	}
	for _, suffix := range []string{".pem", ".key", ".pfx", ".p12", ".lock", "-lock.json", "go.sum"} {
		if strings.HasSuffix(name, suffix) {
			return true
		}
	}
	return false
}

func (s *Server) handleProjectEditorCreate(w http.ResponseWriter, r *http.Request, current project.Project) {
	relPath := strings.TrimSpace(filepath.ToSlash(r.FormValue("path")))
	kind := r.FormValue("kind")
	if relPath == "" || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(filepath.Clean(relPath), ".."+string(filepath.Separator)) || isProtectedEditorPath(relPath) {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "ungueltiger oder geschuetzter Pfad")
		return
	}
	root, err := filepath.EvalSymlinks(current.Path)
	if err != nil {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", err.Error())
		return
	}
	target := filepath.Join(root, filepath.FromSlash(relPath))
	parent, err := filepath.EvalSymlinks(filepath.Dir(target))
	if err != nil {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "übergeordneter Ordner existiert nicht")
		return
	}
	inside, err := filepath.Rel(root, parent)
	if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "Pfad liegt ausserhalb des Projekts")
		return
	}
	if _, err := os.Stat(target); !os.IsNotExist(err) {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "Datei oder Ordner existiert bereits")
		return
	}
	if kind == "folder" {
		err = os.Mkdir(target, 0o755)
	} else {
		err = os.WriteFile(target, nil, 0o644)
	}
	if err != nil {
		redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", fmt.Sprintf("anlegen: %v", err))
		return
	}
	targetURL := "/projects/" + current.ID + "/editor"
	if kind != "folder" {
		targetURL += "?path=" + urlQueryEscape(relPath)
	}
	redirectWithFlash(w, r, targetURL, "success", "Eintrag angelegt")
}

func (s *Server) handleProjectGit(w http.ResponseWriter, r *http.Request, current project.Project) {
	if !current.Git.IsRepo {
		s.renderGitUnavailable(w, current, "Dieses Projekt ist kein Git-Repository.")
		return
	}
	info, err := os.Stat(current.Path)
	if err != nil {
		s.renderGitUnavailable(w, current, "Der Projektpfad ist momentan nicht erreichbar. Bitte Laufwerk oder Netzwerkfreigabe prüfen und die Projekte neu scannen.")
		return
	}
	if !info.IsDir() {
		s.renderGitUnavailable(w, current, "Der gespeicherte Projektpfad ist kein Verzeichnis. Bitte die Projekte neu scannen.")
		return
	}

	branches, currentBranch, err := s.gitClient.Branches(r.Context(), current.Path)
	if err != nil {
		s.renderGitUnavailable(w, current, "Git-Informationen konnten nicht geladen werden. Bitte den Projektpfad prüfen und die Projekte neu scannen.")
		return
	}
	commits, err := s.gitClient.Log(r.Context(), current.Path, 20)
	if err != nil {
		s.renderGitUnavailable(w, current, "Git-Informationen konnten nicht geladen werden. Bitte das Repository prüfen.")
		return
	}
	changedFiles, err := s.gitClient.ChangedFiles(r.Context(), current.Path)
	if err != nil {
		s.renderGitUnavailable(w, current, "Der Git-Arbeitsbaum konnte nicht gelesen werden. Bitte das Repository prüfen.")
		return
	}
	diffSummary, err := s.gitClient.DiffSummary(r.Context(), current.Path)
	if err != nil {
		s.renderGitUnavailable(w, current, "Die Git-Diff-Zusammenfassung konnte nicht geladen werden. Bitte das Repository prüfen.")
		return
	}
	if err := gitPanelTemplate.Execute(w, map[string]any{
		"Project":       current,
		"Branches":      branches,
		"CurrentBranch": currentBranch,
		"Commits":       commits,
		"ChangedFiles":  changedFiles,
		"DiffSummary":   diffSummary,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) renderGitUnavailable(w http.ResponseWriter, current project.Project, message string) {
	if err := gitUnavailableTemplate.Execute(w, map[string]any{
		"Project": current,
		"Message": message,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleProjectTodo(w http.ResponseWriter, r *http.Request, current project.Project) {
	todoPath, todoBody, err := todo.Read(current.Path)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if err := todoPanelTemplate.Execute(w, map[string]any{
		"Project":  current,
		"TodoPath": todoPath,
		"TodoBody": todoBody,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleProjectFiles(w http.ResponseWriter, r *http.Request, current project.Project) {
	files := loadFileTree(current.Path, s.cfg.Scanner.IgnoreDirs)
	if err := filesPanelTemplate.Execute(w, map[string]any{
		"Files": files,
	}); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *Server) handleSaveTodo(w http.ResponseWriter, r *http.Request, projectID, projectPath string) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	if _, err := todo.Write(projectPath, r.FormValue("body")); err != nil {
		redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
		return
	}
	if _, err := s.projects.Refresh(r.Context()); err != nil {
		redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/projects/"+projectID, "success", "TODO gespeichert")
}

func (s *Server) handleBranchAction(w http.ResponseWriter, r *http.Request, projectID string) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	branch := strings.TrimSpace(r.FormValue("branch"))
	mode := r.FormValue("mode")
	var err error
	switch mode {
	case "checkout":
		err = s.projects.CheckoutBranch(r.Context(), projectID, branch)
	case "create":
		err = s.projects.CreateBranch(r.Context(), projectID, branch)
	default:
		redirectWithFlash(w, r, "/projects/"+projectID, "error", "ungueltige Branch-Aktion")
		return
	}
	if err != nil {
		redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
		return
	}
	message := fmt.Sprintf("Branch-Aktion erfolgreich: %s", branch)
	if mode == "create" {
		message = fmt.Sprintf("Branch erstellt: %s", branch)
	}
	redirectWithFlash(w, r, "/projects/"+projectID, "success", message)
}

func (s *Server) handleOpenProject(w http.ResponseWriter, r *http.Request, projectID string) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	action := launcher.Action(strings.TrimSpace(r.FormValue("action")))
	if err := s.projects.Launch(r.Context(), projectID, action); err != nil {
		redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
		return
	}
	redirectWithFlash(w, r, "/projects/"+projectID, "success", fmt.Sprintf("Projekt geoeffnet: %s", action))
}

func loadReadme(projectDir string) string {
	path := filepath.Join(projectDir, "README.md")
	data, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return string(data)
}

func loadFileTree(projectDir string, ignoreDirs []string) []fileTreeNode {
	nodes, err := buildFileTree(projectDir, projectDir, makeIgnoreSet(ignoreDirs))
	if err != nil {
		return nil
	}
	return nodes
}

func buildFileTree(rootDir, currentDir string, ignoreDirs map[string]struct{}) ([]fileTreeNode, error) {
	remaining := maxFileTreeEntries
	return buildFileTreeDepth(rootDir, currentDir, ignoreDirs, 0, &remaining)
}

func buildFileTreeDepth(rootDir, currentDir string, ignoreDirs map[string]struct{}, depth int, remaining *int) ([]fileTreeNode, error) {
	if depth > maxFileTreeDepth || *remaining <= 0 {
		return nil, nil
	}
	entries, err := os.ReadDir(currentDir)
	if err != nil {
		return nil, err
	}

	nodes := make([]fileTreeNode, 0, len(entries))
	for _, entry := range entries {
		if *remaining <= 0 {
			break
		}
		if _, ignored := ignoreDirs[entry.Name()]; ignored && entry.IsDir() {
			continue
		}
		if entry.Type()&os.ModeSymlink != 0 {
			continue
		}

		fullPath := filepath.Join(currentDir, entry.Name())
		*remaining--
		node := fileTreeNode{
			Name:  entry.Name(),
			Path:  strings.TrimPrefix(strings.TrimPrefix(fullPath, rootDir), string(filepath.Separator)),
			IsDir: entry.IsDir(),
		}
		if entry.IsDir() {
			children, err := buildFileTreeDepth(rootDir, fullPath, ignoreDirs, depth+1, remaining)
			if err != nil {
				return nil, err
			}
			node.Children = children
			node.Name += "/"
		}
		nodes = append(nodes, node)
	}
	slices.SortFunc(nodes, func(a, b fileTreeNode) int {
		if a.IsDir != b.IsDir {
			if a.IsDir {
				return -1
			}
			return 1
		}
		return strings.Compare(a.Name, b.Name)
	})
	return nodes, nil
}

func makeIgnoreSet(ignoreDirs []string) map[string]struct{} {
	set := make(map[string]struct{}, len(ignoreDirs))
	for _, dir := range ignoreDirs {
		set[dir] = struct{}{}
	}
	return set
}

func readFlash(r *http.Request) flashMessage {
	q := r.URL.Query()
	kind := q.Get("flash_kind")
	text := q.Get("flash")
	if text == "" {
		return flashMessage{}
	}
	if kind == "" {
		kind = "info"
	}
	return flashMessage{Kind: kind, Text: text}
}

func redirectWithFlash(w http.ResponseWriter, r *http.Request, path, kind, text string) {
	q := make([]string, 0, 2)
	if kind != "" {
		q = append(q, "flash_kind="+urlQueryEscape(kind))
	}
	if text != "" {
		q = append(q, "flash="+urlQueryEscape(text))
	}
	target := path
	if len(q) > 0 {
		separator := "?"
		if strings.Contains(target, "?") {
			separator = "&"
		}
		target += separator + strings.Join(q, "&")
	}
	http.Redirect(w, r, target, http.StatusSeeOther)
}

func urlQueryEscape(value string) string {
	replacer := strings.NewReplacer(
		"%", "%25",
		" ", "%20",
		"!", "%21",
		"\"", "%22",
		"#", "%23",
		"$", "%24",
		"&", "%26",
		"'", "%27",
		"(", "%28",
		")", "%29",
		"+", "%2B",
		",", "%2C",
		"/", "%2F",
		":", "%3A",
		";", "%3B",
		"=", "%3D",
		"?", "%3F",
		"@", "%40",
		"[", "%5B",
		"]", "%5D",
	)
	return replacer.Replace(value)
}

func renderMarkdown(input string) string {
	lines := strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n")
	var b strings.Builder
	inList := false
	inCode := false

	closeList := func() {
		if inList {
			b.WriteString("</ul>")
			inList = false
		}
	}

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		switch {
		case strings.HasPrefix(trimmed, "```"):
			closeList()
			if !inCode {
				b.WriteString("<pre><code>")
				inCode = true
			} else {
				b.WriteString("</code></pre>")
				inCode = false
			}
		case inCode:
			b.WriteString(html.EscapeString(line))
			b.WriteByte('\n')
		case trimmed == "":
			closeList()
		case strings.HasPrefix(trimmed, "# "):
			closeList()
			b.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(trimmed[2:])) + "</h1>")
		case strings.HasPrefix(trimmed, "## "):
			closeList()
			b.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(trimmed[3:])) + "</h2>")
		case strings.HasPrefix(trimmed, "### "):
			closeList()
			b.WriteString("<h3>" + html.EscapeString(strings.TrimSpace(trimmed[4:])) + "</h3>")
		case strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* "):
			if !inList {
				b.WriteString("<ul>")
				inList = true
			}
			b.WriteString("<li>" + html.EscapeString(strings.TrimSpace(trimmed[2:])) + "</li>")
		default:
			closeList()
			b.WriteString("<p>" + html.EscapeString(trimmed) + "</p>")
		}
	}
	closeList()
	if inCode {
		b.WriteString("</code></pre>")
	}
	return b.String()
}

func filterProjects(projects []project.Project, query string) []project.Project {
	query = strings.ToLower(strings.TrimSpace(query))
	if query == "" {
		return projects
	}
	filtered := make([]project.Project, 0, len(projects))
	for _, p := range projects {
		haystack := []string{
			p.Name,
			p.RootName,
			p.Path,
			p.PrimaryType,
			p.Git.Branch,
			strings.Join(p.MiseTools, " "),
			strings.Join(p.ComposeFiles, " "),
			p.Description,
			strings.Join(p.Labels, " "),
		}
		for _, port := range p.Ports {
			haystack = append(haystack, strconv.Itoa(port))
		}
		if strings.Contains(strings.ToLower(strings.Join(haystack, " ")), query) {
			filtered = append(filtered, p)
		}
	}
	return filtered
}

func filterByState(projects []project.Project, state string) []project.Project {
	state = strings.ToLower(strings.TrimSpace(state))
	filtered := make([]project.Project, 0, len(projects))
	for _, p := range projects {
		if strings.ToLower(string(p.State)) == state {
			filtered = append(filtered, p)
		}
	}
	return filtered
}

func filterFavorites(projects []project.Project) []project.Project {
	filtered := make([]project.Project, 0, len(projects))
	for _, p := range projects {
		if p.Favorite {
			filtered = append(filtered, p)
		}
	}
	return filtered
}

func filterWithoutArchived(projects []project.Project) []project.Project {
	filtered := make([]project.Project, 0, len(projects))
	for _, p := range projects {
		if p.State != project.StateArchived {
			filtered = append(filtered, p)
		}
	}
	return filtered
}

func backTo(r *http.Request, fallback string) string {
	if target := strings.TrimSpace(r.FormValue("return_to")); target != "" {
		parsed, err := url.Parse(target)
		if err == nil && !parsed.IsAbs() && parsed.Host == "" && strings.HasPrefix(parsed.Path, "/") && !strings.HasPrefix(parsed.Path, "//") {
			return target
		}
	}
	return fallback
}

var gitPanelTemplate = template.Must(template.New("git-panel").Parse(`
<section class="card">
  <div class="section-head">
    <h2>Branches</h2>
    <div class="section-note">Aktiv: {{if .CurrentBranch}}{{.CurrentBranch}}{{else}}-{{end}}</div>
  </div>
  <form class="inline-form" method="post" action="/projects/{{.Project.ID}}/git-branch" {{if .Project.Git.Dirty}}onsubmit="return confirm('Das Repository enthält ungespeicherte Änderungen. Branch-Aktion trotzdem ausführen?')"{{end}}>
    <select name="branch"><option value="">Branch auswählen</option>{{range .Branches}}<option value="{{.}}" {{if eq . $.CurrentBranch}}selected{{end}}>{{.}}</option>{{end}}</select>
    <button type="submit" name="mode" value="checkout">Wechseln</button>
  </form>
  <form class="inline-form" method="post" action="/projects/{{.Project.ID}}/git-branch" {{if .Project.Git.Dirty}}onsubmit="return confirm('Das Repository enthält ungespeicherte Änderungen. Branch trotzdem anlegen?')"{{end}}>
    <input type="text" name="branch" placeholder="neuer-branch">
    <button type="submit" name="mode" value="create">Anlegen</button>
  </form>
</section>
{{if .ChangedFiles}}<section class="card"><div class="section-head"><h2>Geänderte Dateien</h2><div class="section-note">{{len .ChangedFiles}} Einträge</div></div><ul class="list">{{range .ChangedFiles}}<li><span class="pill">{{.Status}}</span> <span class="path">{{.Path}}</span></li>{{end}}</ul></section>{{end}}
{{if .DiffSummary}}<section class="card"><div class="section-head"><h2>Diff-Zusammenfassung</h2></div><pre>{{.DiffSummary}}</pre></section>{{end}}
<section class="card">
  <div class="section-head">
    <h2>Commits</h2>
    <div class="section-note">Letzte 20 Eintraege</div>
  </div>
  <ul class="list">
    {{range .Commits}}
    <li>
      <div class="commit-subject">{{.Subject}}</div>
      <div class="muted">{{.Hash}} · {{.Committed.Format "2006-01-02 15:04"}}</div>
    </li>
    {{else}}
    <li class="empty">Keine Commits verfuegbar.</li>
    {{end}}
  </ul>
</section>`))

var gitUnavailableTemplate = template.Must(template.New("git-unavailable").Parse(`
<section class="card">
  <div class="section-head">
    <h2>Git nicht verfügbar</h2>
    <div class="section-note">{{.Project.Path}}</div>
  </div>
  <p class="empty">{{.Message}}</p>
</section>`))

var todoPanelTemplate = template.Must(template.New("todo-panel").Parse(`
<form method="post" action="/projects/{{.Project.ID}}/todo">
  <div class="section-note" style="margin-bottom:0.75rem;">{{if .TodoPath}}{{.TodoPath}}{{else}}Neue Datei wird unter .stackyard/todo.md angelegt{{end}}</div>
  <textarea name="body">{{.TodoBody}}</textarea>
  <p><button type="submit">TODO speichern</button></p>
</form>`))

var filesPanelTemplate = template.Must(template.New("files-panel").Parse(`
{{define "tree"}}
<ul>
  {{range .}}
  <li>
    {{if .Children}}
    <details class="tree-dir" open>
      <summary>{{.Name}}</summary>
      {{template "tree" .Children}}
    </details>
    {{else}}
    <span class="tree-file">{{.Name}}</span>
    {{end}}
  </li>
  {{else}}
  <li class="empty">Keine Dateien verfuegbar.</li>
  {{end}}
</ul>
{{end}}
{{template "tree" .Files}}
`))