| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | tea "charm.land/bubbletea/v2" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/i18n" |
| 13 | ) |
| 14 | |
| 15 | func (m *chatTUI) runLanguageSubcommand(input string) tea.Cmd { |
| 16 | args := tokenizeArgs(input) |
| 17 | if len(args) < 2 { |
| 18 | cfg, err := config.Load() |
| 19 | if err != nil { |
| 20 | m.notice("language: " + err.Error()) |
| 21 | return nil |
| 22 | } |
| 23 | saved := languageDisplay(cfg.Language) |
| 24 | resolved := i18n.DetectLanguage(cfg.Language) |
| 25 | m.notice(i18n.M.LanguageHeader + "\n" + describeLanguages(saved, resolved) + "\n" + i18n.M.LanguageHint) |
| 26 | return nil |
| 27 | } |
| 28 | if len(args) > 2 { |
| 29 | m.notice(i18n.M.LanguageHint) |
| 30 | return nil |
| 31 | } |
| 32 | |
| 33 | lang, err := normalizeLanguageArg(args[1]) |
| 34 | if err != nil { |
| 35 | m.notice(err.Error()) |
| 36 | return nil |
| 37 | } |
| 38 | if !m.runtimeSettingChangeReady() { |
| 39 | return nil |
| 40 | } |
| 41 | path := config.SourcePath() |
| 42 | if path == "" { |
| 43 | path = config.UserConfigPath() |
| 44 | } |
| 45 | if path == "" { |
| 46 | m.notice("language: cannot resolve config path") |
| 47 | return nil |
| 48 | } |
| 49 | // Lock both possible write targets before resolving either one. This keeps |
| 50 | // a project language update and user-override cleanup atomic even when the |
| 51 | // files are reached through aliases. |
| 52 | if err := func() error { |
| 53 | userPath := config.UserConfigPath() |
| 54 | lockPaths := []string{path} |
| 55 | if userPath != "" && !sameConfigPath(path, userPath) { |
| 56 | lockPaths = append(lockPaths, userPath) |
| 57 | } |
| 58 | unlock, err := config.LockConfigFilesEdits(lockPaths...) |
| 59 | if err != nil { |
| 60 | return err |
| 61 | } |
| 62 | defer unlock() |
| 63 | edit, err := config.LoadForEditReadOnlyStrict(path) |
| 64 | if err != nil { |
| 65 | return err |
| 66 | } |
| 67 | if err := edit.SetLanguage(lang); err != nil { |
| 68 | return err |
| 69 | } |
| 70 | if err := edit.SaveTo(path); err != nil { |
| 71 | return err |
| 72 | } |
| 73 | if lang == "" { |
| 74 | return clearUserLanguageOverride(path) |
| 75 | } |
| 76 | return nil |
| 77 | }(); err != nil { |
| 78 | m.notice("language: " + err.Error()) |
| 79 | return nil |
| 80 | } |
| 81 | |
| 82 | resolved := i18n.DetectLanguage(lang) |
| 83 | m.refreshInputPlaceholder() |
| 84 | if m.ctrl != nil { |
| 85 | m.ctrl.SetResponseLanguage(lang) |
| 86 | } |
| 87 | success := fmt.Sprintf(i18n.M.LanguageChangedFmt, languageDisplay(lang), resolved) |
| 88 | if m.ctrl == nil { |
| 89 | m.notice(success) |
| 90 | return nil |
| 91 | } |
| 92 | return m.scheduleCurrentControllerRebuild("language", success) |
| 93 | } |
| 94 | |
| 95 | // clearUserLanguageOverride drops a stale user-level language override after a |
| 96 | // project-level write. Callers must hold LockUserConfigEdits: this is its own |
| 97 | // load-modify-save on the user config and must not take the lock itself. |
| 98 | func clearUserLanguageOverride(primaryPath string) error { |
| 99 | userPath := config.UserConfigPath() |
| 100 | if userPath == "" || sameConfigPath(primaryPath, userPath) { |
| 101 | return nil |
| 102 | } |
| 103 | if _, err := os.Stat(userPath); err != nil { |
| 104 | if os.IsNotExist(err) { |
| 105 | return nil |
| 106 | } |
| 107 | return err |
| 108 | } |
| 109 | edit, err := config.LoadForEditReadOnlyStrict(userPath) |
| 110 | if err != nil { |
| 111 | return err |
| 112 | } |
| 113 | if strings.TrimSpace(edit.Language) == "" { |
| 114 | return nil |
| 115 | } |
| 116 | if err := edit.SetLanguage(""); err != nil { |
| 117 | return err |
| 118 | } |
| 119 | return edit.SaveTo(userPath) |
| 120 | } |
| 121 | |
| 122 | func sameConfigPath(a, b string) bool { |
| 123 | aa, errA := filepath.Abs(a) |
| 124 | bb, errB := filepath.Abs(b) |
| 125 | if errA == nil { |
| 126 | a = aa |
| 127 | } |
| 128 | if errB == nil { |
| 129 | b = bb |
| 130 | } |
| 131 | return filepath.Clean(a) == filepath.Clean(b) |
| 132 | } |
| 133 | |
| 134 | func normalizeLanguageArg(s string) (string, error) { |
| 135 | switch strings.ToLower(strings.TrimSpace(s)) { |
| 136 | case "", "auto", "detect", "default": |
| 137 | return "", nil |
| 138 | case "en", "english": |
| 139 | return "en", nil |
| 140 | case "zh", "cn", "chinese", "中文": |
| 141 | return "zh", nil |
| 142 | default: |
| 143 | return "", fmt.Errorf("usage: /language auto|en|zh") |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func languageDisplay(lang string) string { |
| 148 | if strings.TrimSpace(lang) == "" { |
| 149 | return "auto" |
| 150 | } |
| 151 | return lang |
| 152 | } |
| 153 | |
| 154 | func describeLanguages(current, resolved string) string { |
| 155 | items := []struct { |
| 156 | tag string |
| 157 | hint string |
| 158 | }{ |
| 159 | {"auto", i18n.M.ArgLanguageAuto}, |
| 160 | {"en", i18n.M.ArgLanguageEn}, |
| 161 | {"zh", i18n.M.ArgLanguageZh}, |
| 162 | } |
| 163 | var b strings.Builder |
| 164 | for _, it := range items { |
| 165 | marker := " " |
| 166 | if it.tag == current { |
| 167 | marker = "• " |
| 168 | } |
| 169 | hint := it.hint |
| 170 | if it.tag == current { |
| 171 | hint += " · " + i18n.M.ArgThemeCurrent |
| 172 | } |
| 173 | if it.tag == "auto" && current == "auto" { |
| 174 | hint += " · " + resolved |
| 175 | } |
| 176 | fmt.Fprintf(&b, "%s%-6s %s\n", marker, it.tag, hint) |
| 177 | } |
| 178 | return strings.TrimRight(b.String(), "\n") |
| 179 | } |
| 180 |