From ab8a9e981292d872fe7317fe65282e97fbdd3474 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 8 Mar 2026 12:18:10 -0400 Subject: [PATCH] fix(penpal): only watch filesystem paths relevant to the focused tab Previously the watcher deep-watched all sources and comments directories for every discovered project at startup (~16K file descriptors across 114 projects), causing constant CPU activity from filesystem events and directory walks that drained battery even when idle. Now the watcher only deep-watches what the current tab needs: - FilePage: the single directory containing the viewed file - ProjectPage: the project's source and comment directories - WorkspacePage/Recent/InReview/Search: no deep watches (workspace roots are already watched for project discovery) The frontend calls POST /api/focus?project=X (project-level) or POST /api/focus?project=X&path=Y (file-level) on navigation, and DELETE /api/focus when moving to pages that don't need it. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/frontend/src/App.test.tsx | 1 + apps/penpal/frontend/src/api.ts | 8 + .../frontend/src/components/Layout.test.tsx | 1 + .../frontend/src/pages/FilePage.test.tsx | 2 + apps/penpal/frontend/src/pages/FilePage.tsx | 5 + .../frontend/src/pages/InReviewPage.test.tsx | 1 + .../frontend/src/pages/InReviewPage.tsx | 1 + .../frontend/src/pages/ProjectPage.test.tsx | 1 + .../penpal/frontend/src/pages/ProjectPage.tsx | 5 + .../frontend/src/pages/RecentPage.test.tsx | 1 + apps/penpal/frontend/src/pages/RecentPage.tsx | 1 + .../frontend/src/pages/SearchPage.test.tsx | 1 + apps/penpal/frontend/src/pages/SearchPage.tsx | 1 + .../frontend/src/pages/WorkspacePage.test.tsx | 1 + .../frontend/src/pages/WorkspacePage.tsx | 3 + apps/penpal/internal/server/api_focus_test.go | 57 ++++++ apps/penpal/internal/server/server.go | 32 ++++ apps/penpal/internal/watcher/watcher.go | 164 ++++++++++++----- apps/penpal/internal/watcher/watcher_test.go | 166 +++++++++++++++++- 19 files changed, 409 insertions(+), 43 deletions(-) create mode 100644 apps/penpal/internal/server/api_focus_test.go diff --git a/apps/penpal/frontend/src/App.test.tsx b/apps/penpal/frontend/src/App.test.tsx index a5412f769..7e54b96a6 100644 --- a/apps/penpal/frontend/src/App.test.tsx +++ b/apps/penpal/frontend/src/App.test.tsx @@ -10,6 +10,7 @@ vi.mock('./api', () => ({ listProjects: vi.fn().mockResolvedValue([]), getRecentFiles: vi.fn().mockResolvedValue([]), getInReview: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/apps/penpal/frontend/src/api.ts b/apps/penpal/frontend/src/api.ts index 6486a0d96..c9098c53b 100644 --- a/apps/penpal/frontend/src/api.ts +++ b/apps/penpal/frontend/src/api.ts @@ -147,6 +147,14 @@ export const api = { installTools: () => apiFetch('/api/install-tools', { method: 'POST' }), + // Focus (tells server what to deep-watch for the current view) + focusProject: (project: string) => + apiVoid(`/api/focus?project=${encodeURIComponent(project)}`, { method: 'POST' }), + focusFile: (project: string, path: string, worktree?: string) => + apiVoid(`/api/focus?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}${wtParam(worktree)}`, { method: 'POST' }), + clearFocus: () => + apiVoid('/api/focus', { method: 'DELETE' }), + // Misc open: (path: string) => apiFetch<{ url: string }>('/api/open', { method: 'POST', body: JSON.stringify({ path }) }), diff --git a/apps/penpal/frontend/src/components/Layout.test.tsx b/apps/penpal/frontend/src/components/Layout.test.tsx index 0e3299e4e..1f6f5932f 100644 --- a/apps/penpal/frontend/src/components/Layout.test.tsx +++ b/apps/penpal/frontend/src/components/Layout.test.tsx @@ -34,6 +34,7 @@ vi.mock('../api', () => ({ }, ]), getInReview: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/apps/penpal/frontend/src/pages/FilePage.test.tsx b/apps/penpal/frontend/src/pages/FilePage.test.tsx index 5539cb96b..ac3afd27d 100644 --- a/apps/penpal/frontend/src/pages/FilePage.test.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.test.tsx @@ -19,6 +19,8 @@ vi.mock('../api', () => ({ startAgent: vi.fn().mockResolvedValue({ running: true, project: 'ws/proj' }), getPublishState: vi.fn().mockResolvedValue({}), recordView: vi.fn().mockResolvedValue(undefined), + focusProject: vi.fn().mockResolvedValue(undefined), + focusFile: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/apps/penpal/frontend/src/pages/FilePage.tsx b/apps/penpal/frontend/src/pages/FilePage.tsx index bbd136efc..587f33158 100644 --- a/apps/penpal/frontend/src/pages/FilePage.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.tsx @@ -255,6 +255,11 @@ export default function FilePage() { }).catch(() => {}); }, [project, path, worktree]); + // Tell the server to watch just this file's directory + useEffect(() => { + if (project && path) api.focusFile(project, path, worktree || undefined).catch(() => {}); + }, [project, path, worktree]); + // Initial data load useEffect(() => { fetchContent(); diff --git a/apps/penpal/frontend/src/pages/InReviewPage.test.tsx b/apps/penpal/frontend/src/pages/InReviewPage.test.tsx index c891ddce1..25b2ab02b 100644 --- a/apps/penpal/frontend/src/pages/InReviewPage.test.tsx +++ b/apps/penpal/frontend/src/pages/InReviewPage.test.tsx @@ -24,6 +24,7 @@ vi.mock('../api', () => ({ }, ]), listProjects: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, API_BASE: 'http://localhost:8080', isDesktopApp: false, diff --git a/apps/penpal/frontend/src/pages/InReviewPage.tsx b/apps/penpal/frontend/src/pages/InReviewPage.tsx index d6ba11a67..953171913 100644 --- a/apps/penpal/frontend/src/pages/InReviewPage.tsx +++ b/apps/penpal/frontend/src/pages/InReviewPage.tsx @@ -29,6 +29,7 @@ export default function InReviewPage() { api.getInReview().then(setGroups).catch(() => {}); }, []); + useEffect(() => { api.clearFocus().catch(() => {}); }, []); useEffect(() => { refresh(); }, [refresh]); const debouncedRefresh = useCallback(() => debounce(refresh, 200)(), [refresh]); diff --git a/apps/penpal/frontend/src/pages/ProjectPage.test.tsx b/apps/penpal/frontend/src/pages/ProjectPage.test.tsx index 660df779e..955ee7b9a 100644 --- a/apps/penpal/frontend/src/pages/ProjectPage.test.tsx +++ b/apps/penpal/frontend/src/pages/ProjectPage.test.tsx @@ -24,6 +24,7 @@ vi.mock('../api', () => ({ getAgentStatus: vi.fn().mockResolvedValue({ running: false }), listProjects: vi.fn().mockResolvedValue([{ qualifiedName: 'ws/proj' }]), getInReview: vi.fn().mockResolvedValue([]), + focusProject: vi.fn().mockResolvedValue(undefined), }, API_BASE: 'http://localhost:8080', isDesktopApp: false, diff --git a/apps/penpal/frontend/src/pages/ProjectPage.tsx b/apps/penpal/frontend/src/pages/ProjectPage.tsx index 4c81a1d47..36776e0ef 100644 --- a/apps/penpal/frontend/src/pages/ProjectPage.tsx +++ b/apps/penpal/frontend/src/pages/ProjectPage.tsx @@ -81,6 +81,11 @@ export default function ProjectPage() { }).catch(() => {}); }, [qn]); + // Tell the server to deep-watch this project's files + useEffect(() => { + if (qn) api.focusProject(qn).catch(() => {}); + }, [qn]); + useEffect(() => { refreshFiles(); refreshReviews(); diff --git a/apps/penpal/frontend/src/pages/RecentPage.test.tsx b/apps/penpal/frontend/src/pages/RecentPage.test.tsx index f8493222e..20f3647d6 100644 --- a/apps/penpal/frontend/src/pages/RecentPage.test.tsx +++ b/apps/penpal/frontend/src/pages/RecentPage.test.tsx @@ -11,6 +11,7 @@ vi.mock('../api', () => ({ ]), getInReview: vi.fn().mockResolvedValue([]), listProjects: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, API_BASE: 'http://localhost:8080', isDesktopApp: false, diff --git a/apps/penpal/frontend/src/pages/RecentPage.tsx b/apps/penpal/frontend/src/pages/RecentPage.tsx index c6ba1d4ae..e179eb5bf 100644 --- a/apps/penpal/frontend/src/pages/RecentPage.tsx +++ b/apps/penpal/frontend/src/pages/RecentPage.tsx @@ -20,6 +20,7 @@ export default function RecentPage() { api.getRecentFiles().then(setFiles).catch(() => {}); }, []); + useEffect(() => { api.clearFocus().catch(() => {}); }, []); useEffect(() => { refresh(); }, [refresh]); const debouncedRefresh = useCallback(() => debounce(refresh, 200)(), [refresh]); diff --git a/apps/penpal/frontend/src/pages/SearchPage.test.tsx b/apps/penpal/frontend/src/pages/SearchPage.test.tsx index 09aa90d69..9d74de0a3 100644 --- a/apps/penpal/frontend/src/pages/SearchPage.test.tsx +++ b/apps/penpal/frontend/src/pages/SearchPage.test.tsx @@ -25,6 +25,7 @@ vi.mock('../api', () => ({ }), getInReview: vi.fn().mockResolvedValue([]), listProjects: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, API_BASE: 'http://localhost:8080', isDesktopApp: false, diff --git a/apps/penpal/frontend/src/pages/SearchPage.tsx b/apps/penpal/frontend/src/pages/SearchPage.tsx index a6d1bb766..d7b6360eb 100644 --- a/apps/penpal/frontend/src/pages/SearchPage.tsx +++ b/apps/penpal/frontend/src/pages/SearchPage.tsx @@ -19,6 +19,7 @@ export default function SearchPage() { .finally(() => setLoading(false)); }, [query]); + useEffect(() => { api.clearFocus().catch(() => {}); }, []); useEffect(() => { doSearch(); }, [doSearch]); const hasProjects = (results?.matchingProjects?.length ?? 0) > 0; diff --git a/apps/penpal/frontend/src/pages/WorkspacePage.test.tsx b/apps/penpal/frontend/src/pages/WorkspacePage.test.tsx index d1c96e7f8..49fed648a 100644 --- a/apps/penpal/frontend/src/pages/WorkspacePage.test.tsx +++ b/apps/penpal/frontend/src/pages/WorkspacePage.test.tsx @@ -34,6 +34,7 @@ vi.mock('../api', () => ({ ]), getProjectInfo: vi.fn().mockResolvedValue({ fileCount: 5, dirty: false, unpushedCommits: 0 }), getInReview: vi.fn().mockResolvedValue([]), + clearFocus: vi.fn().mockResolvedValue(undefined), }, API_BASE: 'http://localhost:8080', isDesktopApp: false, diff --git a/apps/penpal/frontend/src/pages/WorkspacePage.tsx b/apps/penpal/frontend/src/pages/WorkspacePage.tsx index ccf502db4..858a8ccf1 100644 --- a/apps/penpal/frontend/src/pages/WorkspacePage.tsx +++ b/apps/penpal/frontend/src/pages/WorkspacePage.tsx @@ -36,6 +36,9 @@ export default function WorkspacePage() { }).catch(() => {}); }, [name]); + // Workspace dirs are already watched; clear any deep project/file focus + useEffect(() => { api.clearFocus().catch(() => {}); }, []); + useEffect(() => { refreshProjects(); }, [refreshProjects]); diff --git a/apps/penpal/internal/server/api_focus_test.go b/apps/penpal/internal/server/api_focus_test.go new file mode 100644 index 000000000..457a80e57 --- /dev/null +++ b/apps/penpal/internal/server/api_focus_test.go @@ -0,0 +1,57 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAPIFocus_Project(t *testing.T) { + s, _, _ := testServer(t) + seedProject(s.cache, "ws/proj", t.TempDir(), nil) + + req := httptest.NewRequest(http.MethodPost, "/api/focus?project=ws/proj", nil) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIFocus_File(t *testing.T) { + s, _, _ := testServer(t) + seedProject(s.cache, "ws/proj", t.TempDir(), nil) + + req := httptest.NewRequest(http.MethodPost, "/api/focus?project=ws/proj&path=thoughts/plan.md", nil) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIFocus_Clear(t *testing.T) { + s, _, _ := testServer(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/focus", nil) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIFocus_MissingProject(t *testing.T) { + s, _, _ := testServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/focus", nil) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/apps/penpal/internal/server/server.go b/apps/penpal/internal/server/server.go index d0e511d87..6e738f8d8 100644 --- a/apps/penpal/internal/server/server.go +++ b/apps/penpal/internal/server/server.go @@ -284,6 +284,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/threads/", s.handleAPIThreadAction) s.mux.HandleFunc("/api/reviews", s.handleAPIListReviews) // Agent management endpoints + s.mux.HandleFunc("/api/focus", s.handleFocus) s.mux.HandleFunc("/api/agents", s.handleAgentStatus) s.mux.HandleFunc("/api/agents/start", s.handleAgentStart) s.mux.HandleFunc("/api/agents/stop", s.handleAgentStop) @@ -309,6 +310,37 @@ func (s *Server) routes() { } +// handleFocus tells the watcher what to deep-watch based on current view. +// +// POST /api/focus?project=X — watch project sources (ProjectPage) +// POST /api/focus?project=X&path=Y — watch file directory only (FilePage) +// DELETE /api/focus — clear all deep watches +func (s *Server) handleFocus(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + project := r.URL.Query().Get("project") + if project == "" { + http.Error(w, "missing project parameter", http.StatusBadRequest) + return + } + filePath := r.URL.Query().Get("path") + worktree := r.URL.Query().Get("worktree") + if filePath != "" { + s.watcher.FocusFile(project, filePath, worktree) + } else { + s.watcher.FocusProject(project) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + case http.MethodDelete: + s.watcher.ClearFocus() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func formatAge(t time.Time) string { d := time.Since(t) switch { diff --git a/apps/penpal/internal/watcher/watcher.go b/apps/penpal/internal/watcher/watcher.go index 76e15d654..47326e259 100644 --- a/apps/penpal/internal/watcher/watcher.go +++ b/apps/penpal/internal/watcher/watcher.go @@ -49,6 +49,11 @@ type Watcher struct { // Multi-workspace support workspacePaths []string discoverFn func() ([]discovery.Project, error) // called on workspace change + + // Focus tracking: only deep-watch what the user is looking at + focusMu sync.Mutex + focusKey string // dedup key for the current focus (e.g. "project:X" or "file:X/path") + focusWatched []string // paths added for the current focus (for cleanup) } // New creates a new watcher @@ -70,7 +75,9 @@ func New(c *cache.Cache, act *activity.Tracker) (*Watcher, error) { return w, nil } -// Start begins watching for changes across all workspaces and project sources. +// Start begins watching for changes across all workspaces. +// Only workspace directories are watched initially; individual projects +// are deep-watched on demand via FocusProject. func (w *Watcher) Start(workspacePaths []string, discoverFn func() ([]discovery.Project, error)) error { w.workspacePaths = workspacePaths w.discoverFn = discoverFn @@ -82,16 +89,18 @@ func (w *Watcher) Start(workspacePaths []string, discoverFn func() ([]discovery. } } - // Watch each project's sources and .penpal/comments directory + // Watch each project root (shallow — just for detecting new source dirs) for _, p := range w.cache.Projects() { - w.watchProject(p) + if err := w.watcher.Add(p.Path); err != nil { + log.Printf("Warning: could not watch project root %s: %v", p.Path, err) + } } go w.loop() return nil } -// Refresh updates workspace paths and watches any new projects. +// Refresh updates workspace paths and watches any new project roots (shallow). // Called after config changes (add/remove workspace or project). func (w *Watcher) Refresh(workspacePaths []string, projects []discovery.Project) { w.workspacePaths = workspacePaths @@ -101,50 +110,113 @@ func (w *Watcher) Refresh(workspacePaths []string, projects []discovery.Project) } } for _, p := range projects { - w.watchProject(p) + if err := w.watcher.Add(p.Path); err != nil { + log.Printf("Warning: could not watch project root %s: %v", p.Path, err) + } + } +} + +// FocusProject watches a project's sources and comments directories. +// Use this when the user is on a ProjectPage. +func (w *Watcher) FocusProject(name string) { + key := "project:" + name + w.focusMu.Lock() + defer w.focusMu.Unlock() + + if w.focusKey == key { + return + } + w.removeFocusWatches() + w.focusKey = key + + project := w.cache.FindProject(name) + if project == nil { + return } + + w.focusWatched = nil + w.watchProjectSources(*project) + log.Printf("Focus: watching project %s sources (%d dirs)", name, len(w.focusWatched)) } -// watchProject sets up file watches for all sources and comments of a project. -func (w *Watcher) watchProject(p discovery.Project) { - // Watch the project root directory itself so we can detect new - // auto-detectable source directories (e.g., thoughts/, .rp1/) at runtime. - if err := w.watcher.Add(p.Path); err != nil { - log.Printf("Warning: could not watch project root %s: %v", p.Path, err) +// FocusFile watches only the directory containing a specific file and its +// comments directory. Use this when the user is on a FilePage. +func (w *Watcher) FocusFile(projectName, filePath, worktree string) { + key := "file:" + projectName + "/" + worktree + "/" + filePath + w.focusMu.Lock() + defer w.focusMu.Unlock() + + if w.focusKey == key { + return + } + w.removeFocusWatches() + w.focusKey = key + + project := w.cache.FindProject(projectName) + if project == nil { + return + } + + w.focusWatched = nil + + // Determine the base path (main project or worktree) + basePath := project.Path + if worktree != "" { + for _, wt := range project.Worktrees { + if wt.Name == worktree { + basePath = wt.Path + break + } + } + } + + // Watch only the directory containing the file (for external edits). + // Comments are broadcast via the API — no fs watch needed. + absFile := filepath.Join(basePath, filePath) + fileDir := filepath.Dir(absFile) + if info, err := os.Stat(fileDir); err == nil && info.IsDir() { + if err := w.watcher.Add(fileDir); err == nil { + w.focusWatched = append(w.focusWatched, fileDir) + } } + log.Printf("Focus: watching file %s/%s (%d dirs)", projectName, filePath, len(w.focusWatched)) +} + +// ClearFocus removes all deep watches. +func (w *Watcher) ClearFocus() { + w.focusMu.Lock() + defer w.focusMu.Unlock() + w.removeFocusWatches() + w.focusKey = "" +} + +// watchProjectSources adds watches for all sources and comments of a project. +// Must be called with focusMu held. +func (w *Watcher) watchProjectSources(p discovery.Project) { for _, src := range p.Sources { if src.RootPath != "" { - if err := w.watchDir(src.RootPath); err != nil { - log.Printf("Warning: could not watch %s: %v", src.RootPath, err) - } + w.walkAndWatch(src.RootPath) } } commentsDir := filepath.Join(p.Path, ".penpal", "comments") if info, err := os.Stat(commentsDir); err == nil && info.IsDir() { - if err := w.watchDir(commentsDir); err != nil { - log.Printf("Warning: could not watch %s: %v", commentsDir, err) - } + w.walkAndWatch(commentsDir) } - // Watch worktree directories for source and comment changes for _, wt := range p.Worktrees { if wt.IsMain { continue } - // Watch worktree source directories (thoughts/, etc.) for _, st := range discovery.AllSourceTypes() { if st.AutoDetectDir == "" { continue } wtSourceDir := filepath.Join(wt.Path, st.AutoDetectDir) if info, err := os.Stat(wtSourceDir); err == nil && info.IsDir() { - if err := w.watchDir(wtSourceDir); err != nil { - log.Printf("Warning: could not watch worktree source %s: %v", wtSourceDir, err) - } + w.walkAndWatch(wtSourceDir) } } - // Watch remapped manual sources (sources with RootPath but no AutoDetectDir) for _, src := range p.Sources { if src.RootPath == "" { continue @@ -155,36 +227,42 @@ func (w *Watcher) watchProject(p discovery.Project) { } wtSourceDir := filepath.Join(wt.Path, rel) if info, err := os.Stat(wtSourceDir); err == nil && info.IsDir() { - if err := w.watchDir(wtSourceDir); err != nil { - log.Printf("Warning: could not watch worktree source %s: %v", wtSourceDir, err) - } + w.walkAndWatch(wtSourceDir) } } - // Watch worktree comments directory wtCommentsDir := filepath.Join(wt.Path, ".penpal", "comments") if info, err := os.Stat(wtCommentsDir); err == nil && info.IsDir() { - if err := w.watchDir(wtCommentsDir); err != nil { - log.Printf("Warning: could not watch worktree comments %s: %v", wtCommentsDir, err) - } + w.walkAndWatch(wtCommentsDir) } } } -// watchDir recursively watches a directory and its subdirectories -func (w *Watcher) watchDir(dir string) error { - return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { +// walkAndWatch recursively watches a directory, recording paths for later cleanup. +// Must be called with focusMu held. +func (w *Watcher) walkAndWatch(dir string) { + filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { - if err := w.watcher.Add(path); err != nil { - log.Printf("Warning: could not watch %s: %v", path, err) + if err := w.watcher.Add(path); err == nil { + w.focusWatched = append(w.focusWatched, path) } } return nil }) } +// removeFocusWatches removes all watches added by addFocusWatch. +// Must be called with focusMu held. +func (w *Watcher) removeFocusWatches() { + for _, path := range w.focusWatched { + w.watcher.Remove(path) + } + w.focusWatched = nil +} + + // Stop stops the watcher and closes all subscriber channels func (w *Watcher) Stop() { close(w.done) @@ -260,9 +338,9 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { projects, err := w.discoverFn() if err == nil { w.cache.RescanWith(projects) - // Watch any new project sources + // Shallow-watch new project roots for _, p := range projects { - w.watchProject(p) + w.watcher.Add(p.Path) } w.Broadcast(Event{Type: EventProjectsChanged}) } @@ -295,7 +373,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) { if err == nil { w.cache.RescanWith(projects) for _, proj := range projects { - w.watchProject(proj) + w.watcher.Add(proj.Path) } w.Broadcast(Event{Type: EventProjectsChanged}) } @@ -315,10 +393,16 @@ notAutoDetect: return } - // If a new directory was created, watch it + // If a new directory was created inside a focused project's sources, watch it if event.Op&fsnotify.Create != 0 { if info, err := os.Stat(path); err == nil && info.IsDir() { - w.watcher.Add(path) + w.focusMu.Lock() + if w.focusKey == "project:"+projectName { + if err := w.watcher.Add(path); err == nil { + w.focusWatched = append(w.focusWatched, path) + } + } + w.focusMu.Unlock() } } diff --git a/apps/penpal/internal/watcher/watcher_test.go b/apps/penpal/internal/watcher/watcher_test.go index 8bd71e345..790e30bd2 100644 --- a/apps/penpal/internal/watcher/watcher_test.go +++ b/apps/penpal/internal/watcher/watcher_test.go @@ -6,10 +6,11 @@ import ( "sort" "testing" + "github.com/loganj/penpal/internal/cache" "github.com/loganj/penpal/internal/discovery" ) -func TestWatchProjectRemappedManualSources(t *testing.T) { +func TestFocusProjectRemappedManualSources(t *testing.T) { // Create a temporary project directory with a manual source projectDir := t.TempDir() manualDir := filepath.Join(projectDir, "docs", "api") @@ -54,13 +55,16 @@ func TestWatchProjectRemappedManualSources(t *testing.T) { }, } - w, err := New(nil, nil) + c := cache.New() + c.SetProjects([]discovery.Project{project}) + + w, err := New(c, nil) if err != nil { t.Fatal(err) } defer w.Stop() - w.watchProject(project) + w.FocusProject(project.QualifiedName()) watched := w.watcher.WatchList() sort.Strings(watched) @@ -89,3 +93,159 @@ func TestWatchProjectRemappedManualSources(t *testing.T) { t.Errorf("expected worktree auto-detect dir %s to be watched, watched: %v", wtThoughtsDir, watched) } } + +func TestFocusProjectCleansUpOnSwitch(t *testing.T) { + projDir1 := t.TempDir() + thoughtsDir1 := filepath.Join(projDir1, "thoughts") + os.MkdirAll(thoughtsDir1, 0o755) + + projDir2 := t.TempDir() + thoughtsDir2 := filepath.Join(projDir2, "thoughts") + os.MkdirAll(thoughtsDir2, 0o755) + + proj1 := discovery.Project{ + Name: "proj1", Path: projDir1, + Sources: []discovery.FileSource{{ + Name: "thoughts", Type: "tree", SourceTypeName: "thoughts", + RootPath: thoughtsDir1, Auto: true, + }}, + } + proj2 := discovery.Project{ + Name: "proj2", Path: projDir2, + Sources: []discovery.FileSource{{ + Name: "thoughts", Type: "tree", SourceTypeName: "thoughts", + RootPath: thoughtsDir2, Auto: true, + }}, + } + + c := cache.New() + c.SetProjects([]discovery.Project{proj1, proj2}) + + w, err := New(c, nil) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Focus proj1 + w.FocusProject("proj1") + assertWatched(t, w, thoughtsDir1, true, "after focusing proj1") + + // Switch to proj2 — proj1 should be unwatched + w.FocusProject("proj2") + assertWatched(t, w, thoughtsDir1, false, "after focusing proj2") + assertWatched(t, w, thoughtsDir2, true, "after focusing proj2") +} + +func TestFocusFileWatchesOnlyFileDir(t *testing.T) { + projDir := t.TempDir() + thoughtsDir := filepath.Join(projDir, "thoughts") + plansDir := filepath.Join(projDir, "thoughts", "plans") + os.MkdirAll(plansDir, 0o755) + // Create a file so the dir exists + os.WriteFile(filepath.Join(plansDir, "design.md"), []byte("# Design"), 0o644) + + project := discovery.Project{ + Name: "proj", Path: projDir, + Sources: []discovery.FileSource{{ + Name: "thoughts", Type: "tree", SourceTypeName: "thoughts", + RootPath: thoughtsDir, Auto: true, + }}, + } + + c := cache.New() + c.SetProjects([]discovery.Project{project}) + + w, err := New(c, nil) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + w.FocusFile("proj", "thoughts/plans/design.md", "") + + // Only the plans/ dir should be watched, not the whole thoughts/ tree + assertWatched(t, w, plansDir, true, "file's parent dir") + assertWatched(t, w, thoughtsDir, false, "thoughts root (should not be watched)") +} + +func TestFocusFileSwitchToProject(t *testing.T) { + projDir := t.TempDir() + thoughtsDir := filepath.Join(projDir, "thoughts") + plansDir := filepath.Join(projDir, "thoughts", "plans") + os.MkdirAll(plansDir, 0o755) + + project := discovery.Project{ + Name: "proj", Path: projDir, + Sources: []discovery.FileSource{{ + Name: "thoughts", Type: "tree", SourceTypeName: "thoughts", + RootPath: thoughtsDir, Auto: true, + }}, + } + + c := cache.New() + c.SetProjects([]discovery.Project{project}) + + w, err := New(c, nil) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Start with file focus + w.FocusFile("proj", "thoughts/plans/design.md", "") + assertWatched(t, w, plansDir, true, "file focus") + + // Switch to project focus — should now watch all sources + w.FocusProject("proj") + assertWatched(t, w, thoughtsDir, true, "project focus includes root") + assertWatched(t, w, plansDir, true, "project focus includes subdirs") +} + +func TestClearFocusRemovesAllWatches(t *testing.T) { + projDir := t.TempDir() + thoughtsDir := filepath.Join(projDir, "thoughts") + os.MkdirAll(thoughtsDir, 0o755) + + project := discovery.Project{ + Name: "proj", Path: projDir, + Sources: []discovery.FileSource{{ + Name: "thoughts", Type: "tree", SourceTypeName: "thoughts", + RootPath: thoughtsDir, Auto: true, + }}, + } + + c := cache.New() + c.SetProjects([]discovery.Project{project}) + + w, err := New(c, nil) + if err != nil { + t.Fatal(err) + } + defer w.Stop() + + w.FocusProject("proj") + assertWatched(t, w, thoughtsDir, true, "before clear") + + w.ClearFocus() + assertWatched(t, w, thoughtsDir, false, "after clear") +} + +func assertWatched(t *testing.T, w *Watcher, dir string, expected bool, context string) { + t.Helper() + watched := w.watcher.WatchList() + found := false + for _, p := range watched { + if p == dir { + found = true + break + } + } + if found != expected { + if expected { + t.Errorf("%s: expected %s to be watched, but it wasn't. watched: %v", context, dir, watched) + } else { + t.Errorf("%s: expected %s to NOT be watched, but it was", context, dir) + } + } +}