mirror of
https://gitee.com/johng/gf
synced 2026-07-03 11:51:04 +08:00
feat: 添加依赖分析命令 dep
This commit is contained in:
@ -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
|
||||
|
||||
110
cmd/gf/internal/cmd/cmddep/cmddep.go
Normal file
110
cmd/gf/internal/cmd/cmddep/cmddep.go
Normal 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
|
||||
}
|
||||
191
cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go
Normal file
191
cmd/gf/internal/cmd/cmddep/cmddep_analyzer.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
257
cmd/gf/internal/cmd/cmddep/cmddep_output.go
Normal file
257
cmd/gf/internal/cmd/cmddep/cmddep_output.go
Normal 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()
|
||||
}
|
||||
752
cmd/gf/internal/cmd/cmddep/cmddep_server.go
Normal file
752
cmd/gf/internal/cmd/cmddep/cmddep_server.go
Normal 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})
|
||||
}
|
||||
114
cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go
Normal file
114
cmd/gf/internal/cmd/cmddep/cmddep_z_unit_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
863
cmd/gf/internal/cmd/cmddep/static/app.js
Normal file
863
cmd/gf/internal/cmd/cmddep/static/app.js
Normal 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);
|
||||
156
cmd/gf/internal/cmd/cmddep/static/i18n.js
Normal file
156
cmd/gf/internal/cmd/cmddep/static/i18n.js
Normal 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();
|
||||
});
|
||||
133
cmd/gf/internal/cmd/cmddep/static/index.html
Normal file
133
cmd/gf/internal/cmd/cmddep/static/index.html
Normal 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>
|
||||
811
cmd/gf/internal/cmd/cmddep/static/style.css
Normal file
811
cmd/gf/internal/cmd/cmddep/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user