feat: 为依赖分析命令添加外部依赖和主模块过滤功能

This commit is contained in:
hailaz
2026-01-09 14:37:36 +08:00
parent f23c6096cc
commit e16b70475e
8 changed files with 576 additions and 52 deletions

View File

@ -37,9 +37,15 @@ gf dep -f json -d 0
gf dep -g
gf dep -r
gf dep -i=false
gf dep -e
gf dep -e -i=false
gf dep -m
gf dep -m -e
gf dep -s
gf dep -s -p 8080
gf dep ./internal/... -f tree -d 2
gf dep --external --group -f mermaid
gf dep --main --external -f json
`
)
@ -58,6 +64,8 @@ type Input struct {
Depth int `name:"depth" short:"d" brief:"dependency depth limit, 0 means unlimited" d:"3"`
Group bool `name:"group" short:"g" brief:"group by top-level directory" d:"false"`
Internal bool `name:"internal" short:"i" brief:"show only internal packages" d:"true"`
External bool `name:"external" short:"e" brief:"show external packages" d:"false"`
MainOnly bool `name:"main" short:"m" brief:"analyze only main module packages (exclude submodules)" d:"false"`
NoStd bool `name:"nostd" short:"n" brief:"exclude standard library" d:"true"`
Reverse bool `name:"reverse" short:"r" brief:"show reverse dependencies" d:"false"`
Serve bool `name:"serve" short:"s" brief:"start HTTP server to view dependencies" d:"false" orphan:"true"`

View File

@ -24,6 +24,9 @@ type goPackage struct {
Imports []string `json:"Imports"`
Deps []string `json:"Deps"`
Standard bool `json:"Standard"`
Module struct {
Path string `json:"Path"`
} `json:"Module"`
}
// depNode represents a node in the dependency tree.
@ -67,10 +70,14 @@ func (a *analyzer) detectModulePrefix() string {
// loadPackages loads package information using go list.
func (a *analyzer) loadPackages(ctx context.Context, pkgPath string) error {
// Load main packages first
cmd := fmt.Sprintf("go list -json %s", pkgPath)
result, err := gproc.ShellExec(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to execute go list: %v", err)
// Try to get more detailed error information
detailCmd := fmt.Sprintf("go list %s 2>&1", pkgPath)
detailResult, _ := gproc.ShellExec(ctx, detailCmd)
return fmt.Errorf("failed to execute go list: %v, details: %s", err, detailResult)
}
// Parse JSON stream (multiple JSON objects)
@ -82,14 +89,40 @@ func (a *analyzer) loadPackages(ctx context.Context, pkgPath string) error {
}
a.packages[pkg.ImportPath] = &pkg
}
// For external dependency analysis, also load dependencies
// This is optional and won't fail the entire operation
cmd = fmt.Sprintf("go list -json -deps %s", pkgPath)
result, err = gproc.ShellExec(ctx, cmd)
if err == nil {
// Parse dependency JSON stream
decoder = json.NewDecoder(strings.NewReader(result))
for decoder.More() {
var pkg goPackage
if err := decoder.Decode(&pkg); err != nil {
continue
}
// Only add if not already present
if _, exists := a.packages[pkg.ImportPath]; !exists {
a.packages[pkg.ImportPath] = &pkg
}
}
}
return nil
}
// filterDeps filters dependencies based on options.
func (a *analyzer) filterDeps(deps []string, in Input) []string {
result := make([]string, 0)
for _, dep := range deps {
if a.shouldInclude(dep, in) {
seen := make(map[string]bool)
for _, original := range deps {
dep := original
if in.MainOnly {
dep = a.getModuleRoot(original)
}
if a.shouldInclude(dep, in) && !seen[dep] {
seen[dep] = true
result = append(result, dep)
}
}
@ -98,17 +131,34 @@ func (a *analyzer) filterDeps(deps []string, in Input) []string {
// shouldInclude checks if a dependency should be included.
func (a *analyzer) shouldInclude(dep string, in Input) bool {
// Exclude standard library
// Exclude standard library if requested
if in.NoStd && a.isStdLib(dep) {
return false
}
// Only internal packages
if in.Internal && a.modulePrefix != "" {
if !gstr.HasPrefix(dep, a.modulePrefix) {
isInternal := a.modulePrefix != "" && gstr.HasPrefix(dep, a.modulePrefix)
// Handle main-only filtering - only keep module root packages
if in.MainOnly {
if dep != a.getModuleRoot(dep) {
return false
}
}
return true
// Handle internal/external filtering
if in.Internal && in.External {
// Show both internal and external
return true
} else if in.Internal && !in.External {
// Show only internal packages
return isInternal
} else if !in.Internal && in.External {
// Show only external packages
return !isInternal
} else {
// Default behavior: show internal packages only
return isInternal
}
}
// isStdLib checks if a package is from standard library.
@ -124,6 +174,82 @@ func (a *analyzer) isStdLib(pkg string) bool {
return true
}
// isModuleRootPackage checks if a package path is the root package of its module.
// It prefers go list Module.Path metadata; if missing, it falls back to guessing by domain/repo segments.
func (a *analyzer) isModuleRootPackage(pkg string) bool {
p, ok := a.packages[pkg]
if ok {
// Standard library has no module, treat as root
if p.Module.Path == "" {
return true
}
return p.Module.Path == p.ImportPath
}
// Fallback: derive a plausible module root from the import path
return pkg == guessModuleRoot(pkg)
}
// getModuleRoot returns the module root path for a package, using Module metadata when available.
func (a *analyzer) getModuleRoot(pkg string) string {
if p, ok := a.packages[pkg]; ok {
if p.Module.Path != "" {
return p.Module.Path
}
}
return guessModuleRoot(pkg)
}
// guessModuleRoot tries to infer the module root path from an import path when Module metadata is missing.
// It keeps domain/owner/repo and also preserves a trailing /vN version segment if present.
func guessModuleRoot(pkg string) string {
parts := strings.Split(pkg, "/")
if len(parts) < 3 {
return pkg
}
rootLen := 3 // domain/owner/repo
// Handle semantic import path version like .../v2
if len(parts) > 3 && strings.HasPrefix(parts[3], "v") {
rootLen = 4
}
if rootLen > len(parts) {
rootLen = len(parts)
}
return strings.Join(parts[:rootLen], "/")
}
// isMainModulePackage checks if a package belongs to the main module (not a submodule).
func (a *analyzer) isMainModulePackage(pkg string) bool {
if a.modulePrefix == "" {
return true // If no module prefix, consider all as main module
}
if !gstr.HasPrefix(pkg, a.modulePrefix) {
return false // Not even in our module
}
// Remove the module prefix to get the relative path
relativePath := gstr.TrimLeft(pkg[len(a.modulePrefix):], "/")
if relativePath == "" {
return true // This is the root module itself
}
// Check if this path contains a go.mod file (indicating a submodule)
// We check from the most specific path up to the root
parts := gstr.Split(relativePath, "/")
for i := len(parts); i > 0; i-- {
subPath := gstr.Join(parts[:i], "/")
if subPath != "" && gfile.Exists(subPath+"/go.mod") {
// Found a go.mod file in a subdirectory, this indicates a submodule
return false
}
}
return true // This is part of the main module
}
// shortName returns a shortened package name.
func (a *analyzer) shortName(pkg string, group bool) string {
if a.modulePrefix != "" && gstr.HasPrefix(pkg, a.modulePrefix) {
@ -137,6 +263,35 @@ func (a *analyzer) shortName(pkg string, group bool) string {
}
return short
}
// For external packages, handle grouping differently
if group {
return a.getExternalGroup(pkg)
}
return pkg
}
// getExternalGroup returns the group name for external packages.
func (a *analyzer) getExternalGroup(pkg string) string {
// For standard library packages
if a.isStdLib(pkg) {
return "stdlib"
}
// For external packages, group by domain/organization
parts := gstr.Split(pkg, "/")
if len(parts) > 0 {
// Handle common patterns like github.com/user/repo
if len(parts) >= 3 && (parts[0] == "github.com" || parts[0] == "gitlab.com" || parts[0] == "bitbucket.org") {
return parts[0] + "/" + parts[1]
}
// For other domains, use the domain name
if gstr.Contains(parts[0], ".") {
return parts[0]
}
// For simple names, use the first part
return parts[0]
}
return pkg
}
@ -189,3 +344,39 @@ func (a *analyzer) collectEdgesRecursive(pkg *goPackage, in Input, edges map[str
}
}
}
// getDependencyStats returns statistics about dependencies.
func (a *analyzer) getDependencyStats(_ Input) map[string]any {
stats := make(map[string]any)
var internalCount, externalCount, stdlibCount int
externalGroups := make(map[string]int)
for _, pkg := range a.packages {
if !a.shouldInclude(pkg.ImportPath, Input{
Internal: true,
External: true,
NoStd: false,
}) {
continue
}
if a.isStdLib(pkg.ImportPath) {
stdlibCount++
} else if a.modulePrefix != "" && gstr.HasPrefix(pkg.ImportPath, a.modulePrefix) {
internalCount++
} else {
externalCount++
group := a.getExternalGroup(pkg.ImportPath)
externalGroups[group]++
}
}
stats["total"] = len(a.packages)
stats["internal"] = internalCount
stats["external"] = externalCount
stats["stdlib"] = stdlibCount
stats["external_groups"] = externalGroups
return stats
}

View File

@ -0,0 +1,83 @@
// Copyright GoFrame gf Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.
package cmddep
import (
"testing"
"github.com/gogf/gf/v2/test/gtest"
)
func TestExternalDependencyAnalysis(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
analyzer := newAnalyzer()
analyzer.modulePrefix = "github.com/gogf/gf/cmd/gf/v2"
// Test shouldInclude with external dependencies
in := Input{
Internal: false,
External: true,
NoStd: true,
}
// Test external package (should be included)
t.Assert(analyzer.shouldInclude("github.com/other/package", in), true)
// Test internal package (should not be included)
t.Assert(analyzer.shouldInclude("github.com/gogf/gf/cmd/gf/v2/internal", in), false)
// Test standard library (should not be included due to NoStd)
t.Assert(analyzer.shouldInclude("fmt", in), false)
})
}
func TestExternalGrouping(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
analyzer := newAnalyzer()
// Test external group extraction
t.Assert(analyzer.getExternalGroup("github.com/user/repo"), "github.com/user")
t.Assert(analyzer.getExternalGroup("golang.org/x/tools"), "golang.org")
t.Assert(analyzer.getExternalGroup("fmt"), "stdlib")
t.Assert(analyzer.getExternalGroup("simple"), "simple")
})
}
func TestDependencyStats(t *testing.T) {
gtest.C(t, func(t *gtest.T) {
analyzer := newAnalyzer()
analyzer.modulePrefix = "github.com/gogf/gf/cmd/gf/v2"
// Add test packages
analyzer.packages = map[string]*goPackage{
"github.com/gogf/gf/cmd/gf/v2/internal": {
ImportPath: "github.com/gogf/gf/cmd/gf/v2/internal",
Standard: false,
},
"github.com/external/package": {
ImportPath: "github.com/external/package",
Standard: false,
},
"fmt": {
ImportPath: "fmt",
Standard: true,
},
}
in := Input{
Internal: true,
External: true,
NoStd: false,
}
stats := analyzer.getDependencyStats(in)
t.Assert(stats["total"], 3)
t.Assert(stats["internal"], 1)
t.Assert(stats["external"], 1)
t.Assert(stats["stdlib"], 1)
})
}

View File

@ -27,6 +27,7 @@ func (a *analyzer) generate(in Input) string {
case "json":
return a.generateJSON(in)
default:
// Default to tree format
return a.generateTree(in)
}
}
@ -35,6 +36,24 @@ func (a *analyzer) generate(in Input) string {
func (a *analyzer) generateTree(in Input) string {
var sb strings.Builder
// Add statistics header if showing external dependencies
if in.External {
stats := a.getDependencyStats(in)
sb.WriteString("Dependency Statistics:\n")
fmt.Fprintf(&sb, " Total packages: %v\n", stats["total"])
fmt.Fprintf(&sb, " Internal: %v\n", stats["internal"])
fmt.Fprintf(&sb, " External: %v\n", stats["external"])
fmt.Fprintf(&sb, " Standard library: %v\n", stats["stdlib"])
if groups, ok := stats["external_groups"].(map[string]int); ok && len(groups) > 0 {
sb.WriteString(" External groups:\n")
for group, count := range groups {
fmt.Fprintf(&sb, " %s: %d\n", group, count)
}
}
sb.WriteString("\nDependency Tree:\n")
}
// Find root packages (packages that are not imported by any other package)
rootPkgs := a.findRootPackages()
@ -43,9 +62,11 @@ func (a *analyzer) generateTree(in Input) string {
for _, pkgPath := range rootPkgs {
pkg := a.packages[pkgPath]
shortName := a.shortName(pkg.ImportPath, in.Group)
sb.WriteString(shortName + "\n")
a.printTreeNode(&sb, pkg, "", in, 0)
if a.shouldInclude(pkg.ImportPath, in) {
shortName := a.shortName(pkg.ImportPath, in.Group)
sb.WriteString(shortName + "\n")
a.printTreeNode(&sb, pkg, "", in, 0)
}
}
return sb.String()
}
@ -82,6 +103,7 @@ func (a *analyzer) printTreeNode(sb *strings.Builder, pkg *goPackage, prefix str
return
}
// filterDeps already applies all filtering including main-only
deps := a.filterDeps(pkg.Imports, in)
sort.Strings(deps)
@ -117,14 +139,37 @@ func (a *analyzer) printTreeNode(sb *strings.Builder, pkg *goPackage, prefix str
// generateList generates simple list output.
func (a *analyzer) generateList(in Input) string {
var sb strings.Builder
// Add statistics header if showing external dependencies
if in.External {
stats := a.getDependencyStats(in)
sb.WriteString("# Dependency Statistics\n")
fmt.Fprintf(&sb, "# Total: %v, Internal: %v, External: %v, Stdlib: %v\n",
stats["total"], stats["internal"], stats["external"], stats["stdlib"])
sb.WriteString("\n")
}
// Debug mainOnly state
// sb.WriteString(fmt.Sprintf("# DEBUG mainOnly=%v\\n", in.MainOnly))
allDeps := make(map[string]bool)
// Collect dependencies from packages that should be included
for _, pkg := range a.packages {
for _, dep := range a.filterDeps(pkg.Imports, in) {
allDeps[dep] = true
if a.shouldInclude(pkg.ImportPath, in) {
// Collect dependencies (filterDeps already applies all filtering including main-only)
for _, dep := range a.filterDeps(pkg.Imports, in) {
allDeps[dep] = true
}
}
// Additionally, keep the package itself when it passes main-only check
if in.MainOnly && a.isModuleRootPackage(pkg.ImportPath) && a.shouldInclude(pkg.ImportPath, in) {
allDeps[pkg.ImportPath] = true
}
}
deps := make([]string, 0, len(allDeps))
for dep := range allDeps {
deps = append(deps, a.shortName(dep, in.Group))
@ -201,15 +246,36 @@ func (a *analyzer) generateDot(in Input) string {
// generateJSON generates JSON output.
func (a *analyzer) generateJSON(in Input) string {
result := make(map[string]any)
// Add dependency nodes
nodes := make([]*depNode, 0)
for _, pkgPath := range a.getSortedPackages() {
pkg := a.packages[pkgPath]
a.visited = make(map[string]bool)
node := a.buildDepNode(pkg, in, 0)
nodes = append(nodes, node)
if a.shouldInclude(pkg.ImportPath, in) {
a.visited = make(map[string]bool)
node := a.buildDepNode(pkg, in, 0)
nodes = append(nodes, node)
}
}
data, err := json.MarshalIndent(nodes, "", " ")
result["dependencies"] = nodes
// Add statistics
result["statistics"] = a.getDependencyStats(in)
// Add metadata
result["metadata"] = map[string]any{
"module": a.modulePrefix,
"format": in.Format,
"depth": in.Depth,
"group": in.Group,
"internal": in.Internal,
"external": in.External,
"nostd": in.NoStd,
"main": in.MainOnly,
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Sprintf("Error: %v", err)
}

View File

@ -174,6 +174,12 @@ func (a *analyzer) handleGraphAPI(w http.ResponseWriter, r *http.Request, in Inp
if i := query.Get("internal"); i != "" {
in.Internal = i == "true"
}
if e := query.Get("external"); e != "" {
in.External = e == "true"
}
if m := query.Get("main"); m != "" {
in.MainOnly = m == "true"
}
pkg := query.Get("package")
var data *graphData
@ -192,10 +198,19 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
if i := query.Get("internal"); i != "" {
in.Internal = i == "true"
}
if e := query.Get("external"); e != "" {
in.External = e == "true"
}
if m := query.Get("main"); m != "" {
in.MainOnly = m == "true"
}
// Build reverse dependency map (who uses each package)
usedByMap := make(map[string]int)
for fullPath, pkg := range a.packages {
if !a.shouldInclude(fullPath, in) {
continue
}
fromShort := a.shortName(fullPath, false)
if fromShort == "" {
continue
@ -210,6 +225,9 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
packages := make([]packageSummary, 0)
for _, pkgPath := range a.getSortedPackages() {
if !a.shouldInclude(pkgPath, in) {
continue
}
shortName := a.shortName(pkgPath, false)
if shortName == "" {
continue
@ -232,8 +250,15 @@ func (a *analyzer) handlePackagesAPI(w http.ResponseWriter, r *http.Request, in
UsedByCount: usedByMap[shortName],
})
}
// Add statistics to response
result := map[string]any{
"packages": packages,
"statistics": a.getDependencyStats(in),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(packages)
json.NewEncoder(w).Encode(result)
}
// handlePackageAPI returns detailed info for a specific package.
@ -303,6 +328,12 @@ func (a *analyzer) handleTreeAPI(w http.ResponseWriter, r *http.Request, in Inpu
if i := query.Get("internal"); i != "" {
in.Internal = i == "true"
}
if e := query.Get("external"); e != "" {
in.External = e == "true"
}
if m := query.Get("main"); m != "" {
in.MainOnly = m == "true"
}
pkg := query.Get("package")
var output string
@ -322,6 +353,12 @@ func (a *analyzer) handleListAPI(w http.ResponseWriter, r *http.Request, in Inpu
if i := query.Get("internal"); i != "" {
in.Internal = i == "true"
}
if e := query.Get("external"); e != "" {
in.External = e == "true"
}
if m := query.Get("main"); m != "" {
in.MainOnly = m == "true"
}
pkg := query.Get("package")
var output string

View File

@ -65,6 +65,7 @@ const theme = {
mermaid.initialize({
startOnLoad: false,
theme: this.current === 'dark' ? 'dark' : 'default',
maxEdges: 2000, // Increase edge limit for large dependency graphs
flowchart: {
useMaxWidth: false,
htmlLabels: true,
@ -82,6 +83,7 @@ const theme = {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
maxEdges: 2000, // Increase edge limit for large dependency graphs
flowchart: {
useMaxWidth: false,
htmlLabels: true,
@ -407,8 +409,26 @@ async function loadModuleName() {
async function loadPackages() {
try {
const internal = document.getElementById('internal').checked;
const response = await fetch(`/api/packages?internal=${internal}`);
allPackages = await response.json();
const external = document.getElementById('external') ? document.getElementById('external').checked : false;
const mainOnly = document.getElementById('mainOnly') ? document.getElementById('mainOnly').checked : false;
const response = await fetch(`/api/packages?internal=${internal}&external=${external}&main=${mainOnly}`);
const data = await response.json();
// Handle new API response format with packages and statistics
if (data.packages && Array.isArray(data.packages)) {
allPackages = data.packages;
// Update statistics display if available
if (data.statistics) {
updateStatisticsDisplay(data.statistics);
}
} else if (Array.isArray(data)) {
// Fallback for old format
allPackages = data;
} else {
console.error('Unexpected API response format:', data);
allPackages = [];
}
document.getElementById('packageCount').textContent = allPackages.length;
renderPackageList(allPackages);
} catch (e) {
@ -416,6 +436,19 @@ async function loadPackages() {
}
}
// Update statistics display
function updateStatisticsDisplay(statistics) {
if (statistics) {
document.getElementById('internalCount').textContent = statistics.internal || 0;
document.getElementById('externalCount').textContent = statistics.external || 0;
document.getElementById('stdlibCount').textContent = statistics.stdlib || 0;
// Update total count
const totalCount = statistics.total || (statistics.internal + statistics.external + statistics.stdlib);
document.getElementById('nodeCount').textContent = totalCount;
}
}
// Get package name from package object or string
function getPkgName(pkg) {
return typeof pkg === 'object' ? pkg.name : pkg;
@ -644,6 +677,8 @@ async function refresh() {
const group = document.getElementById('group').checked;
const reverse = document.getElementById('reverse').checked;
const internal = document.getElementById('internal').checked;
const external = document.getElementById('external') ? document.getElementById('external').checked : false;
const mainOnly = document.getElementById('mainOnly') ? document.getElementById('mainOnly').checked : false;
if (selectedPackage) {
await showPackageInfo(selectedPackage);
@ -652,11 +687,11 @@ async function refresh() {
}
if (currentView === 'graph') {
await refreshGraph(depth, group, reverse, internal);
await refreshGraph(depth, group, reverse, internal, external, mainOnly);
} else if (currentView === 'tree') {
await refreshTree(depth, internal);
await refreshTree(depth, internal, external, mainOnly);
} else {
await refreshList(internal);
await refreshList(internal, external, mainOnly);
}
}
@ -689,7 +724,7 @@ async function showPackageInfo(pkg) {
}
// Refresh graph view
async function refreshGraph(depth, group, reverse, internal) {
async function refreshGraph(depth, group, reverse, internal, external, mainOnly) {
document.getElementById('graphView').classList.remove('hidden');
document.getElementById('textView').classList.add('hidden');
@ -700,6 +735,12 @@ async function refreshGraph(depth, group, reverse, internal) {
applyTransform();
let url = `/api/graph?depth=${depth}&group=${group}&reverse=${reverse}&internal=${internal}`;
if (external !== undefined) {
url += `&external=${external}`;
}
if (mainOnly !== undefined) {
url += `&main=${mainOnly}`;
}
if (selectedPackage) {
url += '&package=' + encodeURIComponent(selectedPackage);
}
@ -774,11 +815,17 @@ function autoFitGraph() {
}
// Refresh tree view
async function refreshTree(depth, internal) {
async function refreshTree(depth, internal, external, mainOnly) {
document.getElementById('graphView').classList.add('hidden');
document.getElementById('textView').classList.remove('hidden');
let url = `/api/tree?depth=${depth}&internal=${internal}`;
if (external !== undefined) {
url += `&external=${external}`;
}
if (mainOnly !== undefined) {
url += `&main=${mainOnly}`;
}
if (selectedPackage) {
url += '&package=' + encodeURIComponent(selectedPackage);
}
@ -797,11 +844,17 @@ async function refreshTree(depth, internal) {
}
// Refresh list view
async function refreshList(internal) {
async function refreshList(internal, external, mainOnly) {
document.getElementById('graphView').classList.add('hidden');
document.getElementById('textView').classList.remove('hidden');
let url = `/api/list?internal=${internal}`;
if (external !== undefined) {
url += `&external=${external}`;
}
if (mainOnly !== undefined) {
url += `&main=${mainOnly}`;
}
if (selectedPackage) {
url += '&package=' + encodeURIComponent(selectedPackage);
}

View File

@ -23,6 +23,11 @@ const i18n = {
reverseLabel: 'Reverse (show who uses)',
groupLabel: 'Group by directory',
internalLabel: 'Internal only',
externalLabel: 'External packages',
mainOnlyLabel: 'Main module only',
statsInternal: 'Internal',
statsExternal: 'External',
statsStdlib: 'Stdlib',
layoutLabel: 'Layout:',
layoutTD: 'Top-Down',
layoutLR: 'Left-Right',
@ -47,7 +52,32 @@ const i18n = {
latestVersion: 'Latest',
errorFetchVersions: 'Failed to fetch versions',
errorAnalyze: 'Failed to analyze module',
packageDetails: 'Package Details'
packageDetails: 'Package Details',
viewGraphTooltip: 'Visualize dependencies as a directed graph',
viewTreeTooltip: 'Show dependencies in a hierarchical tree structure',
viewListTooltip: 'Display dependencies as a text list',
depthTooltip: 'Limit dependency traversal depth',
reverseTooltip: 'Show reverse dependencies (who uses the selected package)',
groupTooltip: 'Group packages by directory structure',
internalTooltip: 'Show only internal packages from current module',
externalTooltip: 'Include external dependency packages',
mainOnlyTooltip: 'Show only main module packages (exclude vendor)',
layoutTDTooltip: 'Top-down layout for dependency graph',
layoutLRTooltip: 'Left-right layout for dependency graph',
showAllTooltip: 'Clear package selection and show all packages',
analyzeTooltip: 'Analyze remote Go module dependencies',
resetTooltip: 'Reset to local module analysis',
themeTooltip: 'Toggle light/dark theme',
langTooltip: 'Select language',
flatModeTooltip: 'Flat list view',
treeModeTooltip: 'Tree view',
zoomInTooltip: 'Zoom in',
zoomOutTooltip: 'Zoom out',
fitToScreenTooltip: 'Fit to screen',
resetZoomTooltip: 'Reset zoom',
remoteModuleTooltip: 'Enter a remote Go module path to analyze',
versionSelectTooltip: 'Select module version (optional)',
searchTooltip: 'Search packages by name'
},
zh: {
title: 'Go 包依赖分析',
@ -69,6 +99,11 @@ const i18n = {
reverseLabel: '反向依赖 (谁引用了它)',
groupLabel: '按目录分组',
internalLabel: '仅内部包',
externalLabel: '外部依赖包',
mainOnlyLabel: '仅主模块',
statsInternal: '内部',
statsExternal: '外部',
statsStdlib: '标准库',
layoutLabel: '布局:',
layoutTD: '从上到下',
layoutLR: '从左到右',
@ -93,7 +128,32 @@ const i18n = {
latestVersion: '最新版本',
errorFetchVersions: '获取版本失败',
errorAnalyze: '分析模块失败',
packageDetails: '包详情'
packageDetails: '包详情',
viewGraphTooltip: '以有向图形式可视化依赖关系',
viewTreeTooltip: '以层次树结构显示依赖关系',
viewListTooltip: '以文本列表形式显示依赖关系',
depthTooltip: '限制依赖遍历的深度',
reverseTooltip: '显示反向依赖谁引用了选中的包',
groupTooltip: '按目录结构分组显示包',
internalTooltip: '仅显示当前模块的内部包',
externalTooltip: '包含外部依赖包',
mainOnlyTooltip: '仅显示主模块包排除vendor目录',
layoutTDTooltip: '依赖图从上到下布局',
layoutLRTooltip: '依赖图从左到右布局',
showAllTooltip: '清除包选择显示所有包',
analyzeTooltip: '分析远程Go模块依赖',
resetTooltip: '重置为本地模块分析',
themeTooltip: '切换明暗主题',
langTooltip: '选择语言',
flatModeTooltip: '平铺列表视图',
treeModeTooltip: '树形视图',
zoomInTooltip: '放大',
zoomOutTooltip: '缩小',
fitToScreenTooltip: '适应屏幕',
resetZoomTooltip: '重置缩放',
remoteModuleTooltip: '输入远程Go模块路径进行分析',
versionSelectTooltip: '选择模块版本可选',
searchTooltip: '按名称搜索包'
}
},
@ -147,6 +207,12 @@ const i18n = {
el.placeholder = this.t(key);
});
// Update title attributes
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = this.t(key);
});
// Update page title
document.title = this.t('title');
}

View File

@ -19,24 +19,24 @@
<div class="header-center">
<div class="remote-input-group">
<label for="remoteModuleInput" data-i18n="remoteModuleLabel">Module:</label>
<input type="text" id="remoteModuleInput" data-i18n-placeholder="remoteModulePlaceholder" placeholder="e.g. github.com/gogf/gf/v2">
<select id="versionSelect" disabled>
<input type="text" id="remoteModuleInput" data-i18n-placeholder="remoteModulePlaceholder" placeholder="e.g. github.com/gogf/gf/v2" data-i18n-title="remoteModuleTooltip">
<select id="versionSelect" disabled data-i18n-title="versionSelectTooltip">
<option value="" data-i18n="selectVersion">Select version</option>
</select>
<button class="btn btn-sm" id="analyzeBtn" onclick="analyzeRemoteModule()" data-i18n="analyze">Analyze</button>
<button class="btn btn-sm btn-secondary" id="resetBtn" onclick="resetToLocal()" data-i18n="resetLocal">Reset</button>
<button class="btn btn-sm" id="analyzeBtn" onclick="analyzeRemoteModule()" data-i18n="analyze" data-i18n-title="analyzeTooltip">Analyze</button>
<button class="btn btn-sm btn-secondary" id="resetBtn" onclick="resetToLocal()" data-i18n="resetLocal" data-i18n-title="resetTooltip">Reset</button>
<span class="loading-spinner hidden" id="loadingSpinner"></span>
</div>
</div>
<div class="header-right">
<div class="theme-switch">
<button class="icon-btn" id="themeToggle" title="Toggle theme">
<button class="icon-btn" id="themeToggle" data-i18n-title="themeTooltip">
<span class="icon-sun"></span>
<span class="icon-moon">🌙</span>
</button>
</div>
<div class="lang-switch">
<select id="langSelect">
<select id="langSelect" data-i18n-title="langTooltip">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
@ -47,14 +47,14 @@
<div class="control-group">
<label data-i18n="viewLabel">View:</label>
<div class="btn-group">
<button class="btn active" id="btnGraph" onclick="setView('graph')" data-i18n="viewGraph">Graph</button>
<button class="btn" id="btnTree" onclick="setView('tree')" data-i18n="viewTree">Tree</button>
<button class="btn" id="btnList" onclick="setView('list')" data-i18n="viewList">List</button>
<button class="btn active" id="btnGraph" onclick="setView('graph')" data-i18n="viewGraph" data-i18n-title="viewGraphTooltip">Graph</button>
<button class="btn" id="btnTree" onclick="setView('tree')" data-i18n="viewTree" data-i18n-title="viewTreeTooltip">Tree</button>
<button class="btn" id="btnList" onclick="setView('list')" data-i18n="viewList" data-i18n-title="viewListTooltip">List</button>
</div>
</div>
<div class="control-group">
<label data-i18n="depthLabel">Depth:</label>
<select id="depth" onchange="refresh()">
<select id="depth" onchange="refresh()" data-i18n-title="depthTooltip">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
@ -63,25 +63,33 @@
</select>
</div>
<div class="control-group">
<input type="checkbox" id="reverse" onchange="refresh()">
<input type="checkbox" id="reverse" onchange="refresh()" data-i18n-title="reverseTooltip">
<label for="reverse" data-i18n="reverseLabel">Reverse (show who uses)</label>
</div>
<div class="control-group">
<input type="checkbox" id="group" onchange="refresh()">
<input type="checkbox" id="group" onchange="refresh()" data-i18n-title="groupTooltip">
<label for="group" data-i18n="groupLabel">Group by directory</label>
</div>
<div class="control-group">
<input type="checkbox" id="internal" checked onchange="refresh()">
<input type="checkbox" id="internal" checked onchange="refresh()" data-i18n-title="internalTooltip">
<label for="internal" data-i18n="internalLabel">Internal only</label>
</div>
<div class="control-group">
<input type="checkbox" id="external" onchange="refresh()" data-i18n-title="externalTooltip">
<label for="external" data-i18n="externalLabel">External packages</label>
</div>
<div class="control-group">
<input type="checkbox" id="mainOnly" onchange="refresh()" data-i18n-title="mainOnlyTooltip">
<label for="mainOnly" data-i18n="mainOnlyLabel">Main module only</label>
</div>
<div class="control-group graph-only" id="layoutGroup">
<label data-i18n="layoutLabel">Layout:</label>
<div class="btn-group">
<button class="btn active" id="layoutTD" onclick="setLayout('TD')" data-i18n="layoutTD">Top-Down</button>
<button class="btn" id="layoutLR" onclick="setLayout('LR')" data-i18n="layoutLR">Left-Right</button>
<button class="btn active" id="layoutTD" onclick="setLayout('TD')" data-i18n="layoutTD" data-i18n-title="layoutTDTooltip">Top-Down</button>
<button class="btn" id="layoutLR" onclick="setLayout('LR')" data-i18n="layoutLR" data-i18n-title="layoutLRTooltip">Left-Right</button>
</div>
</div>
<button class="btn btn-secondary" onclick="clearSelection()" data-i18n="showAll">Show All</button>
<button class="btn btn-secondary" onclick="clearSelection()" data-i18n="showAll" data-i18n-title="showAllTooltip">Show All</button>
</div>
<div class="main-content">
<div class="sidebar">
@ -91,12 +99,12 @@
<span class="count" id="packageCount">0</span>
</div>
<div class="sidebar-mode">
<button class="mode-btn" id="modeFlat" onclick="setPackageListMode('flat')" title="Flat list"></button>
<button class="mode-btn active" id="modeTree" onclick="setPackageListMode('tree')" title="Tree view">🌲</button>
<button class="mode-btn" id="modeFlat" onclick="setPackageListMode('flat')" data-i18n-title="flatModeTooltip"></button>
<button class="mode-btn active" id="modeTree" onclick="setPackageListMode('tree')" data-i18n-title="treeModeTooltip">🌲</button>
</div>
</div>
<div class="search-box">
<input type="text" id="searchInput" data-i18n-placeholder="searchPlaceholder" placeholder="Search packages..." oninput="filterPackages()">
<input type="text" id="searchInput" data-i18n-placeholder="searchPlaceholder" placeholder="Search packages..." oninput="filterPackages()" data-i18n-title="searchTooltip">
</div>
<div class="package-list" id="packageList"></div>
</div>
@ -110,6 +118,18 @@
<div class="value" id="edgeCount">0</div>
<div class="label" data-i18n="statsDependencies">Dependencies</div>
</div>
<div class="stat-card">
<div class="value" id="internalCount">0</div>
<div class="label" data-i18n="statsInternal">Internal</div>
</div>
<div class="stat-card">
<div class="value" id="externalCount">0</div>
<div class="label" data-i18n="statsExternal">External</div>
</div>
<div class="stat-card">
<div class="value" id="stdlibCount">0</div>
<div class="label" data-i18n="statsStdlib">Stdlib</div>
</div>
<div class="package-info" id="packageInfo">
<div class="package-info-content" id="packageInfoContent"></div>
</div>
@ -126,10 +146,10 @@
</div>
</div>
<div class="zoom-controls" id="zoomControls">
<button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button>
<button class="zoom-btn" onclick="zoomOut()" title="Zoom out"></button>
<button class="zoom-btn" onclick="fitToScreen()" title="Fit to screen"></button>
<button class="zoom-btn" onclick="resetZoom()" title="Reset zoom"></button>
<button class="zoom-btn" onclick="zoomIn()" data-i18n-title="zoomInTooltip">+</button>
<button class="zoom-btn" onclick="zoomOut()" data-i18n-title="zoomOutTooltip"></button>
<button class="zoom-btn" onclick="fitToScreen()" data-i18n-title="fitToScreenTooltip"></button>
<button class="zoom-btn" onclick="resetZoom()" data-i18n-title="resetZoomTooltip"></button>
</div>
<script src="/static/i18n.js"></script>
<script src="/static/app.js"></script>