diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index c9e8bedd6..0ff706c24 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -82,8 +82,8 @@ see-also: - **E-PENPAL-SRC-CLAUDE-PLANS**: The `claude-plans` source type classifies all files as `plan`. No custom grouping. Injected via `DiscoverClaudePlans()` which creates a synthetic standalone project or injects a tree source into an existing manually-added project. ← [P-PENPAL-SRC-CLAUDE-PLANS](PRODUCT.md#P-PENPAL-SRC-CLAUDE-PLANS), [P-PENPAL-CLAUDE-PLANS](PRODUCT.md#P-PENPAL-CLAUDE-PLANS) -- **E-PENPAL-SRC-MANUAL**: The `manual` source type is used for user-added sources (via `penpal open` CLI or the add-workspace/project modal). Directory sources create "tree" type entries; individual file sources create "files" type entries. `GroupFiles()` generates directory headings (`Dir`, `ShowDir` fields on `FileInfo`) for subdirectory boundaries. Configuration is persisted in `config.json` under `ProjectSources` (workspace projects) or inline with the `ProjectConfig` entry (standalone projects). - ← [P-PENPAL-CLI-OPEN](PRODUCT.md#P-PENPAL-CLI-OPEN), [P-PENPAL-STANDALONE](PRODUCT.md#P-PENPAL-STANDALONE) +- **E-PENPAL-SRC-MANUAL**: The `manual` source type is used for explicit user-added paths, including CLI/opened paths and persisted favorites. Directory sources create `tree` entries; individual file sources create `files` entries. `GroupFiles()` generates directory headings (`Dir`, `ShowDir` fields on `FileInfo`) for subdirectory boundaries. Configuration is persisted in `config.json` under `ProjectSources` (workspace projects) or inline with the `ProjectConfig` entry (standalone projects). When building the regular project source list, manual groups are filtered out so they can be surfaced separately through Favorites. + ← [P-PENPAL-CLI-OPEN](PRODUCT.md#P-PENPAL-CLI-OPEN), [P-PENPAL-STANDALONE](PRODUCT.md#P-PENPAL-STANDALONE), [P-PENPAL-FAVORITES](PRODUCT.md#P-PENPAL-FAVORITES) - **E-PENPAL-SRC-ALL-MD**: An "All Markdown" source is always present in every project. Registered as a `SourceType` with `GroupFiles` returning a single "All Markdown" group, `ShowDirHeadings: true`, and `SkipDirs` for `node_modules`, `.hg`, `.svn`. Always appended by `DetectSources()` after the detection loop — not conditionally detected. The `Auto` field is `true` — the frontend uses this to prevent removal. During scanning, `__all_markdown__` bypasses the per-source dedup (`seen` map) so it claims every `.md` file regardless of other sources. `AllFiles()` deduplicates by project+path, preferring typed-source entries over `__all_markdown__`, so the recent-files list and activity seeding see each file exactly once. The `handleAddSource` conflict check skips `__all_markdown__` so it doesn't block manual file additions. ← [P-PENPAL-SRC-ALL-MD](PRODUCT.md#P-PENPAL-SRC-ALL-MD) @@ -303,8 +303,11 @@ see-also: - **E-PENPAL-WORKTREE-DROPDOWN**: Rendered in `Layout.tsx` as a full-width `.worktree-selector-row` below the breadcrumb bar (outside the `.breadcrumb-bar` container) with `worktreeDropdownRef` for click-outside dismiss (via `mousedown` document listener). The `::after` pseudo-element renders a `▾` indicator. The `.worktree-dropdown-menu` is absolutely positioned. Each worktree button shows `active` class for the current selection; selecting a worktree navigates to `/project/{qn}@{worktreeName}`. Single-worktree projects render a static `.worktree-selector-row.deemphasized` div with "no worktrees" text (no ref, no click handler). State: `showWorktreeDropdown` boolean, toggled on click. ← [P-PENPAL-PROJECT-WORKTREE-DROPDOWN](PRODUCT.md#P-PENPAL-PROJECT-WORKTREE-DROPDOWN) -- **E-PENPAL-SOURCE-SECTIONS**: The project sidebar shows collapsible source sections populated from `projectFiles` state (via `api.getProjectFiles()`), a collapsible "In Review" section (from `projectReviews` via `api.getReviews()`), and a collapsible "Recent" section. Each source header shows its badge and file count. Virtual sources with zero files show alternate labels ("No Markdown Found", "Nothing in Review", "Nothing Recent") with `deemphasized` class and are not expandable. State: `expandedSources` `Set`. SSE `files` and `comments` events refresh the data. - ← [P-PENPAL-PROJECT-SOURCES](PRODUCT.md#P-PENPAL-PROJECT-SOURCES), [P-PENPAL-PROJECT-IN-REVIEW](PRODUCT.md#P-PENPAL-PROJECT-IN-REVIEW), [P-PENPAL-PROJECT-RECENT](PRODUCT.md#P-PENPAL-PROJECT-RECENT) +- **E-PENPAL-SOURCE-SECTIONS**: The project sidebar shows a collapsible Favorites section populated from `favorites` state (via `api.getFavorites()`), collapsible detected-source sections populated from `projectFiles` state (via `api.getProjectFiles()`), a collapsible "In Review" section (from `projectReviews` via `api.getReviews()`), and a collapsible "Recent" section. Each header shows its count. Empty sections render alternate labels ("No Favorites", "No Markdown Found", "Nothing in Review", "Nothing Recent") with `deemphasized` class and are not expandable. State: `expandedSources` `Set`. When favorites load as non-empty for a project/worktree, the frontend auto-expands the Favorites section once for that view. SSE `files` and `comments` events refresh the data. + ← [P-PENPAL-PROJECT-SOURCES](PRODUCT.md#P-PENPAL-PROJECT-SOURCES), [P-PENPAL-FAVORITES](PRODUCT.md#P-PENPAL-FAVORITES), [P-PENPAL-PROJECT-IN-REVIEW](PRODUCT.md#P-PENPAL-PROJECT-IN-REVIEW), [P-PENPAL-PROJECT-RECENT](PRODUCT.md#P-PENPAL-PROJECT-RECENT) + +- **E-PENPAL-FAVORITES**: `GET /api/favorites` returns `APIFavoriteEntry[]` for the active project/worktree by projecting persisted manual source config into either `kind: "file"` or `kind: "tree"` entries. `POST /api/favorites` validates a project-relative path; directories are stored as manual `tree` sources and markdown files as manual `files` sources. `DELETE /api/favorites` removes the matching manual source entry. Favorite tree population prefers richer metadata from non-manual entries (typed sources or `__all_markdown__`) when the same file is known through multiple sources, but falls back to any known markdown file under the subtree so populated favorites do not depend on an All Markdown row being visible. + ← [P-PENPAL-FAVORITES](PRODUCT.md#P-PENPAL-FAVORITES), [P-PENPAL-FAVORITE-ACTIONS](PRODUCT.md#P-PENPAL-FAVORITE-ACTIONS) - **E-PENPAL-FE-SRC-DISAMBIG**: When rendering source sections in the sidebar, the frontend computes which `badgeText` values appear on more than one group. For groups with duplicated badges, the group `name` (module path) is rendered as a `` to the right of the badge with `text-overflow: ellipsis`, deemphasized color (`var(--text-disabled)`), and a `title` attribute showing the full path. Groups with unique badges show no disambiguation text. ← [P-PENPAL-SRC-DISAMBIG](PRODUCT.md#P-PENPAL-SRC-DISAMBIG) @@ -318,14 +321,14 @@ see-also: - **E-PENPAL-PROJECT-WELCOME**: `ProjectPage.tsx` is a simple welcome screen. Extracts the project name from the URL path via `parseProjectWorktree()` and shows the project name with a hint to expand a source in the sidebar. Data-testid `project-page`. ← [P-PENPAL-PROJECT-BROWSE](PRODUCT.md#P-PENPAL-PROJECT-BROWSE) -- **E-PENPAL-SOURCE-ACTIONS**: Source section headers in the sidebar support right-click context menus with: "Copy relative paths" (joins `@` + path with newlines), "Copy absolute paths", "Publish" (parallel `api.publish()` calls), "Remove from Penpal" (only for non-auto sources; `group.auto` controls visibility), "Delete from disk". No "(auto)" badge is displayed. File tree items support right-click with: "Copy markdown", "Copy relative path", "Copy absolute path", "Publish", "Remove from Penpal" (files source only), "Delete from disk". - ← [P-PENPAL-SOURCE-ACTIONS](PRODUCT.md#P-PENPAL-SOURCE-ACTIONS), [P-PENPAL-FILE-ACTIONS](PRODUCT.md#P-PENPAL-FILE-ACTIONS) +- **E-PENPAL-SOURCE-ACTIONS**: Source section headers in the sidebar support right-click context menus with: "Copy relative paths" (joins `@` + path with newlines), "Copy absolute paths", "Publish" (parallel `api.publish()` calls), "Remove from Penpal" (only for non-auto sources; `group.auto` controls visibility), and "Delete from disk". No "(auto)" badge is displayed. File tree items support right-click with: "Copy markdown", "Copy relative path", "Copy absolute path", "Add to Favorites"/"Remove from Favorites", "Publish", "Remove from Penpal" (files source only), and "Delete from disk". Directory tree items support right-click with: "Copy relative path", "Copy absolute path", and "Add to Favorites"/"Remove from Favorites". No inline star affordance is rendered. + ← [P-PENPAL-SOURCE-ACTIONS](PRODUCT.md#P-PENPAL-SOURCE-ACTIONS), [P-PENPAL-FILE-ACTIONS](PRODUCT.md#P-PENPAL-FILE-ACTIONS), [P-PENPAL-FAVORITE-ACTIONS](PRODUCT.md#P-PENPAL-FAVORITE-ACTIONS) - **E-PENPAL-BATCH-OPS**: `Layout.tsx` maintains a `Set` selection state for the project sidebar. Shift-click on a file extends the selection from the last-clicked file to the shift-clicked file within the same source group. A floating selection bar at the bottom of the sidebar appears when `selected.size > 0` with actions: "Copy markdown" (`Promise.all` of `api.getRawFile()` joined with `\n\n---\n\n`), "Copy paths" (joins `@` + path), "Publish" (parallel), "Delete" (triggers delete confirmation modal), and "Clear". ← [P-PENPAL-BATCH-OPS](PRODUCT.md#P-PENPAL-BATCH-OPS) -- **E-PENPAL-CONTEXT-MENU**: A shared `ContextMenu` component renders an absolutely-positioned menu at mouse coordinates on `contextmenu` events. Items are `{ label, className?, onClick }`. The menu auto-dismisses on click-outside or item selection. Used for workspace items (remove), standalone projects (close), source headers (copy/publish/remove/delete), and file items (copy/publish/remove/delete). - ← [P-PENPAL-REMOVE-WORKSPACE](PRODUCT.md#P-PENPAL-REMOVE-WORKSPACE), [P-PENPAL-CLOSE-PROJECT](PRODUCT.md#P-PENPAL-CLOSE-PROJECT), [P-PENPAL-FILE-ACTIONS](PRODUCT.md#P-PENPAL-FILE-ACTIONS), [P-PENPAL-SOURCE-ACTIONS](PRODUCT.md#P-PENPAL-SOURCE-ACTIONS) +- **E-PENPAL-CONTEXT-MENU**: A shared `ContextMenu` component renders an absolutely-positioned menu at mouse coordinates on `contextmenu` events. Items are `{ label, className?, onClick }`. The menu auto-dismisses on click-outside or item selection. Used for workspace items (remove), standalone projects (close), source headers (copy/publish/remove/delete), file items (copy/favorite/publish/remove/delete), and directory items (copy/favorite). + ← [P-PENPAL-REMOVE-WORKSPACE](PRODUCT.md#P-PENPAL-REMOVE-WORKSPACE), [P-PENPAL-CLOSE-PROJECT](PRODUCT.md#P-PENPAL-CLOSE-PROJECT), [P-PENPAL-FILE-ACTIONS](PRODUCT.md#P-PENPAL-FILE-ACTIONS), [P-PENPAL-FAVORITE-ACTIONS](PRODUCT.md#P-PENPAL-FAVORITE-ACTIONS), [P-PENPAL-SOURCE-ACTIONS](PRODUCT.md#P-PENPAL-SOURCE-ACTIONS) - **E-PENPAL-NO-SELECT-CHROME**: CSS `user-select: none` is applied to all application chrome: `.sidebar`, `.tab-bar`, `.topbar`, `.breadcrumb-bar`, and context menus. Only `.main-content` (the markdown viewer area) allows text selection. ← [P-PENPAL-NO-SELECT-CHROME](PRODUCT.md#P-PENPAL-NO-SELECT-CHROME) diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md index b0fd5126d..06135ebe6 100644 --- a/apps/penpal/PRODUCT.md +++ b/apps/penpal/PRODUCT.md @@ -148,7 +148,9 @@ The project view shows the contents of a single project in the sidebar, organize - **P-PENPAL-PROJECT-WORKTREE-DROPDOWN**: When a project has multiple worktrees, a worktree selector row spans the full width of the sidebar below the breadcrumb bar. It shows the current worktree: "main repo" when viewing the main worktree, or the worktree icon and worktree name when viewing a non-main worktree. Content is left-aligned. Clicking the selector shows all available worktrees; each item shows its name with a worktree icon (non-main only), matching the home tree format. Selecting a worktree switches to that worktree's view. For single-worktree projects (no additional worktrees), the row shows dimmed static text "no worktrees" (no dropdown). -- **P-PENPAL-PROJECT-SOURCES**: The project sidebar shows each detected source type as a collapsible top-level section with its badge and file count. Sources appear in this order: typed sources (RPI, RP1, ANCHORS), then All Markdown, then In Review, then Recent. Each source expands to show its files in a tree view. Virtual sources with no files are shown with an alternate label and dimmed styling: "All Markdown" becomes "No Markdown Found", "In Review" becomes "Nothing in Review", "Recent" becomes "Nothing Recent". These empty virtual sources are not expandable. +- **P-PENPAL-PROJECT-SOURCES**: The project sidebar shows top-level collapsible sections with counts. Sections appear in this order: Favorites, typed sources (RPI, RP1, ANCHORS), All Markdown, In Review, then Recent. Each expandable section shows its files in a tree view. Empty sections are shown with alternate dimmed labels: "Favorites" becomes "No Favorites", "All Markdown" becomes "No Markdown Found", "In Review" becomes "Nothing in Review", and "Recent" becomes "Nothing Recent". Empty sections are not expandable. + +- **P-PENPAL-FAVORITES**: Each project view includes a collapsible Favorites section above detected sources. Favorites are user-curated rather than auto-detected. A favorite can target either a single markdown file or a directory. File favorites render as normal file rows. Directory favorites render as collapsible directory rows with a nested tree of markdown files under that subtree, using paths relative to the favorite root. The Favorites header count reflects the number of favorite entries; a directory favorite row also shows the number of files contained within that favorite. - **P-PENPAL-PROJECT-FILE-TREE**: Within each source section, files are organized in a tree view that mirrors the directory structure. Directories are collapsible. Single-child directory chains are compacted into one tree item with a combined path (e.g., `a/b/c/` instead of three nested levels), similar to IntelliJ's "compact middle packages" behavior. Files show their name (or H1 heading when available), type badge, and an "in review" badge when they have open threads. @@ -192,7 +194,9 @@ Global views aggregate content across all projects. They appear as top-level ite - **P-PENPAL-FILE-TYPES**: Files are classified by type (research, plan, knowledge, prd, design, task, etc.) based on their path within a source. Type badges appear next to the filename in the sidebar tree and file viewer. -- **P-PENPAL-FILE-ACTIONS**: Each file has a right-click context menu (in the sidebar tree or file viewer toolbar) with: copy markdown, copy relative path (with `@` prefix), copy absolute path, publish to Blockcell, remove from Penpal, and delete from disk. In the file viewer toolbar, "copy file" places the file on the clipboard as a file reference, so pasting in Finder or other apps inserts the file itself (macOS only). +- **P-PENPAL-FILE-ACTIONS**: Each file has a right-click context menu (in the sidebar tree or file viewer toolbar) with: copy markdown, copy relative path (with `@` prefix), copy absolute path, add to Favorites or remove from Favorites, publish to Blockcell, remove from Penpal when the row comes from an explicit file source, and delete from disk. In the file viewer toolbar, "copy file" places the file on the clipboard as a file reference, so pasting in Finder or other apps inserts the file itself (macOS only). + +- **P-PENPAL-FAVORITE-ACTIONS**: Add to Favorites / Remove from Favorites is available from right-click context menus on file rows across the project sidebar, including detected sources, the Favorites section itself, and the project In Review section. Directory rows in project trees also expose add/remove favorites from their context menu. No inline star buttons are shown next to file or directory rows. - **P-PENPAL-SOURCE-ACTIONS**: Each source section header in the sidebar has a right-click context menu with: copy relative paths, copy absolute paths, publish all files, remove source from Penpal (non-auto sources only), and delete from disk. diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md index 4ea13b939..bee14564c 100644 --- a/apps/penpal/TESTING.md +++ b/apps/penpal/TESTING.md @@ -63,13 +63,14 @@ see-also: | Source Types — rp1 (P-PENPAL-SRC-RP1, SRC-RP1-CLASSIFY, SRC-RP1-GROUP) | discovery_test.go (TestClassifyRP1File, TestGroupRP1Paths) | — | grouping_test.go (TestBuildFileGroups_RP1Grouped) | — | | Source Types — anchors (P-PENPAL-SRC-ANCHORS, SRC-ANCHORS-GROUP, SRC-ANCHORS-NESTED) | discovery_test.go (TestClassifyAnchorsFile, TestGroupAnchorsPaths, TestGroupAnchorsPaths_MarkerOnlyModule, TestAnchorsFileOrder, TestAnchorsRequireSibling) | — | — | — | | Source Types — claude-plans (P-PENPAL-SRC-CLAUDE-PLANS) | — | — | — | — | -| Source Types — manual (P-PENPAL-SRC-MANUAL) | — | — | grouping_test.go (TestBuildFileGroups_ManualSourceDirHeadings) | — | +| Source Types — manual (E-PENPAL-SRC-MANUAL) | — | — | grouping_test.go (TestBuildFileGroups_ManualSourceDirHeadings) | — | +| Favorites (P-PENPAL-FAVORITES, P-PENPAL-FAVORITE-ACTIONS, E-PENPAL-FAVORITES) | api_favorites_test.go (TestBuildFavoriteEntries_TreeFallsBackWithoutAllMarkdown) | — | api_favorites_test.go (TestAPIFavorites_ListExistingManualSources, TestAPIFavorites_AddAndRemove) | — | | Cache & File Scanning (E-PENPAL-CACHE, SCAN) | cache_test.go (TestCheckAllProjectsHasFiles, TestProjectHasAnyMarkdown_IgnoresGitignore, TestProjectHasAnyMarkdown_SkipsVCSDirs, TestAllFiles_DeduplicatesAllMarkdown, TestEnsureProjectScanned_NoDuplicateScans, TestResolveFileInfo, TestUpsertFile, TestRemoveFile, TestRescanWith_PreservesUnchangedProjects, TestSourcesChanged) | — | — | — | | Worktree Support (P-PENPAL-WORKTREE) | discovery/worktree_test.go, cache/worktree_test.go | Layout.test.tsx | worktree_test.go (API + MCP) | — | | Worktree Watch (E-PENPAL-WORKTREE-WATCH) | watcher_test.go | — | — | — | | Worktree Dropdown (P-PENPAL-PROJECT-WORKTREE-DROPDOWN) | — | Layout.test.tsx | — | — | | Git Integration (P-PENPAL-GIT-INFO) | — | — | — | — | -| File List & Grouping (P-PENPAL-FILE-LIST) | — | ProjectPage.test.tsx | grouping_test.go, integration_test.go | — | +| File List & Grouping (P-PENPAL-PROJECT-SOURCES, P-PENPAL-PROJECT-FILE-TREE) | — | ProjectPage.test.tsx | grouping_test.go, integration_test.go | — | | Markdown Rendering (P-PENPAL-GFM, MERMAID) | — | MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts | | Stable Components (E-PENPAL-MD-STABLE-COMPONENTS) | — | MarkdownViewer.test.tsx | — | — | | Text Selection & Anchors (P-PENPAL-SELECT-COMMENT, ANCHOR) | — | SelectionToolbar.test.tsx | — | review-workflow.spec.ts | @@ -87,12 +88,13 @@ see-also: | Wait for Changes (P-PENPAL-WAIT-CHANGES) | — | — | tools_test.go (TestWaitForChanges_Triggered) | — | | Agent Management (P-PENPAL-AGENT-LAUNCH, STATUS) | stream_test.go | FilePage.test.tsx (auto-start) | api_agents_test.go | — | | Agent Detection (P-PENPAL-AGENT-PRESENCE) | — | — | — | — | -| Review Workflow (P-PENPAL-IN-REVIEW) | — | InReviewPage.test.tsx | api_projects_test.go (TestAPIInReview) | — | +| Review Workflow (P-PENPAL-PROJECT-IN-REVIEW, P-PENPAL-GLOBAL-IN-REVIEW) | — | InReviewPage.test.tsx | api_projects_test.go (TestAPIInReview) | — | | Publishing (P-PENPAL-PUBLISH) | blockcell_test.go, render_test.go, state_test.go | — | — | — | | Tabs (P-PENPAL-TABS) | — | useTabs.test.ts, Layout.test.tsx | — | tab-navigation.spec.ts | -| Recent Files (P-PENPAL-RECENT, E-PENPAL-ACTIVITY-PERSIST) | activity_test.go | RecentPage.test.tsx | integration_test.go | — | +| Search (P-PENPAL-SEARCH) | — | SearchPage.test.tsx | — | react-app.spec.ts | +| Recent Files (P-PENPAL-PROJECT-RECENT, P-PENPAL-GLOBAL-RECENT, E-PENPAL-ACTIVITY-PERSIST) | activity_test.go | RecentPage.test.tsx | integration_test.go | — | | CLI Open (P-PENPAL-CLI-OPEN) | — | — | api_manage_test.go | cli-open.spec.ts | -| Source Management (P-PENPAL-ADD-SOURCE, REMOVE-SOURCE) | — | — | api_manage_test.go (TestAPISources_AddFileNotBlockedByAllMarkdown) | — | +| Manual Source Management (E-PENPAL-SRC-MANUAL, E-PENPAL-SRC-ALL-MD) | — | — | api_manage_test.go (TestAPISources_AddFileNotBlockedByAllMarkdown) | — | | Real-Time Updates (P-PENPAL-REALTIME, FOCUS) | watcher_test.go | useSSE.test.ts | api_focus_test.go | — | | Config & Migration (E-PENPAL-CONFIG) | config_test.go, migrate_test.go | — | — | — | | Install Tools (P-PENPAL-INSTALL) | — | InstallStartup.test.tsx, InstallToolsModal.test.tsx | install_test.go | — | @@ -101,7 +103,8 @@ see-also: | SPA Serving (E-PENPAL-SPA-SERVE) | — | — | spa_test.go | react-app.spec.ts | | Path Traversal (E-PENPAL-PATH-TRAVERSAL) | — | — | pathutil_test.go, spa_test.go | — | | View Tracking (E-PENPAL-ACTIVITY) | — | — | — | view-tracking.spec.ts | -| File Handler (P-PENPAL-FILE-HANDLER) | — | — | api_manage_test.go | — | +| Desktop Shell (E-PENPAL-TAURI) | — | src-tauri/src/lib.rs (ready probe request test) | — | — | +| File Handler (P-PENPAL-FILE-HANDLER, E-PENPAL-FILE-HANDLER-PLIST, E-PENPAL-FILE-HANDLER-EVENT) | api_manage_test.go (TestMacOSInfoPlist_FileHandlerRegistration) | src-tauri/src/lib.rs (open request test) | api_manage_test.go (TestAPIOpen_StandaloneMarkdownFile, TestAPIOpen_RejectsNonMarkdown) | — | | Source Disambiguation (P-PENPAL-SRC-DISAMBIG) | — | Layout.test.tsx | — | — | | Home Label (P-PENPAL-HOME-LABEL) | — | Layout.test.tsx | — | — | | Session Persistence — Tabs (P-PENPAL-PERSIST-TABS) | — | useTabs.test.ts | — | — | diff --git a/apps/penpal/frontend/src-tauri/src/lib.rs b/apps/penpal/frontend/src-tauri/src/lib.rs index 96f3c69c7..fca95d252 100644 --- a/apps/penpal/frontend/src-tauri/src/lib.rs +++ b/apps/penpal/frontend/src-tauri/src/lib.rs @@ -138,6 +138,20 @@ fn update_active_path(window: tauri::Window, path: String, geo: tauri::State<'_, } } +fn ready_probe_request(addr: &str) -> String { + format!("GET /api/ready HTTP/1.0\r\nHost: {}\r\n\r\n", addr) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn file_open_request(addr: &str, body: &str) -> String { + format!( + "POST /api/open HTTP/1.0\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + addr, + body.len(), + body + ) +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) @@ -192,7 +206,7 @@ pub fn run() { for _ in 0..300 { if let Ok(mut stream) = std::net::TcpStream::connect(&addr) { use std::io::{Read, Write}; - let req = format!("GET /api/ready HTTP/1.0\r\nHost: {}\r\n\r\n", addr); + let req = ready_probe_request(&addr); if stream.write_all(req.as_bytes()).is_ok() { // Set a generous timeout — initialization may take a while stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); @@ -379,10 +393,7 @@ pub fn run() { let body = serde_json::json!({"path": path_str}).to_string(); if let Ok(mut stream) = std::net::TcpStream::connect(&addr) { use std::io::Write; - let req = format!( - "POST /api/open HTTP/1.0\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", - addr, body.len(), body - ); + let req = file_open_request(&addr, &body); let _ = stream.write_all(req.as_bytes()); } } @@ -407,6 +418,30 @@ pub fn run() { }); } +#[cfg(test)] +mod tests { + use super::{file_open_request, ready_probe_request}; + + #[test] + fn ready_probe_request_targets_ready_endpoint() { + // E-PENPAL-TAURI: verifies desktop shell readiness probe targets /api/ready. + let req = ready_probe_request("127.0.0.1:8080"); + assert!(req.starts_with("GET /api/ready HTTP/1.0\r\n")); + assert!(req.contains("Host: 127.0.0.1:8080\r\n")); + } + + #[test] + fn open_request_targets_api_open_with_json_body() { + // E-PENPAL-FILE-HANDLER-EVENT: verifies desktop file-open dispatch targets /api/open. + let body = r#"{"path":"notes.md"}"#; + let req = file_open_request("127.0.0.1:8080", body); + assert!(req.starts_with("POST /api/open HTTP/1.0\r\n")); + assert!(req.contains("Content-Type: application/json\r\n")); + assert!(req.contains(&format!("Content-Length: {}\r\n", body.len()))); + assert!(req.ends_with(body)); + } +} + fn build_menu(app: &tauri::AppHandle) -> Result, tauri::Error> { let menu = Menu::new(app)?; diff --git a/apps/penpal/frontend/src/api.ts b/apps/penpal/frontend/src/api.ts index f24a77a1b..38b7ee3c8 100644 --- a/apps/penpal/frontend/src/api.ts +++ b/apps/penpal/frontend/src/api.ts @@ -2,6 +2,7 @@ import type { APIProject, APIFileGroupView, APIFile, + APIFavoriteEntry, ReviewGroup, ThreadResponse, ThreadWithFile, @@ -106,6 +107,8 @@ export const api = { // Project files getProjectFiles: (qn: string, worktree?: string) => apiFetch(`/api/project/${qn}${worktree ? '?worktree=' + encodeURIComponent(worktree) : ''}`), + getFavorites: (project: string, worktree?: string) => + apiFetch(`/api/favorites?project=${encodeURIComponent(project)}${worktree ? '&worktree=' + encodeURIComponent(worktree) : ''}`), getProjectInfo: (name: string) => apiFetch(`/api/project-info?name=${encodeURIComponent(name)}`), deleteProject: (project: string) => @@ -171,6 +174,10 @@ export const api = { apiVoid('/api/sources', { method: 'POST', body: JSON.stringify({ project, path, name }) }), removeSource: (project: string, name?: string, file?: string) => apiVoid('/api/sources', { method: 'DELETE', body: JSON.stringify({ project, name, file }) }), + addFavorite: (project: string, path: string, worktree?: string) => + apiVoid('/api/favorites', { method: 'POST', body: JSON.stringify({ project, path, worktree }) }), + removeFavorite: (project: string, path: string) => + apiVoid('/api/favorites', { method: 'DELETE', body: JSON.stringify({ project, path }) }), // Publish publish: (project: string, path: string) => diff --git a/apps/penpal/frontend/src/components/CommentsPanel.tsx b/apps/penpal/frontend/src/components/CommentsPanel.tsx index a48045b27..d355345b3 100644 --- a/apps/penpal/frontend/src/components/CommentsPanel.tsx +++ b/apps/penpal/frontend/src/components/CommentsPanel.tsx @@ -37,7 +37,7 @@ function CommentBody({ text }: { text: string }) { ); } -// E-PENPAL-AGENT-STATUS: color-coded agent context usage in the status bar. +// E-PENPAL-AGENT-STREAM: color-coded agent context usage in the status bar. function agentContextColorClass(pct: number): string { if (pct >= 85) return 'critical'; if (pct >= 60) return 'warning'; diff --git a/apps/penpal/frontend/src/components/Layout.close-tab.test.tsx b/apps/penpal/frontend/src/components/Layout.close-tab.test.tsx index f66bc8fc4..be1614e4b 100644 --- a/apps/penpal/frontend/src/components/Layout.close-tab.test.tsx +++ b/apps/penpal/frontend/src/components/Layout.close-tab.test.tsx @@ -56,6 +56,7 @@ vi.mock('../api', () => ({ }, ]), getInReview: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), clearFocus: vi.fn().mockResolvedValue(undefined), checkInstallStatus: vi.fn().mockResolvedValue({ cli: { installed: true }, plugin: { installed: true } }), }, diff --git a/apps/penpal/frontend/src/components/Layout.external-links.test.tsx b/apps/penpal/frontend/src/components/Layout.external-links.test.tsx index 94cfdfa2e..ffd5020de 100644 --- a/apps/penpal/frontend/src/components/Layout.external-links.test.tsx +++ b/apps/penpal/frontend/src/components/Layout.external-links.test.tsx @@ -42,6 +42,7 @@ vi.mock('../api', () => ({ }, ]), getInReview: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), getProjectFiles: vi.fn().mockResolvedValue([]), getReviews: vi.fn().mockResolvedValue([]), clearFocus: vi.fn().mockResolvedValue(undefined), diff --git a/apps/penpal/frontend/src/components/Layout.test.tsx b/apps/penpal/frontend/src/components/Layout.test.tsx index 5bdf479dd..4faaf2927 100644 --- a/apps/penpal/frontend/src/components/Layout.test.tsx +++ b/apps/penpal/frontend/src/components/Layout.test.tsx @@ -35,6 +35,7 @@ vi.mock('../api', () => ({ }, ]), getInReview: vi.fn().mockResolvedValue([]), + getFavorites: vi.fn().mockResolvedValue([]), getProjectFiles: vi.fn().mockResolvedValue([]), getReviews: vi.fn().mockResolvedValue([]), clearFocus: vi.fn().mockResolvedValue(undefined), diff --git a/apps/penpal/frontend/src/components/Layout.tsx b/apps/penpal/frontend/src/components/Layout.tsx index e192b6fcc..f1bf33927 100644 --- a/apps/penpal/frontend/src/components/Layout.tsx +++ b/apps/penpal/frontend/src/components/Layout.tsx @@ -13,7 +13,7 @@ import TabBar from './TabBar'; import HomeSidebar from './HomeSidebar'; import ProjectSidebar from './ProjectSidebar'; import type { Heading } from './TableOfContents'; -import type { APIProject, APIFileGroupView, APIFileInReview, SSEEvent } from '../types'; +import type { APIProject, APIFavoriteEntry, APIFileGroupView, APIFileInReview, SSEEvent } from '../types'; import { parseProjectWorktree } from '../utils/worktree'; import { useProjectSort } from '../hooks/useProjectSort'; @@ -144,11 +144,21 @@ export default function Layout() { // Project sidebar state const [projectFiles, setProjectFiles] = useState([]); + const [favorites, setFavorites] = useState([]); const [projectReviews, setProjectReviews] = useState>({}); const [expandedSources, setExpandedSources] = useState>(new Set()); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [showWorktreeDropdown, setShowWorktreeDropdown] = useState(false); const worktreeDropdownRef = useRef(null); + const favoritesAutoExpandedKey = useRef(''); + const favoriteFilePaths = useMemo( + () => new Set(favorites.filter(entry => entry.kind === 'file').map(entry => entry.path)), + [favorites], + ); + const favoriteDirPaths = useMemo( + () => new Set(favorites.filter(entry => entry.kind === 'tree').map(entry => entry.path)), + [favorites], + ); // Clear headings when navigating away from file pages useEffect(() => { @@ -166,6 +176,19 @@ export default function Layout() { }).catch(() => {}); }, []); + const refreshProjectView = useCallback((projectQN: string, worktree?: string) => { + api.getProjectFiles(projectQN, worktree).then(setProjectFiles).catch(() => setProjectFiles([])); + api.getFavorites(projectQN, worktree).then(setFavorites).catch(() => setFavorites([])); + }, []); + + const refreshProjectReviewState = useCallback((projectQN: string, worktree?: string) => { + api.getReviews(projectQN, worktree).then((reviews) => { + const map: Record = {}; + for (const review of reviews) map[review.filePath] = review; + setProjectReviews(map); + }).catch(() => setProjectReviews({})); + }, []); + const clearWindowFocusOnClose = useCallback( (options?: RequestInit) => api.clearFocus(options).catch(() => {}), [], @@ -289,18 +312,29 @@ export default function Layout() { useEffect(() => { if (!activeProject) { setProjectFiles([]); + setFavorites([]); setProjectReviews({}); + favoritesAutoExpandedKey.current = ''; return; } const qn = activeProject.qualifiedName; const wt = activeWorktree || undefined; - api.getProjectFiles(qn, wt).then(setProjectFiles).catch(() => setProjectFiles([])); - api.getReviews(qn, wt).then((reviews) => { - const map: Record = {}; - for (const r of reviews) map[r.filePath] = r; - setProjectReviews(map); - }).catch(() => setProjectReviews({})); - }, [activeProject?.qualifiedName, activeWorktree]); // eslint-disable-line react-hooks/exhaustive-deps + refreshProjectView(qn, wt); + refreshProjectReviewState(qn, wt); + }, [activeProject?.qualifiedName, activeWorktree, refreshProjectReviewState, refreshProjectView]); + + useEffect(() => { + if (!activeProject || favorites.length === 0) return; + const autoExpandKey = `${activeProject.qualifiedName}@${activeWorktree || '(main)'}`; + if (favoritesAutoExpandedKey.current === autoExpandKey) return; + favoritesAutoExpandedKey.current = autoExpandKey; + setExpandedSources(prev => { + if (prev.has('__favorites__')) return prev; + const next = new Set(prev); + next.add('__favorites__'); + return next; + }); + }, [activeProject, activeWorktree, favorites.length]); // Refresh project files on SSE file/comment events useSSE( @@ -309,18 +343,14 @@ export default function Layout() { if (!activeProject) return; if (event.type === 'files' && event.project === activeProject.qualifiedName) { const wt = activeWorktree || undefined; - api.getProjectFiles(activeProject.qualifiedName, wt).then(setProjectFiles).catch(() => {}); + refreshProjectView(activeProject.qualifiedName, wt); } if (event.type === 'comments' && event.project === activeProject.qualifiedName) { const wt = activeWorktree || undefined; - api.getReviews(activeProject.qualifiedName, wt).then((reviews) => { - const map: Record = {}; - for (const r of reviews) map[r.filePath] = r; - setProjectReviews(map); - }).catch(() => {}); + refreshProjectReviewState(activeProject.qualifiedName, wt); } }, - [activeProject?.qualifiedName, activeWorktree], + [activeProject?.qualifiedName, activeWorktree, refreshProjectReviewState, refreshProjectView], ), useCallback(() => {}, []), ); @@ -550,10 +580,29 @@ export default function Layout() { if (!qn) return; clearTimeout(refreshFilesTimer.current); refreshFilesTimer.current = setTimeout(() => { - api.getProjectFiles(qn, activeWorktree || undefined).then(setProjectFiles).catch(() => {}); + refreshProjectView(qn, activeWorktree || undefined); }, 200); } + function handleToggleFavorite(path: string, favorited: boolean) { + if (!qn) return; + const request = favorited + ? api.removeFavorite(qn, path) + : api.addFavorite(qn, path, activeWorktree || undefined); + request + .then(() => { + if (!favorited) { + setExpandedSources(prev => { + const next = new Set(prev); + next.add('__favorites__'); + return next; + }); + } + refreshProjectView(qn, activeWorktree || undefined); + }) + .catch((err) => alert(`Failed to ${favorited ? 'remove from' : 'add to'} Favorites: ${err.message}`)); + } + function showContextMenu(e: React.MouseEvent, items: ContextMenuItem[]) { e.preventDefault(); e.stopPropagation(); @@ -562,11 +611,17 @@ export default function Layout() { // File actions function fileContextMenu(e: React.MouseEvent, file: { path: string; sourceType?: string }, source: APIFileGroupView) { + const isFavorite = favoriteFilePaths.has(file.path); const items: ContextMenuItem[] = [ { label: 'Copy markdown', onClick: () => api.getRawFile(qn, file.path).then(t => navigator.clipboard.writeText(t)).catch(() => {}) }, { label: 'Copy relative path', onClick: () => navigator.clipboard.writeText('@' + file.path) }, { label: 'Copy absolute path', onClick: () => navigator.clipboard.writeText((activeProject?.projectPath || '') + '/' + file.path) }, { label: '---', onClick: () => {} }, + { + label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', + onClick: () => handleToggleFavorite(file.path, isFavorite), + }, + { label: '---', onClick: () => {} }, { label: 'Publish', onClick: () => api.publish(qn, file.path).then(d => navigator.clipboard.writeText(d.url)).catch(err => alert(err.message)) }, ]; if (source.sourceType === 'files') { @@ -578,6 +633,20 @@ export default function Layout() { showContextMenu(e, items); } + function directoryContextMenu(e: React.MouseEvent, dirPath: string) { + const isFavorite = favoriteDirPaths.has(dirPath); + const items: ContextMenuItem[] = [ + { label: 'Copy relative path', onClick: () => navigator.clipboard.writeText('@' + dirPath) }, + { label: 'Copy absolute path', onClick: () => navigator.clipboard.writeText((activeProject?.projectPath || '') + '/' + dirPath) }, + { label: '---', onClick: () => {} }, + { + label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', + onClick: () => handleToggleFavorite(dirPath, isFavorite), + }, + ]; + showContextMenu(e, items); + } + // Source actions function sourceContextMenu(e: React.MouseEvent, group: APIFileGroupView) { const items: ContextMenuItem[] = [ @@ -806,6 +875,7 @@ export default function Layout() { isFilePage={isFilePage} headings={headings} projectFiles={projectFiles} + favorites={favorites} projectReviews={projectReviews} expandedSources={expandedSources} expandedDirs={expandedDirs} @@ -818,6 +888,7 @@ export default function Layout() { onToggleDir={toggleDir} onFileClick={handleFileClick} onFileContextMenu={fileContextMenu} + onDirectoryContextMenu={directoryContextMenu} onSourceContextMenu={sourceContextMenu} /> ) : ( diff --git a/apps/penpal/frontend/src/components/ProjectSidebar.tsx b/apps/penpal/frontend/src/components/ProjectSidebar.tsx index e66c379a0..df3a7247b 100644 --- a/apps/penpal/frontend/src/components/ProjectSidebar.tsx +++ b/apps/penpal/frontend/src/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import type { APIProject, APIFileGroupView, APIFileInReview } from '../types'; +import type { APIFile, APIFavoriteEntry, APIProject, APIFileGroupView, APIFileInReview } from '../types'; import TableOfContents from './TableOfContents'; import type { Heading } from './TableOfContents'; @@ -10,6 +10,7 @@ export interface ProjectSidebarProps { isFilePage: boolean; headings: Heading[]; projectFiles: APIFileGroupView[]; + favorites: APIFavoriteEntry[]; projectReviews: Record; expandedSources: Set; expandedDirs: Set; @@ -22,74 +23,83 @@ export interface ProjectSidebarProps { onToggleDir: (key: string) => void; onFileClick: (e: React.MouseEvent, filePath: string, allFilePaths: string[]) => void; onFileContextMenu: (e: React.MouseEvent, file: { path: string; sourceType?: string }, source: APIFileGroupView) => void; + onDirectoryContextMenu: (e: React.MouseEvent, dirPath: string) => void; onSourceContextMenu: (e: React.MouseEvent, group: APIFileGroupView) => void; } -// Build a tree structure from flat file list, then compact single-child directory chains -function buildFileTree(files: { path: string; name: string; title?: string; fileType?: string; dir?: string }[]) { - interface TreeNode { - name: string; - path: string; - isDir: boolean; - children: TreeNode[]; - file?: typeof files[0]; - } +type SidebarFile = Pick; + +interface TreeNode { + name: string; + path: string; + actualPath?: string; + isDir: boolean; + children: TreeNode[]; + file?: SidebarFile; +} + +function buildFileTree(files: SidebarFile[]) { const root: TreeNode = { name: '', path: '', isDir: true, children: [] }; for (const file of files) { - const parts = file.path.split('/'); + const treePath = file.displayPath || file.path; + const parts = treePath.split('/').filter(Boolean); + if (parts.length === 0) continue; let node = root; for (let i = 0; i < parts.length - 1; i++) { const dirPath = parts.slice(0, i + 1).join('/'); - let child = node.children.find(c => c.isDir && c.path === dirPath); + let child = node.children.find(candidate => candidate.isDir && candidate.path === dirPath); if (!child) { child = { name: parts[i], path: dirPath, isDir: true, children: [] }; node.children.push(child); } node = child; } - node.children.push({ name: file.name, path: file.path, isDir: false, children: [], file }); + node.children.push({ + name: parts[parts.length - 1], + path: treePath, + actualPath: file.path, + isDir: false, + children: [], + file, + }); } - // Compact single-child directory chains: a/ -> b/ -> c/ becomes a/b/c/ function compact(node: TreeNode): TreeNode { node.children = node.children.map(compact); if (node.isDir && node.children.length === 1 && node.children[0].isDir) { const child = node.children[0]; - return { ...child, name: node.name + '/' + child.name }; + const name = node.name ? `${node.name}/${child.name}` : child.name; + return { ...child, name }; } return node; } return compact(root); } -// Flatten a file tree into visual (depth-first) order for shift-click ranges. -function flattenTree(node: ReturnType): string[] { +function flattenTree(node: TreeNode): string[] { const paths: string[] = []; for (const child of node.children) { if (child.isDir) { paths.push(...flattenTree(child)); } else { - paths.push(child.path); + paths.push(child.actualPath || child.path); } } return paths; } -// Build file URL for a project file function fileUrl(activeProject: APIProject, activeWorktree: string, file: { path: string }) { const base = `/file/${activeProject.qualifiedName}`; const wt = activeWorktree ? `@${activeWorktree}` : ''; return `${base}${wt}/${file.path}`; } -// E-PENPAL-PROJECT-RESOLVE, E-PENPAL-BREADCRUMB, E-PENPAL-WORKTREE-DROPDOWN, -// E-PENPAL-SOURCE-SECTIONS, E-PENPAL-FILE-TREE, E-PENPAL-FILE-TREE-ITEM: -// project view sidebar with breadcrumb, worktree dropdown, source file trees. export default function ProjectSidebar({ activeProject, activeWorktree, isFilePage, headings, projectFiles, + favorites, projectReviews, expandedSources, expandedDirs, @@ -102,58 +112,142 @@ export default function ProjectSidebar({ onToggleDir, onFileClick, onFileContextMenu, + onDirectoryContextMenu, onSourceContextMenu, }: ProjectSidebarProps) { const navigate = useNavigate(); + const favoriteGroup: APIFileGroupView = { + name: '__favorites__', + source: '__favorites__', + sourceType: 'favorites', + auto: false, + files: [], + }; + const reviewGroup: APIFileGroupView = { + name: '__in_review__', + source: '__in_review__', + sourceType: 'review', + auto: true, + files: [], + }; + const allFavoriteFilePaths = Array.from(new Set( + favorites.flatMap(entry => entry.kind === 'file' ? [entry.path] : entry.files.map(file => file.path)), + )); + + function handleFileRowClick(e: React.MouseEvent, path: string, allFilePaths: string[]) { + onFileClick(e, path, allFilePaths); + if (e.defaultPrevented) return; + navigate(fileUrl(activeProject, activeWorktree, { path })); + } + + function renderFileRow( + key: string, + label: string, + path: string, + allFilePaths: string[], + group: APIFileGroupView, + file?: SidebarFile, + ) { + const isActive = currentFilePath === path; + const inReview = !!projectReviews[path]; + const isSelected = selected.has(path); + return ( +
handleFileRowClick(e, path, allFilePaths)} + onContextMenu={(e) => onFileContextMenu(e, { path, sourceType: group.sourceType }, group)} + > + + {label} + {file?.fileType && file.fileType !== 'other' && {file.fileType}} + {inReview && in review} +
+ ); + } + function renderTreeNode( - node: ReturnType, + node: TreeNode, sourceKey: string, group: APIFileGroupView, allFilePaths: string[], + dirPathPrefix = '', ): ReactNode { return node.children.map(child => { if (child.isDir) { - const dirKey = `${sourceKey}:${child.path}`; + const dirPath = dirPathPrefix ? `${dirPathPrefix}/${child.path}` : child.path; + const dirKey = `${sourceKey}:${dirPath}`; const isDirExpanded = expandedDirs.has(dirKey); return (
-
onToggleDir(dirKey)}> +
onToggleDir(dirKey)} onContextMenu={(e) => onDirectoryContextMenu(e, dirPath)}> {child.name}/
{isDirExpanded && (
- {renderTreeNode(child, sourceKey, group, allFilePaths)} + {renderTreeNode(child, sourceKey, group, allFilePaths, dirPathPrefix)}
)}
); } - const url = fileUrl(activeProject, activeWorktree, child); - const isActive = currentFilePath === child.path; - const inReview = !!projectReviews[child.path]; - const isSelected = selected.has(child.path); - return ( - onFileClick(e, child.path, allFilePaths)} - onContextMenu={(e) => onFileContextMenu(e, { path: child.path, sourceType: group.sourceType }, group)} - > - - {child.file?.title || child.name} - {child.file?.fileType && child.file.fileType !== 'other' && {child.file.fileType}} - {inReview && in review} - + const path = child.actualPath || child.path; + return renderFileRow( + path, + child.file?.title || child.name, + path, + allFilePaths, + group, + child.file, ); }); } + function renderFavoriteEntry(entry: APIFavoriteEntry) { + if (entry.kind === 'file') { + return renderFileRow( + entry.id, + entry.label, + entry.path, + allFavoriteFilePaths, + favoriteGroup, + entry.files[0], + ); + } + + const dirKey = `__favorites__:${entry.path}`; + const isExpanded = expandedDirs.has(dirKey); + const tree = buildFileTree(entry.files); + const isEmpty = entry.files.length === 0; + + return ( +
+
onToggleDir(dirKey)} + onContextMenu={(e) => onDirectoryContextMenu(e, entry.path)} + > + {isEmpty ? ( + + ) : ( + + )} + {entry.label}/ + {entry.files.length} +
+ {isExpanded && !isEmpty && ( +
+ {renderTreeNode(tree, '__favorites__', favoriteGroup, allFavoriteFilePaths, entry.path)} +
+ )} +
+ ); + } + return ( <> - {/* Breadcrumb bar */}
/ @@ -162,11 +256,10 @@ export default function ProjectSidebar({ {activeProject.agentConnected && }
- {/* E-PENPAL-WORKTREE-DROPDOWN: full-width worktree selector row below breadcrumb */} {activeProject.worktrees && activeProject.worktrees.length > 1 ? (
onSetShowWorktreeDropdown(!showWorktreeDropdown)}> {(() => { - const wt = activeProject.worktrees!.find(wt => activeWorktree ? wt.name === activeWorktree : wt.isMain); + const wt = activeProject.worktrees!.find(candidate => activeWorktree ? candidate.name === activeWorktree : candidate.isMain); const isMain = !wt || wt.isMain; return isMain ? 'main repo' : ( <> @@ -214,71 +307,91 @@ export default function ProjectSidebar({ )} {isFilePage ? ( - /* File view: only show table of contents below breadcrumb */ headings.length > 0 ? : null ) : ( - /* Project view: show source file trees */ <> - {/* E-PENPAL-FE-SRC-DISAMBIG: compute badge texts that appear on multiple groups */} {(() => { - const badgeCounts = new Map(); - for (const g of projectFiles) { - if (g.badgeText) { - badgeCounts.set(g.badgeText, (badgeCounts.get(g.badgeText) || 0) + 1); - } - } - const duplicatedBadges = new Set(); - for (const [badge, count] of badgeCounts) { - if (count > 1) duplicatedBadges.add(badge); - } - return projectFiles.map((group) => { - const isExpanded = expandedSources.has(group.name); - const tree = isExpanded ? buildFileTree(group.files) : null; - const allFilePaths = tree ? flattenTree(tree) : []; - - const isEmpty = !group.files || group.files.length === 0; - const isVirtual = group.source === '__all_markdown__'; - const displayName = isEmpty && isVirtual ? 'No Markdown Found' : group.name; - + const favoritesExpanded = expandedSources.has('__favorites__'); + const favoritesEmpty = favorites.length === 0; return ( -
+
onToggleSource(group.name)} - onContextMenu={isEmpty ? undefined : (e) => onSourceContextMenu(e, group)} + className={`source-header${favoritesEmpty ? ' deemphasized' : ''}`} + onClick={favoritesEmpty ? undefined : () => onToggleSource('__favorites__')} > - {isEmpty ? ( + {favoritesEmpty ? ( ) : ( - + )} - {group.badgeText ? ( - - {group.badgeText} - - ) : ( - {displayName} - )} - {/* E-PENPAL-FE-SRC-DISAMBIG: show source path when badge is shared by multiple groups */} - {group.badgeText && duplicatedBadges.has(group.badgeText) && ( - {group.name} - )} - {!isEmpty && {group.files.length}} + {favoritesEmpty ? 'No Favorites' : 'Favorites'} + {!favoritesEmpty && {favorites.length}}
- {isExpanded && tree && ( + {favoritesExpanded && !favoritesEmpty && (
- {renderTreeNode(tree, group.name, group, allFilePaths)} + {favorites.map(renderFavoriteEntry)}
)}
); - }); })()} - {/* Per-project In Review section */} + {(() => { + const badgeCounts = new Map(); + for (const group of projectFiles) { + if (group.badgeText) { + badgeCounts.set(group.badgeText, (badgeCounts.get(group.badgeText) || 0) + 1); + } + } + const duplicatedBadges = new Set(); + for (const [badge, count] of badgeCounts) { + if (count > 1) duplicatedBadges.add(badge); + } + return projectFiles.map((group) => { + const isExpanded = expandedSources.has(group.name); + const tree = isExpanded ? buildFileTree(group.files) : null; + const allFilePaths = tree ? flattenTree(tree) : []; + const isEmpty = !group.files || group.files.length === 0; + const isVirtual = group.source === '__all_markdown__'; + const displayName = isEmpty && isVirtual ? 'No Markdown Found' : group.name; + + return ( +
+
onToggleSource(group.name)} + onContextMenu={isEmpty ? undefined : (e) => onSourceContextMenu(e, group)} + > + {isEmpty ? ( + + ) : ( + + )} + {group.badgeText ? ( + + {group.badgeText} + + ) : ( + {displayName} + )} + {group.badgeText && duplicatedBadges.has(group.badgeText) && ( + {group.name} + )} + {!isEmpty && {group.files.length}} +
+ {isExpanded && tree && ( +
+ {renderTreeNode(tree, group.name, group, allFilePaths)} +
+ )} +
+ ); + }); + })()} + {(() => { const reviewFiles = Object.keys(projectReviews); const isEmpty = reviewFiles.length === 0; @@ -304,7 +417,12 @@ export default function ProjectSidebar({ const name = filePath.split('/').pop() || filePath; const isActive = currentFilePath === filePath; return ( - + onFileContextMenu(e, { path: filePath, sourceType: reviewGroup.sourceType }, reviewGroup)} + > {name} @@ -316,7 +434,6 @@ export default function ProjectSidebar({ ); })()} - {/* Per-project Recent section -- currently always empty (TODO: fetch per-project recent files) */}
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