From 0825e06dbe28fae055bf5e5c3640e2098057f009 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 3 Jan 2026 14:39:57 +0800 Subject: [PATCH] Implement Multi-Directory Watching and Enhance Locale File Handling - Introduced the IWatchDirs interface to support watching multiple directories for changes, allowing templates to specify directories dynamically. - Updated the watch command to handle multiple directories and display watched directories in the output. - Added functionality to write locale files from a page's __locales directory to the public directory, improving localization support. - Enhanced error handling during directory watching and locale file processing for better reliability and logging. --- cmd/sui/watch.go | 138 ++++++++++++++++++++++++++++++++- sui/core/interfaces.go | 10 +++ sui/storages/agent/page.go | 121 +++++++++++++++++++++++++++++ sui/storages/agent/template.go | 27 +++++++ 4 files changed, 295 insertions(+), 1 deletion(-) diff --git a/cmd/sui/watch.go b/cmd/sui/watch.go index 13bcf6c3..9c77a8ef 100644 --- a/cmd/sui/watch.go +++ b/cmd/sui/watch.go @@ -99,7 +99,20 @@ var WatchCmd = &cobra.Command{ return } - go watch(root, func(event, name string) { + // Get all directories to watch + watchDirs := []string{root} + if watchDirsProvider, ok := tmpl.(core.IWatchDirs); ok { + watchDirs = []string{} + watchRoot := cfg.DataRoot + if watchDirsProvider.GetWatchRoot() == "app" { + watchRoot = cfg.Root + } + for _, dir := range watchDirsProvider.GetWatchDirs() { + watchDirs = append(watchDirs, filepath.Join(watchRoot, dir)) + } + } + + go watchMultiple(watchDirs, func(event, name string) { if event == "WRITE" || event == "CREATE" || event == "RENAME" { // @Todo build single page and sync single asset file to public fmt.Print(color.WhiteString("Building... ")) @@ -134,6 +147,15 @@ var WatchCmd = &cobra.Command{ fmt.Println(color.WhiteString("Public Root: /public%s", publicRoot)) fmt.Println(color.WhiteString(" Template: %s", tmpl.GetRoot())) fmt.Println(color.WhiteString(" Session: %s", strings.TrimLeft(data, "::"))) + fmt.Println(color.WhiteString("Watch Dirs:")) + for _, dir := range watchDirs { + // Show path relative to either app root or data root + displayDir := strings.TrimPrefix(dir, cfg.Root) + if displayDir == dir { + displayDir = strings.TrimPrefix(dir, cfg.DataRoot) + } + fmt.Println(color.WhiteString(" - %s", displayDir)) + } fmt.Println(color.WhiteString("-----------------------")) fmt.Println(color.GreenString("Watching...")) fmt.Println(color.GreenString("Press Ctrl+C to exit")) @@ -151,6 +173,116 @@ var WatchCmd = &cobra.Command{ }, } +func watchMultiple(roots []string, handler func(event string, name string), interrupt chan uint8) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + shutdown := make(chan bool, 1) + + // Walk all root directories + watchedCount := 0 + for _, root := range roots { + // Check if root exists + if _, err := os.Stat(root); os.IsNotExist(err) { + fmt.Println(color.YellowString("[Watch] Directory not found: %s", root)) + continue + } + + err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { + if err != nil { + log.Warn("[Watch] Error accessing path %s: %v", path, err) + return nil // Skip this path and continue walking + } + if info.IsDir() { + if filepath.Base(path) == ".tmp" { + return filepath.SkipDir + } + + err = watcher.Add(path) + if err != nil { + return err + } + watchedCount++ + log.Info("[Watch] Watching: %s", path) + watched.Store(path, true) + } + return nil + }) + if err != nil { + fmt.Println(color.YellowString("[Watch] Error walking root %s: %v", root, err)) + } + } + fmt.Println(color.GreenString("[Watch] Total directories watched: %d", watchedCount)) + + go func() { + for { + select { + case <-shutdown: + log.Info("[Watch] handler exit") + return + + case event, ok := <-watcher.Events: + if !ok { + interrupt <- 1 + return + } + + basname := filepath.Base(event.Name) + isdir := true + if strings.Contains(basname, ".") { + isdir = false + } + + events := strings.Split(event.Op.String(), "|") + for _, eventType := range events { + // ADD / REMOVE Watching dir + if isdir { + switch eventType { + case "CREATE": + log.Info("[Watch] Watching: %s", event.Name) + watcher.Add(event.Name) + watched.Store(event.Name, true) + break + + case "REMOVE": + log.Info("[Watch] Unwatching: %s", event.Name) + watcher.Remove(event.Name) + watched.Delete(event.Name) + break + } + continue + } + + handler(eventType, event.Name) + log.Info("[Watch] %s %s", eventType, event.Name) + } + + break + + case err, ok := <-watcher.Errors: + if !ok { + interrupt <- 2 + return + } + log.Error("[Watch] Error: %s", err.Error()) + break + } + } + }() + + for { + select { + case code := <-interrupt: + shutdown <- true + log.Info("[Watch] Exit(%d)", code) + fmt.Println(color.YellowString("[Watch] Exit(%d)", code)) + return nil + } + } +} + func watch(root string, handler func(event string, name string), interrupt chan uint8) error { watcher, err := fsnotify.NewWatcher() if err != nil { @@ -160,6 +292,10 @@ func watch(root string, handler func(event string, name string), interrupt chan shutdown := make(chan bool, 1) err = filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { + if err != nil { + log.Warn("[Watch] Error accessing path %s: %v", path, err) + return nil // Skip this path and continue walking + } if info.IsDir() { if filepath.Base(path) == ".tmp" { return filepath.SkipDir diff --git a/sui/core/interfaces.go b/sui/core/interfaces.go index 7338d0d9..07c886d4 100644 --- a/sui/core/interfaces.go +++ b/sui/core/interfaces.go @@ -118,3 +118,13 @@ type IComponent interface { Load() error Source() string } + +// IWatchDirs is an optional interface for templates that need to watch multiple directories +type IWatchDirs interface { + // GetWatchDirs returns all directories that should be watched for changes + // The returned paths are relative to the application source root (not data root) + GetWatchDirs() []string + // GetWatchRoot returns the root directory for watch paths + // Returns "app" for application source root, "data" for data root + GetWatchRoot() string +} diff --git a/sui/storages/agent/page.go b/sui/storages/agent/page.go index 2ce6e215..f45bdad5 100644 --- a/sui/storages/agent/page.go +++ b/sui/storages/agent/page.go @@ -11,6 +11,7 @@ import ( v8 "github.com/yaoapp/gou/runtime/v8" "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/sui/core" + "gopkg.in/yaml.v3" ) // Page wraps core.Page with agent-specific functionality @@ -315,6 +316,13 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp return warnings, err } + // Write locale files from page's __locales directory + err = page.writeLocaleFiles(option.Data) + if err != nil { + log.Warn("[Agent] Write locale files error: %s", err.Error()) + // Don't fail the build for locale errors + } + return warnings, nil } @@ -465,3 +473,116 @@ func (page *Page) AssetRoot() string { func (page *Page) AssistantID() string { return page.assistantID } + +// writeLocaleFiles writes locale files from page's __locales directory to public +func (page *Page) writeLocaleFiles(data map[string]interface{}) error { + fs := page.tmpl.agent.fs + + // Check if page has __locales directory + localesDir := filepath.Join(page.Path, "__locales") + if !fs.IsDir(localesDir) { + return nil + } + + // Get the public root + root, err := page.tmpl.agent.DSL.PublicRoot(data) + if err != nil { + log.Error("writeLocaleFiles: Get the public root error: %s. use %s", err.Error(), page.tmpl.agent.DSL.Public.Root) + root = page.tmpl.agent.DSL.Public.Root + } + + // Read all locale files in __locales directory + files, err := fs.ReadDir(localesDir, false) + if err != nil { + return err + } + + for _, file := range files { + // Skip directories + if fs.IsDir(file) { + continue + } + + // Only process .yml files + if filepath.Ext(file) != ".yml" { + continue + } + + // Get locale name (e.g., "zh-cn" from "zh-cn.yml") + localeName := filepath.Base(file) + localeName = localeName[:len(localeName)-4] // Remove .yml extension + + // Read the locale file + content, err := fs.ReadFile(file) + if err != nil { + log.Error("[Agent] Read locale file error: %s", err.Error()) + continue + } + + // Parse the locale file + var localeData map[string]interface{} + err = yaml.Unmarshal(content, &localeData) + if err != nil { + log.Error("[Agent] Parse locale file error: %s", err.Error()) + continue + } + + // Convert to the format expected by core.Locale + locale := core.Locale{ + Name: localeName, + Keys: map[string]string{}, + Messages: map[string]string{}, + ScriptMessages: map[string]string{}, + } + + // Extract messages + if messages, ok := localeData["messages"].(map[string]interface{}); ok { + for k, v := range messages { + if strVal, ok := v.(string); ok { + locale.Messages[k] = strVal + } + } + } + + // Extract script_messages + if scriptMessages, ok := localeData["script_messages"].(map[string]interface{}); ok { + for k, v := range scriptMessages { + if strVal, ok := v.(string); ok { + locale.ScriptMessages[k] = strVal + } + } + } + + // Extract timezone and direction + if tz, ok := localeData["timezone"].(string); ok { + locale.Timezone = tz + } + if dir, ok := localeData["direction"].(string); ok { + locale.Direction = dir + } + + // Write to public/.locales//.yml + // page.Route may contain path like /expense/test, so we need to create nested directories + targetFile := filepath.Join(application.App.Root(), "public", root, ".locales", localeName, fmt.Sprintf("%s.yml", page.Route)) + targetDir := filepath.Dir(targetFile) + if exist, _ := os.Stat(targetDir); exist == nil { + os.MkdirAll(targetDir, os.ModePerm) + } + + localeContent, err := yaml.Marshal(locale) + if err != nil { + log.Error("[Agent] Marshal locale error: %s", err.Error()) + continue + } + + err = os.WriteFile(targetFile, localeContent, 0644) + if err != nil { + log.Error("[Agent] Write locale file error: %s", err.Error()) + continue + } + + log.Info("[Agent] Wrote locale file: %s", targetFile) + } + + return nil +} diff --git a/sui/storages/agent/template.go b/sui/storages/agent/template.go index 30484219..ac921480 100644 --- a/sui/storages/agent/template.go +++ b/sui/storages/agent/template.go @@ -226,6 +226,33 @@ func (tmpl *Template) GetRoot() string { return tmpl.agent.root } +// GetWatchDirs returns all directories that should be watched for changes +// This implements the core.IWatchDirs interface +func (tmpl *Template) GetWatchDirs() []string { + dirs := []string{} + + // 1. Add the main agent template directory + dirs = append(dirs, tmpl.agent.root) + + // 2. Add each assistant's pages directory + assistants, err := tmpl.agent.getAssistants() + if err != nil { + return dirs + } + + for _, assistantID := range assistants { + pagesDir := tmpl.agent.getAssistantPagesRoot(assistantID) + dirs = append(dirs, pagesDir) + } + + return dirs +} + +// GetWatchRoot returns "app" to indicate paths are relative to application source root +func (tmpl *Template) GetWatchRoot() string { + return "app" +} + // Asset get the asset (check agent assets first, then assistant assets) func (tmpl *Template) Asset(file string, width, height uint) (*core.Asset, error) { // First check in agent assets