返回 DeepSeek-Reasonix
hooks_settings_app.go
根目录 / desktop / hooks_settings_app.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "slices"
10 "strings"
11 "sync"
12
13 "reasonix/internal/config"
14 "reasonix/internal/fileutil"
15 fileencoding "reasonix/internal/fileutil/encoding"
16 "reasonix/internal/hook"
17 )
18
19 type HookConfigView struct {
20 Event string `json:"event"`
21 Match string `json:"match,omitempty"`
22 Command string `json:"command"`
23 Description string `json:"description,omitempty"`
24 Timeout int `json:"timeout,omitempty"`
25 Cwd string `json:"cwd,omitempty"`
26 }
27
28 type HooksSettingsView struct {
29 Scope string `json:"scope"`
30 Path string `json:"path"`
31 ProjectRoot string `json:"projectRoot"`
32 Trusted bool `json:"trusted"`
33 Hooks []HookConfigView `json:"hooks"`
34 Events []string `json:"events"`
35 }
36
37 func (a *App) HooksSettings(scope string) HooksSettingsView {
38 s, path, root := normalizeHooksScope(scope, a.activeHookProjectRoot())
39 view := HooksSettingsView{
40 Scope: s,
41 Path: path,
42 ProjectRoot: root,
43 Hooks: []HookConfigView{},
44 Events: hookEventNames(),
45 }
46 // Project hooks run only once approved as they stand now; what this view
47 // showed is what the Approve button may approve.
48 program, pending := hook.PendingProjectHooks(hook.LoadOptions{ProjectRoot: root})
49 view.Trusted = s != string(hook.ScopeProject) || !pending
50 if !view.Trusted {
51 shownProjectHooks.Store(root, program.Digest)
52 }
53 settings, err := readHooksSettingsFile(path)
54 if err != nil || settings.Hooks == nil {
55 return view
56 }
57 for _, event := range hook.Events {
58 for _, cfg := range settings.Hooks[event] {
59 if strings.TrimSpace(cfg.Command) == "" {
60 continue
61 }
62 view.Hooks = append(view.Hooks, hookConfigView(event, cfg))
63 }
64 }
65 return view
66 }
67
68 func (a *App) SaveHooksSettings(scope string, hooks []HookConfigView) error {
69 return a.SaveHooksSettingsForRoot(scope, a.activeHookProjectRoot(), hooks)
70 }
71
72 func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookConfigView) error {
73 s, path, _ := normalizeHooksScope(scope, projectRoot)
74 settings := hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{}}
75 for _, h := range hooks {
76 event := hook.Event(strings.TrimSpace(h.Event))
77 if !validHookEvent(event) {
78 return fmt.Errorf("unknown hook event %q", h.Event)
79 }
80 cmd := strings.TrimSpace(h.Command)
81 if cmd == "" {
82 continue
83 }
84 cmd = hook.NormalizeCommand(cmd)
85 settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
86 Match: strings.TrimSpace(h.Match),
87 Command: cmd,
88 Description: strings.TrimSpace(h.Description),
89 Timeout: h.Timeout,
90 Cwd: strings.TrimSpace(h.Cwd),
91 })
92 }
93 if s == string(hook.ScopeProject) && strings.TrimSpace(path) == "" {
94 return fmt.Errorf("no active project workspace")
95 }
96 if err := writeHooksSettingsFile(path, settings); err != nil {
97 return err
98 }
99 // Saving from the editor is the person choosing these hooks as written.
100 if s == string(hook.ScopeProject) {
101 return hook.ApproveProjectHooksAs(hook.LoadOptions{ProjectRoot: projectRoot}, settings)
102 }
103 return nil
104 }
105
106 func (a *App) TrustProjectHooks() error {
107 return a.TrustProjectHooksForRoot(a.activeHookProjectRoot())
108 }
109
110 // errHooksNotShown refuses approving hooks that carry fields this editor
111 // cannot display: a person may only approve what they were shown.
112 var errHooksNotShown = errors.New("these project hooks set env or contextFile, which this editor does not show; review and approve them with `reasonix trust`")
113
114 // TrustProjectHooksForRoot approves root's project hooks as they stand now.
115 func (a *App) TrustProjectHooksForRoot(root string) error {
116 if strings.TrimSpace(root) == "" {
117 return fmt.Errorf("no active project workspace")
118 }
119 settings, err := readHooksSettingsFile(hook.ProjectSettingsPath(root))
120 if err != nil {
121 return err
122 }
123 for _, list := range settings.Hooks {
124 for _, cfg := range list {
125 if len(cfg.Env) > 0 || strings.TrimSpace(cfg.ContextFile) != "" {
126 return errHooksNotShown
127 }
128 }
129 }
130 program, ok := hook.ProjectHooksProgram(root)
131 if !ok {
132 return nil
133 }
134 if shown, _ := shownProjectHooks.Load(root); shown != program.Digest {
135 return errHooksChangedSinceShown
136 }
137 return config.NewProjectProgramStore(config.ReasonixHomeDir()).Approve(root, program)
138 }
139
140 // shownProjectHooks is the digest of the project hooks each root's settings
141 // view last showed as waiting, so approving never covers a later rewrite.
142 var shownProjectHooks sync.Map
143
144 var errHooksChangedSinceShown = errors.New("these project hooks changed after they were shown; reload them and review again")
145
146 func (a *App) activeHookProjectRoot() string {
147 a.mu.RLock()
148 defer a.mu.RUnlock()
149 if tab := a.activeTabLocked(); tab != nil && tab.Scope == "project" {
150 return strings.TrimSpace(tab.WorkspaceRoot)
151 }
152 return ""
153 }
154
155 func normalizeHooksScope(scope, projectRoot string) (string, string, string) {
156 if strings.EqualFold(strings.TrimSpace(scope), string(hook.ScopeProject)) {
157 root := strings.TrimSpace(projectRoot)
158 if root == "" {
159 return string(hook.ScopeProject), "", ""
160 }
161 return string(hook.ScopeProject), hook.ProjectSettingsPath(root), root
162 }
163 return string(hook.ScopeGlobal), hook.GlobalSettingsPath(""), ""
164 }
165
166 func hookEventNames() []string {
167 out := make([]string, 0, len(hook.Events))
168 for _, event := range hook.Events {
169 out = append(out, string(event))
170 }
171 return out
172 }
173
174 func validHookEvent(event hook.Event) bool {
175 return slices.Contains(hook.Events, event)
176 }
177
178 func hookConfigView(event hook.Event, cfg hook.HookConfig) HookConfigView {
179 return HookConfigView{
180 Event: string(event),
181 Match: cfg.Match,
182 Command: cfg.Command,
183 Description: cfg.Description,
184 Timeout: cfg.Timeout,
185 Cwd: cfg.Cwd,
186 }
187 }
188
189 func readHooksSettingsFile(path string) (hook.Settings, error) {
190 var settings hook.Settings
191 body, err := fileencoding.ReadFileUTF8(path)
192 if err != nil {
193 return settings, err
194 }
195 if err := json.Unmarshal(body, &settings); err != nil {
196 return settings, err
197 }
198 if settings.Hooks == nil {
199 settings.Hooks = map[hook.Event][]hook.HookConfig{}
200 }
201 return settings, nil
202 }
203
204 func writeHooksSettingsFile(path string, settings hook.Settings) error {
205 if strings.TrimSpace(path) == "" {
206 return fmt.Errorf("empty hooks settings path")
207 }
208 raw := map[string]json.RawMessage{}
209 if body, err := fileencoding.ReadFileUTF8(path); err == nil {
210 if err := json.Unmarshal(body, &raw); err != nil {
211 return err
212 }
213 }
214 hooksJSON, err := json.Marshal(settings.Hooks)
215 if err != nil {
216 return err
217 }
218 raw["hooks"] = hooksJSON
219 body, err := json.MarshalIndent(raw, "", " ")
220 if err != nil {
221 return err
222 }
223 body = append(body, '\n')
224 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
225 return err
226 }
227 return fileutil.AtomicWriteFile(path, body, 0o644)
228 }
229
229 lines GO