feat: 添加依赖分析命令 dep

This commit is contained in:
hailaz
2026-01-08 14:30:17 +08:00
parent c5778127b1
commit 470b492ba7
10 changed files with 3389 additions and 0 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}
}
}

View File

@ -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()
}

View File

@ -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})
}

View File

@ -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)
})
}

View File

@ -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 = `<option value="">${i18n.t('selectVersion')}</option>`;
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 = `<option value="">${i18n.t('errorFetchVersions')}</option>`;
} 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 `<option value="${v}">${label}</option>`;
}).join('');
versionSelect.disabled = false;
} else {
versionSelect.innerHTML = `<option value="">${i18n.t('noVersions')}</option>`;
}
} catch (e) {
console.error('Failed to fetch versions:', e);
versionSelect.innerHTML = `<option value="">${i18n.t('errorFetchVersions')}</option>`;
} 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 = `<option value="">${i18n.t('selectVersion')}</option>`;
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 = '<div class="loading">' + i18n.t('noPackages') + '</div>';
return;
}
if (packageListMode === 'tree') {
renderPackageTree(packages, list);
} else {
renderPackageFlat(packages, list);
}
}
// Render flat package list
function renderPackageFlat(packages, container) {
container.innerHTML = packages.map(pkg => {
const name = getPkgName(pkg);
const isActive = name === selectedPackage ? ' active' : '';
const escaped = name.replace(/'/g, "\\'");
// Build stats display
let statsHtml = '';
if (typeof pkg === 'object') {
statsHtml = `<span class="pkg-stats-inline">
<span class="dep-count" title="${i18n.t('dependencies')}">→${pkg.depCount}</span>
<span class="used-count" title="${i18n.t('usedBy')}">←${pkg.usedByCount}</span>
</span>`;
}
return `<div class="package-item${isActive}" onclick="selectPackage('${escaped}')" title="${name}">
<span class="pkg-name-text">${name}</span>${statsHtml}
</div>`;
}).join('');
}
// Build tree structure from package paths
function buildPackageTree(packages) {
const root = { children: {}, packages: [] };
packages.forEach(pkg => {
const name = getPkgName(pkg);
const parts = name.split('/');
let current = root;
parts.forEach((part, index) => {
if (!current.children[part]) {
current.children[part] = { children: {}, packages: [], path: parts.slice(0, index + 1).join('/') };
}
current = current.children[part];
});
current.isPackage = true;
current.fullPath = name;
// Store stats if available
if (typeof pkg === 'object') {
current.depCount = pkg.depCount;
current.usedByCount = pkg.usedByCount;
}
});
return root;
}
// Render package tree
function renderPackageTree(packages, container) {
const tree = buildPackageTree(packages);
container.innerHTML = renderTreeNode(tree, '');
}
// Render a tree node recursively
function renderTreeNode(node, path) {
const children = Object.keys(node.children).sort();
if (children.length === 0) return '';
return children.map(name => {
const child = node.children[name];
const childPath = path ? `${path}/${name}` : name;
const hasChildren = Object.keys(child.children).length > 0;
const isExpanded = expandedNodes.has(childPath);
const isActive = child.fullPath === selectedPackage;
const isPackage = child.isPackage;
const toggleClass = hasChildren ? (isExpanded ? 'expanded' : '') : 'empty';
const activeClass = isActive ? ' active' : '';
const nameClass = isPackage ? ' package' : '';
const icon = isPackage ? '📦' : '📁';
// Build stats for packages
let statsHtml = '';
if (isPackage && child.depCount !== undefined) {
statsHtml = `<span class="pkg-stats-inline">
<span class="dep-count" title="${i18n.t('dependencies')}">→${child.depCount}</span>
<span class="used-count" title="${i18n.t('usedBy')}">←${child.usedByCount}</span>
</span>`;
}
let html = `
<div class="tree-node" data-path="${childPath}">
<div class="tree-node-header${activeClass}">
<span class="tree-node-toggle ${toggleClass}" onclick="handleToggleClick(event, '${childPath.replace(/'/g, "\\'")}', ${hasChildren})">▶</span>
<span class="tree-node-icon">${icon}</span>
<span class="tree-node-name${nameClass}" onclick="handleNameClick(event, '${childPath.replace(/'/g, "\\'")}', ${isPackage}, ${hasChildren})">${name}</span>
${statsHtml}
</div>`;
if (hasChildren) {
const childrenHtml = renderTreeNode(child, childPath);
html += `<div class="tree-node-children${isExpanded ? ' expanded' : ''}">${childrenHtml}</div>`;
}
html += '</div>';
return html;
}).join('');
}
// Handle toggle arrow click - expand/collapse
function handleToggleClick(event, path, hasChildren) {
event.stopPropagation();
if (hasChildren) {
toggleTreeNode(path);
}
}
// Handle name click - select package or toggle if folder
function handleNameClick(event, path, isPackage, hasChildren) {
event.stopPropagation();
if (isPackage) {
selectPackage(path);
} else if (hasChildren) {
toggleTreeNode(path);
}
}
// Toggle tree node expansion
function toggleTreeNode(path) {
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
} else {
expandedNodes.add(path);
}
// Re-render with current filter
const query = document.getElementById('searchInput').value.toLowerCase();
const filtered = query ? allPackages.filter(pkg => pkg.toLowerCase().includes(query)) : allPackages;
renderPackageList(filtered);
}
// Filter packages by search query
function filterPackages() {
const query = document.getElementById('searchInput').value.toLowerCase();
const filtered = allPackages.filter(pkg => pkg.toLowerCase().includes(query));
renderPackageList(filtered);
}
// Select a package
async function selectPackage(pkg) {
selectedPackage = pkg;
// Update flat list items
document.querySelectorAll('.package-item').forEach(el => {
el.classList.toggle('active', el.textContent === pkg);
});
// Update tree items
document.querySelectorAll('.tree-node-header').forEach(el => {
const node = el.closest('.tree-node');
el.classList.toggle('active', node && node.dataset.path === pkg);
});
await refresh();
}
// Clear package selection
function clearSelection() {
selectedPackage = null;
document.querySelectorAll('.package-item').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tree-node-header').forEach(el => el.classList.remove('active'));
closePackageInfo();
refresh();
}
// Close package info sidebar
function closePackageInfo() {
document.getElementById('packageInfo').classList.remove('visible');
}
// Set layout direction
function setLayout(layout) {
currentLayout = layout;
document.getElementById('layoutTD').classList.toggle('active', layout === 'TD');
document.getElementById('layoutLR').classList.toggle('active', layout === 'LR');
refresh();
}
// Set view mode
function setView(view) {
currentView = view;
document.getElementById('btnGraph').classList.toggle('active', view === 'graph');
document.getElementById('btnTree').classList.toggle('active', view === 'tree');
document.getElementById('btnList').classList.toggle('active', view === 'list');
document.getElementById('zoomControls').classList.toggle('hidden', view !== 'graph');
document.getElementById('layoutGroup').classList.toggle('hidden', view !== 'graph');
refresh();
}
// Main refresh function
async function refresh() {
const depth = document.getElementById('depth').value;
const group = document.getElementById('group').checked;
const reverse = document.getElementById('reverse').checked;
if (selectedPackage) {
await showPackageInfo(selectedPackage);
} else {
closePackageInfo();
}
if (currentView === 'graph') {
await refreshGraph(depth, group, reverse);
} else if (currentView === 'tree') {
await refreshTree(depth);
} else {
await refreshList();
}
}
// Show package info panel
async function showPackageInfo(pkg) {
try {
const response = await fetch('/api/package?name=' + encodeURIComponent(pkg));
const info = await response.json();
const infoDiv = document.getElementById('packageInfo');
const contentDiv = document.getElementById('packageInfoContent');
infoDiv.classList.add('visible');
contentDiv.innerHTML = `
<span class="pkg-name" title="${info.name}">${info.name}</span>
<div class="pkg-stats">
<div class="pkg-stat">
<span class="pkg-stat-label">${i18n.t('dependencies')}:</span>
<span class="pkg-stat-value">${info.dependencies.length}</span>
</div>
<div class="pkg-stat">
<span class="pkg-stat-label">${i18n.t('usedBy')}:</span>
<span class="pkg-stat-value">${info.usedBy.length}</span>
</div>
</div>
`;
} catch (e) {
console.error('Failed to load package info:', e);
}
}
// Refresh graph view
async function refreshGraph(depth, group, reverse) {
document.getElementById('graphView').classList.remove('hidden');
document.getElementById('textView').classList.add('hidden');
// Reset pan/zoom on refresh
currentZoom = 1;
panX = 0;
panY = 0;
applyTransform();
let url = `/api/graph?depth=${depth}&group=${group}&reverse=${reverse}`;
if (selectedPackage) {
url += '&package=' + encodeURIComponent(selectedPackage);
}
try {
const response = await fetch(url);
const data = await response.json();
document.getElementById('nodeCount').textContent = data.nodes.length;
document.getElementById('edgeCount').textContent = data.edges.length;
// Generate mermaid code
// Build node id to label map
const nodeLabels = {};
data.nodes.forEach(node => {
nodeLabels[node.id] = node.label;
});
// Use current layout direction
let mermaidCode = `graph ${currentLayout}\n`;
// First define all nodes with labels
data.nodes.forEach(node => {
mermaidCode += ` ${node.id}["${node.label}"]\n`;
});
// Then add edges
data.edges.forEach(edge => {
mermaidCode += ` ${edge.from} --> ${edge.to}\n`;
});
const container = document.getElementById('mermaidGraph');
container.innerHTML = mermaidCode;
container.removeAttribute('data-processed');
await mermaid.run({ nodes: [container] });
// Auto-fit if graph is large
setTimeout(() => {
autoFitGraph();
}, 100);
} catch (e) {
console.error('Failed to render graph:', e);
document.getElementById('mermaidGraph').innerHTML =
`<div class="loading">${i18n.t('renderError')}</div>`;
}
}
// Auto-fit graph to viewport
function autoFitGraph() {
const viewport = document.getElementById('graphView');
const svg = document.querySelector('.mermaid svg');
if (!viewport || !svg) return;
const viewportRect = viewport.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
// Calculate scale to fit
const scaleX = (viewportRect.width - 40) / svgRect.width;
const scaleY = (viewportRect.height - 40) / svgRect.height;
const scale = Math.min(scaleX, scaleY, 1); // Don't zoom in beyond 100%
if (scale < 1) {
currentZoom = scale;
// Center the graph
panX = (viewportRect.width - svgRect.width * scale) / 2;
panY = 20;
applyTransform();
showZoomIndicator();
}
}
// Refresh tree view
async function refreshTree(depth) {
document.getElementById('graphView').classList.add('hidden');
document.getElementById('textView').classList.remove('hidden');
let url = `/api/tree?depth=${depth}`;
if (selectedPackage) {
url += '&package=' + encodeURIComponent(selectedPackage);
}
try {
const response = await fetch(url);
const text = await response.text();
document.getElementById('textView').textContent = text;
const lines = text.split('\n').filter(l => l.trim());
document.getElementById('nodeCount').textContent = lines.length;
document.getElementById('edgeCount').textContent = '-';
} catch (e) {
console.error('Failed to load tree:', e);
}
}
// Refresh list view
async function refreshList() {
document.getElementById('graphView').classList.add('hidden');
document.getElementById('textView').classList.remove('hidden');
let url = '/api/list';
if (selectedPackage) {
url += '?package=' + encodeURIComponent(selectedPackage);
}
try {
const response = await fetch(url);
const text = await response.text();
document.getElementById('textView').textContent = text;
const lines = text.split('\n').filter(l => l.trim());
document.getElementById('nodeCount').textContent = lines.length;
document.getElementById('edgeCount').textContent = '-';
} catch (e) {
console.error('Failed to load list:', e);
}
}
// Zoom functions
function zoomIn() {
const viewport = document.getElementById('graphView');
if (!viewport) return;
const rect = viewport.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const newZoom = Math.min(MAX_ZOOM, currentZoom * 1.2);
const scale = newZoom / currentZoom;
panX = centerX - (centerX - panX) * scale;
panY = centerY - (centerY - panY) * scale;
currentZoom = newZoom;
applyTransform();
showZoomIndicator();
}
function zoomOut() {
const viewport = document.getElementById('graphView');
if (!viewport) return;
const rect = viewport.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const newZoom = Math.max(MIN_ZOOM, currentZoom / 1.2);
const scale = newZoom / currentZoom;
panX = centerX - (centerX - panX) * scale;
panY = centerY - (centerY - panY) * scale;
currentZoom = newZoom;
applyTransform();
showZoomIndicator();
}
function resetZoom() {
currentZoom = 1;
panX = 0;
panY = 0;
applyTransform();
showZoomIndicator();
}
function fitToScreen() {
autoFitGraph();
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', init);

View File

@ -0,0 +1,156 @@
// Internationalization (i18n) Module
const i18n = {
currentLang: 'en',
translations: {
en: {
title: 'Go Package Dependencies',
pageTitle: 'Package Dependency Viewer',
currentModule: 'Current:',
remoteModuleLabel: 'Analyze Module:',
remoteModulePlaceholder: 'e.g. github.com/gogf/gf/v2',
selectVersion: 'Version',
analyze: 'Analyze',
resetLocal: 'Reset',
fetchingVersions: 'Fetching...',
analyzing: 'Analyzing...',
viewLabel: 'View:',
viewGraph: 'Graph',
viewTree: 'Tree',
viewList: 'List',
depthLabel: 'Depth:',
depthUnlimited: 'Unlimited',
reverseLabel: 'Reverse (show who uses)',
groupLabel: 'Group by directory',
layoutLabel: 'Layout:',
layoutTD: 'Top-Down',
layoutLR: 'Left-Right',
showAll: 'Show All',
packagesTitle: 'Packages',
searchPlaceholder: 'Search packages...',
flatMode: 'Flat list',
treeMode: 'Tree view',
statsPackages: 'Packages',
statsDependencies: 'Dependencies',
dependencies: 'Dependencies',
usedBy: 'Used by',
noPackages: 'No packages found',
renderError: 'Unable to render graph. Try reducing depth or selecting a specific package.',
packageNotFound: 'Package not found',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
fitToScreen: 'Fit to screen',
resetZoom: 'Reset zoom',
dragToMove: 'Drag to move, scroll to zoom',
noVersions: 'No versions found',
latestVersion: 'Latest',
errorFetchVersions: 'Failed to fetch versions',
errorAnalyze: 'Failed to analyze module',
packageDetails: 'Package Details'
},
zh: {
title: 'Go 包依赖分析',
pageTitle: '包依赖查看器',
currentModule: '当前模块:',
remoteModuleLabel: '分析模块:',
remoteModulePlaceholder: '例如 github.com/gogf/gf/v2',
selectVersion: '版本',
analyze: '分析',
resetLocal: '重置',
fetchingVersions: '获取中...',
analyzing: '分析中...',
viewLabel: '视图:',
viewGraph: '图表',
viewTree: '树形',
viewList: '列表',
depthLabel: '深度:',
depthUnlimited: '无限制',
reverseLabel: '反向依赖 (谁引用了它)',
groupLabel: '按目录分组',
layoutLabel: '布局:',
layoutTD: '从上到下',
layoutLR: '从左到右',
showAll: '显示全部',
packagesTitle: '包列表',
searchPlaceholder: '搜索包...',
flatMode: '平铺列表',
treeMode: '目录树',
statsPackages: '包数量',
statsDependencies: '依赖数',
dependencies: '依赖',
usedBy: '被引用',
noPackages: '未找到包',
renderError: '无法渲染图表请尝试减少深度或选择特定的包',
packageNotFound: '未找到包',
zoomIn: '放大',
zoomOut: '缩小',
fitToScreen: '适应屏幕',
resetZoom: '重置缩放',
dragToMove: '拖拽移动滚轮缩放',
noVersions: '未找到版本',
latestVersion: '最新版本',
errorFetchVersions: '获取版本失败',
errorAnalyze: '分析模块失败',
packageDetails: '包详情'
}
},
init() {
// Load saved language preference
const savedLang = localStorage.getItem('dep-viewer-lang');
if (savedLang && this.translations[savedLang]) {
this.currentLang = savedLang;
} else {
// Detect browser language
const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith('zh')) {
this.currentLang = 'zh';
}
}
// Set select value
const langSelect = document.getElementById('langSelect');
if (langSelect) {
langSelect.value = this.currentLang;
langSelect.addEventListener('change', (e) => {
this.setLanguage(e.target.value);
});
}
this.applyTranslations();
},
setLanguage(lang) {
if (this.translations[lang]) {
this.currentLang = lang;
localStorage.setItem('dep-viewer-lang', lang);
this.applyTranslations();
}
},
t(key) {
return this.translations[this.currentLang][key] || this.translations['en'][key] || key;
},
applyTranslations() {
// Update elements with data-i18n attribute
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = this.t(key);
});
// Update placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
// Update page title
document.title = this.t('title');
}
};
// Initialize i18n when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
i18n.init();
});

