mirror of
https://gitee.com/johng/gf
synced 2026-06-06 16:21:40 +08:00
feat: 为依赖分析命令添加外部依赖和主模块过滤功能
This commit is contained in:
@ -37,9 +37,15 @@ gf dep -f json -d 0
|
||||
gf dep -g
|
||||
gf dep -r
|
||||
gf dep -i=false
|
||||
gf dep -e
|
||||
gf dep -e -i=false
|
||||
gf dep -m
|
||||
gf dep -m -e
|
||||
gf dep -s
|
||||
gf dep -s -p 8080
|
||||
gf dep ./internal/... -f tree -d 2
|
||||
gf dep --external --group -f mermaid
|
||||
gf dep --main --external -f json
|
||||
`
|
||||
)
|
||||
|
||||
@ -58,6 +64,8 @@ type Input struct {
|
||||
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"`
|
||||
External bool `name:"external" short:"e" brief:"show external packages" d:"false"`
|
||||
MainOnly bool `name:"main" short:"m" brief:"analyze only main module packages (exclude submodules)" d:"false"`
|
||||
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"`
|
||||
|
||||
@ -24,6 +24,9 @@ type goPackage struct {
|
||||
Imports []string `json:"Imports"`
|
||||
Deps []string `json:"Deps"`
|
||||
Standard bool `json:"Standard"`
|
||||
Module struct {
|
||||
Path string `json:"Path"`
|
||||
} `json:"Module"`
|
||||
}
|
||||
|
||||
// depNode represents a node in the dependency tree.
|
||||
@ -67,10 +70,14 @@ func (a *analyzer) detectModulePrefix() string {
|
||||
|
||||
// loadPackages loads package information using go list.
|
||||
func (a *analyzer) loadPackages(ctx context.Context, pkgPath string) error {
|
||||
// Load main packages first
|
||||
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)
|
||||
// Try to get more detailed error information
|
||||
detailCmd := fmt.Sprintf("go list %s 2>&1", pkgPath)
|
||||
detailResult, _ := gproc.ShellExec(ctx, detailCmd)
|
||||
return fmt.Errorf("failed to execute go list: %v, details: %s", err, detailResult)
|
||||
}
|
||||
|
||||
// Parse JSON stream (multiple JSON objects)
|
||||
@ -82,14 +89,40 @@ func (a *analyzer) loadPackages(ctx context.Context, pkgPath string) error {
|
||||
}
|
||||
a.packages[pkg.ImportPath] = &pkg
|
||||
}
|
||||
|
||||
// For external dependency analysis, also load dependencies
|
||||
// This is optional and won't fail the entire operation
|
||||
cmd = fmt.Sprintf("go list -json -deps %s", pkgPath)
|
||||
result, err = gproc.ShellExec(ctx, cmd)
|
||||
if err == nil {
|
||||
// Parse dependency JSON stream
|
||||
decoder = json.NewDecoder(strings.NewReader(result))
|
||||
for decoder.More() {
|
||||
var pkg goPackage
|
||||
if err := decoder.Decode(&pkg); err != nil {
|
||||
continue
|
||||
}
|
||||
// Only add if not already present
|
||||
if _, exists := a.packages[pkg.ImportPath]; !exists {
|
||||
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) {
|
||||
seen := make(map[string]bool)
|
||||
for _, original := range deps {
|
||||
dep := original
|
||||
if in.MainOnly {
|
||||
dep = a.getModuleRoot(original)
|
||||
}
|
||||
|
||||
if a.shouldInclude(dep, in) && !seen[dep] {
|
||||
seen[dep] = true
|
||||
result = append(result, dep)
|
||||
}
|
||||
}
|
||||
@ -98,17 +131,34 @@ func (a *analyzer) filterDeps(deps []string, in Input) []string {
|
||||
|
||||
// shouldInclude checks if a dependency should be included.
|
||||
func (a *analyzer) shouldInclude(dep string, in Input) bool {
|
||||
// Exclude standard library
|
||||
// Exclude standard library if requested
|
||||
if in.NoStd && a.isStdLib(dep) {
|
||||
return false
|
||||
}
|
||||
// Only internal packages
|
||||
if in.Internal && a.modulePrefix != "" {
|
||||
if !gstr.HasPrefix(dep, a.modulePrefix) {
|
||||
|
||||
isInternal := a.modulePrefix != "" && gstr.HasPrefix(dep, a.modulePrefix)
|
||||
|
||||
// Handle main-only filtering - only keep module root packages
|
||||
if in.MainOnly {
|
||||
if dep != a.getModuleRoot(dep) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
// Handle internal/external filtering
|
||||
if in.Internal && in.External {
|
||||
// Show both internal and external
|
||||
return true
|
||||
} else if in.Internal && !in.External {
|
||||
// Show only internal packages
|
||||
return isInternal
|
||||
} else if !in.Internal && in.External {
|
||||
// Show only external packages
|
||||
return !isInternal
|
||||
} else {
|
||||
// Default behavior: show internal packages only
|
||||
return isInternal
|
||||
}
|
||||
}
|
||||
|
||||
// isStdLib checks if a package is from standard library.
|
||||
@ -124,6 +174,82 @@ func (a *analyzer) isStdLib(pkg string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// isModuleRootPackage checks if a package path is the root package of its module.
|
||||
// It prefers go list Module.Path metadata; if missing, it falls back to guessing by domain/repo segments.
|
||||
func (a *analyzer) isModuleRootPackage(pkg string) bool {
|
||||
p, ok := a.packages[pkg]
|
||||
if ok {
|
||||
// Standard library has no module, treat as root
|
||||
if p.Module.Path == "" {
|
||||
return true
|
||||
}
|
||||
return p.Module.Path == p.ImportPath
|
||||
}
|
||||
|
||||
// Fallback: derive a plausible module root from the import path
|
||||
return pkg == guessModuleRoot(pkg)
|
||||
}
|
||||
|
||||
// getModuleRoot returns the module root path for a package, using Module metadata when available.
|
||||
func (a *analyzer) getModuleRoot(pkg string) string {
|
||||
if p, ok := a.packages[pkg]; ok {
|
||||
if p.Module.Path != "" {
|
||||
return p.Module.Path
|
||||
}
|
||||
}
|
||||
return guessModuleRoot(pkg)
|
||||
}
|
||||
|
||||
// guessModuleRoot tries to infer the module root path from an import path when Module metadata is missing.
|
||||
// It keeps domain/owner/repo and also preserves a trailing /vN version segment if present.
|
||||
func guessModuleRoot(pkg string) string {
|
||||
parts := strings.Split(pkg, "/")
|
||||
if len(parts) < 3 {
|
||||
return pkg
|
||||
}
|
||||
|
||||
rootLen := 3 // domain/owner/repo
|
||||
// Handle semantic import path version like .../v2
|
||||
if len(parts) > 3 && strings.HasPrefix(parts[3], "v") {
|
||||
rootLen = 4
|
||||
}
|
||||
|
||||
if rootLen > len(parts) {
|
||||
rootLen = len(parts)
|
||||
}
|
||||
return strings.Join(parts[:rootLen], "/")
|
||||
}
|
||||
|
||||
// isMainModulePackage checks if a package belongs to the main module (not a submodule).
|
||||
func (a *analyzer) isMainModulePackage(pkg string) bool {
|
||||
if a.modulePrefix == "" {
|
||||
return true // If no module prefix, consider all as main module
|
||||
}
|
||||
|
||||
if !gstr.HasPrefix(pkg, a.modulePrefix) {
|
||||
return false // Not even in our module
|
||||
}
|
||||
|
||||
// Remove the module prefix to get the relative path
|
||||
relativePath := gstr.TrimLeft(pkg[len(a.modulePrefix):], "/")
|
||||
if relativePath == "" {
|
||||
return true // This is the root module itself
|
||||
}
|
||||
|
||||
// Check if this path contains a go.mod file (indicating a submodule)
|
||||
// We check from the most specific path up to the root
|
||||
parts := gstr.Split(relativePath, "/")
|
||||
for i := len(parts); i > 0; i-- {
|
||||
subPath := gstr.Join(parts[:i], "/")
|
||||
if subPath != "" && gfile.Exists(subPath+"/go.mod") {
|
||||
// Found a go.mod file in a subdirectory, this indicates a submodule
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true // This is part of the main module
|
||||
}
|
||||
|
||||
// shortName returns a shortened package name.
|
||||
func (a *analyzer) shortName(pkg string, group bool) string {
|
||||
if a.modulePrefix != "" && gstr.HasPrefix(pkg, a.modulePrefix) {
|
||||
@ -137,6 +263,35 @@ func (a *analyzer) shortName(pkg string, group bool) string {
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
// For external packages, handle grouping differently
|
||||
if group {
|
||||
return a.getExternalGroup(pkg)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// getExternalGroup returns the group name for external packages.
|
||||
func (a *analyzer) getExternalGroup(pkg string) string {
|
||||
// For standard library packages
|
||||
if a.isStdLib(pkg) {
|
||||
return "stdlib"
|
||||
}
|
||||
|
||||
// For external packages, group by domain/organization
|
||||
parts := gstr.Split(pkg, "/")
|
||||
if len(parts) > 0 {
|
||||
// Handle common patterns like github.com/user/repo
|
||||
if len(parts) >= 3 && (parts[0] == "github.com" || parts[0] == "gitlab.com" || parts[0] == "bitbucket.org") {
|
||||
return parts[0] + "/" + parts[1]
|
||||
}
|
||||
// For other domains, use the domain name
|
||||
if gstr.Contains(parts[0], ".") {
|
||||
return parts[0]
|
||||
}
|
||||
// For simple names, use the first part
|
||||
return parts[0]
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
@ -189,3 +344,39 @@ func (a *analyzer) collectEdgesRecursive(pkg *goPackage, in Input, edges map[str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getDependencyStats returns statistics about dependencies.
|
||||
func (a *analyzer) getDependencyStats(_ Input) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
|
||||
var internalCount, externalCount, stdlibCount int
|
||||
externalGroups := make(map[string]int)
|
||||
|
||||
for _, pkg := range a.packages {
|
||||
if !a.shouldInclude(pkg.ImportPath, Input{
|
||||
Internal: true,
|
||||
External: true,
|
||||
NoStd: false,
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
if a.isStdLib(pkg.ImportPath) {
|
||||
stdlibCount++
|
||||
} else if a.modulePrefix != "" && gstr.HasPrefix(pkg.ImportPath, a.modulePrefix) {
|
||||
internalCount++
|
||||
} else {
|
||||
externalCount++
|
||||
group := a.getExternalGroup(pkg.ImportPath)
|
||||
externalGroups[group]++
|
||||
}
|
||||
}
|
||||
|
||||
stats["total"] = len(a.packages)
|
||||
stats["internal"] = internalCount
|
||||
stats["external"] = externalCount
|
||||
stats["stdlib"] = stdlibCount
|
||||
stats["external_groups"] = externalGroups
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
83
cmd/gf/internal/cmd/cmddep/cmddep_external_test.go
Normal file
83
cmd/gf/internal/cmd/cmddep/cmddep_external_test.go
Normal file
@ -0,0 +1,83 @@
|
||||
// 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/test/gtest"
|
||||
)
|
||||
|
||||
func TestExternalDependencyAnalysis(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
analyzer := newAnalyzer()
|
||||
analyzer.modulePrefix = "github.com/gogf/gf/cmd/gf/v2"
|
||||
|
||||
// Test shouldInclude with external dependencies
|
||||
in := Input{
|
||||
Internal: false,
|
||||
External: true,
|
||||
NoStd: true,
|
||||
}
|
||||
|
||||
// Test external package (should be included)
|
||||
t.Assert(analyzer.shouldInclude("github.com/other/package", in), true)
|
||||
|
||||
// Test internal package (should not be included)
|
||||
t.Assert(analyzer.shouldInclude("github.com/gogf/gf/cmd/gf/v2/internal", in), false)
|
||||
|
||||
// Test standard library (should not be included due to NoStd)
|
||||
t.Assert(analyzer.shouldInclude("fmt", in), false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExternalGrouping(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
analyzer := newAnalyzer()
|
||||
|
||||
// Test external group extraction
|
||||
t.Assert(analyzer.getExternalGroup("github.com/user/repo"), "github.com/user")
|
||||
t.Assert(analyzer.getExternalGroup("golang.org/x/tools"), "golang.org")
|
||||
t.Assert(analyzer.getExternalGroup("fmt"), "stdlib")
|
||||
t.Assert(analyzer.getExternalGroup("simple"), "simple")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDependencyStats(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
analyzer := newAnalyzer()
|
||||
analyzer.modulePrefix = "github.com/gogf/gf/cmd/gf/v2"
|
||||
|
||||
// Add test packages
|
||||
analyzer.packages = map[string]*goPackage{
|
||||
"github.com/gogf/gf/cmd/gf/v2/internal": {
|
||||
ImportPath: "github.com/gogf/gf/cmd/gf/v2/internal",
|
||||
Standard: false,
|
||||
},
|
||||
"github.com/external/package": {
|
||||
ImportPath: "github.com/external/package",
|
||||
Standard: false,
|
||||
},
|
||||
"fmt": {
|
||||
ImportPath: "fmt",
|
||||
Standard: true,
|
||||
},
|
||||
}
|
||||
|
||||
in := Input{
|
||||
Internal: true,
|
||||
External: true,
|
||||
NoStd: false,
|
||||
}
|
||||
|
||||
stats := analyzer.getDependencyStats(in)
|
||||
t.Assert(stats["total"], 3)
|
||||
t.Assert(stats["internal"], 1)
|
||||
t.Assert(stats["external"], 1)
|
||||
t.Assert(stats["stdlib"], 1)
|
||||
})
|
||||
}
|
||||
@ -27,6 +27,7 @@ func (a *analyzer) generate(in Input) string {
|
||||
case "json":
|
||||
return a.generateJSON(in)
|
||||
default:
|
||||
// Default to tree format
|
||||
return a.generateTree(in)
|
||||
}
|
||||
}
|
||||
@ -35,6 +36,24 @@ func (a *analyzer) generate(in Input) string {
|
||||
func (a *analyzer) generateTree(in Input) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Add statistics header if showing external dependencies
|
||||
if in.External {
|
||||
stats := a.getDependencyStats(in)
|
||||
sb.WriteString("Dependency Statistics:\n")
|
||||
fmt.Fprintf(&sb, " Total packages: %v\n", stats["total"])
|
||||
fmt.Fprintf(&sb, " Internal: %v\n", stats["internal"])
|
||||
fmt.Fprintf(&sb, " External: %v\n", stats["external"])
|
||||
fmt.Fprintf(&sb, " Standard library: %v\n", stats["stdlib"])
|
||||
|
||||
if groups, ok := stats["external_groups"].(map[string]int); ok && len(groups) > 0 {
|
||||
sb.WriteString(" External groups:\n")
|
||||
for group, count := range groups {
|
||||
fmt.Fprintf(&sb, " %s: %d\n", group, count)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\nDependency Tree:\n")
|
||||
}
|
||||
|
||||
// Find root packages (packages that are not imported by any other package)
|
||||
rootPkgs := a.findRootPackages()
|
||||
|
||||
@ -43,9 +62,11 @@ func (a *analyzer) generateTree(in Input) string {
|
||||
|
||||
for _, pkgPath := range rootPkgs {
|
||||
pkg := a.packages[pkgPath]
|
||||
shortName := a.shortName(pkg.ImportPath, in.Group)
|
||||
sb.WriteString(shortName + "\n")
|
||||
a.printTreeNode(&sb, pkg, "", in, 0)
|
||||
if a.shouldInclude(pkg.ImportPath, in) {
|
||||
shortName := a.shortName(pkg.ImportPath, in.Group)
|
||||
sb.WriteString(shortName + "\n")
|
||||
a.printTreeNode(&sb, pkg, "", in, 0)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@ -82,6 +103,7 @@ func (a *analyzer) printTreeNode(sb *strings.Builder, pkg *goPackage, prefix str
|
||||
return
|
||||
}
|
||||
|
||||
// filterDeps already applies all filtering including main-only
|
||||
deps := a.filterDeps(pkg.Imports, in)
|
||||
sort.Strings(deps)
|
||||
|
||||
@ -117,14 +139,37 @@ func (a *analyzer) printTreeNode(sb *strings.Builder, pkg *goPackage, prefix str
|
||||
// generateList generates simple list output.
|
||||
func (a *analyzer) generateList(in Input) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Add statistics header if showing external dependencies
|
||||
if in.External {
|
||||
stats := a.getDependencyStats(in)
|
||||
sb.WriteString("# Dependency Statistics\n")
|
||||
fmt.Fprintf(&sb, "# Total: %v, Internal: %v, External: %v, Stdlib: %v\n",
|
||||
stats["total"], stats["internal"], stats["external"], stats["stdlib"])
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Debug mainOnly state
|
||||
// sb.WriteString(fmt.Sprintf("# DEBUG mainOnly=%v\\n", in.MainOnly))
|
||||
|
||||
allDeps := make(map[string]bool)
|
||||
|
||||
// Collect dependencies from packages that should be included
|
||||
for _, pkg := range a.packages {
|
||||
for _, dep := range a.filterDeps(pkg.Imports, in) {
|
||||
allDeps[dep] = true
|
||||
if a.shouldInclude(pkg.ImportPath, in) {
|
||||
// Collect dependencies (filterDeps already applies all filtering including main-only)
|
||||
for _, dep := range a.filterDeps(pkg.Imports, in) {
|
||||
allDeps[dep] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Additionally, keep the package itself when it passes main-only check
|
||||
if in.MainOnly && a.isModuleRootPackage(pkg.ImportPath) && a.shouldInclude(pkg.ImportPath, in) {
|
||||
allDeps[pkg.ImportPath] = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deps := make([]string, 0, len(allDeps))
|
||||
for dep := range allDeps {
|
||||
deps = append(deps, a.shortName(dep, in.Group))
|
||||
@ -201,15 +246,36 @@ func (a *analyzer) generateDot(in Input) string {
|
||||
|
||||
// generateJSON generates JSON output.
|
||||
func (a *analyzer) generateJSON(in Input) string {
|
||||
result := make(map[string]any)
|
||||
|
||||
// Add dependency nodes
|
||||
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)
|
||||
if a.shouldInclude(pkg.ImportPath, in) {
|
||||
a.visited = make(map[string]bool)
|
||||
node := a.buildDepNode(pkg, in, 0)
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(nodes, "", " ")
|
||||
result["dependencies"] = nodes
|
||||
|
||||
// Add statistics
|
||||
result["statistics"] = a.getDependencyStats(in)
|
||||
|
||||
// Add metadata
|
||||
result["metadata"] = map[string]any{
|
||||
"module": a.modulePrefix,
|
||||
"format": in.Format,
|
||||
"depth": in.Depth,
|
||||
"group": in.Group,
|
||||
"internal": in.Internal,
|
||||
"external": in.External,
|
||||
"nostd": in.NoStd,
|
||||
"main": in.MainOnly,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
|
||||
@ -174,6 +174,12 @@ func (a *analyzer) handleGraphAPI(w http.ResponseWriter, r *http.Request, in Inp
|
||||
if i := query.Get("internal"); i != "" {
|
||||
in.Internal = i == "true"
|
||||
}
|
||||
if e := query.Get("external"); e != "" {
|
||||
in.External = e == "true"
|
||||
}
|
||||
if m := query.Get("main"); m != "" {
|
||||
in.MainOnly = m == "true"
|
||||
}
|
||||
pkg := query.Get("package")
|
||||
|
||||
var data *graphData
|
||||
@ -192,10 +198,19 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
|
||||
if i := query.Get("internal"); i != "" {
|
||||
in.Internal = i == "true"
|
||||
}
|
||||
if e := query.Get("external"); e != "" {
|
||||
in.External = e == "true"
|
||||
}
|
||||
if m := query.Get("main"); m != "" {
|
||||
in.MainOnly = m == "true"
|
||||
}
|
||||
|
||||
// Build reverse dependency map (who uses each package)
|
||||
usedByMap := make(map[string]int)
|
||||
for fullPath, pkg := range a.packages {
|
||||
if !a.shouldInclude(fullPath, in) {
|
||||
continue
|
||||
}
|
||||
fromShort := a.shortName(fullPath, false)
|
||||
if fromShort == "" {
|
||||
continue
|
||||
@ -210,6 +225,9 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
|
||||
|
||||
packages := make([]packageSummary, 0)
|
||||
for _, pkgPath := range a.getSortedPackages() {
|
||||
if !a.shouldInclude(pkgPath, in) {
|
||||
continue
|
||||
}
|
||||
shortName := a.shortName(pkgPath, false)
|
||||
if shortName == "" {
|
||||
continue
|
||||
@ -232,8 +250,15 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
|
||||
UsedByCount: usedByMap[shortName],
|
||||
})
|
||||
}
|
||||
|
||||
// Add statistics to response
|
||||
result := map[string]any{
|
||||
"packages": packages,
|
||||
"statistics": a.getDependencyStats(in),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(packages)
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// handlePackageAPI returns detailed info for a specific package.
|
||||
@ -303,6 +328,12 @@ func (a *analyzer) handleTreeAPI(w http.ResponseWriter, r *http.Request, in Inpu
|
||||
if i := query.Get("internal"); i != "" {
|
||||
in.Internal = i == "true"
|
||||
}
|
||||
if e := query.Get("external"); e != "" {
|
||||
in.External = e == "true"
|
||||
}
|
||||
if m := query.Get("main"); m != "" {
|
||||
in.MainOnly = m == "true"
|
||||
}
|
||||
pkg := query.Get("package")
|
||||
|
||||
var output string
|
||||
@ -322,6 +353,12 @@ func (a *analyzer) handleListAPI(w http.ResponseWriter, r *http.Request, in Inpu
|
||||
if i := query.Get("internal"); i != "" {
|
||||
in.Internal = i == "true"
|
||||
}
|
||||
if e := query.Get("external"); e != "" {
|
||||
in.External = e == "true"
|
||||
}
|
||||
if m := query.Get("main"); m != "" {
|
||||
in.MainOnly = m == "true"
|
||||
}
|
||||
pkg := query.Get("package")
|
||||
|
||||
var output string
|
||||
|
||||
@ -65,6 +65,7 @@ const theme = {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: this.current === 'dark' ? 'dark' : 'default',
|
||||
maxEdges: 2000, // Increase edge limit for large dependency graphs
|
||||
flowchart: {
|
||||
useMaxWidth: false,
|
||||
htmlLabels: true,
|
||||
@ -82,6 +83,7 @@ const theme = {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
maxEdges: 2000, // Increase edge limit for large dependency graphs
|
||||
flowchart: {
|
||||
useMaxWidth: false,
|
||||
htmlLabels: true,
|
||||
@ -407,8 +409,26 @@ async function loadModuleName() {
|
||||
async function loadPackages() {
|
||||
try {
|
||||
const internal = document.getElementById('internal').checked;
|
||||
const response = await fetch(`/api/packages?internal=${internal}`);
|
||||
allPackages = await response.json();
|
||||
const external = document.getElementById('external') ? document.getElementById('external').checked : false;
|
||||
const mainOnly = document.getElementById('mainOnly') ? document.getElementById('mainOnly').checked : false;
|
||||
const response = await fetch(`/api/packages?internal=${internal}&external=${external}&main=${mainOnly}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Handle new API response format with packages and statistics
|
||||
if (data.packages && Array.isArray(data.packages)) {
|
||||
allPackages = data.packages;
|
||||
// Update statistics display if available
|
||||
if (data.statistics) {
|
||||
updateStatisticsDisplay(data.statistics);
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
// Fallback for old format
|
||||
allPackages = data;
|
||||
} else {
|
||||
console.error('Unexpected API response format:', data);
|
||||
allPackages = [];
|
||||
}
|
||||
|
||||
document.getElementById('packageCount').textContent = allPackages.length;
|
||||
renderPackageList(allPackages);
|
||||
} catch (e) {
|
||||
@ -416,6 +436,19 @@ async function loadPackages() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics display
|
||||
function updateStatisticsDisplay(statistics) {
|
||||
if (statistics) {
|
||||
document.getElementById('internalCount').textContent = statistics.internal || 0;
|
||||
document.getElementById('externalCount').textContent = statistics.external || 0;
|
||||
document.getElementById('stdlibCount').textContent = statistics.stdlib || 0;
|
||||
|
||||
// Update total count
|
||||
const totalCount = statistics.total || (statistics.internal + statistics.external + statistics.stdlib);
|
||||
document.getElementById('nodeCount').textContent = totalCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Get package name from package object or string
|
||||
function getPkgName(pkg) {
|
||||
return typeof pkg === 'object' ? pkg.name : pkg;
|
||||
@ -644,6 +677,8 @@ async function refresh() {
|
||||
const group = document.getElementById('group').checked;
|
||||
const reverse = document.getElementById('reverse').checked;
|
||||
const internal = document.getElementById('internal').checked;
|
||||
const external = document.getElementById('external') ? document.getElementById('external').checked : false;
|
||||
const mainOnly = document.getElementById('mainOnly') ? document.getElementById('mainOnly').checked : false;
|
||||
|
||||
if (selectedPackage) {
|
||||
await showPackageInfo(selectedPackage);
|
||||
@ -652,11 +687,11 @@ async function refresh() {
|
||||
}
|
||||
|
||||
if (currentView === 'graph') {
|
||||
await refreshGraph(depth, group, reverse, internal);
|
||||
await refreshGraph(depth, group, reverse, internal, external, mainOnly);
|
||||
} else if (currentView === 'tree') {
|
||||
await refreshTree(depth, internal);
|
||||
await refreshTree(depth, internal, external, mainOnly);
|
||||
} else {
|
||||
await refreshList(internal);
|
||||
await refreshList(internal, external, mainOnly);
|
||||
}
|
||||
}
|
||||
|
||||
@ -689,7 +724,7 @@ async function showPackageInfo(pkg) {
|
||||
}
|
||||
|
||||
// Refresh graph view
|
||||
async function refreshGraph(depth, group, reverse, internal) {
|
||||
async function refreshGraph(depth, group, reverse, internal, external, mainOnly) {
|
||||
document.getElementById('graphView').classList.remove('hidden');
|
||||
document.getElementById('textView').classList.add('hidden');
|
||||
|
||||
@ -700,6 +735,12 @@ async function refreshGraph(depth, group, reverse, internal) {
|
||||
applyTransform();
|
||||
|
||||
let url = `/api/graph?depth=${depth}&group=${group}&reverse=${reverse}&internal=${internal}`;
|
||||
if (external !== undefined) {
|
||||
url += `&external=${external}`;
|
||||
}
|
||||
if (mainOnly !== undefined) {
|
||||
url += `&main=${mainOnly}`;
|
||||
}
|
||||
if (selectedPackage) {
|
||||
url += '&package=' + encodeURIComponent(selectedPackage);
|
||||
}
|
||||
@ -774,11 +815,17 @@ function autoFitGraph() {
|
||||
}
|
||||
|
||||
// Refresh tree view
|
||||
async function refreshTree(depth, internal) {
|
||||
async function refreshTree(depth, internal, external, mainOnly) {
|
||||
document.getElementById('graphView').classList.add('hidden');
|
||||
document.getElementById('textView').classList.remove('hidden');
|
||||
|
||||
let url = `/api/tree?depth=${depth}&internal=${internal}`;
|
||||
if (external !== undefined) {
|
||||
url += `&external=${external}`;
|
||||
}
|
||||
if (mainOnly !== undefined) {
|
||||
url += `&main=${mainOnly}`;
|
||||
}
|
||||
if (selectedPackage) {
|
||||
url += '&package=' + encodeURIComponent(selectedPackage);
|
||||
}
|
||||
@ -797,11 +844,17 @@ async function refreshTree(depth, internal) {
|
||||
}
|
||||
|
||||
// Refresh list view
|
||||
async function refreshList(internal) {
|
||||
async function refreshList(internal, external, mainOnly) {
|
||||
document.getElementById('graphView').classList.add('hidden');
|
||||
document.getElementById('textView').classList.remove('hidden');
|
||||
|
||||
let url = `/api/list?internal=${internal}`;
|
||||
if (external !== undefined) {
|
||||
url += `&external=${external}`;
|
||||
}
|
||||
if (mainOnly !== undefined) {
|
||||
url += `&main=${mainOnly}`;
|
||||
}
|
||||
if (selectedPackage) {
|
||||
url += '&package=' + encodeURIComponent(selectedPackage);
|
||||
}
|
||||
|
||||
@ -23,6 +23,11 @@ const i18n = {
|
||||
reverseLabel: 'Reverse (show who uses)',
|
||||
groupLabel: 'Group by directory',
|
||||
internalLabel: 'Internal only',
|
||||
externalLabel: 'External packages',
|
||||
mainOnlyLabel: 'Main module only',
|
||||
statsInternal: 'Internal',
|
||||
statsExternal: 'External',
|
||||
statsStdlib: 'Stdlib',
|
||||
layoutLabel: 'Layout:',
|
||||
layoutTD: 'Top-Down',
|
||||
layoutLR: 'Left-Right',
|
||||
@ -47,7 +52,32 @@ const i18n = {
|
||||
latestVersion: 'Latest',
|
||||
errorFetchVersions: 'Failed to fetch versions',
|
||||
errorAnalyze: 'Failed to analyze module',
|
||||
packageDetails: 'Package Details'
|
||||
packageDetails: 'Package Details',
|
||||
viewGraphTooltip: 'Visualize dependencies as a directed graph',
|
||||
viewTreeTooltip: 'Show dependencies in a hierarchical tree structure',
|
||||
viewListTooltip: 'Display dependencies as a text list',
|
||||
depthTooltip: 'Limit dependency traversal depth',
|
||||
reverseTooltip: 'Show reverse dependencies (who uses the selected package)',
|
||||
groupTooltip: 'Group packages by directory structure',
|
||||
internalTooltip: 'Show only internal packages from current module',
|
||||
externalTooltip: 'Include external dependency packages',
|
||||
mainOnlyTooltip: 'Show only main module packages (exclude vendor)',
|
||||
layoutTDTooltip: 'Top-down layout for dependency graph',
|
||||
layoutLRTooltip: 'Left-right layout for dependency graph',
|
||||
showAllTooltip: 'Clear package selection and show all packages',
|
||||
analyzeTooltip: 'Analyze remote Go module dependencies',
|
||||
resetTooltip: 'Reset to local module analysis',
|
||||
themeTooltip: 'Toggle light/dark theme',
|
||||
langTooltip: 'Select language',
|
||||
flatModeTooltip: 'Flat list view',
|
||||
treeModeTooltip: 'Tree view',
|
||||
zoomInTooltip: 'Zoom in',
|
||||
zoomOutTooltip: 'Zoom out',
|
||||
fitToScreenTooltip: 'Fit to screen',
|
||||
resetZoomTooltip: 'Reset zoom',
|
||||
remoteModuleTooltip: 'Enter a remote Go module path to analyze',
|
||||
versionSelectTooltip: 'Select module version (optional)',
|
||||
searchTooltip: 'Search packages by name'
|
||||
},
|
||||
zh: {
|
||||
title: 'Go 包依赖分析',
|
||||
@ -69,6 +99,11 @@ const i18n = {
|
||||
reverseLabel: '反向依赖 (谁引用了它)',
|
||||
groupLabel: '按目录分组',
|
||||
internalLabel: '仅内部包',
|
||||
externalLabel: '外部依赖包',
|
||||
mainOnlyLabel: '仅主模块',
|
||||
statsInternal: '内部',
|
||||
statsExternal: '外部',
|
||||
statsStdlib: '标准库',
|
||||
layoutLabel: '布局:',
|
||||
layoutTD: '从上到下',
|
||||
layoutLR: '从左到右',
|
||||
@ -93,7 +128,32 @@ const i18n = {
|
||||
latestVersion: '最新版本',
|
||||
errorFetchVersions: '获取版本失败',
|
||||
errorAnalyze: '分析模块失败',
|
||||
packageDetails: '包详情'
|
||||
packageDetails: '包详情',
|
||||
viewGraphTooltip: '以有向图形式可视化依赖关系',
|
||||
viewTreeTooltip: '以层次树结构显示依赖关系',
|
||||
viewListTooltip: '以文本列表形式显示依赖关系',
|
||||
depthTooltip: '限制依赖遍历的深度',
|
||||
reverseTooltip: '显示反向依赖(谁引用了选中的包)',
|
||||
groupTooltip: '按目录结构分组显示包',
|
||||
internalTooltip: '仅显示当前模块的内部包',
|
||||
externalTooltip: '包含外部依赖包',
|
||||
mainOnlyTooltip: '仅显示主模块包(排除vendor目录)',
|
||||
layoutTDTooltip: '依赖图从上到下布局',
|
||||
layoutLRTooltip: '依赖图从左到右布局',
|
||||
showAllTooltip: '清除包选择,显示所有包',
|
||||
analyzeTooltip: '分析远程Go模块依赖',
|
||||
resetTooltip: '重置为本地模块分析',
|
||||
themeTooltip: '切换明暗主题',
|
||||
langTooltip: '选择语言',
|
||||
flatModeTooltip: '平铺列表视图',
|
||||
treeModeTooltip: '树形视图',
|
||||
zoomInTooltip: '放大',
|
||||
zoomOutTooltip: '缩小',
|
||||
fitToScreenTooltip: '适应屏幕',
|
||||
resetZoomTooltip: '重置缩放',
|
||||
remoteModuleTooltip: '输入远程Go模块路径进行分析',
|
||||
versionSelectTooltip: '选择模块版本(可选)',
|
||||
searchTooltip: '按名称搜索包'
|
||||
}
|
||||
},
|
||||
|
||||
@ -147,6 +207,12 @@ const i18n = {
|
||||
el.placeholder = this.t(key);
|
||||
});
|
||||
|
||||
// Update title attributes
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = this.t(key);
|
||||
});
|
||||
|
||||
// Update page title
|
||||
document.title = this.t('title');
|
||||
}
|
||||
|
||||
@ -19,24 +19,24 @@
|
||||
<div class="header-center">
|
||||
<div class="remote-input-group">
|
||||
<label for="remoteModuleInput" data-i18n="remoteModuleLabel">Module:</label>
|
||||
<input type="text" id="remoteModuleInput" data-i18n-placeholder="remoteModulePlaceholder" placeholder="e.g. github.com/gogf/gf/v2">
|
||||
<select id="versionSelect" disabled>
|
||||
<input type="text" id="remoteModuleInput" data-i18n-placeholder="remoteModulePlaceholder" placeholder="e.g. github.com/gogf/gf/v2" data-i18n-title="remoteModuleTooltip">
|
||||
<select id="versionSelect" disabled data-i18n-title="versionSelectTooltip">
|
||||
<option value="" data-i18n="selectVersion">Select version</option>
|
||||
</select>
|
||||
<button class="btn btn-sm" id="analyzeBtn" onclick="analyzeRemoteModule()" data-i18n="analyze">Analyze</button>
|
||||
<button class="btn btn-sm btn-secondary" id="resetBtn" onclick="resetToLocal()" data-i18n="resetLocal">Reset</button>
|
||||
<button class="btn btn-sm" id="analyzeBtn" onclick="analyzeRemoteModule()" data-i18n="analyze" data-i18n-title="analyzeTooltip">Analyze</button>
|
||||
<button class="btn btn-sm btn-secondary" id="resetBtn" onclick="resetToLocal()" data-i18n="resetLocal" data-i18n-title="resetTooltip">Reset</button>
|
||||
<span class="loading-spinner hidden" id="loadingSpinner"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="theme-switch">
|
||||
<button class="icon-btn" id="themeToggle" title="Toggle theme">
|
||||
<button class="icon-btn" id="themeToggle" data-i18n-title="themeTooltip">
|
||||
<span class="icon-sun">☀️</span>
|
||||
<span class="icon-moon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="lang-switch">
|
||||
<select id="langSelect">
|
||||
<select id="langSelect" data-i18n-title="langTooltip">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
@ -47,14 +47,14 @@
|
||||
<div class="control-group">
|
||||
<label data-i18n="viewLabel">View:</label>
|
||||
<div class="btn-group">
|
||||
<button class="btn active" id="btnGraph" onclick="setView('graph')" data-i18n="viewGraph">Graph</button>
|
||||
<button class="btn" id="btnTree" onclick="setView('tree')" data-i18n="viewTree">Tree</button>
|
||||
<button class="btn" id="btnList" onclick="setView('list')" data-i18n="viewList">List</button>
|
||||
<button class="btn active" id="btnGraph" onclick="setView('graph')" data-i18n="viewGraph" data-i18n-title="viewGraphTooltip">Graph</button>
|
||||
<button class="btn" id="btnTree" onclick="setView('tree')" data-i18n="viewTree" data-i18n-title="viewTreeTooltip">Tree</button>
|
||||
<button class="btn" id="btnList" onclick="setView('list')" data-i18n="viewList" data-i18n-title="viewListTooltip">List</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label data-i18n="depthLabel">Depth:</label>
|
||||
<select id="depth" onchange="refresh()">
|
||||
<select id="depth" onchange="refresh()" data-i18n-title="depthTooltip">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3" selected>3</option>
|
||||
@ -63,25 +63,33 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="reverse" onchange="refresh()">
|
||||
<input type="checkbox" id="reverse" onchange="refresh()" data-i18n-title="reverseTooltip">
|
||||
<label for="reverse" data-i18n="reverseLabel">Reverse (show who uses)</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="group" onchange="refresh()">
|
||||
<input type="checkbox" id="group" onchange="refresh()" data-i18n-title="groupTooltip">
|
||||
<label for="group" data-i18n="groupLabel">Group by directory</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="internal" checked onchange="refresh()">
|
||||
<input type="checkbox" id="internal" checked onchange="refresh()" data-i18n-title="internalTooltip">
|
||||
<label for="internal" data-i18n="internalLabel">Internal only</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="external" onchange="refresh()" data-i18n-title="externalTooltip">
|
||||
<label for="external" data-i18n="externalLabel">External packages</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<input type="checkbox" id="mainOnly" onchange="refresh()" data-i18n-title="mainOnlyTooltip">
|
||||
<label for="mainOnly" data-i18n="mainOnlyLabel">Main module only</label>
|
||||
</div>
|
||||
<div class="control-group graph-only" id="layoutGroup">
|
||||
<label data-i18n="layoutLabel">Layout:</label>
|
||||
<div class="btn-group">
|
||||
<button class="btn active" id="layoutTD" onclick="setLayout('TD')" data-i18n="layoutTD">Top-Down</button>
|
||||
<button class="btn" id="layoutLR" onclick="setLayout('LR')" data-i18n="layoutLR">Left-Right</button>
|
||||
<button class="btn active" id="layoutTD" onclick="setLayout('TD')" data-i18n="layoutTD" data-i18n-title="layoutTDTooltip">Top-Down</button>
|
||||
<button class="btn" id="layoutLR" onclick="setLayout('LR')" data-i18n="layoutLR" data-i18n-title="layoutLRTooltip">Left-Right</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="clearSelection()" data-i18n="showAll">Show All</button>
|
||||
<button class="btn btn-secondary" onclick="clearSelection()" data-i18n="showAll" data-i18n-title="showAllTooltip">Show All</button>
|
||||
</div>
|
||||
<div class="main-content">
|
||||
<div class="sidebar">
|
||||
@ -91,12 +99,12 @@
|
||||
<span class="count" id="packageCount">0</span>
|
||||
</div>
|
||||
<div class="sidebar-mode">
|
||||
<button class="mode-btn" id="modeFlat" onclick="setPackageListMode('flat')" title="Flat list">☰</button>
|
||||
<button class="mode-btn active" id="modeTree" onclick="setPackageListMode('tree')" title="Tree view">🌲</button>
|
||||
<button class="mode-btn" id="modeFlat" onclick="setPackageListMode('flat')" data-i18n-title="flatModeTooltip">☰</button>
|
||||
<button class="mode-btn active" id="modeTree" onclick="setPackageListMode('tree')" data-i18n-title="treeModeTooltip">🌲</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" data-i18n-placeholder="searchPlaceholder" placeholder="Search packages..." oninput="filterPackages()">
|
||||
<input type="text" id="searchInput" data-i18n-placeholder="searchPlaceholder" placeholder="Search packages..." oninput="filterPackages()" data-i18n-title="searchTooltip">
|
||||
</div>
|
||||
<div class="package-list" id="packageList"></div>
|
||||
</div>
|
||||
@ -110,6 +118,18 @@
|
||||
<div class="value" id="edgeCount">0</div>
|
||||
<div class="label" data-i18n="statsDependencies">Dependencies</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="internalCount">0</div>
|
||||
<div class="label" data-i18n="statsInternal">Internal</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="externalCount">0</div>
|
||||
<div class="label" data-i18n="statsExternal">External</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value" id="stdlibCount">0</div>
|
||||
<div class="label" data-i18n="statsStdlib">Stdlib</div>
|
||||
</div>
|
||||
<div class="package-info" id="packageInfo">
|
||||
<div class="package-info-content" id="packageInfoContent"></div>
|
||||
</div>
|
||||
@ -126,10 +146,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="zoom-controls" id="zoomControls">
|
||||
<button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button>
|
||||
<button class="zoom-btn" onclick="zoomOut()" title="Zoom out">−</button>
|
||||
<button class="zoom-btn" onclick="fitToScreen()" title="Fit to screen">⊡</button>
|
||||
<button class="zoom-btn" onclick="resetZoom()" title="Reset zoom">⟲</button>
|
||||
<button class="zoom-btn" onclick="zoomIn()" data-i18n-title="zoomInTooltip">+</button>
|
||||
<button class="zoom-btn" onclick="zoomOut()" data-i18n-title="zoomOutTooltip">−</button>
|
||||
<button class="zoom-btn" onclick="fitToScreen()" data-i18n-title="fitToScreenTooltip">⊡</button>
|
||||
<button class="zoom-btn" onclick="resetZoom()" data-i18n-title="resetZoomTooltip">⟲</button>
|
||||
</div>
|
||||
<script src="/static/i18n.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user