Skip to content

Commit db024b9

Browse files
committed
Replace git check-ignore with pure-Go gitignore matcher + multi-window session fix
1 parent 041a8fa commit db024b9

10 files changed

Lines changed: 1157 additions & 169 deletions

File tree

apps/penpal/ERD.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ see-also:
101101
- <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.
102102
[P-PENPAL-PROJECT-FILE-TREE](PRODUCT.md#P-PENPAL-PROJECT-FILE-TREE)
103103

104-
- <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.
104+
- <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.
105105
[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)
106106

107107
- <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.

apps/penpal/TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ see-also:
6464
| Source Types — anchors (P-PENPAL-SRC-ANCHORS, SRC-ANCHORS-GROUP, SRC-ANCHORS-NESTED) | discovery_test.go (TestClassifyAnchorsFile, TestGroupAnchorsPaths, TestGroupAnchorsPaths_MarkerOnlyModule, TestAnchorsFileOrder, TestAnchorsRequireSibling) ||||
6565
| Source Types — claude-plans (P-PENPAL-SRC-CLAUDE-PLANS) |||||
6666
| Source Types — manual (P-PENPAL-SRC-MANUAL) ||| grouping_test.go (TestBuildFileGroups_ManualSourceDirHeadings) ||
67-
| 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) ||||
67+
| 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) ||||
6868
| Worktree Support (P-PENPAL-WORKTREE) | discovery/worktree_test.go, cache/worktree_test.go | Layout.test.tsx | worktree_test.go (API + MCP) ||
6969
| Worktree Dropdown (P-PENPAL-PROJECT-WORKTREE-DROPDOWN) || Layout.test.tsx |||
7070
| Git Integration (P-PENPAL-GIT-INFO) |||||

