返回 DeepSeek-Reasonix
pinned_files.go
根目录 / desktop / pinned_files.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "sync/atomic"
13 "unicode/utf8"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/control"
17 "reasonix/internal/fileutil"
18 )
19
20 const (
21 maxPinnedFileCount = agent.MaxPinnedContextFiles
22 maxPinnedFileSize = agent.MaxPinnedContextFileBytes
23 maxPinnedContextSize = agent.MaxPinnedContextRevisionBytes
24 )
25
26 var (
27 errPinnedNotRegular = errors.New("only regular files can be pinned")
28 errPinnedFileTooLarge = errors.New("pinned file exceeds the size limit")
29 )
30
31 // PinnedFileInfo holds metadata about one pinned context file.
32 type PinnedFileInfo struct {
33 Path string `json:"path"`
34 SizeBytes int64 `json:"sizeBytes"`
35 TokenEstimate int `json:"tokenEstimate"`
36 Error string `json:"error,omitempty"`
37 }
38
39 type pinnedContextBuild struct {
40 Snapshot agent.PinnedContextSnapshot
41 Infos []PinnedFileInfo
42 }
43
44 // pinnedFileReadHookForTest coordinates deterministic Pin/New/turn races.
45 // Production leaves it nil.
46 var pinnedFileReadHookForTest atomic.Pointer[func()]
47
48 func normalizePinnedRelPath(relPath string) (string, error) {
49 clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relPath)))
50 clean = strings.TrimPrefix(clean, "./")
51 if clean == "" || clean == "." || filepath.IsAbs(relPath) || strings.HasPrefix(clean, "/") {
52 return "", errors.New("invalid empty or absolute path")
53 }
54 if clean == ".." || strings.HasPrefix(clean, "../") {
55 return "", errors.New("path traversal outside workspace is forbidden")
56 }
57 if !utf8.ValidString(clean) {
58 return "", errors.New("pinned path is not valid UTF-8")
59 }
60 return clean, nil
61 }
62
63 func readPinnedWorkspaceFile(root, relPath string) (string, []byte, int64, error) {
64 clean, err := normalizePinnedRelPath(relPath)
65 if err != nil {
66 return "", nil, 0, err
67 }
68 if strings.TrimSpace(root) == "" {
69 return clean, nil, 0, errors.New("tab has no workspace root")
70 }
71 file, err := fileutil.OpenFileBeneath(root, filepath.FromSlash(clean))
72 if err != nil {
73 return clean, nil, 0, err
74 }
75 defer file.Close()
76 info, err := file.Stat()
77 if err != nil {
78 return clean, nil, 0, err
79 }
80 if !info.Mode().IsRegular() {
81 return clean, nil, info.Size(), errPinnedNotRegular
82 }
83 if hook := pinnedFileReadHookForTest.Load(); hook != nil {
84 (*hook)()
85 }
86 if info.Size() > maxPinnedFileSize {
87 return clean, nil, info.Size(), fmt.Errorf("%w: file size (%d bytes) exceeds the %d-byte limit", errPinnedFileTooLarge, info.Size(), maxPinnedFileSize)
88 }
89 data, err := io.ReadAll(io.LimitReader(file, maxPinnedFileSize+1))
90 if err != nil {
91 return clean, nil, info.Size(), err
92 }
93 if len(data) > maxPinnedFileSize {
94 return clean, nil, int64(len(data)), fmt.Errorf("%w: file grew beyond the %d-byte limit while reading", errPinnedFileTooLarge, maxPinnedFileSize)
95 }
96 return clean, data, int64(len(data)), nil
97 }
98
99 func buildPinnedContext(root string, files []string) pinnedContextBuild {
100 result := pinnedContextBuild{
101 Snapshot: agent.PinnedContextSnapshot{
102 Files: make([]agent.PinnedContextFile, 0, len(files)),
103 Issues: make([]agent.PinnedContextIssue, 0, len(files)),
104 },
105 Infos: make([]PinnedFileInfo, 0, len(files)),
106 }
107 if len(files) == 0 || strings.TrimSpace(root) == "" {
108 return result
109 }
110 for _, rel := range files {
111 clean, data, size, err := readPinnedWorkspaceFile(root, rel)
112 info := PinnedFileInfo{Path: rel, SizeBytes: size, TokenEstimate: estimateTokensFromBytes(size)}
113 if clean != "" {
114 info.Path = clean
115 }
116 if err != nil {
117 info.Error = err.Error()
118 result.Snapshot.Issues = append(result.Snapshot.Issues, agent.PinnedContextIssue{
119 Path: info.Path, Reason: pinnedContextIssueReason(err),
120 })
121 result.Infos = append(result.Infos, info)
122 continue
123 }
124 content := agent.SanitizePinnedContextContent(string(data))
125 if len(content) > maxPinnedFileSize {
126 info.Error = fmt.Sprintf("pinned file exceeds the %d-byte limit after XML normalization", maxPinnedFileSize)
127 result.Snapshot.Issues = append(result.Snapshot.Issues, agent.PinnedContextIssue{
128 Path: clean, Reason: agent.PinnedContextIssueFileTooLarge,
129 })
130 result.Infos = append(result.Infos, info)
131 continue
132 }
133 candidate, err := agent.NormalizePinnedContextFile(agent.PinnedContextFile{Path: clean, Content: content})
134 if err != nil {
135 info.Error = err.Error()
136 result.Snapshot.Issues = append(result.Snapshot.Issues, agent.PinnedContextIssue{
137 Path: clean, Reason: agent.PinnedContextIssueReadFailed,
138 })
139 result.Infos = append(result.Infos, info)
140 continue
141 }
142 result.Snapshot.Files = append(result.Snapshot.Files, candidate)
143 if err := agent.ValidatePinnedContextSnapshot(result.Snapshot); err != nil {
144 result.Snapshot.Files = result.Snapshot.Files[:len(result.Snapshot.Files)-1]
145 result.Snapshot.Issues = append(result.Snapshot.Issues, agent.PinnedContextIssue{
146 Path: clean, Reason: agent.PinnedContextIssueTotalLimit,
147 })
148 info.Error = fmt.Sprintf("pinned context would exceed the %d-byte total limit", maxPinnedContextSize)
149 result.Infos = append(result.Infos, info)
150 continue
151 }
152 result.Infos = append(result.Infos, info)
153 }
154 return result
155 }
156
157 func pinnedContextIssueReason(err error) agent.PinnedContextIssueReason {
158 switch {
159 case errors.Is(err, os.ErrNotExist):
160 return agent.PinnedContextIssueNotFound
161 case errors.Is(err, errPinnedNotRegular):
162 return agent.PinnedContextIssueNotRegular
163 case errors.Is(err, errPinnedFileTooLarge):
164 return agent.PinnedContextIssueFileTooLarge
165 default:
166 return agent.PinnedContextIssueReadFailed
167 }
168 }
169
170 func pinnedContextLoader(root string) control.PinnedContextLoader {
171 return func(ctx context.Context, sessionPath string) (agent.PinnedContextSnapshot, error) {
172 if err := ctx.Err(); err != nil {
173 return agent.PinnedContextSnapshot{}, err
174 }
175 state, err := loadPinnedContextState(sessionPath)
176 if err != nil {
177 return agent.PinnedContextSnapshot{}, err
178 }
179 build := buildPinnedContext(root, state.Files)
180 if err := ctx.Err(); err != nil {
181 return agent.PinnedContextSnapshot{}, err
182 }
183 return build.Snapshot, nil
184 }
185 }
186
187 func pinnedInfoForPath(infos []PinnedFileInfo, path string) (PinnedFileInfo, bool) {
188 for _, info := range infos {
189 if info.Path == path {
190 return info, true
191 }
192 }
193 return PinnedFileInfo{}, false
194 }
195
196 func (t *WorkspaceTab) setPinnedFiles(files []string) {
197 t.setPinnedFilesState(files, nil)
198 }
199
200 func (t *WorkspaceTab) setPinnedFilesState(files, pendingLegacy []string) {
201 if t == nil {
202 return
203 }
204 t.pinnedFilesMu.Lock()
205 t.PinnedFiles = append([]string(nil), files...)
206 t.pendingLegacyPinnedFiles = append([]string(nil), pendingLegacy...)
207 t.pinnedFilesMu.Unlock()
208 }
209
210 func (t *WorkspaceTab) pinnedFilesState() ([]string, []string) {
211 if t == nil {
212 return []string{}, []string{}
213 }
214 t.pinnedFilesMu.RLock()
215 defer t.pinnedFilesMu.RUnlock()
216 return append([]string{}, t.PinnedFiles...), append([]string{}, t.pendingLegacyPinnedFiles...)
217 }
218
219 func (t *WorkspaceTab) retainLegacyPinnedFiles(files []string) {
220 normalized, err := normalizePinnedContextFiles(files)
221 if err != nil {
222 normalized = []string{}
223 }
224 t.setPinnedFilesState(normalized, files)
225 }
226
227 func (t *WorkspaceTab) pendingLegacyPinnedFilesForPersistence() []string {
228 _, pending := t.pinnedFilesState()
229 return pending
230 }
231
232 func (t *WorkspaceTab) clearPendingLegacyPinnedFiles() {
233 if t == nil {
234 return
235 }
236 t.pinnedFilesMu.Lock()
237 t.pendingLegacyPinnedFiles = nil
238 t.pinnedFilesMu.Unlock()
239 }
240
241 // PinFile updates the tab-local cache. Durable desktop mutations go through
242 // PinFileForTab so the session sidecar and controller change atomically.
243 func (t *WorkspaceTab) PinFile(relPath string) (PinnedFileInfo, error) {
244 if t == nil {
245 return PinnedFileInfo{}, errors.New("tab is nil")
246 }
247 clean, err := normalizePinnedRelPath(relPath)
248 if err != nil {
249 return PinnedFileInfo{}, err
250 }
251 files := t.GetPinnedFiles()
252 if slices.Contains(files, clean) {
253 build := buildPinnedContext(t.WorkspaceRoot, files)
254 info, _ := pinnedInfoForPath(build.Infos, clean)
255 return info, nil
256 }
257 if len(files) >= maxPinnedFileCount {
258 return PinnedFileInfo{}, fmt.Errorf("at most %d files can be pinned", maxPinnedFileCount)
259 }
260 candidate := append(files, clean)
261 candidate, err = normalizePinnedContextFiles(candidate)
262 if err != nil {
263 return PinnedFileInfo{}, err
264 }
265 build := buildPinnedContext(t.WorkspaceRoot, candidate)
266 info, ok := pinnedInfoForPath(build.Infos, clean)
267 if !ok {
268 return PinnedFileInfo{}, errors.New("pinned file could not be inspected")
269 }
270 if info.Error != "" {
271 return PinnedFileInfo{}, errors.New(info.Error)
272 }
273 t.setPinnedFiles(candidate)
274 return info, nil
275 }
276
277 func (t *WorkspaceTab) UnpinFile(relPath string) error {
278 if t == nil {
279 return errors.New("tab is nil")
280 }
281 clean, err := normalizePinnedRelPath(relPath)
282 if err != nil {
283 return err
284 }
285 files := t.GetPinnedFiles()
286 next := make([]string, 0, len(files))
287 for _, path := range files {
288 if path != clean {
289 next = append(next, path)
290 }
291 }
292 t.setPinnedFiles(next)
293 return nil
294 }
295
296 func (t *WorkspaceTab) GetPinnedFiles() []string {
297 if t == nil {
298 return []string{}
299 }
300 t.pinnedFilesMu.RLock()
301 defer t.pinnedFilesMu.RUnlock()
302 return append([]string{}, t.PinnedFiles...)
303 }
304
305 func (t *WorkspaceTab) GetPinnedFilesInfo() []PinnedFileInfo {
306 if t == nil {
307 return []PinnedFileInfo{}
308 }
309 return buildPinnedContext(t.WorkspaceRoot, t.GetPinnedFiles()).Infos
310 }
311
312 func estimateTokensFromBytes(bytes int64) int {
313 if bytes <= 0 {
314 return 0
315 }
316 tok := int(bytes / 4)
317 if tok == 0 {
318 return 1
319 }
320 return tok
321 }
322
323 func (a *App) mutatePinnedFiles(tabID, relPath string, pin bool) (PinnedFileInfo, string, error) {
324 unlockRuntime := a.lockRuntimeMutation("pinned context")
325 defer unlockRuntime()
326 tab := a.tabByID(tabID)
327 if tab == nil {
328 return PinnedFileInfo{}, "", errors.New("tab not found")
329 }
330 tab.turnStartMu.Lock()
331 defer tab.turnStartMu.Unlock()
332
333 a.mu.RLock()
334 if a.tabs[tab.ID] != tab || tab.removed {
335 a.mu.RUnlock()
336 return PinnedFileInfo{}, "", errors.New("tab changed while updating pinned context")
337 }
338 root := tab.WorkspaceRoot
339 ctrl := tab.Ctrl
340 a.mu.RUnlock()
341 if ctrl == nil {
342 return PinnedFileInfo{}, "", a.workspaceNotReadyErr(tab)
343 }
344 if ctrl.RuntimeStatus().Running {
345 return PinnedFileInfo{}, "", control.ErrTurnRunning
346 }
347 sessionPath := ctrl.SessionPath()
348 state, err := loadPinnedContextState(sessionPath)
349 if err != nil {
350 return PinnedFileInfo{}, "", err
351 }
352 clean, err := normalizePinnedRelPath(relPath)
353 if err != nil {
354 return PinnedFileInfo{}, "", err
355 }
356 oldFiles := append([]string(nil), state.Files...)
357 candidate := append([]string(nil), oldFiles...)
358 alreadyPinned := false
359 if pin {
360 alreadyPinned = slices.Contains(candidate, clean)
361 if !alreadyPinned && len(candidate) >= maxPinnedFileCount {
362 return PinnedFileInfo{}, "", fmt.Errorf("at most %d files can be pinned", maxPinnedFileCount)
363 }
364 if !alreadyPinned {
365 candidate = append(candidate, clean)
366 }
367 } else {
368 next := make([]string, 0, len(candidate))
369 for _, path := range candidate {
370 if path != clean {
371 next = append(next, path)
372 }
373 }
374 candidate = next
375 }
376 candidate, err = normalizePinnedContextFiles(candidate)
377 if err != nil {
378 return PinnedFileInfo{}, "", err
379 }
380 build := buildPinnedContext(root, candidate)
381 info := PinnedFileInfo{Path: clean}
382 if pin {
383 var ok bool
384 info, ok = pinnedInfoForPath(build.Infos, clean)
385 if !ok {
386 return PinnedFileInfo{}, "", errors.New("pinned file could not be inspected")
387 }
388 if info.Error != "" && !alreadyPinned {
389 return PinnedFileInfo{}, "", errors.New(info.Error)
390 }
391 }
392 if candidateChanged := strings.Join(oldFiles, "\x00") != strings.Join(candidate, "\x00"); candidateChanged {
393 if err := savePinnedContextState(sessionPath, candidate); err != nil {
394 return PinnedFileInfo{}, "", err
395 }
396 }
397 tab.setPinnedFiles(candidate)
398 return info, tab.ID, nil
399 }
400
401 func (a *App) PinFileForTab(tabID, relPath string) (PinnedFileInfo, error) {
402 info, changedTabID, err := a.mutatePinnedFiles(tabID, relPath, true)
403 if err != nil {
404 return PinnedFileInfo{}, err
405 }
406 if changedTabID != "" {
407 a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: changedTabID, Meta: a.MetaForTab(changedTabID)})
408 }
409 return info, nil
410 }
411
412 func (a *App) UnpinFileForTab(tabID, relPath string) error {
413 _, changedTabID, err := a.mutatePinnedFiles(tabID, relPath, false)
414 if err != nil {
415 return err
416 }
417 if changedTabID != "" {
418 a.emitRuntimeEvent(tabMetaRefreshEventChannel, TabMetaRefreshEvent{TabID: changedTabID, Meta: a.MetaForTab(changedTabID)})
419 }
420 return nil
421 }
422
423 func (a *App) GetPinnedFilesForTab(tabID string) ([]PinnedFileInfo, error) {
424 tab := a.tabByID(tabID)
425 if tab == nil {
426 return []PinnedFileInfo{}, errors.New("tab not found")
427 }
428 a.mu.RLock()
429 if a.tabs[tab.ID] != tab || tab.removed {
430 a.mu.RUnlock()
431 return []PinnedFileInfo{}, errors.New("tab not found")
432 }
433 root := tab.WorkspaceRoot
434 ctrl := tab.Ctrl
435 a.mu.RUnlock()
436 if ctrl == nil {
437 return []PinnedFileInfo{}, a.workspaceNotReadyErr(tab)
438 }
439 state, err := loadPinnedContextState(ctrl.SessionPath())
440 if err != nil {
441 return []PinnedFileInfo{}, err
442 }
443 infos := buildPinnedContext(root, state.Files).Infos
444 if infos == nil {
445 infos = []PinnedFileInfo{}
446 }
447 return infos, nil
448 }
449
449 lines GO