View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="title">Go Package Dependencies</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
</head>
<body>
<div class="header">
<div class="header-left">
<h1 data-i18n="pageTitle">Package Dependency Viewer</h1>
<div class="module-info">
<span data-i18n="currentModule">Current:</span>
<span class="module" id="moduleName"></span>
</div>
</div>
<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>
<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>
<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">
<span class="icon-sun"></span>
<span class="icon-moon">🌙</span>
</button>
</div>
<div class="lang-switch">
<select id="langSelect">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
</div>
</div>
<div class="controls">
<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>
</div>
</div>
<div class="control-group">
<label data-i18n="depthLabel">Depth:</label>
<select id="depth" onchange="refresh()">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="5">5</option>
<option value="0" data-i18n="depthUnlimited">Unlimited</option>
</select>
</div>
<div class="control-group">
<input type="checkbox" id="reverse" onchange="refresh()">
<label for="reverse" data-i18n="reverseLabel">Reverse (show who uses)</label>
</div>
<div class="control-group">
<input type="checkbox" id="group" onchange="refresh()">
<label for="group" data-i18n="groupLabel">Group by directory</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>
</div>
</div>
<button class="btn btn-secondary" onclick="clearSelection()" data-i18n="showAll">Show All</button>
</div>
<div class="main-content">
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<h3 data-i18n="packagesTitle">Packages</h3>
<span class="count" id="packageCount">0</span>
</div>
<div class="sidebar-mode">
<button class="mode-btn active" id="modeFlat" onclick="setPackageListMode('flat')" title="Flat list"></button>
<button class="mode-btn" id="modeTree" onclick="setPackageListMode('tree')" title="Tree view">🌲</button>
</div>
</div>
<div class="search-box">
<input type="text" id="searchInput" data-i18n-placeholder="searchPlaceholder" placeholder="Search packages..." oninput="filterPackages()">
</div>
<div class="package-list" id="packageList"></div>
</div>
<div class="content-area">
<div class="stats">
<div class="stat-card">
<div class="value" id="nodeCount">0</div>
<div class="label" data-i18n="statsPackages">Packages</div>
</div>
<div class="stat-card">
<div class="value" id="edgeCount">0</div>
<div class="label" data-i18n="statsDependencies">Dependencies</div>
</div>
<div class="package-info" id="packageInfo">
<div class="package-info-content" id="packageInfoContent"></div>
</div>
</div>
<div class="graph-container" id="graphContainer">
<div id="graphView" class="graph-viewport">
<div class="mermaid-container" id="mermaidContainer">
<div class="mermaid" id="mermaidGraph"></div>
</div>
<div class="zoom-indicator" id="zoomIndicator">100%</div>
</div>
<div id="textView" class="text-output hidden"></div>
</div>
</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>
</div>
<script src="/static/i18n.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,811 @@
/* CSS Variables for Theming */
:root {
/* Light Theme (Default) */
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-hover: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--accent-light: #dbeafe;
--success-color: #10b981;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--input-bg: #ffffff;
--input-border: #cbd5e1;
}
[data-theme="dark"] {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-hover: #475569;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #64748b;
--border-color: #334155;
--accent-color: #60a5fa;
--accent-hover: #3b82f6;
--accent-light: #1e3a5f;
--success-color: #34d399;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--input-bg: #1e293b;
--input-border: #475569;
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
transition: background-color 0.3s, color 0.3s;
}
/* Header */
.header {
background: var(--bg-secondary);
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
box-shadow: var(--card-shadow);
flex-shrink: 0;
}
.header-left {
flex-shrink: 0;
}
.header-left h1 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.header-left .module-info {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
font-size: 12px;
margin-top: 2px;
}
.header-left .module {
color: var(--accent-color);
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Header Center - Remote Module Input */
.header-center {
flex: 1;
display: flex;
justify-content: center;
}
.remote-input-group {
display: flex;
align-items: center;
gap: 8px;
}
.remote-input-group input[type="text"] {
width: 260px;
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
transition: border-color 0.2s;
}
.remote-input-group input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
}
.remote-input-group select {
width: 120px;
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.remote-input-group select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.icon-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.icon-btn:hover {
background: var(--bg-hover);
}
.icon-sun, .icon-moon {
display: none;
}
body:not([data-theme="dark"]) .icon-moon {
display: inline;
}
[data-theme="dark"] .icon-sun {
display: inline;
}
.lang-switch select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
/* Controls */
.controls {
background: var(--bg-secondary);
padding: 12px 24px;
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.control-group select {
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.control-group input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-color);
}
/* Buttons */
.btn {
background: var(--accent-color);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.btn:hover {
background: var(--accent-hover);
}
.btn.active {
background: var(--accent-color);
box-shadow: 0 0 0 2px var(--accent-light);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-group {
display: flex;
gap: 0;
}
.btn-group .btn {
border-radius: 0;
border-right: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-group .btn:first-child {
border-radius: 6px 0 0 6px;
}
.btn-group .btn:last-child {
border-radius: 0 6px 6px 0;
border-right: none;
}
.btn-group .btn:not(.active) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-group .btn:not(.active):hover {
background: var(--bg-hover);
}
/* Main Content */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0; /* Important for flex children to respect overflow */
}
/* Sidebar */
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%; /* Ensure sidebar takes full height */
}
.sidebar-header {
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0; /* Prevent shrinking */
}
.sidebar-title {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-title h3 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.sidebar-title .count {
background: var(--accent-color);
color: white;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.sidebar-mode {
display: flex;
gap: 4px;
}
.mode-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.mode-btn:hover {
background: var(--bg-hover);
}
.mode-btn.active {
background: var(--accent-color);
border-color: var(--accent-color);
color: white;
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0; /* Prevent shrinking */
}
.search-box input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
transition: border-color 0.2s;
}
.search-box input:focus {
outline: none;
border-color: var(--accent-color);
}
.search-box input::placeholder {
color: var(--text-muted);
}
.package-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
min-height: 0; /* Important for flex overflow to work */
}
.package-item {
padding: 10px 16px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
border-left: 3px solid transparent;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
justify-content: space-between;
align-items: center;
}
.package-item .pkg-name-text {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.pkg-stats-inline {
display: flex;
gap: 6px;
font-size: 11px;
margin-left: 8px;
flex-shrink: 0;
}
.pkg-stats-inline .dep-count {
color: var(--accent-color);
opacity: 0.8;
}
.pkg-stats-inline .used-count {
color: #10b981;
opacity: 0.8;
}
.package-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.package-item.active {
background: var(--accent-light);
border-left-color: var(--accent-color);
color: var(--accent-color);
font-weight: 500;
}
/* Tree View Styles */
.tree-node {
user-select: none;
}
.tree-node-header {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.tree-node-header .pkg-stats-inline {
margin-left: auto;
padding-left: 8px;
}
.tree-node-header:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tree-node-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 2px;
font-size: 10px;
color: var(--text-muted);
transition: transform 0.2s;
cursor: pointer;
border-radius: 4px;
}
.tree-node-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tree-node-toggle.expanded {
transform: rotate(90deg);
}
.tree-node-toggle.empty {
visibility: hidden;
}
.tree-node-icon {
margin-right: 6px;
font-size: 12px;
}
.tree-node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
}
.tree-node-name:hover {
background: var(--bg-hover);
}
.tree-node-name.package {
color: var(--accent-color);
}
.tree-node-children {
padding-left: 16px;
display: none;
}
.tree-node-children.expanded {
display: block;
}
.tree-node-header.active {
background: var(--accent-light);
color: var(--accent-color);
}
.tree-node-header.active .tree-node-name {
font-weight: 500;
}
/* Content Area */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Stats */
.stats {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.stat-card {
background: var(--bg-tertiary);
padding: 10px 20px;
border-radius: 10px;
text-align: center;
min-width: 90px;
}
.stat-card .value {
font-size: 20px;
font-weight: 700;
color: var(--accent-color);
}
.stat-card .label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Package Info - in stats bar */
.package-info {
display: none;
align-items: center;
gap: 16px;
background: var(--bg-tertiary);
padding: 8px 16px;
border-radius: 10px;
}
.package-info.visible {
display: flex;
}
.package-info-content {
display: flex;
align-items: center;
gap: 20px;
}
.package-info-content .pkg-name {
font-size: 14px;
font-weight: 600;
color: var(--accent-color);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-info-content .pkg-stats {
display: flex;
gap: 16px;
}
.package-info-content .pkg-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.package-info-content .pkg-stat-label {
color: var(--text-muted);
}
.package-info-content .pkg-stat-value {
color: var(--text-primary);
font-weight: 600;
}
/* Graph Container */
.graph-container {
flex: 1;
padding: 24px;
overflow: hidden;
background: var(--bg-primary);
position: relative;
display: flex;
flex-direction: column;
min-height: 0; /* Important for flex overflow */
}
.graph-viewport {
flex: 1;
width: 100%;
overflow: hidden;
position: relative;
cursor: grab;
min-height: 0; /* Important for flex overflow */
}
.graph-viewport:active {
cursor: grabbing;
}
.mermaid-container {
display: inline-block;
min-width: 100%;
min-height: 100%;
transform-origin: 0 0;
transition: none;
}
.mermaid {
display: inline-block;
padding: 20px;
}
.mermaid svg {
display: block;
max-width: none !important;
height: auto;
}
.zoom-indicator {
position: absolute;
bottom: 16px;
left: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
box-shadow: var(--card-shadow);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.zoom-indicator.visible {
opacity: 1;
}
.text-output {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 20px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre;
overflow: auto;
min-height: 0; /* Important for flex overflow */
color: var(--text-primary);
}
/* Zoom Controls */
.zoom-controls {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
gap: 8px;
}
.zoom-btn {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: var(--card-shadow);
}
.zoom-btn:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
/* Utility Classes */
.hidden {
display: none !important;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-muted);
font-size: 14px;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
width: 220px;
}
.controls {
padding: 10px 16px;
gap: 12px;
}
.header {
padding: 12px 16px;
}
}