diff --git a/internal/actions/actions.go b/internal/actions/actions.go index d110ba3..fa255ae 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -227,28 +227,39 @@ func referenceChanges(project scanner.Project, item scanner.AssetItem, targetPat File: ref.File, Line: ref.Line, OldSpecifier: ref.Specifier, - NewSpecifier: rewriteSpecifier(ref.Specifier, ref.File, targetPath), + NewSpecifier: rewriteSpecifier(ref.Specifier, ref.File, targetPath, project.ImportAliases), }) } return changes, blockers } -func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string) string { +func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string, aliases map[string]string) string { spec := strings.Split(oldSpec, "?")[0] query := "" if i := strings.IndexByte(oldSpec, '?'); i >= 0 { query = oldSpec[i:] } - for _, prefix := range []string{"@/", "~/"} { - if strings.HasPrefix(spec, prefix) { - srcBase := findSrcBase(importerRepoPath) - if strings.HasPrefix(targetRepoPath, srcBase+"/") { - newSpec := prefix + strings.TrimPrefix(targetRepoPath, srcBase+"/") - return newSpec + query + for aliasKey, aliasPath := range aliases { + cleanKey := strings.Trim(aliasKey, "/") + cleanPath := strings.Trim(aliasPath, "/") + if spec == cleanKey || strings.HasPrefix(spec, cleanKey+"/") { + if strings.HasPrefix(targetRepoPath, cleanPath+"/") || targetRepoPath == cleanPath { + suffix := strings.TrimPrefix(targetRepoPath, cleanPath) + if suffix == "" { + return cleanKey + query + } + return cleanKey + "/" + strings.TrimPrefix(suffix, "/") + query } - return relativeSpecifier(importerRepoPath, targetRepoPath) + query } } + if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") { + prefix := spec[:2] + srcBase := findSrcBase(importerRepoPath) + if strings.HasPrefix(targetRepoPath, srcBase+"/") { + return prefix + strings.TrimPrefix(targetRepoPath, srcBase+"/") + query + } + return relativeSpecifier(importerRepoPath, targetRepoPath) + query + } if strings.HasPrefix(spec, "/") { return "/" + targetRepoPath + query } diff --git a/internal/actions/actions_test.go b/internal/actions/actions_test.go index 834d859..3fbf1cc 100644 --- a/internal/actions/actions_test.go +++ b/internal/actions/actions_test.go @@ -276,25 +276,34 @@ func TestPathAndSpecifierHelpers(t *testing.T) { t.Fatalf("relativeSpecifier nested = %q", got) } // rewriteSpecifier preserves @/ alias - if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/src/assets/logo.avif"); got != "@/assets/logo.avif" { + if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/src/assets/logo.avif", nil); got != "@/assets/logo.avif" { t.Fatalf("rewriteSpecifier @/ = %q", got) } // rewriteSpecifier preserves ~/ alias - if got := rewriteSpecifier("~/assets/icon.svg", "packages/ui/src/components/Card.tsx", "packages/ui/src/assets/icon.avif"); got != "~/assets/icon.avif" { + if got := rewriteSpecifier("~/assets/icon.svg", "packages/ui/src/components/Card.tsx", "packages/ui/src/assets/icon.avif", nil); got != "~/assets/icon.avif" { t.Fatalf("rewriteSpecifier ~/ = %q", got) } // rewriteSpecifier preserves query string - if got := rewriteSpecifier("@/assets/bg.png?raw", "src/App.tsx", "src/assets/bg.avif"); got != "@/assets/bg.avif?raw" { + if got := rewriteSpecifier("@/assets/bg.png?raw", "src/App.tsx", "src/assets/bg.avif", nil); got != "@/assets/bg.avif?raw" { t.Fatalf("rewriteSpecifier query = %q", got) } // rewriteSpecifier falls back when the target leaves the alias base - if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/public/logo.avif"); got != "../../public/logo.avif" { + if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/public/logo.avif", nil); got != "../../public/logo.avif" { t.Fatalf("rewriteSpecifier outside alias base = %q", got) } // rewriteSpecifier falls back to relative for non-alias paths - if got := rewriteSpecifier("./assets/logo.png", "src/components/App.tsx", "src/assets/logo.avif"); got != "../assets/logo.avif" { + if got := rewriteSpecifier("./assets/logo.png", "src/components/App.tsx", "src/assets/logo.avif", nil); got != "../assets/logo.avif" { t.Fatalf("rewriteSpecifier relative = %q", got) } + // rewriteSpecifier with custom import alias + aliases := map[string]string{"@acme/shared-ui": "packages/shared-ui"} + if got := rewriteSpecifier("@acme/shared-ui/images/icon.png", "src/App.tsx", "packages/shared-ui/images/icon.avif", aliases); got != "@acme/shared-ui/images/icon.avif" { + t.Fatalf("rewriteSpecifier alias = %q", got) + } + // rewriteSpecifier with alias + query string + if got := rewriteSpecifier("@acme/shared-ui/images/icon.svg?component", "src/App.tsx", "packages/shared-ui/images/icon.avif", aliases); got != "@acme/shared-ui/images/icon.avif?component" { + t.Fatalf("rewriteSpecifier alias+query = %q", got) + } for _, invalid := range []string{"", "../escape.png", "/absolute.png", "."} { if got := cleanRepoPath(invalid); got != "" { t.Fatalf("cleanRepoPath(%q) = %q", invalid, got) diff --git a/internal/config/settings.go b/internal/config/settings.go index e030b58..c4130c7 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -448,6 +448,9 @@ func (s *Store) UpdateSettings(update SettingsUpdate) (AppSettings, error) { if update.ExcludePatternsByIntent != nil { settings.ExcludePatternsByIntent = normalizeExcludePatternsByIntent(update.ExcludePatternsByIntent) } + if update.ImportAliases != nil { + settings.ImportAliases = update.ImportAliases + } if update.OptimizationDefaultQuality != nil { settings.OptimizationDefaultQuality = *update.OptimizationDefaultQuality } diff --git a/internal/config/types.go b/internal/config/types.go index 101237a..85ed378 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -39,6 +39,7 @@ type AppSettings struct { OCRFuzzySearch bool `json:"ocrFuzzySearch"` ExcludePatterns []string `json:"excludePatterns"` ExcludePatternsByIntent scanner.ExcludePatternsByIntent `json:"excludePatternsByIntent"` + ImportAliases map[string]string `json:"importAliases"` OptimizationDefaultQuality int `json:"optimizationDefaultQuality"` OptimizationWorkers int `json:"optimizationWorkers"` OptimizationAvifSpeed int `json:"optimizationAvifSpeed"` @@ -101,6 +102,7 @@ type SettingsUpdate struct { OCRFuzzySearch *bool `json:"ocrFuzzySearch"` ExcludePatterns []string `json:"excludePatterns"` ExcludePatternsByIntent scanner.ExcludePatternsByIntent `json:"excludePatternsByIntent"` + ImportAliases map[string]string `json:"importAliases"` OptimizationDefaultQuality *int `json:"optimizationDefaultQuality"` OptimizationWorkers *int `json:"optimizationWorkers"` OptimizationAvifSpeed *int `json:"optimizationAvifSpeed"` diff --git a/internal/references/references.go b/internal/references/references.go index 6e9b049..509e918 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -14,6 +14,7 @@ type Project struct { ID string Path string ExcludePatterns []string + ImportAliases map[string]string } type Asset struct { @@ -85,10 +86,18 @@ func BuildMapWithProgress(ctx context.Context, projects []Project, assets []Asse } for _, ref := range Extract(string(bytes)) { ref.File = file.repo - resolved := Resolve(file.project.Path, file.repo, ref.Specifier) + resolved := ResolveWithAliases(file.project.Path, file.repo, ref.Specifier, file.project.ImportAliases) if ref.Kind == "pattern" { + globPattern := resolvePattern(file.repo, ref.Specifier, file.project.ImportAliases) for candidate := range assetSets[file.project.ID] { - if referenceMayPointTo(file.project.Path, candidate, file.repo, ref.Specifier) { + matched := false + if globPattern != "" { + matched = globMatchRepoPath(globPattern, candidate) + } + if !matched { + matched = referenceMayPointToWithAliases(file.project.Path, candidate, file.repo, ref.Specifier, file.project.ImportAliases) + } + if matched { ref.ProjectID = file.project.ID ref.AssetPath = candidate out[key(file.project.ID, candidate)] = append(out[key(file.project.ID, candidate)], ref) @@ -103,7 +112,7 @@ func BuildMapWithProgress(ctx context.Context, projects []Project, assets []Asse continue } for candidate := range assetSets[file.project.ID] { - if referenceMayPointTo(file.project.Path, candidate, file.repo, ref.Specifier) { + if referenceMayPointToWithAliases(file.project.Path, candidate, file.repo, ref.Specifier, file.project.ImportAliases) { ref.ProjectID = file.project.ID ref.AssetPath = candidate out[key(file.project.ID, candidate)] = append(out[key(file.project.ID, candidate)], ref) @@ -302,10 +311,17 @@ func spanCovered(start, end int, spans [][2]int) bool { } func Resolve(projectRoot, importerRepoPath, specifier string) string { + return ResolveWithAliases(projectRoot, importerRepoPath, specifier, nil) +} + +func ResolveWithAliases(projectRoot, importerRepoPath, specifier string, aliases map[string]string) string { spec := stripQuery(filepath.ToSlash(strings.TrimSpace(specifier))) if spec == "" || strings.Contains(spec, "${") || strings.ContainsAny(spec, "*{}") { return "" } + if resolved := resolveAlias(spec, aliases); resolved != "" { + return cleanRepoPath(resolved) + } if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") { srcBase := findSrcAncestor(importerRepoPath) return cleanRepoPath(filepath.ToSlash(filepath.Join(srcBase, spec[2:]))) @@ -327,6 +343,53 @@ func Resolve(projectRoot, importerRepoPath, specifier string) string { return cleanRepoPath(spec) } +func resolvePattern(importerRepoPath, specifier string, aliases map[string]string) string { + spec := stripQuery(filepath.ToSlash(strings.TrimSpace(specifier))) + if spec == "" { + return "" + } + if resolved := resolveAlias(spec, aliases); resolved != "" { + return resolved + } + if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") { + srcBase := findSrcAncestor(importerRepoPath) + return filepath.ToSlash(filepath.Join(srcBase, spec[2:])) + } + if strings.HasPrefix(spec, "/") { + return strings.TrimPrefix(spec, "/") + } + if strings.HasPrefix(spec, "./") || strings.HasPrefix(spec, "../") { + base := filepath.Dir(filepath.FromSlash(importerRepoPath)) + return filepath.ToSlash(filepath.Join(base, filepath.FromSlash(spec))) + } + return spec +} + +func globMatchRepoPath(pattern, repoPath string) bool { + return matchPathParts(splitPathPattern(pattern), splitPathPattern(repoPath)) +} + +func resolveAlias(spec string, aliases map[string]string) string { + if len(aliases) == 0 { + return "" + } + bestKey := "" + bestClean := "" + for key := range aliases { + clean := strings.Trim(key, "/") + if (spec == clean || strings.HasPrefix(spec, clean+"/")) && len(clean) > len(bestClean) { + bestKey = key + bestClean = clean + } + } + if bestKey == "" { + return "" + } + aliasPath := strings.Trim(aliases[bestKey], "/") + suffix := strings.TrimPrefix(spec, bestClean) + return filepath.ToSlash(filepath.Clean(aliasPath + "/" + suffix)) +} + func findSrcAncestor(importerRepoPath string) string { dir := pathpkg.Dir(importerRepoPath) for dir != "." && dir != "/" { @@ -418,6 +481,20 @@ func referenceMayPointTo(projectRoot, repoPath, importerRepoPath, specifier stri return false } +func referenceMayPointToWithAliases(projectRoot, repoPath, importerRepoPath, specifier string, aliases map[string]string) bool { + if referenceMayPointTo(projectRoot, repoPath, importerRepoPath, specifier) { + return true + } + resolved := resolveAlias(stripQuery(filepath.ToSlash(specifier)), aliases) + if resolved != "" { + resolved = cleanRepoPath(resolved) + if resolved == repoPath || strings.HasSuffix(resolved, "/"+repoPath) || strings.HasSuffix(repoPath, "/"+resolved) { + return true + } + } + return false +} + func isPattern(spec string) bool { return strings.Contains(spec, "${") || strings.ContainsAny(spec, "*{}") } diff --git a/internal/references/references_test.go b/internal/references/references_test.go index 5580858..2106392 100644 --- a/internal/references/references_test.go +++ b/internal/references/references_test.go @@ -36,6 +36,55 @@ func TestResolveReferenceKinds(t *testing.T) { } } +func TestResolveWithImportAliases(t *testing.T) { + root := t.TempDir() + aliases := map[string]string{ + "@acme/shared-ui": "packages/shared-ui", + "@acme/design-tokens": "packages/design-tokens", + } + tests := []struct { + importer string + spec string + want string + }{ + {"src/App.tsx", "@acme/shared-ui/images/icon.svg", "packages/shared-ui/images/icon.svg"}, + {"src/App.tsx", "@acme/shared-ui/images/icon.svg?component", "packages/shared-ui/images/icon.svg"}, + {"src/App.tsx", "@acme/design-tokens/images/logo.png", "packages/design-tokens/images/logo.png"}, + // No alias match falls through to existing behavior + {"src/App.tsx", "@/assets/logo.png", "src/assets/logo.png"}, + {"src/App.tsx", "./assets/logo.png", "src/assets/logo.png"}, + } + for _, tt := range tests { + if got := ResolveWithAliases(root, tt.importer, tt.spec, aliases); got != tt.want { + t.Fatalf("ResolveWithAliases(%q, %q) = %q, want %q", tt.importer, tt.spec, got, tt.want) + } + } + // Nil aliases = same as Resolve + if got := ResolveWithAliases(root, "src/App.tsx", "@/assets/logo.png", nil); got != "src/assets/logo.png" { + t.Fatalf("ResolveWithAliases nil aliases = %q", got) + } +} + +func TestBuildMapResolvesImportAliases(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "packages", "shared-assets", "images", "icon.svg"), "image") + mustWrite(t, filepath.Join(root, "apps", "web", "src", "views", "Home.vue"), + `import Icon from '@acme/shared-ui/images/icon.svg'`) + + aliases := map[string]string{"@acme/shared-ui": "packages/shared-ui"} + refs, err := BuildMap(context.Background(), + []Project{{ID: "p", Path: root, ImportAliases: aliases}}, + []Asset{{ProjectID: "p", RepoPath: "packages/shared-ui/images/icon.svg"}}, + ) + if err != nil { + t.Fatal(err) + } + got := refs["p\x00packages/shared-ui/images/icon.svg"] + if len(got) != 1 || got[0].File != "apps/web/src/views/Home.vue" { + t.Fatalf("alias refs = %#v, want 1 ref from Home.vue", got) + } +} + func TestExtractCSSStringAndPatternReferences(t *testing.T) { content := ` const a = "./assets/a.png" @@ -110,6 +159,33 @@ func TestBuildMapResolvesAbsolutePublicReferences(t *testing.T) { } } +func TestBuildMapResolvesGlobPattern(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "images", "icons", "star.avif"), "image") + mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "images", "icons", "heart.avif"), "image") + mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "index.vue"), + "const imgs = import.meta.glob('./images/icons/**/*.avif', { eager: true })") + + refs, err := BuildMap(context.Background(), + []Project{{ID: "p", Path: root}}, + []Asset{ + {ProjectID: "p", RepoPath: "apps/web/src/components/Card/images/icons/star.avif"}, + {ProjectID: "p", RepoPath: "apps/web/src/components/Card/images/icons/heart.avif"}, + }, + ) + if err != nil { + t.Fatal(err) + } + star := refs["p\x00apps/web/src/components/Card/images/icons/star.avif"] + if len(star) != 1 || star[0].File != "apps/web/src/components/Card/index.vue" { + t.Fatalf("glob star refs = %#v", star) + } + heart := refs["p\x00apps/web/src/components/Card/images/icons/heart.avif"] + if len(heart) != 1 || heart[0].File != "apps/web/src/components/Card/index.vue" { + t.Fatalf("glob heart refs = %#v", heart) + } +} + func TestBuildMapResolvesAbsolutePathInMonorepo(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "apps", "dashboard", "src", "assets", "hero.webp"), "image") diff --git a/internal/scanner/references.go b/internal/scanner/references.go index 7d17b21..f761356 100644 --- a/internal/scanner/references.go +++ b/internal/scanner/references.go @@ -14,6 +14,7 @@ func buildReferenceMap(ctx context.Context, projects []Project, items []AssetIte ID: project.ID, Path: project.Path, ExcludePatterns: EffectiveExcludePatterns(project, options), + ImportAliases: options.ImportAliases, }) } assets := make([]references.Asset, 0, len(items)) diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 157f9ce..1141711 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -97,6 +97,7 @@ type ScanOptions struct { Profile ScanProfile `json:"profile"` ExcludePatterns []string `json:"excludePatterns,omitempty"` ExcludePatternsByIntent ExcludePatternsByIntent `json:"excludePatternsByIntent,omitempty"` + ImportAliases map[string]string `json:"importAliases,omitempty"` Analyses AnalysisOptions `json:"analyses"` OptimizationThresholds imageproc.OptimizationThresholds `json:"optimizationThresholds,omitempty"` LintSettings lint.Settings `json:"lintSettings,omitempty"` @@ -120,12 +121,13 @@ type ScanProgress struct { type ProgressFunc func(ScanProgress) type Project struct { - ID string `json:"id"` - WorkspaceID string `json:"workspaceId,omitempty"` - Name string `json:"name"` - Path string `json:"path"` - ScanIntent ProjectScanIntent `json:"scanIntent"` - CreatedAt string `json:"createdAt,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspaceId,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + ScanIntent ProjectScanIntent `json:"scanIntent"` + CreatedAt string `json:"createdAt,omitempty"` + ImportAliases map[string]string `json:"-"` } type Catalog struct { diff --git a/internal/server/actions_handlers.go b/internal/server/actions_handlers.go index fe6120b..a018164 100644 --- a/internal/server/actions_handlers.go +++ b/internal/server/actions_handlers.go @@ -49,6 +49,9 @@ func (s *Server) handleOptimizationPreview(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, err) return } + if settings, err := s.store.Settings(); err == nil { + project.ImportAliases = settings.ImportAliases + } for _, item := range items { if item.ProjectID != project.ID { writeError(w, http.StatusBadRequest, apierr.New("optimization_project_mixed", "optimization preview can only apply one project at a time")) @@ -456,6 +459,9 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + if settings, err := s.store.Settings(); err == nil { + project.ImportAliases = settings.ImportAliases + } var result actions.ApplyResult if preview.Type == "optimization" { result, err = optimize.Apply(project, preview) @@ -507,6 +513,10 @@ func (s *Server) projectAndItem(ctx context.Context, assetID string) (scanner.Pr if err != nil { return scanner.Project{}, scanner.AssetItem{}, err } + settings, err := s.store.Settings() + if err == nil { + project.ImportAliases = settings.ImportAliases + } return project, detail.Item, nil } diff --git a/internal/server/catalog.go b/internal/server/catalog.go index 5f3e8d8..d20cbd8 100644 --- a/internal/server/catalog.go +++ b/internal/server/catalog.go @@ -610,6 +610,7 @@ func (s *Server) scanWithProgress(ctx context.Context, override scanner.ScanOpti Analyses: settings.ScanAnalyses, ExcludePatterns: settings.ExcludePatterns, ExcludePatternsByIntent: settings.ExcludePatternsByIntent, + ImportAliases: settings.ImportAliases, OptimizationThresholds: settings.OptimizationThresholds, LintSettings: settings.LintRules, }) @@ -619,6 +620,7 @@ func (s *Server) scanWithProgress(ctx context.Context, override scanner.ScanOpti options = scanner.NormalizeScanOptions(options) options.ExcludePatterns = settings.ExcludePatterns options.ExcludePatternsByIntent = settings.ExcludePatternsByIntent + options.ImportAliases = settings.ImportAliases options.LintSettings = settings.LintRules } catalog, err := s.scanner.ScanWithOptions(ctx, projects, options, progress) @@ -695,6 +697,7 @@ func (s *Server) analysisIncomplete(summary config.CatalogSummary) bool { Analyses: settings.ScanAnalyses, ExcludePatterns: settings.ExcludePatterns, ExcludePatternsByIntent: settings.ExcludePatternsByIntent, + ImportAliases: settings.ImportAliases, }) want := options.Analyses if want.References && a.References != scanner.AnalysisComputed { diff --git a/internal/server/settings.go b/internal/server/settings.go index 6c0d840..9bc6f02 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -110,6 +110,7 @@ func settingsCatalogInputsChanged(update config.SettingsUpdate, previous, update (update.OptimizationStrategies != nil && !reflect.DeepEqual(previous.OptimizationStrategies, updated.OptimizationStrategies)) || (update.ExcludePatterns != nil && !reflect.DeepEqual(previous.ExcludePatterns, updated.ExcludePatterns)) || (update.ExcludePatternsByIntent != nil && !reflect.DeepEqual(previous.ExcludePatternsByIntent, updated.ExcludePatternsByIntent)) || + (update.ImportAliases != nil && !reflect.DeepEqual(previous.ImportAliases, updated.ImportAliases)) || (update.LintRules != nil && !reflect.DeepEqual(previous.LintRules, updated.LintRules)) } diff --git a/ui/src/features/settings/ScanningSection.tsx b/ui/src/features/settings/ScanningSection.tsx index 3dc376f..5d6b802 100644 --- a/ui/src/features/settings/ScanningSection.tsx +++ b/ui/src/features/settings/ScanningSection.tsx @@ -1,11 +1,14 @@ import { Download, Globe2, + Link, LoaderCircle, + Plus, ScanText, Sliders, Square, Trash2, + X, } from "lucide-react"; import type { ReactNode } from "react"; import { useState } from "react"; @@ -265,6 +268,84 @@ export function ScanningSection({

+ } + align="start" + > +
+ {draft.importAliases.map((alias) => ( +
+ { + const value = event.target.value; + onUpdateDraft((prev) => ({ + ...prev, + importAliases: prev.importAliases.map((a) => + a.id === alias.id ? { ...a, key: value } : a, + ), + })); + }} + placeholder="@scope/package" + className="flex-1" + inputClassName="font-g-mono text-g-ui tracking-g-mono" + /> + + { + const value = event.target.value; + onUpdateDraft((prev) => ({ + ...prev, + importAliases: prev.importAliases.map((a) => + a.id === alias.id ? { ...a, value } : a, + ), + })); + }} + placeholder="packages/package" + className="flex-1" + inputClassName="font-g-mono text-g-ui tracking-g-mono" + /> + +
+ ))} + +
+
{updateError && ( {errorMessage(updateError)} )} diff --git a/ui/src/features/settings/helpers.ts b/ui/src/features/settings/helpers.ts index e49f322..dd4b77c 100644 --- a/ui/src/features/settings/helpers.ts +++ b/ui/src/features/settings/helpers.ts @@ -397,6 +397,9 @@ export function draftFromSettings(settings?: SettingsInfo): SettingsDraft { vlmBackendTranslate: settings?.vlmBackendTranslate ?? "", vlmBackendCanvas: settings?.vlmBackendCanvas ?? "", aiNickname: settings?.aiNickname ?? "", + importAliases: Object.entries(settings?.importAliases ?? {}).map( + ([key, value], index) => ({ id: `alias-${index}`, key, value }), + ), excludePatternsText: (settings?.excludePatterns ?? []).join("\n"), excludePatternsByIntentText: Object.fromEntries( projectScanIntentValues.map((intent) => [ @@ -475,6 +478,11 @@ export function updateFromDraft(draft: SettingsDraft): SettingsUpdate { vlmBackendTranslate: draft.vlmBackendTranslate, vlmBackendCanvas: draft.vlmBackendCanvas, aiNickname: draft.aiNickname, + importAliases: Object.fromEntries( + draft.importAliases + .filter((a) => a.key.trim() && a.value.trim()) + .map((a) => [a.key.trim(), a.value.trim()]), + ), excludePatterns: splitPatterns(draft.excludePatternsText), excludePatternsByIntent: Object.fromEntries( projectScanIntentValues.map((intent) => [ @@ -544,6 +552,7 @@ export function resetSectionDraft( scanAnalyses: defaults.scanAnalyses, excludePatternsText: defaults.excludePatternsText, excludePatternsByIntentText: defaults.excludePatternsByIntentText, + importAliases: defaults.importAliases, }; case "ocr": return { diff --git a/ui/src/features/settings/types.ts b/ui/src/features/settings/types.ts index ec60c61..14bd444 100644 --- a/ui/src/features/settings/types.ts +++ b/ui/src/features/settings/types.ts @@ -116,6 +116,7 @@ export type SettingsDraft = { aiNickname: string; excludePatternsText: string; excludePatternsByIntentText: Record; + importAliases: Array<{ id: string; key: string; value: string }>; optimizationDefaultQuality: number; optimizationWorkers: number; optimizationAvifSpeed: number; diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index a5412c9..7f8b170 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -852,6 +852,10 @@ }, "excludePatterns": "Exclude patterns", "excludePatternsHint": "Patterns are project-relative. Choose Global rules or rules appended for one project type.", + "importAliases": "Import aliases", + "importAliasesHint": "Map package names to repo paths so references like @scope/pkg/image.png resolve correctly.", + "importAliasAdd": "Add alias", + "importAliasRemove": "Remove alias", "excludeScopeLabel": "Exclude pattern scope", "excludeScope": { "global": "Global", diff --git a/ui/src/i18n/locales/zh-TW.json b/ui/src/i18n/locales/zh-TW.json index e132056..9dfcea6 100644 --- a/ui/src/i18n/locales/zh-TW.json +++ b/ui/src/i18n/locales/zh-TW.json @@ -1773,6 +1773,10 @@ }, "excludePatterns": "排除規則", "excludePatternsHint": "規則以專案根目錄為基準;選擇 Global,或只追加到特定專案類型", + "importAliases": "匯入別名", + "importAliasesHint": "將套件名稱對應到 repo 內的實際路徑,讓 @scope/pkg/image.png 形式的引用能正確解析", + "importAliasAdd": "新增別名", + "importAliasRemove": "移除別名", "excludeScopeLabel": "排除規則範圍", "excludeScope": { "global": "Global", diff --git a/ui/src/types/settings.ts b/ui/src/types/settings.ts index e3c82a0..8c1df7d 100644 --- a/ui/src/types/settings.ts +++ b/ui/src/types/settings.ts @@ -18,6 +18,7 @@ export type AppSettings = { ocrFuzzySearch: boolean; excludePatterns: string[]; excludePatternsByIntent: ExcludePatternsByIntent; + importAliases: Record | null; optimizationDefaultQuality: number; optimizationWorkers: number; optimizationAvifSpeed: number;