Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/penpal/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ see-also:
- <a id="E-PENPAL-CACHE"></a>**E-PENPAL-CACHE**: An in-memory cache (`sync.RWMutex`-protected) holds the full project list and per-project file lists. `RefreshProject()` walks the filesystem for full rescans; `RefreshAllProjects()` runs in parallel with a concurrency limit of 4. `RescanWith()` replaces the project list while preserving git enrichment and cached file data for unchanged projects — only new or source-changed projects are rescanned. Incremental mutations (`UpsertFile`, `RemoveFile`) update individual cache entries without walking the filesystem.
← [P-PENPAL-PROJECT-FILE-TREE](PRODUCT.md#P-PENPAL-PROJECT-FILE-TREE)

- <a id="E-PENPAL-SCAN"></a>**E-PENPAL-SCAN**: `scanProjectSources()` walks `RootPath` recursively for tree sources, skipping `.git`-file directories (nested worktrees), gitignored directories (via `git check-ignore`), source-type `SkipDirs`, and non-`.md` files. Gitignore checking is initialized once per scan via `newGitIgnoreChecker(projectPath)`, which detects whether the project is a git repo; non-git projects skip the gitignore check gracefully. On write or read failure (partial 4-field response), the checker disables itself (`isGitRepo=false`) to prevent permanent stream desync. The source's own `rootPath` is never checked against gitignore (the `path != rootPath` guard ensures registered sources always scan). Files returning `""` from `ClassifyFile()` are hidden. Files are de-duplicated by project-relative path (first source wins) and sorted by `ModTime` descending. `EnsureProjectScanned()` is the lazy-scan entry point — it uses write-lock gating (`projectScanned` set under `mu.Lock` before scanning) to prevent concurrent requests from triggering duplicate filesystem walks. `projectHasAnyMarkdown()` performs a cheap startup check that aligns with the full scan: it uses the same gitignore checking, skips `.git`, `node_modules`, `.hg`, `.svn`, and nested worktree directories, and stops at the first `.md` file found. `CheckAllProjectsHasFiles()` runs with a concurrency limit of 4 to cap subprocess spawning. `ResolveFileInfo()` resolves source membership for a single absolute path without spawning a git check-ignore process — it applies the same source-priority, SkipDirs, RequireSibling, and ClassifyFile rules as the full walk.
- <a id="E-PENPAL-SCAN"></a>**E-PENPAL-SCAN**: `scanProjectSources()` walks `RootPath` recursively for tree sources, skipping `.git`-file directories (nested worktrees), gitignored directories (via a pure-Go `gitignore.Matcher`), source-type `SkipDirs`, and non-`.md` files. Gitignore matching is initialized once per scan via `newGitIgnoreMatcher(projectPath)`, which parses `.gitignore` files, `.git/info/exclude`, and the global gitignore in-process — no subprocesses are spawned. Non-git projects return a nil matcher that never reports paths as ignored. The source's own `rootPath` is never checked against gitignore (the `path != rootPath` guard ensures registered sources always scan). Files returning `""` from `ClassifyFile()` are hidden. Files are de-duplicated by project-relative path (first source wins) and sorted by `ModTime` descending. `EnsureProjectScanned()` is the lazy-scan entry point — it uses write-lock gating (`projectScanned` set under `mu.Lock` before scanning) to prevent concurrent requests from triggering duplicate filesystem walks. `projectHasAnyMarkdown()` performs a cheap startup check: it skips `.git`, `node_modules`, `.hg`, `.svn`, and nested worktree directories, and stops at the first `.md` file found — it does not use gitignore matching (false positives are harmless since the full scan applies proper filtering). `CheckAllProjectsHasFiles()` runs with a concurrency limit of 4. `ResolveFileInfo()` resolves source membership for a single absolute path using the same pure-Go gitignore matcher — it applies the same source-priority, SkipDirs, RequireSibling, and ClassifyFile rules as the full walk, and checks ancestor directories against gitignore without spawning subprocesses.
← [P-PENPAL-PROJECT-FILE-TREE](PRODUCT.md#P-PENPAL-PROJECT-FILE-TREE), [P-PENPAL-FILE-TYPES](PRODUCT.md#P-PENPAL-FILE-TYPES), [P-PENPAL-SRC-DEDUP](PRODUCT.md#P-PENPAL-SRC-DEDUP), [P-PENPAL-SRC-GITIGNORE](PRODUCT.md#P-PENPAL-SRC-GITIGNORE)

- <a id="E-PENPAL-TITLE-EXTRACT"></a>**E-PENPAL-TITLE-EXTRACT**: `EnrichTitles()` reads the first 20 lines of each file to extract H1 headings. Titles are cached and shown as the primary display name when present.
Expand Down
2 changes: 1 addition & 1 deletion apps/penpal/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ see-also:
| Source Types — claude-plans (P-PENPAL-SRC-CLAUDE-PLANS) | — | — | — | — |
| 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) | — | — | — |
| Cache & File Scanning (E-PENPAL-CACHE, SCAN) | cache_test.go (TestCheckAllProjectsHasFiles, TestProjectHasAnyMarkdown_IgnoresGitignore, TestProjectHasAnyMarkdown_SkipsVCSDirs, TestAllFiles_DeduplicatesAllMarkdown, TestEnsureProjectScanned_NoDuplicateScans, TestResolveFileInfo, TestResolveFileInfo_SkipsGitignored, TestUpsertFile, TestRemoveFile, TestRescanWith_PreservesUnchangedProjects, TestSourcesChanged), gitignore_test.go (TestParseLine, TestGlobMatch, TestIsIgnoredDir_BasicPatterns, TestIsIgnoredDir_WildcardPatterns, TestIsIgnoredDir_Negation, TestIsIgnoredDir_DoubleStarPattern, TestIsIgnoredDir_NestedGitignore, TestIsIgnoredDir_AnchoredPattern, TestIsIgnoredDir_GitInfoExclude, TestIsIgnoredDir_Caching, TestIsIgnoredDir_PatternWithoutTrailingSlash) | — | — | — |
| 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 | — | — |
Expand Down
2 changes: 0 additions & 2 deletions apps/penpal/cmd/penpal-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ func runServe(port int, rootOverride string) {
saveTimer.Stop()
}
saveTimer = time.AfterFunc(5*time.Second, func() {
saveMu.Lock()
defer saveMu.Unlock()
if err := act.Save(activityPath); err != nil {
Comment on lines 70 to 71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize debounced activity saves

The debounce AfterFunc now calls act.Save without taking saveMu, while shutdown still performs a locked save, so two saves can run concurrently against the same activityPath. Because Tracker.Save writes to a fixed temp file (activity.json.tmp) before rename, concurrent writers can race and produce rename errors (no such file or directory), which can drop the final persisted activity state during shutdown or heavy event bursts. Please reintroduce save serialization for the timer callback (or otherwise make saves mutually exclusive).

Useful? React with 👍 / 👎.

log.Printf("Warning: could not save activity: %v", err)
}
Expand Down
58 changes: 29 additions & 29 deletions apps/penpal/frontend/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ struct SessionState {
/// In-memory geometry registry, updated on move/resize events.
struct GeoRegistry(Mutex<HashMap<String, WindowGeometry>>);

// E-PENPAL-GEO-TRACK: ensure a geometry entry exists for the given window label,
// inserting a new one from the current window state if absent.
fn ensure_geo_entry<'a>(
map: &'a mut HashMap<String, WindowGeometry>,
label: &str,
app: &tauri::AppHandle,
) -> Option<&'a mut WindowGeometry> {
if !map.contains_key(label) {
let win = app.get_webview_window(label)?;
let pos = win.outer_position().unwrap_or(tauri::PhysicalPosition { x: 0, y: 0 });
let size = win.outer_size().unwrap_or(tauri::PhysicalSize { width: 1200, height: 800 });
map.insert(label.to_string(), WindowGeometry {
label: label.to_string(),
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
active_path: String::new(),
});
}
map.get_mut(label)
}

fn session_file_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
std::path::Path::new(&home).join(".config/penpal/window-state.json")
Expand Down Expand Up @@ -300,37 +323,17 @@ pub fn run() {
match win_event {
tauri::WindowEvent::Moved(pos) => {
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
if let Some(entry) = map.get_mut(label) {
if let Some(entry) = ensure_geo_entry(&mut map, label, app_handle) {
entry.x = pos.x;
entry.y = pos.y;
} else if let Some(win) = app_handle.get_webview_window(label) {
let size = win.outer_size().unwrap_or(tauri::PhysicalSize { width: 1200, height: 800 });
map.insert(label.to_string(), WindowGeometry {
label: label.to_string(),
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
active_path: String::new(),
});
}
}
}
tauri::WindowEvent::Resized(size) => {
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
if let Some(entry) = map.get_mut(label) {
if let Some(entry) = ensure_geo_entry(&mut map, label, app_handle) {
entry.width = size.width;
entry.height = size.height;
} else if let Some(win) = app_handle.get_webview_window(label) {
let pos = win.outer_position().unwrap_or(tauri::PhysicalPosition { x: 0, y: 0 });
map.insert(label.to_string(), WindowGeometry {
label: label.to_string(),
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
active_path: String::new(),
});
}
}
}
Expand All @@ -343,14 +346,11 @@ pub fn run() {
..
} = &event
{
// Remove from geometry registry so closed windows aren't persisted.
// On non-macOS, the last window close triggers Exit immediately after
// Destroyed, so save the session while the registry still has this entry.
// E-PENPAL-SESSION-FILE: save session before removing this window so the
// Exit handler always has a recent snapshot even if the map is partially drained.
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
if map.len() == 1 && map.contains_key(label) {
let windows: Vec<WindowGeometry> = map.values().cloned().collect();
save_session(&windows);
}
let windows: Vec<WindowGeometry> = map.values().cloned().collect();
save_session(&windows);
map.remove(label);
}
#[cfg(target_os = "macos")]
Expand Down
18 changes: 18 additions & 0 deletions apps/penpal/frontend/src/hooks/useTabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,24 @@ describe('useTabs persistence', () => {
expect(ids[1]).toMatch(/^tab-/);
});

// E-PENPAL-TAB-PERSIST: restores valid persisted tabs from localStorage.
it('restores persisted tabs from localStorage', () => {
const persistedTabs = [
{ id: 'tab-aaa', path: '/recent', title: 'Recent', history: ['/recent'], historyIndex: 0 },
{ id: 'tab-bbb', path: '/in-review', title: 'In Review', history: ['/in-review'], historyIndex: 0 },
];
localStorage.setItem('penpal:tabs:browser', JSON.stringify({ version: 1, activeTabId: 'tab-bbb', tabs: persistedTabs }));
const reviewWrapper = ({ children }: { children: ReactNode }) =>
createElement(MemoryRouter, { initialEntries: ['/in-review'] }, children);
const { result } = renderHook(() => useTabs(), { wrapper: reviewWrapper });
expect(result.current.tabs).toHaveLength(2);
expect(result.current.tabs[0].path).toBe('/recent');
expect(result.current.tabs[0].title).toBe('Recent');
expect(result.current.tabs[1].path).toBe('/in-review');
expect(result.current.tabs[1].title).toBe('In Review');
expect(result.current.activeTabId).toBe('tab-bbb');
});

// E-PENPAL-SESSION-FALLBACK: corrupt localStorage gracefully falls back.
it('falls back to default tab when localStorage is corrupt', () => {
localStorage.setItem('penpal:tabs:browser', 'not-json');
Expand Down
25 changes: 12 additions & 13 deletions apps/penpal/frontend/src/hooks/useTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,24 +125,23 @@ export function useTabs(): TabsState {
locationRef.current = location;
const windowLabelRef = useRef<string | null>(resolveWindowLabelSync());

const [tabs, setTabs] = useState<Tab[]>(() => {
// E-PENPAL-TAB-PERSIST: try to restore from localStorage synchronously.
// In browser mode the label is available immediately. In desktop mode
// the label may not be available yet — the async useEffect handles that.
// E-PENPAL-TAB-PERSIST: try to restore from localStorage synchronously.
// In browser mode the label is available immediately. In desktop mode
// the label may not be available yet — the async useEffect handles that.
// Parse once to avoid inconsistent state from double localStorage reads.
const initialPersisted = (() => {
const label = windowLabelRef.current;
if (label) {
const persisted = loadPersistedTabs(label);
if (persisted) return persisted.tabs;
}
if (label) return loadPersistedTabs(label);
return null;
})();

const [tabs, setTabs] = useState<Tab[]>(() => {
if (initialPersisted) return initialPersisted.tabs;
const path = location.pathname + location.search;
return [{ id: nextTabId(), path, title: deriveTitleFromPath(path), history: [path], historyIndex: 0 }];
});
const [activeTabId, setActiveTabId] = useState<string>(() => {
const label = windowLabelRef.current;
if (label) {
const persisted = loadPersistedTabs(label);
if (persisted) return persisted.activeTabId;
}
if (initialPersisted) return initialPersisted.activeTabId;
return tabs[0].id;
});
const tabsRef = useRef(tabs);
Expand Down
Loading