From 470b492ba701aa108c772016da21e98737d688f3 Mon Sep 17 00:00:00 2001 From: hailaz <739476267@qq.com> Date: Thu, 8 Jan 2026 14:30:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=91=BD=E4=BB=A4=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/gf/gfcmd/gfcmd.go | 2 + cmd/gf/internal/cmd/cmddep/cmddep.go | 110 +++ cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go | 191 ++++ cmd/gf/internal/cmd/cmddep/cmddep_output.go | 257 ++++++ cmd/gf/internal/cmd/cmddep/cmddep_server.go | 752 +++++++++++++++ .../internal/cmd/cmddep/cmddep_z_unit_test.go | 114 +++ cmd/gf/internal/cmd/cmddep/static/app.js | 863 ++++++++++++++++++ cmd/gf/internal/cmd/cmddep/static/i18n.js | 156 ++++ cmd/gf/internal/cmd/cmddep/static/index.html | 133 +++ cmd/gf/internal/cmd/cmddep/static/style.css | 811 ++++++++++++++++ 10 files changed, 3389 insertions(+) create mode 100644 cmd/gf/internal/cmd/cmddep/cmddep.go create mode 100644 cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go create mode 100644 cmd/gf/internal/cmd/cmddep/cmddep_output.go create mode 100644 cmd/gf/internal/cmd/cmddep/cmddep_server.go create mode 100644 cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go create mode 100644 cmd/gf/internal/cmd/cmddep/static/app.js create mode 100644 cmd/gf/internal/cmd/cmddep/static/i18n.js create mode 100644 cmd/gf/internal/cmd/cmddep/static/index.html create mode 100644 cmd/gf/internal/cmd/cmddep/static/style.css diff --git a/cmd/gf/gfcmd/gfcmd.go b/cmd/gf/gfcmd/gfcmd.go index 44b38af4c..f77614acc 100644 --- a/cmd/gf/gfcmd/gfcmd.go +++ b/cmd/gf/gfcmd/gfcmd.go @@ -22,6 +22,7 @@ import ( "github.com/gogf/gf/v2/text/gstr" "github.com/gogf/gf/cmd/gf/v2/internal/cmd" + "github.com/gogf/gf/cmd/gf/v2/internal/cmd/cmddep" "github.com/gogf/gf/cmd/gf/v2/internal/utility/allyes" "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" ) @@ -89,6 +90,7 @@ func GetCommand(ctx context.Context) (*Command, error) { cmd.Install, cmd.Version, cmd.Doc, + cmddep.Dep, ) if err != nil { return nil, err diff --git a/cmd/gf/internal/cmd/cmddep/cmddep.go b/cmd/gf/internal/cmd/cmddep/cmddep.go new file mode 100644 index 000000000..7d8115da8 --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/cmddep.go @@ -0,0 +1,110 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package cmddep + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gtag" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" +) + +var ( + Dep = cDep{} +) + +type cDep struct { + g.Meta `name:"dep" brief:"{cDepBrief}" eg:"{cDepEg}"` +} + +const ( + cDepBrief = `analyze and display Go package dependencies` + cDepEg = ` +gf dep +gf dep ./... +gf dep ./internal/... +gf dep -f list +gf dep -f mermaid +gf dep -f mermaid -g +gf dep -f dot -d 5 +gf dep -f json -d 0 +gf dep -g +gf dep -r +gf dep -i=false +gf dep -s +gf dep -s -p 8080 +gf dep ./internal/... -f tree -d 2 +` +) + +func init() { + gtag.Sets(g.MapStrStr{ + `cDepBrief`: cDepBrief, + `cDepEg`: cDepEg, + }) +} + +// Input defines the input parameters for dep command. +type Input struct { + g.Meta `name:"dep"` + Package string `name:"PACKAGE" arg:"true" brief:"package path to analyze, default is ./..." d:"./..."` + Format string `name:"format" short:"f" brief:"output format: tree/list/mermaid/dot/json" d:"tree"` + Depth int `name:"depth" short:"d" brief:"dependency depth limit, 0 means unlimited" d:"3"` + Group bool `name:"group" short:"g" brief:"group by top-level directory" d:"false"` + Internal bool `name:"internal" short:"i" brief:"show only internal packages" d:"true"` + NoStd bool `name:"nostd" short:"n" brief:"exclude standard library" d:"true"` + Reverse bool `name:"reverse" short:"r" brief:"show reverse dependencies" d:"false"` + Serve bool `name:"serve" short:"s" brief:"start HTTP server to view dependencies" d:"false" orphan:"true"` + Port int `name:"port" short:"p" brief:"HTTP server port" d:"8888"` +} + +// Output defines the output for dep command. +type Output struct{} + +// Index is the main entry point for the dep command. +func (c cDep) Index(ctx context.Context, in Input) (out *Output, err error) { + analyzer := newAnalyzer() + + // Detect module prefix from go.mod + analyzer.modulePrefix = analyzer.detectModulePrefix() + + // Get package information + loadErr := analyzer.loadPackages(ctx, in.Package) + + // Start HTTP server if requested + // In server mode, allow starting even without local Go module + // because users may want to analyze remote modules + if in.Serve { + if loadErr != nil { + mlog.Print("Warning: No local Go module found, you can analyze remote modules in the web UI") + } + return nil, analyzer.startServer(in) + } + + // For non-server mode, return error if loading failed + if loadErr != nil { + return nil, loadErr + } + + if len(analyzer.packages) == 0 { + mlog.Print("No packages found") + return + } + + // Generate output based on format + var output string + if in.Reverse { + output = analyzer.generateReverse(in) + } else { + output = analyzer.generate(in) + } + + mlog.Print(output) + return +} diff --git a/cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go b/cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go new file mode 100644 index 000000000..b114f2749 --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go @@ -0,0 +1,191 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package cmddep + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gproc" + "github.com/gogf/gf/v2/text/gstr" +) + +// goPackage represents a Go package from go list -json output. +type goPackage struct { + ImportPath string `json:"ImportPath"` + Imports []string `json:"Imports"` + Deps []string `json:"Deps"` + Standard bool `json:"Standard"` +} + +// depNode represents a node in the dependency tree. +type depNode struct { + Package string `json:"package"` + Dependencies []*depNode `json:"dependencies,omitempty"` +} + +// analyzer handles dependency analysis. +type analyzer struct { + packages map[string]*goPackage + modulePrefix string + visited map[string]bool + edges map[string]bool +} + +// newAnalyzer creates a new dependency analyzer. +func newAnalyzer() *analyzer { + return &analyzer{ + packages: make(map[string]*goPackage), + visited: make(map[string]bool), + edges: make(map[string]bool), + } +} + +// detectModulePrefix reads go.mod to get the module path. +func (a *analyzer) detectModulePrefix() string { + content := gfile.GetContents("go.mod") + if content == "" { + return "" + } + lines := gstr.Split(content, "\n") + for _, line := range lines { + line = gstr.Trim(line) + if gstr.HasPrefix(line, "module ") { + return gstr.Trim(line[7:]) + } + } + return "" +} + +// loadPackages loads package information using go list. +func (a *analyzer) loadPackages(ctx context.Context, pkgPath string) error { + cmd := fmt.Sprintf("go list -json %s", pkgPath) + result, err := gproc.ShellExec(ctx, cmd) + if err != nil { + return fmt.Errorf("failed to execute go list: %v", err) + } + + // Parse JSON stream (multiple JSON objects) + decoder := json.NewDecoder(strings.NewReader(result)) + for decoder.More() { + var pkg goPackage + if err := decoder.Decode(&pkg); err != nil { + continue + } + a.packages[pkg.ImportPath] = &pkg + } + return nil +} + +// filterDeps filters dependencies based on options. +func (a *analyzer) filterDeps(deps []string, in Input) []string { + result := make([]string, 0) + for _, dep := range deps { + if a.shouldInclude(dep, in) { + result = append(result, dep) + } + } + return result +} + +// shouldInclude checks if a dependency should be included. +func (a *analyzer) shouldInclude(dep string, in Input) bool { + // Exclude standard library + if in.NoStd && a.isStdLib(dep) { + return false + } + // Only internal packages + if in.Internal && a.modulePrefix != "" { + if !gstr.HasPrefix(dep, a.modulePrefix) { + return false + } + } + return true +} + +// isStdLib checks if a package is from standard library. +func (a *analyzer) isStdLib(pkg string) bool { + // Standard library packages don't contain dots in the first path segment + if strings.Contains(pkg, ".") { + return false + } + // Check if it's in our loaded packages and marked as standard + if p, ok := a.packages[pkg]; ok { + return p.Standard + } + return true +} + +// shortName returns a shortened package name. +func (a *analyzer) shortName(pkg string, group bool) string { + if a.modulePrefix != "" && gstr.HasPrefix(pkg, a.modulePrefix) { + short := gstr.TrimLeft(pkg[len(a.modulePrefix):], "/") + if group { + // Return only top-level directory + parts := gstr.Split(short, "/") + if len(parts) > 0 { + return parts[0] + } + } + return short + } + return pkg +} + +// sanitizeName makes a name safe for mermaid/dot output. +func (a *analyzer) sanitizeName(name string) string { + return gstr.Replace(name, "/", "_") +} + +// getSortedPackages returns sorted package paths. +func (a *analyzer) getSortedPackages() []string { + pkgs := make([]string, 0, len(a.packages)) + for pkg := range a.packages { + pkgs = append(pkgs, pkg) + } + sort.Strings(pkgs) + return pkgs +} + +// collectEdges collects all dependency edges. +func (a *analyzer) collectEdges(in Input) map[string]bool { + edges := make(map[string]bool) + a.visited = make(map[string]bool) + + for _, pkg := range a.packages { + a.collectEdgesRecursive(pkg, in, edges, 0) + } + return edges +} + +func (a *analyzer) collectEdgesRecursive(pkg *goPackage, in Input, edges map[string]bool, depth int) { + if in.Depth > 0 && depth >= in.Depth { + return + } + + fromName := a.shortName(pkg.ImportPath, in.Group) + deps := a.filterDeps(pkg.Imports, in) + + for _, dep := range deps { + toName := a.shortName(dep, in.Group) + if fromName != toName && toName != "" && fromName != "" { + edge := fmt.Sprintf("%s --> %s", a.sanitizeName(fromName), a.sanitizeName(toName)) + edges[edge] = true + } + + if !a.visited[dep] { + a.visited[dep] = true + if depPkg, ok := a.packages[dep]; ok { + a.collectEdgesRecursive(depPkg, in, edges, depth+1) + } + } + } +} diff --git a/cmd/gf/internal/cmd/cmddep/cmddep_output.go b/cmd/gf/internal/cmd/cmddep/cmddep_output.go new file mode 100644 index 000000000..408d4d60e --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/cmddep_output.go @@ -0,0 +1,257 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package cmddep + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// generate creates output based on format. +func (a *analyzer) generate(in Input) string { + switch in.Format { + case "tree": + return a.generateTree(in) + case "list": + return a.generateList(in) + case "mermaid": + return a.generateMermaid(in) + case "dot": + return a.generateDot(in) + case "json": + return a.generateJSON(in) + default: + return a.generateTree(in) + } +} + +// generateTree generates ASCII tree output. +func (a *analyzer) generateTree(in Input) string { + var sb strings.Builder + pkgs := a.getSortedPackages() + + for _, pkgPath := range pkgs { + pkg := a.packages[pkgPath] + a.visited = make(map[string]bool) + shortName := a.shortName(pkg.ImportPath, in.Group) + sb.WriteString(shortName + "\n") + a.printTreeNode(&sb, pkg, "", in, 0) + } + return sb.String() +} + +func (a *analyzer) printTreeNode(sb *strings.Builder, pkg *goPackage, prefix string, in Input, depth int) { + if in.Depth > 0 && depth >= in.Depth { + return + } + + deps := a.filterDeps(pkg.Imports, in) + sort.Strings(deps) + + for i, dep := range deps { + if a.visited[dep] { + continue + } + a.visited[dep] = true + + isLast := i == len(deps)-1 + connector := "├── " + if isLast { + connector = "└── " + } + + shortName := a.shortName(dep, in.Group) + sb.WriteString(prefix + connector + shortName + "\n") + + newPrefix := prefix + if isLast { + newPrefix += " " + } else { + newPrefix += "│ " + } + + // Recursively print dependencies + if depPkg, ok := a.packages[dep]; ok { + a.printTreeNode(sb, depPkg, newPrefix, in, depth+1) + } + } +} + +// generateList generates simple list output. +func (a *analyzer) generateList(in Input) string { + var sb strings.Builder + allDeps := make(map[string]bool) + + for _, pkg := range a.packages { + for _, dep := range a.filterDeps(pkg.Imports, in) { + allDeps[dep] = true + } + } + + deps := make([]string, 0, len(allDeps)) + for dep := range allDeps { + deps = append(deps, a.shortName(dep, in.Group)) + } + sort.Strings(deps) + + for _, dep := range deps { + sb.WriteString(dep + "\n") + } + return sb.String() +} + +// generateMermaid generates Mermaid diagram output. +func (a *analyzer) generateMermaid(in Input) string { + var sb strings.Builder + sb.WriteString("```mermaid\n") + sb.WriteString("graph TD\n") + + edges := a.collectEdges(in) + sortedEdges := make([]string, 0, len(edges)) + for edge := range edges { + sortedEdges = append(sortedEdges, edge) + } + sort.Strings(sortedEdges) + + for _, edge := range sortedEdges { + sb.WriteString(" " + edge + "\n") + } + sb.WriteString("```\n") + return sb.String() +} + +// generateMermaidRaw generates Mermaid code without markdown wrapper. +func (a *analyzer) generateMermaidRaw(in Input) string { + var sb strings.Builder + sb.WriteString("graph TD\n") + + edges := a.collectEdges(in) + sortedEdges := make([]string, 0, len(edges)) + for edge := range edges { + sortedEdges = append(sortedEdges, edge) + } + sort.Strings(sortedEdges) + + for _, edge := range sortedEdges { + sb.WriteString(" " + edge + "\n") + } + return sb.String() +} + +// generateDot generates Graphviz DOT output. +func (a *analyzer) generateDot(in Input) string { + var sb strings.Builder + sb.WriteString("digraph deps {\n") + sb.WriteString(" rankdir=TB;\n") + sb.WriteString(" node [shape=box];\n") + + edges := a.collectEdges(in) + sortedEdges := make([]string, 0, len(edges)) + for edge := range edges { + sortedEdges = append(sortedEdges, edge) + } + sort.Strings(sortedEdges) + + for _, edge := range sortedEdges { + parts := strings.Split(edge, " --> ") + if len(parts) == 2 { + fmt.Fprintf(&sb, " \"%s\" -> \"%s\";\n", parts[0], parts[1]) + } + } + sb.WriteString("}\n") + return sb.String() +} + +// generateJSON generates JSON output. +func (a *analyzer) generateJSON(in Input) string { + nodes := make([]*depNode, 0) + for _, pkgPath := range a.getSortedPackages() { + pkg := a.packages[pkgPath] + a.visited = make(map[string]bool) + node := a.buildDepNode(pkg, in, 0) + nodes = append(nodes, node) + } + + data, err := json.MarshalIndent(nodes, "", " ") + if err != nil { + return fmt.Sprintf("Error: %v", err) + } + return string(data) +} + +func (a *analyzer) buildDepNode(pkg *goPackage, in Input, depth int) *depNode { + node := &depNode{ + Package: a.shortName(pkg.ImportPath, in.Group), + } + + if in.Depth > 0 && depth >= in.Depth { + return node + } + + deps := a.filterDeps(pkg.Imports, in) + sort.Strings(deps) + + for _, dep := range deps { + if a.visited[dep] { + continue + } + a.visited[dep] = true + + if depPkg, ok := a.packages[dep]; ok { + childNode := a.buildDepNode(depPkg, in, depth+1) + node.Dependencies = append(node.Dependencies, childNode) + } else { + node.Dependencies = append(node.Dependencies, &depNode{ + Package: a.shortName(dep, in.Group), + }) + } + } + return node +} + +// generateReverse generates reverse dependency output. +func (a *analyzer) generateReverse(in Input) string { + // Build reverse dependency map + reverseDeps := make(map[string][]string) + for pkgPath, pkg := range a.packages { + for _, dep := range pkg.Imports { + if a.shouldInclude(dep, in) { + reverseDeps[dep] = append(reverseDeps[dep], pkgPath) + } + } + } + + var sb strings.Builder + targets := a.getSortedPackages() + + for _, target := range targets { + deps := reverseDeps[target] + if len(deps) == 0 { + continue + } + + sort.Strings(deps) + shortTarget := a.shortName(target, in.Group) + if shortTarget == "" { + continue + } + fmt.Fprintf(&sb, "%s (used by %d packages):\n", shortTarget, len(deps)) + + for i, dep := range deps { + isLast := i == len(deps)-1 + connector := "├── " + if isLast { + connector = "└── " + } + sb.WriteString(connector + a.shortName(dep, in.Group) + "\n") + } + sb.WriteString("\n") + } + return sb.String() +} diff --git a/cmd/gf/internal/cmd/cmddep/cmddep_server.go b/cmd/gf/internal/cmd/cmddep/cmddep_server.go new file mode 100644 index 000000000..a3257e266 --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/cmddep_server.go @@ -0,0 +1,752 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package cmddep + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/gogf/gf/cmd/gf/v2/internal/utility/mlog" + "github.com/gogf/gf/v2/os/gproc" +) + +//go:embed static/* +var staticFiles embed.FS + +// graphData represents the graph structure for visualization. +type graphData struct { + Nodes []graphNode `json:"nodes"` + Edges []graphEdge `json:"edges"` +} + +type graphNode struct { + ID string `json:"id"` + Label string `json:"label"` + Group string `json:"group,omitempty"` +} + +type graphEdge struct { + From string `json:"from"` + To string `json:"to"` +} + +// packageInfo represents package information for API response. +type packageInfo struct { + Name string `json:"name"` + FullPath string `json:"fullPath"` + Dependencies []string `json:"dependencies"` + UsedBy []string `json:"usedBy"` +} + +// packageSummary represents package summary for list API response. +type packageSummary struct { + Name string `json:"name"` + DepCount int `json:"depCount"` + UsedByCount int `json:"usedByCount"` +} + +// moduleInfo represents module information for API response. +type moduleInfo struct { + Name string `json:"name"` +} + +// versionInfo represents version list response. +type versionInfo struct { + Versions []string `json:"versions,omitempty"` + Error string `json:"error,omitempty"` +} + +// analyzeResult represents analyze result response. +type analyzeResult struct { + Success bool `json:"success"` + Module string `json:"module,omitempty"` + Error string `json:"error,omitempty"` +} + +// serverState holds the server state for remote module analysis. +type serverState struct { + originalAnalyzer *analyzer + currentAnalyzer *analyzer + originalInput Input + tempDir string +} + +// startServer starts an HTTP server to visualize dependencies. +func (a *analyzer) startServer(in Input) error { + addr := fmt.Sprintf(":%d", in.Port) + + // Create server state + state := &serverState{ + originalAnalyzer: a, + currentAnalyzer: a, + originalInput: in, + } + + // Serve static files + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + return err + } + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Serve index.html at root + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + content, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(content) + }) + + // API endpoints + http.HandleFunc("/api/module", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handleModuleAPI(w) + }) + http.HandleFunc("/api/graph", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handleGraphAPI(w, r, in) + }) + http.HandleFunc("/api/packages", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handlePackagesAPI(w, in) + }) + http.HandleFunc("/api/package", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handlePackageAPI(w, r, in) + }) + http.HandleFunc("/api/tree", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handleTreeAPI(w, r, in) + }) + http.HandleFunc("/api/list", func(w http.ResponseWriter, r *http.Request) { + state.currentAnalyzer.handleListAPI(w, r, in) + }) + http.HandleFunc("/api/versions", func(w http.ResponseWriter, r *http.Request) { + handleVersionsAPI(w, r) + }) + http.HandleFunc("/api/analyze", func(w http.ResponseWriter, r *http.Request) { + state.handleAnalyzeAPI(w, r) + }) + http.HandleFunc("/api/reset", func(w http.ResponseWriter, r *http.Request) { + state.handleResetAPI(w) + }) + + mlog.Printf("Starting dependency viewer at http://localhost%s", addr) + mlog.Print("Press Ctrl+C to stop") + + return http.ListenAndServe(addr, nil) +} + +// handleModuleAPI returns module information. +func (a *analyzer) handleModuleAPI(w http.ResponseWriter) { + info := moduleInfo{ + Name: a.modulePrefix, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// handleGraphAPI returns graph data as JSON. +func (a *analyzer) handleGraphAPI(w http.ResponseWriter, r *http.Request, in Input) { + query := r.URL.Query() + if g := query.Get("group"); g != "" { + in.Group = g == "true" + } + if d := query.Get("depth"); d != "" { + fmt.Sscanf(d, "%d", &in.Depth) + } + if rev := query.Get("reverse"); rev != "" { + in.Reverse = rev == "true" + } + pkg := query.Get("package") + + var data *graphData + if pkg != "" { + data = a.buildPackageGraphData(pkg, in) + } else { + data = a.buildGraphData(in) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// handlePackagesAPI returns all packages list with dependency stats. +func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, in Input) { + // Build reverse dependency map (who uses each package) + usedByMap := make(map[string]int) + for fullPath, pkg := range a.packages { + fromShort := a.shortName(fullPath, false) + if fromShort == "" { + continue + } + for _, dep := range a.filterDeps(pkg.Imports, in) { + shortDep := a.shortName(dep, false) + if shortDep != "" { + usedByMap[shortDep]++ + } + } + } + + packages := make([]packageSummary, 0) + for _, pkgPath := range a.getSortedPackages() { + shortName := a.shortName(pkgPath, false) + if shortName == "" { + continue + } + + // Count dependencies (filtered) + depCount := 0 + if pkg, ok := a.packages[pkgPath]; ok { + for _, dep := range a.filterDeps(pkg.Imports, in) { + shortDep := a.shortName(dep, false) + if shortDep != "" { + depCount++ + } + } + } + + packages = append(packages, packageSummary{ + Name: shortName, + DepCount: depCount, + UsedByCount: usedByMap[shortName], + }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(packages) +} + +// handlePackageAPI returns detailed info for a specific package. +func (a *analyzer) handlePackageAPI(w http.ResponseWriter, r *http.Request, in Input) { + query := r.URL.Query() + pkgName := query.Get("name") + if pkgName == "" { + http.Error(w, "package name required", http.StatusBadRequest) + return + } + + // Find the full package path + var fullPath string + for path := range a.packages { + if a.shortName(path, false) == pkgName { + fullPath = path + break + } + } + + if fullPath == "" { + http.Error(w, "package not found", http.StatusNotFound) + return + } + + pkg := a.packages[fullPath] + info := packageInfo{ + Name: pkgName, + FullPath: fullPath, + Dependencies: make([]string, 0), + UsedBy: make([]string, 0), + } + + // Get dependencies + for _, dep := range a.filterDeps(pkg.Imports, in) { + shortName := a.shortName(dep, false) + if shortName != "" { + info.Dependencies = append(info.Dependencies, shortName) + } + } + sort.Strings(info.Dependencies) + + // Get reverse dependencies (who uses this package) + for path, p := range a.packages { + for _, dep := range p.Imports { + if dep == fullPath { + shortName := a.shortName(path, false) + if shortName != "" { + info.UsedBy = append(info.UsedBy, shortName) + } + break + } + } + } + sort.Strings(info.UsedBy) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// handleTreeAPI returns tree format output. +func (a *analyzer) handleTreeAPI(w http.ResponseWriter, r *http.Request, in Input) { + query := r.URL.Query() + if d := query.Get("depth"); d != "" { + fmt.Sscanf(d, "%d", &in.Depth) + } + pkg := query.Get("package") + + var output string + if pkg != "" { + output = a.generatePackageTree(pkg, in) + } else { + output = a.generateTree(in) + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(output)) +} + +// handleListAPI returns list format output. +func (a *analyzer) handleListAPI(w http.ResponseWriter, r *http.Request, in Input) { + query := r.URL.Query() + pkg := query.Get("package") + + var output string + if pkg != "" { + output = a.generatePackageList(pkg, in) + } else { + output = a.generateList(in) + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(output)) +} + +// buildGraphData builds graph data for visualization. +func (a *analyzer) buildGraphData(in Input) *graphData { + data := &graphData{ + Nodes: make([]graphNode, 0), + Edges: make([]graphEdge, 0), + } + + nodeSet := make(map[string]bool) + edges := a.collectEdges(in) + + for edge := range edges { + parts := strings.Split(edge, " --> ") + if len(parts) != 2 { + continue + } + from, to := parts[0], parts[1] + + if !nodeSet[from] { + nodeSet[from] = true + data.Nodes = append(data.Nodes, graphNode{ + ID: from, + Label: strings.ReplaceAll(from, "_", "/"), + Group: a.getNodeGroup(from), + }) + } + if !nodeSet[to] { + nodeSet[to] = true + data.Nodes = append(data.Nodes, graphNode{ + ID: to, + Label: strings.ReplaceAll(to, "_", "/"), + Group: a.getNodeGroup(to), + }) + } + + data.Edges = append(data.Edges, graphEdge{From: from, To: to}) + } + + return data +} + +// buildPackageGraphData builds graph data for a specific package. +func (a *analyzer) buildPackageGraphData(pkgName string, in Input) *graphData { + data := &graphData{ + Nodes: make([]graphNode, 0), + Edges: make([]graphEdge, 0), + } + + // Find the full package path + var fullPath string + for path := range a.packages { + if a.shortName(path, false) == pkgName { + fullPath = path + break + } + } + + if fullPath == "" { + return data + } + + nodeSet := make(map[string]bool) + nodeSet[pkgName] = true + data.Nodes = append(data.Nodes, graphNode{ + ID: a.sanitizeName(pkgName), + Label: pkgName, + Group: a.getNodeGroup(pkgName), + }) + + pkg := a.packages[fullPath] + + if in.Reverse { + // Show packages that depend on this package + for path, p := range a.packages { + for _, dep := range p.Imports { + if dep == fullPath { + shortName := a.shortName(path, false) + if shortName != "" && !nodeSet[shortName] { + nodeSet[shortName] = true + data.Nodes = append(data.Nodes, graphNode{ + ID: a.sanitizeName(shortName), + Label: shortName, + Group: a.getNodeGroup(shortName), + }) + data.Edges = append(data.Edges, graphEdge{ + From: a.sanitizeName(shortName), + To: a.sanitizeName(pkgName), + }) + } + break + } + } + } + } else { + // Show dependencies of this package + a.collectPackageDeps(pkg, pkgName, in, nodeSet, data, 0) + } + + return data +} + +// collectPackageDeps recursively collects dependencies for a package. +func (a *analyzer) collectPackageDeps(pkg *goPackage, pkgName string, in Input, nodeSet map[string]bool, data *graphData, depth int) { + if in.Depth > 0 && depth >= in.Depth { + return + } + + deps := a.filterDeps(pkg.Imports, in) + for _, dep := range deps { + shortName := a.shortName(dep, false) + if shortName == "" { + continue + } + + data.Edges = append(data.Edges, graphEdge{ + From: a.sanitizeName(pkgName), + To: a.sanitizeName(shortName), + }) + + if !nodeSet[shortName] { + nodeSet[shortName] = true + data.Nodes = append(data.Nodes, graphNode{ + ID: a.sanitizeName(shortName), + Label: shortName, + Group: a.getNodeGroup(shortName), + }) + + // Recursively collect dependencies + if depPkg, ok := a.packages[dep]; ok { + a.collectPackageDeps(depPkg, shortName, in, nodeSet, data, depth+1) + } + } + } +} + +// generatePackageTree generates tree output for a specific package. +func (a *analyzer) generatePackageTree(pkgName string, in Input) string { + var fullPath string + for path := range a.packages { + if a.shortName(path, false) == pkgName { + fullPath = path + break + } + } + + if fullPath == "" { + return "Package not found: " + pkgName + } + + var sb strings.Builder + pkg := a.packages[fullPath] + a.visited = make(map[string]bool) + sb.WriteString(pkgName + "\n") + a.printTreeNode(&sb, pkg, "", in, 0) + return sb.String() +} + +// generatePackageList generates list output for a specific package. +func (a *analyzer) generatePackageList(pkgName string, in Input) string { + var fullPath string + for path := range a.packages { + if a.shortName(path, false) == pkgName { + fullPath = path + break + } + } + + if fullPath == "" { + return "Package not found: " + pkgName + } + + var sb strings.Builder + pkg := a.packages[fullPath] + deps := a.filterDeps(pkg.Imports, in) + + shortDeps := make([]string, 0, len(deps)) + for _, dep := range deps { + shortName := a.shortName(dep, false) + if shortName != "" { + shortDeps = append(shortDeps, shortName) + } + } + sort.Strings(shortDeps) + + for _, dep := range shortDeps { + sb.WriteString(dep + "\n") + } + return sb.String() +} + +// getNodeGroup returns the group (top-level directory) of a node. +func (a *analyzer) getNodeGroup(name string) string { + name = strings.ReplaceAll(name, "_", "/") + parts := strings.Split(name, "/") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +// handleVersionsAPI fetches available versions for a Go module from proxy. +func handleVersionsAPI(w http.ResponseWriter, r *http.Request) { + modulePath := r.URL.Query().Get("module") + if modulePath == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(versionInfo{Error: "module parameter required"}) + return + } + + // Fetch versions from Go proxy + versions, err := fetchModuleVersions(modulePath) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(versionInfo{Error: err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(versionInfo{Versions: versions}) +} + +// fetchModuleVersions fetches versions from Go proxy. +func fetchModuleVersions(modulePath string) ([]string, error) { + // Use go list to get available versions + ctx := context.Background() + cmd := fmt.Sprintf("go list -m -versions %s", modulePath) + result, err := gproc.ShellExec(ctx, cmd) + if err != nil { + return nil, fmt.Errorf("failed to fetch versions: %v", err) + } + + // Parse output: module@version version1 version2 ... + result = strings.TrimSpace(result) + if result == "" { + return nil, fmt.Errorf("no versions found") + } + + parts := strings.Fields(result) + if len(parts) < 2 { + // Only module name, try to get latest + return []string{"latest"}, nil + } + + // Reverse order (newest first) + versions := parts[1:] + for i, j := 0, len(versions)-1; i < j; i, j = i+1, j-1 { + versions[i], versions[j] = versions[j], versions[i] + } + + return versions, nil +} + +// handleAnalyzeAPI analyzes a remote module. +func (s *serverState) handleAnalyzeAPI(w http.ResponseWriter, r *http.Request) { + modulePath := r.URL.Query().Get("module") + version := r.URL.Query().Get("version") + + if modulePath == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "module parameter required"}) + return + } + + // Clean up previous temp directory + if s.tempDir != "" { + os.RemoveAll(s.tempDir) + } + + // Create temp directory + tempDir, err := os.MkdirTemp("", "gf-dep-*") + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "failed to create temp directory"}) + return + } + s.tempDir = tempDir + + // Download and analyze module + moduleWithVersion := modulePath + if version != "" && version != "latest" { + moduleWithVersion = modulePath + "@" + version + } + + ctx := context.Background() + + // Initialize go module in temp directory + initCmd := fmt.Sprintf("cd %s && go mod init temp", tempDir) + if _, err := gproc.ShellExec(ctx, initCmd); err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "failed to init module: " + err.Error()}) + return + } + + // Download the module + getCmd := fmt.Sprintf("cd %s && go get %s", tempDir, moduleWithVersion) + if _, err := gproc.ShellExec(ctx, getCmd); err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "failed to download module: " + err.Error()}) + return + } + + // Find the module in GOPATH/pkg/mod + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, _ := os.UserHomeDir() + gopath = filepath.Join(home, "go") + } + + // Find the actual module directory + modCacheDir := filepath.Join(gopath, "pkg", "mod") + moduleDir, err := findModuleDir(modCacheDir, modulePath, version) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "failed to find module: " + err.Error()}) + return + } + + // Create new analyzer for the remote module + newAnalyzer := newAnalyzer() + newAnalyzer.modulePrefix = modulePath + + // Load packages from the module directory + // IMPORTANT: Must run in tempDir context where the module was downloaded, + // otherwise it will use packages from the current project's dependencies + listCmd := fmt.Sprintf("cd %s && go list -json %s/...", tempDir, modulePath) + result, err := gproc.ShellExec(ctx, listCmd) + if err != nil { + // Try loading from the module directory directly + listCmd = fmt.Sprintf("cd %s && go list -json ./...", moduleDir) + result, err = gproc.ShellExec(ctx, listCmd) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "failed to list packages: " + err.Error()}) + return + } + } + + // Parse packages + decoder := json.NewDecoder(strings.NewReader(result)) + for decoder.More() { + var pkg goPackage + if err := decoder.Decode(&pkg); err != nil { + continue + } + newAnalyzer.packages[pkg.ImportPath] = &pkg + } + + if len(newAnalyzer.packages) == 0 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{Error: "no packages found in module"}) + return + } + + s.currentAnalyzer = newAnalyzer + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(analyzeResult{ + Success: true, + Module: moduleWithVersion, + }) +} + +// findModuleDir finds the module directory in the module cache. +func findModuleDir(modCacheDir, modulePath, version string) (string, error) { + // Convert module path to filesystem path + escapedPath := strings.ReplaceAll(modulePath, "/", string(filepath.Separator)) + + // Handle uppercase letters in module path (they're escaped in the cache) + var escapedParts []string + for _, part := range strings.Split(escapedPath, string(filepath.Separator)) { + var escaped strings.Builder + for _, c := range part { + if c >= 'A' && c <= 'Z' { + escaped.WriteRune('!') + escaped.WriteRune(c + 32) // lowercase + } else { + escaped.WriteRune(c) + } + } + escapedParts = append(escapedParts, escaped.String()) + } + escapedPath = strings.Join(escapedParts, string(filepath.Separator)) + + baseDir := filepath.Join(modCacheDir, escapedPath) + + // If version specified, look for exact match + if version != "" && version != "latest" { + versionDir := baseDir + "@" + version + if _, err := os.Stat(versionDir); err == nil { + return versionDir, nil + } + } + + // Find latest version + parent := filepath.Dir(baseDir) + base := filepath.Base(baseDir) + + entries, err := os.ReadDir(parent) + if err != nil { + return "", err + } + + var latestDir string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), base+"@") { + latestDir = filepath.Join(parent, entry.Name()) + } + } + + if latestDir == "" { + return "", fmt.Errorf("module not found in cache") + } + + return latestDir, nil +} + +// handleResetAPI resets to the original local analyzer. +func (s *serverState) handleResetAPI(w http.ResponseWriter) { + // Clean up temp directory + if s.tempDir != "" { + os.RemoveAll(s.tempDir) + s.tempDir = "" + } + + s.currentAnalyzer = s.originalAnalyzer + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} diff --git a/cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go b/cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go new file mode 100644 index 000000000..454252da2 --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go @@ -0,0 +1,114 @@ +// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved. +// +// This Source Code Form is subject to the terms of the MIT License. +// If a copy of the MIT was not distributed with this file, +// You can obtain one at https://github.com/gogf/gf. + +package cmddep + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/test/gtest" +) + +func Test_Dep_Tree(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "tree", + Depth: 1, + Internal: true, + NoStd: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_List(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "list", + Depth: 1, + Internal: true, + NoStd: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_Mermaid(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "mermaid", + Depth: 1, + Internal: true, + NoStd: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_Dot(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "dot", + Depth: 1, + Internal: true, + NoStd: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_JSON(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "json", + Depth: 1, + Internal: true, + NoStd: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_Reverse(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "tree", + Depth: 1, + Internal: true, + NoStd: true, + Reverse: true, + }) + t.AssertNil(err) + }) +} + +func Test_Dep_Group(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + ctx := gctx.New() + _, err := Dep.Index(ctx, Input{ + Package: "./", + Format: "mermaid", + Depth: 1, + Internal: true, + NoStd: true, + Group: true, + }) + t.AssertNil(err) + }) +} diff --git a/cmd/gf/internal/cmd/cmddep/static/app.js b/cmd/gf/internal/cmd/cmddep/static/app.js new file mode 100644 index 000000000..bcd854b31 --- /dev/null +++ b/cmd/gf/internal/cmd/cmddep/static/app.js @@ -0,0 +1,863 @@ +// Main Application Module +let currentZoom = 1; +let currentView = 'graph'; +let currentLayout = 'TD'; +let selectedPackage = null; +let allPackages = []; +let isRemoteMode = false; +let currentRemoteModule = ''; +let packageListMode = 'flat'; // 'flat' or 'tree' +let expandedNodes = new Set(); // Track expanded tree nodes + +// Pan and Zoom state +let panX = 0; +let panY = 0; +let isPanning = false; +let startPanX = 0; +let startPanY = 0; +let zoomIndicatorTimeout = null; + +const MIN_ZOOM = 0.1; +const MAX_ZOOM = 10; +const ZOOM_STEP = 0.1; + +// Theme Management +const theme = { + current: 'light', + + init() { + const savedTheme = localStorage.getItem('dep-viewer-theme'); + if (savedTheme) { + this.current = savedTheme; + } else { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + this.current = 'dark'; + } + } + this.apply(); + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + if (!localStorage.getItem('dep-viewer-theme')) { + this.current = e.matches ? 'dark' : 'light'; + this.apply(); + } + }); + + const toggleBtn = document.getElementById('themeToggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => this.toggle()); + } + }, + + toggle() { + this.current = this.current === 'dark' ? 'light' : 'dark'; + localStorage.setItem('dep-viewer-theme', this.current); + this.apply(); + }, + + apply() { + if (this.current === 'dark') { + document.body.setAttribute('data-theme', 'dark'); + } else { + document.body.removeAttribute('data-theme'); + } + + mermaid.initialize({ + startOnLoad: false, + theme: this.current === 'dark' ? 'dark' : 'default', + flowchart: { + useMaxWidth: false, + htmlLabels: true, + curve: 'basis' + } + }); + + if (currentView === 'graph') { + refresh(); + } + } +}; + +// Initialize mermaid +mermaid.initialize({ + startOnLoad: false, + theme: 'default', + flowchart: { + useMaxWidth: false, + htmlLabels: true, + curve: 'basis' + } +}); + +// Initialize application +async function init() { + theme.init(); + initPanZoom(); + initRemoteModuleInput(); + await loadModuleName(); + await loadPackages(); + await refresh(); +} + +// Initialize remote module input +function initRemoteModuleInput() { + const input = document.getElementById('remoteModuleInput'); + if (input) { + // Fetch versions when input loses focus or Enter is pressed + input.addEventListener('blur', fetchVersions); + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + fetchVersions(); + } + }); + } +} + +// Fetch versions for a module from Go proxy +async function fetchVersions() { + const input = document.getElementById('remoteModuleInput'); + const versionSelect = document.getElementById('versionSelect'); + const spinner = document.getElementById('loadingSpinner'); + + const modulePath = input.value.trim(); + if (!modulePath) { + versionSelect.disabled = true; + versionSelect.innerHTML = ``; + return; + } + + spinner.classList.remove('hidden'); + versionSelect.disabled = true; + + try { + const response = await fetch('/api/versions?module=' + encodeURIComponent(modulePath)); + const data = await response.json(); + + if (data.error) { + versionSelect.innerHTML = ``; + } else if (data.versions && data.versions.length > 0) { + versionSelect.innerHTML = data.versions.map((v, i) => { + const label = i === 0 ? `${v} (${i18n.t('latestVersion')})` : v; + return ``; + }).join(''); + versionSelect.disabled = false; + } else { + versionSelect.innerHTML = ``; + } + } catch (e) { + console.error('Failed to fetch versions:', e); + versionSelect.innerHTML = ``; + } finally { + spinner.classList.add('hidden'); + } +} + +// Analyze remote module +async function analyzeRemoteModule() { + const input = document.getElementById('remoteModuleInput'); + const versionSelect = document.getElementById('versionSelect'); + const spinner = document.getElementById('loadingSpinner'); + const analyzeBtn = document.getElementById('analyzeBtn'); + + const modulePath = input.value.trim(); + const version = versionSelect.value; + + if (!modulePath) { + alert('Please enter a module path'); + return; + } + + spinner.classList.remove('hidden'); + analyzeBtn.disabled = true; + analyzeBtn.textContent = i18n.t('analyzing'); + + try { + const url = `/api/analyze?module=${encodeURIComponent(modulePath)}${version ? '&version=' + encodeURIComponent(version) : ''}`; + const response = await fetch(url); + const data = await response.json(); + + if (data.error) { + alert(data.error); + return; + } + + // Switch to remote mode + isRemoteMode = true; + currentRemoteModule = modulePath + (version ? '@' + version : ''); + document.getElementById('moduleName').textContent = currentRemoteModule; + + // Clear selection and reload + selectedPackage = null; + await loadPackages(); + await refresh(); + } catch (e) { + console.error('Failed to analyze module:', e); + alert(i18n.t('errorAnalyze')); + } finally { + spinner.classList.add('hidden'); + analyzeBtn.disabled = false; + analyzeBtn.textContent = i18n.t('analyze'); + } +} + +// Reset to local module +async function resetToLocal() { + const spinner = document.getElementById('loadingSpinner'); + spinner.classList.remove('hidden'); + + try { + await fetch('/api/reset'); + + isRemoteMode = false; + currentRemoteModule = ''; + document.getElementById('remoteModuleInput').value = ''; + document.getElementById('versionSelect').innerHTML = ``; + document.getElementById('versionSelect').disabled = true; + + selectedPackage = null; + await loadModuleName(); + await loadPackages(); + await refresh(); + } catch (e) { + console.error('Failed to reset:', e); + } finally { + spinner.classList.add('hidden'); + } +} + +// Initialize pan and zoom functionality +function initPanZoom() { + const viewport = document.getElementById('graphView'); + if (!viewport) return; + + // Mouse wheel zoom + viewport.addEventListener('wheel', (e) => { + if (currentView !== 'graph') return; + e.preventDefault(); + + const rect = viewport.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate zoom + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, currentZoom + delta * currentZoom)); + + if (newZoom !== currentZoom) { + // Zoom towards mouse position + const scale = newZoom / currentZoom; + panX = mouseX - (mouseX - panX) * scale; + panY = mouseY - (mouseY - panY) * scale; + currentZoom = newZoom; + applyTransform(); + showZoomIndicator(); + } + }, { passive: false }); + + // Pan with mouse drag + viewport.addEventListener('mousedown', (e) => { + if (currentView !== 'graph') return; + if (e.button !== 0) return; // Only left click + + isPanning = true; + startPanX = e.clientX - panX; + startPanY = e.clientY - panY; + viewport.style.cursor = 'grabbing'; + }); + + document.addEventListener('mousemove', (e) => { + if (!isPanning) return; + + panX = e.clientX - startPanX; + panY = e.clientY - startPanY; + applyTransform(); + }); + + document.addEventListener('mouseup', () => { + if (isPanning) { + isPanning = false; + const viewport = document.getElementById('graphViewport'); + if (viewport) viewport.style.cursor = 'grab'; + } + }); + + // Touch support for mobile + let lastTouchDistance = 0; + let lastTouchCenter = { x: 0, y: 0 }; + + viewport.addEventListener('touchstart', (e) => { + if (currentView !== 'graph') return; + + if (e.touches.length === 1) { + isPanning = true; + startPanX = e.touches[0].clientX - panX; + startPanY = e.touches[0].clientY - panY; + } else if (e.touches.length === 2) { + isPanning = false; + lastTouchDistance = getTouchDistance(e.touches); + lastTouchCenter = getTouchCenter(e.touches); + } + }, { passive: true }); + + viewport.addEventListener('touchmove', (e) => { + if (currentView !== 'graph') return; + e.preventDefault(); + + if (e.touches.length === 1 && isPanning) { + panX = e.touches[0].clientX - startPanX; + panY = e.touches[0].clientY - startPanY; + applyTransform(); + } else if (e.touches.length === 2) { + const distance = getTouchDistance(e.touches); + const center = getTouchCenter(e.touches); + + if (lastTouchDistance > 0) { + const scale = distance / lastTouchDistance; + const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, currentZoom * scale)); + + if (newZoom !== currentZoom) { + const rect = viewport.getBoundingClientRect(); + const centerX = center.x - rect.left; + const centerY = center.y - rect.top; + + const zoomScale = newZoom / currentZoom; + panX = centerX - (centerX - panX) * zoomScale; + panY = centerY - (centerY - panY) * zoomScale; + currentZoom = newZoom; + applyTransform(); + showZoomIndicator(); + } + } + + lastTouchDistance = distance; + lastTouchCenter = center; + } + }, { passive: false }); + + viewport.addEventListener('touchend', () => { + isPanning = false; + lastTouchDistance = 0; + }); +} + +function getTouchDistance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); +} + +function getTouchCenter(touches) { + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2 + }; +} + +function applyTransform() { + const container = document.getElementById('mermaidContainer'); + if (container) { + container.style.transform = `translate(${panX}px, ${panY}px) scale(${currentZoom})`; + } +} + +function showZoomIndicator() { + const indicator = document.getElementById('zoomIndicator'); + if (indicator) { + indicator.textContent = `${Math.round(currentZoom * 100)}%`; + indicator.classList.add('visible'); + + if (zoomIndicatorTimeout) { + clearTimeout(zoomIndicatorTimeout); + } + zoomIndicatorTimeout = setTimeout(() => { + indicator.classList.remove('visible'); + }, 1500); + } +} + +// Load module name from server +async function loadModuleName() { + try { + const response = await fetch('/api/module'); + const data = await response.json(); + document.getElementById('moduleName').textContent = data.name || ''; + + // If no local module, set default value for remote module input + if (!data.name) { + const input = document.getElementById('remoteModuleInput'); + if (input && !input.value) { + input.value = 'github.com/gogf/gf/v2'; + } + } + } catch (e) { + console.error('Failed to load module name:', e); + } +} + +// Load packages list +async function loadPackages() { + try { + const response = await fetch('/api/packages'); + allPackages = await response.json(); + document.getElementById('packageCount').textContent = allPackages.length; + renderPackageList(allPackages); + } catch (e) { + console.error('Failed to load packages:', e); + } +} + +// Get package name from package object or string +function getPkgName(pkg) { + return typeof pkg === 'object' ? pkg.name : pkg; +} + +// Set package list display mode +function setPackageListMode(mode) { + packageListMode = mode; + document.getElementById('modeFlat').classList.toggle('active', mode === 'flat'); + document.getElementById('modeTree').classList.toggle('active', mode === 'tree'); + + const query = document.getElementById('searchInput').value.toLowerCase(); + const filtered = query ? allPackages.filter(pkg => getPkgName(pkg).toLowerCase().includes(query)) : allPackages; + renderPackageList(filtered); +} + +// Render package list in sidebar +function renderPackageList(packages) { + const list = document.getElementById('packageList'); + if (packages.length === 0) { + list.innerHTML = '