apps/penpal/cmd/penpal-server/main.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ func runServe(port int, rootOverride string) {
6868
saveTimer.Stop()
6969
}
7070
saveTimer = time.AfterFunc(5*time.Second, func() {
71-
saveMu.Lock()
72-
defer saveMu.Unlock()
7371
if err := act.Save(activityPath); err != nil {
7472
log.Printf("Warning: could not save activity: %v", err)
7573
}

apps/penpal/frontend/src-tauri/src/lib.rs

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,29 @@ struct SessionState {
3939
/// In-memory geometry registry, updated on move/resize events.
4040
struct GeoRegistry(Mutex<HashMap<String, WindowGeometry>>);
4141

42+
// E-PENPAL-GEO-TRACK: ensure a geometry entry exists for the given window label,
43+
// inserting a new one from the current window state if absent.
44+
fn ensure_geo_entry<'a>(
45+
map: &'a mut HashMap<String, WindowGeometry>,
46+
label: &str,
47+
app: &tauri::AppHandle,
48+
) -> Option<&'a mut WindowGeometry> {
49+
if !map.contains_key(label) {
50+
let win = app.get_webview_window(label)?;
51+
let pos = win.outer_position().unwrap_or(tauri::PhysicalPosition { x: 0, y: 0 });
52+
let size = win.outer_size().unwrap_or(tauri::PhysicalSize { width: 1200, height: 800 });
53+
map.insert(label.to_string(), WindowGeometry {
54+
label: label.to_string(),
55+
x: pos.x,
56+
y: pos.y,
57+
width: size.width,
58+
height: size.height,
59+
active_path: String::new(),
60+
});
61+
}
62+
map.get_mut(label)
63+
}
64+
4265
fn session_file_path() -> std::path::PathBuf {
4366
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
4467
std::path::Path::new(&home).join(".config/penpal/window-state.json")
@@ -286,37 +309,17 @@ pub fn run() {
286309
match win_event {
287310
tauri::WindowEvent::Moved(pos) => {
288311
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
289-
if let Some(entry) = map.get_mut(label) {
312+
if let Some(entry) = ensure_geo_entry(&mut map, label, app_handle) {
290313
entry.x = pos.x;
291314
entry.y = pos.y;
292-
} else if let Some(win) = app_handle.get_webview_window(label) {
293-
let size = win.outer_size().unwrap_or(tauri::PhysicalSize { width: 1200, height: 800 });
294-
map.insert(label.to_string(), WindowGeometry {
295-
label: label.to_string(),
296-
x: pos.x,
297-
y: pos.y,
298-
width: size.width,
299-
height: size.height,
300-
active_path: String::new(),
301-
});
302315
}
303316
}
304317
}
305318
tauri::WindowEvent::Resized(size) => {
306319
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
307-
if let Some(entry) = map.get_mut(label) {
320+
if let Some(entry) = ensure_geo_entry(&mut map, label, app_handle) {
308321
entry.width = size.width;
309322
entry.height = size.height;
310-
} else if let Some(win) = app_handle.get_webview_window(label) {
311-
let pos = win.outer_position().unwrap_or(tauri::PhysicalPosition { x: 0, y: 0 });
312-
map.insert(label.to_string(), WindowGeometry {
313-
label: label.to_string(),
314-
x: pos.x,
315-
y: pos.y,
316-
width: size.width,
317-
height: size.height,
318-
active_path: String::new(),
319-
});
320323
}
321324
}
322325
}
@@ -329,14 +332,11 @@ pub fn run() {
329332
..
330333
} = &event
331334
{
332-
// Remove from geometry registry so closed windows aren't persisted.
333-
// On non-macOS, the last window close triggers Exit immediately after
334-
// Destroyed, so save the session while the registry still has this entry.
335+
// E-PENPAL-SESSION-FILE: save session before removing this window so the
336+
// Exit handler always has a recent snapshot even if the map is partially drained.
335337
if let Ok(mut map) = app_handle.state::<GeoRegistry>().0.lock() {
336-
if map.len() == 1 && map.contains_key(label) {
337-
let windows: Vec<WindowGeometry> = map.values().cloned().collect();
338-
save_session(&windows);
339-
}
338+
let windows: Vec<WindowGeometry> = map.values().cloned().collect();
339+
save_session(&windows);
340340
map.remove(label);
341341
}
342342
#[cfg(target_os = "macos")]

apps/penpal/frontend/src/hooks/useTabs.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,24 @@ describe('useTabs persistence', () => {
165165
expect(ids[1]).toMatch(/^tab-/);
166166
});
167167

168+
// E-PENPAL-TAB-PERSIST: restores valid persisted tabs from localStorage.
169+
it('restores persisted tabs from localStorage', () => {
170+
const persistedTabs = [
171+
{ id: 'tab-aaa', path: '/recent', title: 'Recent', history: ['/recent'], historyIndex: 0 },
172+
{ id: 'tab-bbb', path: '/in-review', title: 'In Review', history: ['/in-review'], historyIndex: 0 },
173+
];
174+
localStorage.setItem('penpal:tabs:browser', JSON.stringify({ version: 1, activeTabId: 'tab-bbb', tabs: persistedTabs }));
175+
const reviewWrapper = ({ children }: { children: ReactNode }) =>
176+
createElement(MemoryRouter, { initialEntries: ['/in-review'] }, children);
177+
const { result } = renderHook(() => useTabs(), { wrapper: reviewWrapper });
178+
expect(result.current.tabs).toHaveLength(2);
179+
expect(result.current.tabs[0].path).toBe('/recent');
180+
expect(result.current.tabs[0].title).toBe('Recent');
181+
expect(result.current.tabs[1].path).toBe('/in-review');
182+
expect(result.current.tabs[1].title).toBe('In Review');
183+
expect(result.current.activeTabId).toBe('tab-bbb');
184+
});
185+
168186
// E-PENPAL-SESSION-FALLBACK: corrupt localStorage gracefully falls back.
169187
it('falls back to default tab when localStorage is corrupt', () => {
170188
localStorage.setItem('penpal:tabs:browser', 'not-json');

apps/penpal/frontend/src/hooks/useTabs.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,24 +125,23 @@ export function useTabs(): TabsState {
125125
locationRef.current = location;
126126
const windowLabelRef = useRef<string | null>(resolveWindowLabelSync());
127127

128-
const [tabs, setTabs] = useState<Tab[]>(() => {
129-
// E-PENPAL-TAB-PERSIST: try to restore from localStorage synchronously.
130-
// In browser mode the label is available immediately. In desktop mode
131-
// the label may not be available yet — the async useEffect handles that.
128+
// E-PENPAL-TAB-PERSIST: try to restore from localStorage synchronously.
129+
// In browser mode the label is available immediately. In desktop mode
130+
// the label may not be available yet — the async useEffect handles that.
131+
// Parse once to avoid inconsistent state from double localStorage reads.
132+
const initialPersisted = (() => {
132133
const label = windowLabelRef.current;
133-
if (label) {
134-
const persisted = loadPersistedTabs(label);
135-
if (persisted) return persisted.tabs;
136-
}
134+
if (label) return loadPersistedTabs(label);
135+
return null;
136+
})();
137+
138+
const [tabs, setTabs] = useState<Tab[]>(() => {
139+
if (initialPersisted) return initialPersisted.tabs;
137140
const path = location.pathname + location.search;
138141
return [{ id: nextTabId(), path, title: deriveTitleFromPath(path), history: [path], historyIndex: 0 }];
139142
});
140143
const [activeTabId, setActiveTabId] = useState<string>(() => {
141-
const label = windowLabelRef.current;
142-
if (label) {
143-
const persisted = loadPersistedTabs(label);
144-
if (persisted) return persisted.activeTabId;
145-
}
144+
if (initialPersisted) return initialPersisted.activeTabId;
146145
return tabs[0].id;
147146
});
148147
const tabsRef = useRef(tabs);

0 commit comments

Comments
 (0)