▶
diff --git a/apps/penpal/frontend/src/types.ts b/apps/penpal/frontend/src/types.ts
index f178bce08..09c0ac58e 100644
--- a/apps/penpal/frontend/src/types.ts
+++ b/apps/penpal/frontend/src/types.ts
@@ -29,6 +29,7 @@ export interface APIFile {
name: string;
title?: string;
path: string;
+ displayPath?: string;
dir?: string;
source?: string;
sourceType?: string;
@@ -52,6 +53,14 @@ export interface APIFileGroupView {
files: APIFile[];
}
+export interface APIFavoriteEntry {
+ id: string;
+ path: string;
+ kind: 'file' | 'tree';
+ label: string;
+ files: APIFile[];
+}
+
export interface SvgRect {
x: number;
y: number;
diff --git a/apps/penpal/internal/agents/manager.go b/apps/penpal/internal/agents/manager.go
index b5d46791f..50945a4cc 100644
--- a/apps/penpal/internal/agents/manager.go
+++ b/apps/penpal/internal/agents/manager.go
@@ -216,7 +216,7 @@ func (m *Manager) Stop(projectName string) error {
}
// AgentStatus contains the status of an agent for a project.
-// E-PENPAL-AGENT-STATUS: context/cost fields populated from NDJSON stdout parsing.
+// E-PENPAL-AGENT-STREAM: context/cost fields populated from NDJSON stdout parsing.
type AgentStatus struct {
Project string `json:"project"`
PID int `json:"pid"`
diff --git a/apps/penpal/internal/agents/prompt_test.go b/apps/penpal/internal/agents/prompt_test.go
index f9f29e815..e8272600c 100644
--- a/apps/penpal/internal/agents/prompt_test.go
+++ b/apps/penpal/internal/agents/prompt_test.go
@@ -27,7 +27,7 @@ func TestBuildPrompt(t *testing.T) {
t.Error("expected prompt to mention 10 consecutive timeouts exit condition")
}
- // E-PENPAL-INCORPORATE-ANSWERS: verify open questions handling guideline
+ // E-PENPAL-AGENT-PROMPT, P-PENPAL-INCORPORATE-ANSWERS: verify open questions handling guideline
if !strings.Contains(prompt, "incorporate the answer into the relevant section") {
t.Error("expected prompt to include open questions incorporation guideline")
}
diff --git a/apps/penpal/internal/cache/cache.go b/apps/penpal/internal/cache/cache.go
index 8c5c57260..9173689ac 100644
--- a/apps/penpal/internal/cache/cache.go
+++ b/apps/penpal/internal/cache/cache.go
@@ -377,7 +377,7 @@ func (c *Cache) RefreshProject(projectName string) {
return
}
- files := scanProjectSources(project)
+ files := filterManualFileInfos(project, scanProjectSources(project))
c.SetProjectFiles(projectName, files)
// Update project metadata
@@ -915,7 +915,7 @@ func (c *Cache) UpsertFile(projectName string, project *discovery.Project, absPa
}
title := extractTitle(absPath)
- resolved := ResolveFileInfo(project, absPath)
+ resolved := filterManualFileInfos(project, ResolveFileInfo(project, absPath))
// Acquire lock only for the short critical section that mutates the cache.
c.mu.Lock()
@@ -956,6 +956,26 @@ func (c *Cache) UpsertFile(projectName string, project *discovery.Project, absPa
return true
}
+func filterManualFileInfos(project *discovery.Project, files []FileInfo) []FileInfo {
+ manualSources := make(map[string]bool)
+ for _, source := range project.Sources {
+ if source.SourceTypeName == "manual" {
+ manualSources[source.Name] = true
+ }
+ }
+ if len(manualSources) == 0 {
+ return files
+ }
+ filtered := make([]FileInfo, 0, len(files))
+ for _, file := range files {
+ if manualSources[file.Source] {
+ continue
+ }
+ filtered = append(filtered, file)
+ }
+ return filtered
+}
+
// RemoveFile removes all cache entries with the given project-relative path.
// E-PENPAL-CACHE: incremental cache mutation without filesystem walk.
func (c *Cache) RemoveFile(projectName, fullPath string) bool {
diff --git a/apps/penpal/internal/server/api_favorites_test.go b/apps/penpal/internal/server/api_favorites_test.go
new file mode 100644
index 000000000..37ee16833
--- /dev/null
+++ b/apps/penpal/internal/server/api_favorites_test.go
@@ -0,0 +1,194 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/loganj/penpal/internal/cache"
+ "github.com/loganj/penpal/internal/config"
+ "github.com/loganj/penpal/internal/discovery"
+)
+
+// E-PENPAL-FAVORITES: verifies directory favorites populate from known markdown metadata even without __all_markdown__.
+func TestBuildFavoriteEntries_TreeFallsBackWithoutAllMarkdown(t *testing.T) {
+ projectPath := t.TempDir()
+ project := &discovery.Project{
+ Path: projectPath,
+ Sources: []discovery.FileSource{{
+ Name: "docs",
+ Type: "tree",
+ SourceTypeName: "manual",
+ RootPath: filepath.Join(projectPath, "docs"),
+ }},
+ }
+
+ favorites := buildFavoriteEntries(project, []cache.FileInfo{
+ {Source: "anchors", FullPath: "docs/guide.md", Name: "guide.md", Title: "Guide"},
+ {Source: "anchors", FullPath: "docs/proposals/idea.md", Name: "idea.md", Title: "Idea"},
+ })
+
+ if len(favorites) != 1 {
+ t.Fatalf("expected 1 favorite, got %d", len(favorites))
+ }
+ if favorites[0].Kind != "tree" || favorites[0].Path != "docs" {
+ t.Fatalf("expected docs tree favorite, got %+v", favorites[0])
+ }
+ if len(favorites[0].Files) != 2 {
+ t.Fatalf("expected docs tree to expose 2 files, got %+v", favorites[0].Files)
+ }
+ if favorites[0].Files[0].Path != "docs/guide.md" || favorites[0].Files[0].DisplayPath != "guide.md" {
+ t.Fatalf("expected first docs file to be guide.md, got %+v", favorites[0].Files[0])
+ }
+ if favorites[0].Files[1].Path != "docs/proposals/idea.md" || favorites[0].Files[1].DisplayPath != "proposals/idea.md" {
+ t.Fatalf("expected nested docs file to preserve subtree display path, got %+v", favorites[0].Files[1])
+ }
+}
+
+// P-PENPAL-FAVORITES, E-PENPAL-FAVORITES: verifies persisted favorites list separately from normal project sources.
+func TestAPIFavorites_ListExistingManualSources(t *testing.T) {
+ s, _, _ := testServer(t)
+
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# Guide"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ s.cfg.Projects = append(s.cfg.Projects, config.ProjectConfig{
+ Path: dir,
+ Sources: []config.SourceConfig{
+ {Type: "tree", Path: "docs"},
+ {Type: "files", Files: []string{"notes.md"}},
+ },
+ })
+ s.refreshAfterConfigChange()
+
+ projectName := filepath.Base(dir)
+ req := httptest.NewRequest(http.MethodGet, "/api/favorites?project="+url.QueryEscape(projectName), 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())
+ }
+
+ var favorites []APIFavoriteEntry
+ if err := json.Unmarshal(rec.Body.Bytes(), &favorites); err != nil {
+ t.Fatalf("parse favorites: %v", err)
+ }
+ if len(favorites) != 2 {
+ t.Fatalf("expected 2 favorites, got %d", len(favorites))
+ }
+ if favorites[0].Kind != "tree" || favorites[0].Path != "docs" {
+ t.Fatalf("expected first favorite to be docs tree, got %+v", favorites[0])
+ }
+ if len(favorites[0].Files) != 1 || favorites[0].Files[0].Path != "docs/guide.md" || favorites[0].Files[0].DisplayPath != "guide.md" {
+ t.Fatalf("expected docs tree to expose guide.md, got %+v", favorites[0].Files)
+ }
+ if favorites[1].Kind != "file" || favorites[1].Path != "notes.md" {
+ t.Fatalf("expected second favorite to be notes.md file, got %+v", favorites[1])
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/api/project/"+projectName, nil)
+ rec = httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("project files: expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var groups []APIFileGroupView
+ if err := json.Unmarshal(rec.Body.Bytes(), &groups); err != nil {
+ t.Fatalf("parse project groups: %v", err)
+ }
+ for _, group := range groups {
+ if group.Source == "docs" || group.Name == "docs" {
+ t.Fatalf("manual favorite leaked into project source groups: %+v", group)
+ }
+ }
+}
+
+// P-PENPAL-FAVORITES, P-PENPAL-FAVORITE-ACTIONS, E-PENPAL-FAVORITES: verifies add/remove favorites API round-trip.
+func TestAPIFavorites_AddAndRemove(t *testing.T) {
+ s, _, _ := testServer(t)
+
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "docs", "guide.md"), []byte("# Guide"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# Notes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ s.cfg.Projects = append(s.cfg.Projects, config.ProjectConfig{Path: dir})
+ s.refreshAfterConfigChange()
+
+ projectName := filepath.Base(dir)
+
+ addFavorite := func(path string) {
+ body, _ := json.Marshal(map[string]string{"project": projectName, "path": path})
+ req := httptest.NewRequest(http.MethodPost, "/api/favorites", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("add %s: expected 204, got %d: %s", path, rec.Code, rec.Body.String())
+ }
+ }
+ removeFavorite := func(path string) {
+ body, _ := json.Marshal(map[string]string{"project": projectName, "path": path})
+ req := httptest.NewRequest(http.MethodDelete, "/api/favorites", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("remove %s: expected 204, got %d: %s", path, rec.Code, rec.Body.String())
+ }
+ }
+ listFavorites := func() []APIFavoriteEntry {
+ req := httptest.NewRequest(http.MethodGet, "/api/favorites?project="+url.QueryEscape(projectName), nil)
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("list: expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var favorites []APIFavoriteEntry
+ if err := json.Unmarshal(rec.Body.Bytes(), &favorites); err != nil {
+ t.Fatalf("parse favorites: %v", err)
+ }
+ return favorites
+ }
+
+ addFavorite("docs")
+ addFavorite("notes.md")
+
+ favorites := listFavorites()
+ if len(favorites) != 2 {
+ t.Fatalf("expected 2 favorites after add, got %d", len(favorites))
+ }
+ if favorites[0].Kind != "tree" || favorites[0].Path != "docs" {
+ t.Fatalf("expected docs tree favorite first, got %+v", favorites[0])
+ }
+ if favorites[1].Kind != "file" || favorites[1].Path != "notes.md" {
+ t.Fatalf("expected notes.md file favorite second, got %+v", favorites[1])
+ }
+
+ removeFavorite("docs")
+ favorites = listFavorites()
+ if len(favorites) != 1 || favorites[0].Path != "notes.md" {
+ t.Fatalf("expected only notes.md favorite after removal, got %+v", favorites)
+ }
+}
diff --git a/apps/penpal/internal/server/api_manage_test.go b/apps/penpal/internal/server/api_manage_test.go
index 1be9d2b0e..cbf742d58 100644
--- a/apps/penpal/internal/server/api_manage_test.go
+++ b/apps/penpal/internal/server/api_manage_test.go
@@ -234,6 +234,28 @@ func TestAPIOpen_RejectsNonMarkdown(t *testing.T) {
}
}
+// E-PENPAL-FILE-HANDLER-PLIST: verifies Info.plist registers Penpal as an alternate markdown handler.
+func TestMacOSInfoPlist_FileHandlerRegistration(t *testing.T) {
+ plistPath := filepath.Join("..", "..", "frontend", "src-tauri", "Info.plist")
+ contents, err := os.ReadFile(plistPath)
+ if err != nil {
+ t.Fatalf("read Info.plist: %v", err)
+ }
+ plist := string(contents)
+ for _, fragment := range []string{
+ "CFBundleDocumentTypes",
+ "net.daringfireball.markdown",
+ "md",
+ "markdown",
+ "LSHandlerRank",
+ "Alternate",
+ } {
+ if !strings.Contains(plist, fragment) {
+ t.Fatalf("expected Info.plist to contain %q", fragment)
+ }
+ }
+}
+
// E-PENPAL-DELETE-FILE: verifies POST /api/delete-file removes file from disk.
func TestAPIDeleteFile_Success(t *testing.T) {
s, c, _ := testServer(t)
diff --git a/apps/penpal/internal/server/favorites.go b/apps/penpal/internal/server/favorites.go
new file mode 100644
index 000000000..83b3c9eb6
--- /dev/null
+++ b/apps/penpal/internal/server/favorites.go
@@ -0,0 +1,423 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/loganj/penpal/internal/activity"
+ "github.com/loganj/penpal/internal/cache"
+ "github.com/loganj/penpal/internal/config"
+ "github.com/loganj/penpal/internal/discovery"
+ "github.com/loganj/penpal/internal/watcher"
+)
+
+type APIFavoriteEntry struct {
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Kind string `json:"kind"`
+ Label string `json:"label"`
+ Files []APIFile `json:"files"`
+}
+
+func (s *Server) handleAPIFavorites(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.handleListFavorites(w, r)
+ case http.MethodPost:
+ s.handleAddFavorite(w, r)
+ case http.MethodDelete:
+ s.handleRemoveFavorite(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *Server) handleListFavorites(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ qualifiedName := r.URL.Query().Get("project")
+ if qualifiedName == "" {
+ http.Error(w, "project is required", http.StatusBadRequest)
+ return
+ }
+
+ project := s.cache.FindProject(qualifiedName)
+ if project == nil {
+ json.NewEncoder(w).Encode([]APIFavoriteEntry{})
+ return
+ }
+
+ worktree := r.URL.Query().Get("worktree")
+ cachedFiles := s.projectFilesForView(project, qualifiedName, worktree)
+ json.NewEncoder(w).Encode(buildFavoriteEntries(project, cachedFiles))
+}
+
+func (s *Server) handleAddFavorite(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Project string `json:"project"`
+ Path string `json:"path"`
+ Worktree string `json:"worktree"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if req.Project == "" {
+ http.Error(w, "project is required", http.StatusBadRequest)
+ return
+ }
+ if req.Path == "" {
+ http.Error(w, "path is required", http.StatusBadRequest)
+ return
+ }
+
+ req.Path = filepath.Clean(req.Path)
+ if filepath.IsAbs(req.Path) {
+ http.Error(w, "path must be relative to project root", http.StatusBadRequest)
+ return
+ }
+
+ project := s.cache.FindProject(req.Project)
+ if project == nil {
+ http.Error(w, "project not found", http.StatusNotFound)
+ return
+ }
+
+ basePath := project.Path
+ if req.Worktree != "" {
+ basePath = s.cache.WorktreePath(req.Project, req.Worktree)
+ if basePath == "" {
+ http.Error(w, "worktree not found", http.StatusBadRequest)
+ return
+ }
+ }
+
+ absPath := filepath.Join(basePath, req.Path)
+ resolved, err := filepath.Abs(absPath)
+ if err != nil || (resolved != filepath.Clean(basePath) && !isSubpath(basePath, resolved)) {
+ http.Error(w, "invalid path", http.StatusBadRequest)
+ return
+ }
+ info, err := os.Stat(resolved)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("path not found: %s", req.Path), http.StatusBadRequest)
+ return
+ }
+
+ s.cfgMu.Lock()
+ defer s.cfgMu.Unlock()
+
+ if info.IsDir() {
+ if projectHasFavorite(project, req.Path, "tree") {
+ http.Error(w, "favorite already exists", http.StatusConflict)
+ return
+ }
+ s.addSourceToConfig(project, config.SourceConfig{Type: "tree", Path: req.Path})
+ s.refreshAfterConfigChange()
+ s.watcher.Broadcast(watcher.Event{Type: watcher.EventFilesChanged, Project: req.Project})
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if !strings.HasSuffix(req.Path, ".md") {
+ http.Error(w, "only .md files can be favorited", http.StatusBadRequest)
+ return
+ }
+ if projectHasFavorite(project, req.Path, "file") {
+ http.Error(w, "favorite already exists", http.StatusConflict)
+ return
+ }
+ added := s.addFileToConfig(project, req.Path)
+ if !added {
+ s.addSourceToConfig(project, config.SourceConfig{Type: "files", Files: []string{req.Path}})
+ }
+ s.refreshAfterConfigChange()
+ s.watcher.Broadcast(watcher.Event{Type: watcher.EventFilesChanged, Project: req.Project})
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) handleRemoveFavorite(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Project string `json:"project"`
+ Path string `json:"path"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if req.Project == "" {
+ http.Error(w, "project is required", http.StatusBadRequest)
+ return
+ }
+ if req.Path == "" {
+ http.Error(w, "path is required", http.StatusBadRequest)
+ return
+ }
+
+ req.Path = filepath.Clean(req.Path)
+ project := s.cache.FindProject(req.Project)
+ if project == nil {
+ http.Error(w, "project not found", http.StatusNotFound)
+ return
+ }
+
+ s.cfgMu.Lock()
+ defer s.cfgMu.Unlock()
+
+ removed := s.removeFavoriteFromConfig(project, req.Path)
+ if !removed {
+ http.Error(w, "favorite not found", http.StatusNotFound)
+ return
+ }
+
+ s.refreshAfterConfigChange()
+ s.watcher.Broadcast(watcher.Event{Type: watcher.EventFilesChanged, Project: req.Project})
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) projectFilesForView(project *discovery.Project, qualifiedName, worktree string) []cache.FileInfo {
+ if worktree != "" {
+ wtPath := s.cache.WorktreePath(qualifiedName, worktree)
+ if wtPath == "" {
+ return nil
+ }
+ return cache.ScanProjectSourcesForWorktree(project, wtPath)
+ }
+ if s.cache.EnsureProjectScanned(qualifiedName) {
+ for _, f := range s.cache.ProjectFiles(qualifiedName) {
+ if f.Source != "__all_markdown__" {
+ s.activity.RecordAt(activity.FileModified, f.Project, f.FullPath, f.ModTime)
+ }
+ }
+ }
+ return s.cache.ProjectFiles(qualifiedName)
+}
+
+func buildFavoriteEntries(project *discovery.Project, cachedFiles []cache.FileInfo) []APIFavoriteEntry {
+ manualSources := make(map[string]bool)
+ for _, src := range project.Sources {
+ if src.SourceTypeName == "manual" {
+ manualSources[src.Name] = true
+ }
+ }
+
+ preferred := make(map[string]cache.FileInfo)
+ for _, f := range cachedFiles {
+ if existing, ok := preferred[f.FullPath]; !ok || preferredFavoriteFile(existing, f, manualSources) {
+ preferred[f.FullPath] = f
+ }
+ }
+
+ entries := make([]APIFavoriteEntry, 0)
+ seenEntries := make(map[string]bool)
+ for _, src := range project.Sources {
+ if src.SourceTypeName != "manual" {
+ continue
+ }
+ if src.Type == "tree" {
+ rootPath, err := filepath.Rel(project.Path, src.RootPath)
+ if err != nil {
+ continue
+ }
+ rootPath = filepath.Clean(rootPath)
+ if rootPath == "." {
+ rootPath = ""
+ }
+ entryID := "tree:" + rootPath
+ if seenEntries[entryID] {
+ continue
+ }
+ seenEntries[entryID] = true
+ files := favoriteTreeFiles(rootPath, preferred)
+ entries = append(entries, APIFavoriteEntry{
+ ID: entryID,
+ Path: rootPath,
+ Kind: "tree",
+ Label: favoriteLabel(rootPath),
+ Files: files,
+ })
+ continue
+ }
+ if src.Type != "files" {
+ continue
+ }
+ for _, absPath := range src.Files {
+ relPath, err := filepath.Rel(project.Path, absPath)
+ if err != nil {
+ continue
+ }
+ relPath = filepath.Clean(relPath)
+ entryID := "file:" + relPath
+ if seenEntries[entryID] {
+ continue
+ }
+ seenEntries[entryID] = true
+ meta, ok := preferred[relPath]
+ if !ok {
+ continue
+ }
+ entries = append(entries, APIFavoriteEntry{
+ ID: entryID,
+ Path: relPath,
+ Kind: "file",
+ Label: favoriteLabel(relPath),
+ Files: []APIFile{favoriteAPIFile(meta, relPath)},
+ })
+ }
+ }
+
+ return entries
+}
+
+func preferredFavoriteFile(existing cache.FileInfo, candidate cache.FileInfo, manualSources map[string]bool) bool {
+ return favoriteFilePriority(candidate, manualSources) > favoriteFilePriority(existing, manualSources)
+}
+
+func favoriteFilePriority(info cache.FileInfo, manualSources map[string]bool) int {
+ if manualSources[info.Source] {
+ return 0
+ }
+ if info.Source == "__all_markdown__" {
+ return 2
+ }
+ return 1
+}
+
+func favoriteTreeFiles(rootPath string, preferred map[string]cache.FileInfo) []APIFile {
+ paths := make([]string, 0)
+ prefix := rootPath + string(filepath.Separator)
+ for path := range preferred {
+ if rootPath == "" || path == rootPath || strings.HasPrefix(path, prefix) {
+ paths = append(paths, path)
+ }
+ }
+ sort.Strings(paths)
+
+ files := make([]APIFile, 0, len(paths))
+ for _, path := range paths {
+ meta := preferred[path]
+ displayPath := path
+ if rootPath != "" {
+ displayPath = strings.TrimPrefix(path, prefix)
+ }
+ files = append(files, favoriteAPIFile(meta, displayPath))
+ }
+ return files
+}
+
+func favoriteLabel(relPath string) string {
+ if relPath == "" {
+ return "."
+ }
+ return relPath
+}
+
+func favoriteAPIFile(info cache.FileInfo, displayPath string) APIFile {
+ return APIFile{
+ Name: info.Name,
+ Title: info.Title,
+ Path: info.FullPath,
+ DisplayPath: displayPath,
+ Source: info.Source,
+ SourceType: info.SourceType,
+ Age: formatAge(info.ModTime),
+ FileType: info.FileType,
+ }
+}
+
+func projectHasFavorite(project *discovery.Project, relPath, kind string) bool {
+ relPath = filepath.Clean(relPath)
+ for _, src := range project.Sources {
+ if src.SourceTypeName != "manual" {
+ continue
+ }
+ switch {
+ case kind == "tree" && src.Type == "tree":
+ rootPath, err := filepath.Rel(project.Path, src.RootPath)
+ if err == nil && filepath.Clean(rootPath) == relPath {
+ return true
+ }
+ case kind == "file" && src.Type == "files":
+ for _, absPath := range src.Files {
+ filePath, err := filepath.Rel(project.Path, absPath)
+ if err == nil && filepath.Clean(filePath) == relPath {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func (s *Server) removeFavoriteFromConfig(project *discovery.Project, relPath string) bool {
+ relPath = filepath.Clean(relPath)
+ if project.Origin == "standalone" {
+ for i, pc := range s.cfg.Projects {
+ if filepath.Clean(pc.Path) != filepath.Clean(project.Path) {
+ continue
+ }
+ filtered, removed := filterFavoriteConfigs(pc.Sources, relPath)
+ if removed {
+ s.cfg.Projects[i].Sources = filtered
+ }
+ return removed
+ }
+ return false
+ }
+
+ sources, ok := s.cfg.ProjectSources[project.Path]
+ if !ok {
+ return false
+ }
+ filtered, removed := filterFavoriteConfigs(sources, relPath)
+ if !removed {
+ return false
+ }
+ if len(filtered) == 0 {
+ delete(s.cfg.ProjectSources, project.Path)
+ } else {
+ s.cfg.ProjectSources[project.Path] = filtered
+ }
+ return true
+}
+
+func filterFavoriteConfigs(sources []config.SourceConfig, relPath string) ([]config.SourceConfig, bool) {
+ filtered := make([]config.SourceConfig, 0, len(sources))
+ removed := false
+ for _, src := range sources {
+ switch src.Type {
+ case "tree":
+ if filepath.Clean(src.Path) == relPath {
+ removed = true
+ continue
+ }
+ filtered = append(filtered, src)
+ case "files":
+ nextFiles := make([]string, 0, len(src.Files))
+ removedHere := false
+ for _, file := range src.Files {
+ if filepath.Clean(file) == relPath {
+ removed = true
+ removedHere = true
+ continue
+ }
+ nextFiles = append(nextFiles, file)
+ }
+ if removedHere {
+ if len(nextFiles) == 0 {
+ continue
+ }
+ src.Files = nextFiles
+ }
+ filtered = append(filtered, src)
+ default:
+ filtered = append(filtered, src)
+ }
+ }
+ return filtered, removed
+}
diff --git a/apps/penpal/internal/server/server.go b/apps/penpal/internal/server/server.go
index 87c2fca2a..6e22e28da 100644
--- a/apps/penpal/internal/server/server.go
+++ b/apps/penpal/internal/server/server.go
@@ -274,6 +274,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/delete-file", s.handleDeleteFile)
// Workspace and project management
s.mux.HandleFunc("/api/workspaces", s.handleAPIWorkspaces)
+ s.mux.HandleFunc("/api/favorites", s.handleAPIFavorites)
s.mux.HandleFunc("/api/sources", s.handleAPISources)
s.mux.HandleFunc("/api/open", s.handleAPIOpen)
s.mux.HandleFunc("/api/navigate", s.handleAPINavigate)
@@ -747,6 +748,7 @@ type APIFile struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Path string `json:"path"`
+ DisplayPath string `json:"displayPath,omitempty"`
Dir string `json:"dir,omitempty"`
Source string `json:"source,omitempty"`
SourceType string `json:"sourceType,omitempty"`
@@ -813,6 +815,22 @@ func (s *Server) handleAPIProjectFiles(w http.ResponseWriter, r *http.Request) {
cachedFiles = s.cache.ProjectFiles(qualifiedName)
}
fileGroups := buildFileGroups(project, cachedFiles)
+ manualSources := make(map[string]bool)
+ for _, src := range project.Sources {
+ if src.SourceTypeName == "manual" {
+ manualSources[src.Name] = true
+ }
+ }
+ if len(manualSources) > 0 {
+ filtered := make([]FileGroupView, 0, len(fileGroups))
+ for _, group := range fileGroups {
+ if manualSources[group.Source] {
+ continue
+ }
+ filtered = append(filtered, group)
+ }
+ fileGroups = filtered
+ }
result := make([]APIFileGroupView, 0, len(fileGroups))
for _, g := range fileGroups {
diff --git a/apps/penpal/internal/server/testhelper_test.go b/apps/penpal/internal/server/testhelper_test.go
index cedf1bec7..9b42bfae8 100644
--- a/apps/penpal/internal/server/testhelper_test.go
+++ b/apps/penpal/internal/server/testhelper_test.go
@@ -3,6 +3,7 @@ package server
import (
"net/http"
"net/http/httptest"
+ "path/filepath"
"strings"
"testing"
@@ -26,7 +27,8 @@ func testServer(t *testing.T) (*Server, *cache.Cache, *comments.Store) {
}
cs := comments.NewStore(c, act)
cfg := &config.Config{}
- s := New(c, w, cs, nil, nil, act, cfg, "")
+ cfgPath := filepath.Join(t.TempDir(), "config.json")
+ s := New(c, w, cs, nil, nil, act, cfg, cfgPath)
// Trigger ensureLoaded so it doesn't interfere with tests
s.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
return s, c, cs