返回 DeepSeek-Reasonix
attach_dropped_test.go
根目录 / desktop / attach_dropped_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 )
17
18 const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
19
20 func TestWorkspaceRelativeIn(t *testing.T) {
21 root := t.TempDir()
22
23 if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
24 t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
25 }
26 if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
27 t.Fatal("a path above the workspace must not resolve as in-tree")
28 }
29 }
30
31 func TestIsImageExt(t *testing.T) {
32 for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
33 if !isImageExt(p) {
34 t.Errorf("%q should be an image extension", p)
35 }
36 }
37 for _, p := range []string{"notes.pdf", "main.go", "noext"} {
38 if isImageExt(p) {
39 t.Errorf("%q should not be an image extension", p)
40 }
41 }
42 }
43
44 func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
45 orig, _ := os.Getwd()
46 defer os.Chdir(orig)
47
48 launchRoot := t.TempDir()
49 projectRoot := t.TempDir()
50 if err := os.Chdir(projectRoot); err != nil {
51 t.Fatal(err)
52 }
53 projectRoot, _ = os.Getwd()
54 if err := os.Chdir(launchRoot); err != nil {
55 t.Fatal(err)
56 }
57 app := &App{
58 tabs: map[string]*WorkspaceTab{
59 "project": {ID: "project", WorkspaceRoot: projectRoot},
60 },
61 activeTabID: "project",
62 }
63
64 got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
65 if err != nil {
66 t.Fatalf("SavePastedImage: %v", err)
67 }
68 if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
69 t.Fatalf("pasted image should be saved under active workspace: %v", err)
70 }
71 if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
72 t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
73 }
74 preview, err := app.AttachmentDataURL(got)
75 if err != nil {
76 t.Fatalf("AttachmentDataURL: %v", err)
77 }
78 if !strings.HasPrefix(preview, "data:image/png;base64,") {
79 t.Fatalf("preview = %q, want png data URL", preview)
80 }
81 }
82
83 func TestReadSessionAttachmentForTabRequiresCanonicalSession(t *testing.T) {
84 app := &App{
85 tabs: map[string]*WorkspaceTab{
86 "a": {ID: "a", WorkspaceRoot: t.TempDir(), SessionGeneration: 1},
87 },
88 activeTabID: "a",
89 }
90 if _, err := app.ReadSessionAttachmentForTab("a", strings.Repeat("ab", 32), 0); err == nil {
91 t.Fatal("ReadSessionAttachmentForTab accepted a tab without a canonical session")
92 }
93 }
94
95 func TestStageImageForTabRequiresBoundController(t *testing.T) {
96 app := &App{
97 tabs: map[string]*WorkspaceTab{
98 "a": {ID: "a", WorkspaceRoot: t.TempDir()},
99 },
100 activeTabID: "a",
101 }
102 if _, err := app.StageImageForTab("a", "op-1", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
103 t.Fatal("StageImageForTab accepted a tab without a session controller")
104 }
105 }
106
107 func TestLegacyAttachmentRPCRejectsMissingTarget(t *testing.T) {
108 t.Chdir(t.TempDir())
109 app := &App{tabs: map[string]*WorkspaceTab{}}
110 if _, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG); err == nil {
111 t.Fatal("legacy attachment RPC fell back to the process working directory")
112 }
113 if _, err := os.Stat(filepath.Join(".reasonix", "attachments")); !os.IsNotExist(err) {
114 t.Fatalf("missing target created a process-relative attachment directory: %v", err)
115 }
116 }
117
118 func TestGlobalAttachmentRPCUsesStableGlobalWorkspace(t *testing.T) {
119 isolateDesktopUserDirs(t)
120 launchRoot := t.TempDir()
121 t.Chdir(launchRoot)
122 app := &App{
123 tabs: map[string]*WorkspaceTab{"global": {ID: "global", Scope: "global"}},
124 activeTabID: "global",
125 }
126 rel, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
127 if err != nil {
128 t.Fatal(err)
129 }
130 if _, err := os.Stat(filepath.Join(globalWorkspaceRoot(), filepath.FromSlash(rel))); err != nil {
131 t.Fatalf("global attachment missing from stable global workspace: %v", err)
132 }
133 if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(rel))); !os.IsNotExist(err) {
134 t.Fatalf("global attachment was written under process cwd: %v", err)
135 }
136 }
137
138 func TestSavePastedImageForTabKeepsCapturedWorkspaceAcrossActiveSwitch(t *testing.T) {
139 launchRoot := t.TempDir()
140 projectA := t.TempDir()
141 projectB := t.TempDir()
142 t.Chdir(launchRoot)
143 app := &App{
144 tabs: map[string]*WorkspaceTab{
145 "a": {ID: "a", WorkspaceRoot: projectA},
146 "b": {ID: "b", WorkspaceRoot: projectB},
147 },
148 activeTabID: "a",
149 }
150 app.attachmentIOHook = func() {
151 app.mu.Lock()
152 app.activeTabID = "b"
153 app.mu.Unlock()
154 }
155
156 before, err := os.Getwd()
157 if err != nil {
158 t.Fatal(err)
159 }
160 got, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG)
161 if err != nil {
162 t.Fatalf("SavePastedImageForTab: %v", err)
163 }
164 after, err := os.Getwd()
165 if err != nil {
166 t.Fatal(err)
167 }
168 if before != after {
169 t.Fatalf("process cwd changed from %q to %q", before, after)
170 }
171 if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
172 t.Fatalf("captured workspace attachment missing: %v", err)
173 }
174 if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
175 t.Fatalf("attachment drifted to active workspace B: %v", err)
176 }
177 }
178
179 func TestAttachmentDataURLForTabIsolatesSameRelativePath(t *testing.T) {
180 projectA := t.TempDir()
181 projectB := t.TempDir()
182 rel := ".reasonix/attachments/shared.png"
183 rawA, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
184 if err != nil {
185 t.Fatal(err)
186 }
187 rawB := append([]byte(nil), rawA...)
188 rawB[len(rawB)-1] ^= 1
189 for root, raw := range map[string][]byte{projectA: rawA, projectB: rawB} {
190 path := filepath.Join(root, filepath.FromSlash(rel))
191 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
192 t.Fatal(err)
193 }
194 if err := os.WriteFile(path, raw, 0o644); err != nil {
195 t.Fatal(err)
196 }
197 }
198 app := &App{tabs: map[string]*WorkspaceTab{
199 "a": {ID: "a", WorkspaceRoot: projectA},
200 "b": {ID: "b", WorkspaceRoot: projectB},
201 }}
202
203 one, err := app.AttachmentDataURLForTab("a", rel)
204 if err != nil {
205 t.Fatal(err)
206 }
207 two, err := app.AttachmentDataURLForTab("b", rel)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if one == two {
212 t.Fatal("same relative attachment path resolved to the same workspace bytes")
213 }
214 }
215
216 func TestSavePastedImageForTabRejectsClosedTargetAndRemovesWrite(t *testing.T) {
217 projectRoot := t.TempDir()
218 tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot}
219 app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
220 var once sync.Once
221 app.attachmentIOHook = func() {
222 once.Do(func() {
223 app.mu.Lock()
224 delete(app.tabs, "a")
225 app.mu.Unlock()
226 })
227 }
228
229 if _, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG); err == nil {
230 t.Fatal("closed attachment target was accepted")
231 }
232 entries, err := os.ReadDir(filepath.Join(projectRoot, ".reasonix", "attachments"))
233 if err != nil {
234 t.Fatal(err)
235 }
236 if len(entries) != 0 {
237 t.Fatalf("rejected attachment write left files: %v", entries)
238 }
239 }
240
241 func TestAttachmentDataURLForTabRejectsReplacedRuntime(t *testing.T) {
242 projectRoot := t.TempDir()
243 rel, err := control.SaveImageBytesInRoot(projectRoot, "image/png", mustDecodeBase64(t, desktopTinyPNG))
244 if err != nil {
245 t.Fatal(err)
246 }
247 first := control.New(control.Options{WorkspaceRoot: projectRoot})
248 second := control.New(control.Options{WorkspaceRoot: projectRoot})
249 t.Cleanup(first.Close)
250 t.Cleanup(second.Close)
251 tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot, Ctrl: first}
252 app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
253 var once sync.Once
254 app.attachmentIOHook = func() {
255 once.Do(func() {
256 app.mu.Lock()
257 tab.Ctrl = second
258 app.mu.Unlock()
259 })
260 }
261
262 if _, err := app.AttachmentDataURLForTab("a", rel); err == nil {
263 t.Fatal("preview from replaced runtime was accepted")
264 }
265 }
266
267 func mustDecodeBase64(t *testing.T, value string) []byte {
268 t.Helper()
269 raw, err := base64.StdEncoding.DecodeString(value)
270 if err != nil {
271 t.Fatal(err)
272 }
273 return raw
274 }
275
276 func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
277 isolateDesktopUserDirs(t)
278 orig, _ := os.Getwd()
279 defer os.Chdir(orig)
280
281 launchRoot := t.TempDir()
282 projectA := t.TempDir()
283 projectB := t.TempDir()
284 if err := addProject(projectA, "Project A"); err != nil {
285 t.Fatalf("add project A: %v", err)
286 }
287 if err := addProject(projectB, "Project B"); err != nil {
288 t.Fatalf("add project B: %v", err)
289 }
290 sessionDirA := desktopSessionDir(projectA)
291 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
292 t.Fatalf("mkdir project A sessions: %v", err)
293 }
294 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
295 if err := os.Chdir(launchRoot); err != nil {
296 t.Fatal(err)
297 }
298
299 app := &App{
300 tabs: map[string]*WorkspaceTab{
301 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
302 },
303 activeTabID: "project",
304 }
305
306 got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
307 if err != nil {
308 t.Fatalf("SavePastedImage: %v", err)
309 }
310 if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
311 t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
312 }
313 if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
314 t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
315 }
316 if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
317 t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
318 }
319 }
320
321 func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
322 orig, _ := os.Getwd()
323 defer os.Chdir(orig)
324
325 launchRoot := t.TempDir()
326 projectRoot := t.TempDir()
327 if err := os.Chdir(projectRoot); err != nil {
328 t.Fatal(err)
329 }
330 projectRoot, _ = os.Getwd()
331 if err := os.Chdir(launchRoot); err != nil {
332 t.Fatal(err)
333 }
334 app := &App{
335 tabs: map[string]*WorkspaceTab{
336 "project": {ID: "project", WorkspaceRoot: projectRoot},
337 },
338 activeTabID: "project",
339 }
340 if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
341 t.Fatal(err)
342 }
343 target := filepath.Join(projectRoot, "sub", "notes.txt")
344 if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
345 t.Fatal(err)
346 }
347
348 got, err := app.AttachDropped(target)
349 if err != nil {
350 t.Fatalf("AttachDropped: %v", err)
351 }
352 if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
353 t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
354 }
355 }
356
357 func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
358 orig, _ := os.Getwd()
359 defer os.Chdir(orig)
360
361 launchRoot := t.TempDir()
362 projectRoot := t.TempDir()
363 if err := os.Chdir(launchRoot); err != nil {
364 t.Fatal(err)
365 }
366 app := &App{
367 tabs: map[string]*WorkspaceTab{
368 "project": {ID: "project", WorkspaceRoot: projectRoot},
369 },
370 activeTabID: "project",
371 }
372 raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
373 if err != nil {
374 t.Fatal(err)
375 }
376 outside := filepath.Join(t.TempDir(), "shot.png")
377 if err := os.WriteFile(outside, raw, 0o644); err != nil {
378 t.Fatal(err)
379 }
380
381 got, err := app.AttachDropped(outside)
382 if err != nil {
383 t.Fatalf("AttachDropped: %v", err)
384 }
385 if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
386 t.Fatalf("got %+v, want png attachment", got)
387 }
388 if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
389 t.Fatalf("dropped image should be saved under active workspace: %v", err)
390 }
391 if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
392 t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
393 }
394 if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
395 t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
396 }
397 }
398
399 func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
400 orig, _ := os.Getwd()
401 defer os.Chdir(orig)
402
403 root := t.TempDir()
404 if err := os.Chdir(root); err != nil {
405 t.Fatal(err)
406 }
407 cwd, _ := os.Getwd()
408 if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
409 t.Fatal(err)
410 }
411 target := filepath.Join(cwd, "sub", "notes.txt")
412 if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
413 t.Fatal(err)
414 }
415 app := &App{
416 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
417 activeTabID: "project",
418 }
419
420 got, err := app.AttachDropped(target)
421 if err != nil {
422 t.Fatalf("AttachDropped: %v", err)
423 }
424 if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
425 t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
426 }
427 }
428
429 func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
430 orig, _ := os.Getwd()
431 defer os.Chdir(orig)
432
433 outside := filepath.Join(t.TempDir(), "report.pdf")
434 if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
435 t.Fatal(err)
436 }
437
438 root := t.TempDir()
439 if err := os.Chdir(root); err != nil {
440 t.Fatal(err)
441 }
442 app := &App{
443 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root}},
444 activeTabID: "project",
445 }
446
447 got, err := app.AttachDropped(outside)
448 if err != nil {
449 t.Fatalf("AttachDropped: %v", err)
450 }
451 if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
452 t.Fatalf("got %+v, want copied pdf attachment", got)
453 }
454 }
455
456 func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
457 orig, _ := os.Getwd()
458 defer os.Chdir(orig)
459
460 root := t.TempDir()
461 if err := os.Chdir(root); err != nil {
462 t.Fatal(err)
463 }
464 cwd, _ := os.Getwd()
465 png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
466 if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
467 t.Fatal(err)
468 }
469 app := &App{
470 tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
471 activeTabID: "project",
472 }
473
474 got, err := app.AttachDropped(filepath.Join(cwd, "shot.png"))
475 if err != nil {
476 t.Fatalf("AttachDropped: %v", err)
477 }
478 if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
479 t.Fatalf("got %+v, want png attachment", got)
480 }
481 if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
482 t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
483 }
484 }
485
486 func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
487 orig, _ := os.Getwd()
488 defer os.Chdir(orig)
489
490 workspace := t.TempDir()
491 outside := filepath.Join(t.TempDir(), "Folder With Spaces")
492 if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
493 t.Fatal(err)
494 }
495 if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
496 t.Fatal(err)
497 }
498 expectedOutside := outside
499 if resolved, err := filepath.EvalSymlinks(outside); err == nil {
500 expectedOutside = resolved
501 }
502 expectedDisplayPath := filepath.ToSlash(expectedOutside)
503 if err := os.Chdir(workspace); err != nil {
504 t.Fatal(err)
505 }
506
507 ctrl := control.New(control.Options{WorkspaceRoot: workspace})
508 app := &App{
509 tabs: map[string]*WorkspaceTab{
510 "project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
511 },
512 activeTabID: "project",
513 }
514
515 got, err := app.AttachDropped(outside)
516 if err != nil {
517 t.Fatalf("AttachDropped: %v", err)
518 }
519 if got.Kind != "workspace" || !got.IsDir {
520 t.Fatalf("got %+v, want workspace directory ref", got)
521 }
522 if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
523 t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
524 }
525 if got.DisplayPath != expectedDisplayPath {
526 t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
527 }
528
529 block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
530 if len(errs) != 0 {
531 t.Fatalf("ResolveScopedRefs errors = %v", errs)
532 }
533 if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
534 !strings.Contains(block, "sub/") ||
535 !strings.Contains(block, "sub/notes.txt") {
536 t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
537 }
538 }
539
540 func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
541 isolateDesktopUserDirs(t)
542 setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
543 cfg := config.Default()
544 cfg.DefaultModel = "test/test-model"
545 cfg.Desktop.ProviderAccess = []string{"test"}
546 cfg.Providers = []config.ProviderEntry{
547 {Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
548 }
549 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
550 t.Fatalf("save config: %v", err)
551 }
552 orig, _ := os.Getwd()
553 defer os.Chdir(orig)
554
555 projectA := t.TempDir()
556 projectB := t.TempDir()
557 outside := filepath.Join(t.TempDir(), "External")
558 if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
559 t.Fatal(err)
560 }
561 if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
562 t.Fatal(err)
563 }
564 if err := addProject(projectA, "Project A"); err != nil {
565 t.Fatalf("add project A: %v", err)
566 }
567 if err := addProject(projectB, "Project B"); err != nil {
568 t.Fatalf("add project B: %v", err)
569 }
570 sessionDirA := desktopSessionDir(projectA)
571 sessionDirB := desktopSessionDir(projectB)
572 if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
573 t.Fatalf("mkdir project A sessions: %v", err)
574 }
575 if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
576 t.Fatalf("mkdir project B sessions: %v", err)
577 }
578 sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
579 sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
580 oldCtrl := control.New(control.Options{
581 SessionDir: sessionDirB,
582 SessionPath: sessionPathB,
583 WorkspaceRoot: projectB,
584 Sink: event.Discard,
585 })
586 app := NewApp()
587 app.readyHook = func() {}
588 tab := &WorkspaceTab{
589 ID: "project",
590 Scope: "project",
591 WorkspaceRoot: projectB,
592 TopicID: "topic_external_ref",
593 TopicTitle: "External ref",
594 SessionPath: sessionPathA,
595 Ready: true,
596 model: "test/test-model",
597 Ctrl: oldCtrl,
598 sink: &tabEventSink{tabID: "project", app: app},
599 disabledMCP: map[string]ServerView{},
600 }
601 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
602 app.tabOrder = []string{tab.ID}
603 app.activeTabID = tab.ID
604 t.Cleanup(func() {
605 if tab.Ctrl != nil {
606 tab.Ctrl.Close()
607 }
608 })
609
610 got, err := app.AttachDropped(outside)
611 if err != nil {
612 t.Fatalf("AttachDropped: %v", err)
613 }
614 if tab.Ctrl == oldCtrl {
615 t.Fatal("stale controller was reused for external folder ref")
616 }
617 if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
618 t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
619 }
620 resolver, ok := tab.Ctrl.(interface {
621 ResolveScopedRefs(context.Context, string) (string, []string)
622 })
623 if !ok {
624 t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
625 }
626 block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
627 if len(errs) != 0 {
628 t.Fatalf("ResolveScopedRefs errors = %v", errs)
629 }
630 if !strings.Contains(block, "sub/notes.txt") {
631 t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
632 }
633 }
634
634 lines GO