Skip to content

Commit 32fe8ed

Browse files
committed
fix: address audit findings - atomic guards, toast lifecycle, cancelable debounce, and more
1 parent f553ebc commit 32fe8ed

12 files changed

Lines changed: 145 additions & 111 deletions

File tree

AUDIT_LOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Major graph view overhaul and cross-platform fixes:
3030

3131
---
3232

33-
## 2026-06-24 - (Uncommitted)
33+
## 2026-06-24 - Native Notifications, Timers, DSL Engine, WebGL Graph
3434
**Change:** feat: native notifications, timers, DSL regex engine, WebGL graph with folder attraction
3535

3636
**Details/Why:**

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- **Lazy-Loaded Graph**: `GraphView` is dynamically imported with `React.lazy()` so the Three.js bundle (~1.3 MB) loads only when the graph is opened.
1717
- **Smooth Graph Fade-in**: The graph overlay animates in with a 250ms CSS keyframe fade.
1818
- **Persistent Node Positions**: After closing the graph view, node positions are cached and restored on next open, preserving manual arrangement.
19+
- **Native OS Reminder Notifications**: Task reminders (`/task`) now fire native OS notifications via the Rust backend using `tokio::time::sleep` + `tauri_plugin_notification`. Notifications fire reliably even when the app is minimized or out of focus, and gracefully handle OS-level permission denials.
20+
- **Countdown Timers**: New timer panel (accessible via the action menu or `/timer` command) lets you create, view, pause/resume, and cancel countdown timers. Timers display a live countdown using drift-corrected `setTimeout` chains and trigger both a native OS notification and an in-app alert on completion — even if you're viewing a different note.
21+
- **DSL Regex Parsing Engine**: New `createRegexPlugin()` factory in `dslPlugin.ts` enables flexible, regex-based Domain Specific Language parsing in the editor. Scans only visible ranges for O(visible lines) performance — lag-free at any document size. Supports custom mark decorations, widget injections, and match callbacks.
22+
- **WebGL Graph View**: The Graph View has been rewritten using Three.js WebGL via `react-force-graph-3d`. Notes in the same folder are attracted to shared centroid positions via custom `d3-force` simulation rules, causing them to cluster together naturally. The graph is lazy-loaded to avoid impacting editor startup time.
1923

2024
### Changed
2125
- **Cmd+Shift+N Behavior**: The global new-note shortcut no longer hides the app if it's already visible. It only shows the window when hidden. The shortcut always creates a new note regardless.
@@ -37,10 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3741
- **Slash Command Autosuggest**: Added an inline ghost text autosuggest widget for slash commands (e.g., `/check`, `/ai`). Pressing `Tab` instantly completes the command without interrupting typing flow.
3842
- **Auto-Open Version Notes**: Upon updating, PaperCache now automatically opens a summary note detailing the new features in the latest release and silently cleans up previous version notes from the workspace.
3943
- **Tag Context Menu**: Right-clicking a tag pill now reveals a beautifully styled inline action menu allowing users to easily delete all notes under that tag, or export them concatenated together into a single Markdown file directly via native system dialogs.
40-
- **Native OS Reminder Notifications**: Task reminders (`/task`) now fire native OS notifications via the Rust backend using `tokio::time::sleep` + `tauri_plugin_notification`. Notifications fire reliably even when the app is minimized or out of focus, and gracefully handle OS-level permission denials.
41-
- **Countdown Timers**: New timer panel (accessible via the action menu or `/timer` command) lets you create, view, pause/resume, and cancel countdown timers. Timers display a live countdown using drift-corrected `setTimeout` chains and trigger both a native OS notification and an in-app alert on completion — even if you're viewing a different note.
42-
- **DSL Regex Parsing Engine**: New `createRegexPlugin()` factory in `dslPlugin.ts` enables flexible, regex-based Domain Specific Language parsing in the editor. Scans only visible ranges for O(visible lines) performance — lag-free at any document size. Supports custom mark decorations, widget injections, and match callbacks.
43-
- **WebGL Graph View**: The Graph View has been rewritten using Three.js WebGL via `react-force-graph-3d`. Notes in the same folder are attracted to shared centroid positions via custom `d3-force` simulation rules, causing them to cluster together naturally. The graph is lazy-loaded to avoid impacting editor startup time.
4444

4545
### Fixed
4646
- Fixed an issue where the unified search view layout could overlap with the context menu or hide important tag management options.

src-tauri/src/commands/notifications.rs

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,11 @@ pub async fn schedule_reminders(
3030
.map(|d| d.as_millis() as i64)
3131
.unwrap_or(0);
3232

33-
// Cancel all existing reminder handles
34-
{
35-
let mut handles = state
36-
.reminder_handles
37-
.write()
38-
.map_err(|e| e.to_string())?;
39-
for (_, handle) in handles.drain() {
40-
handle.abort();
41-
}
42-
}
33+
let mut new_handles: HashMap<String, JoinHandle<()>> = HashMap::new();
4334

44-
// Schedule new reminders
4535
for reminder in reminders {
4636
let delay_ms = reminder.due_at - now_ms;
4737
if delay_ms < 0 {
48-
// Already past – skip (already notified logic is on frontend)
4938
continue;
5039
}
5140

@@ -55,22 +44,27 @@ pub async fn schedule_reminders(
5544

5645
let handle = tokio::spawn(async move {
5746
sleep(Duration::from_millis(delay_ms as u64)).await;
58-
5947
let _ = app_clone
6048
.notification()
6149
.builder()
6250
.title("PaperCache Reminder")
6351
.body(&label)
6452
.show();
65-
6653
let _ = app_clone.emit("reminder-fired", &key);
6754
});
6855

69-
state
56+
new_handles.insert(reminder.key, handle);
57+
}
58+
59+
{
60+
let mut handles = state
7061
.reminder_handles
7162
.write()
72-
.map_err(|e| e.to_string())?
73-
.insert(reminder.key, handle);
63+
.map_err(|e| e.to_string())?;
64+
for (_, handle) in handles.drain() {
65+
handle.abort();
66+
}
67+
handles.extend(new_handles);
7468
}
7569

7670
Ok(())
@@ -98,38 +92,30 @@ pub async fn schedule_timer(
9892
label: String,
9993
state: tauri::State<'_, NotificationState>,
10094
) -> Result<(), String> {
101-
// Cancel any existing timer with the same id
102-
{
103-
let mut handles = state
104-
.timer_handles
105-
.write()
106-
.map_err(|e| e.to_string())?;
107-
if let Some(existing) = handles.remove(&id) {
108-
existing.abort();
109-
}
110-
}
111-
11295
let app_clone = app.clone();
11396
let id_clone = id.clone();
11497

11598
let handle = tokio::spawn(async move {
11699
sleep(Duration::from_millis(duration_ms)).await;
117-
118100
let _ = app_clone
119101
.notification()
120102
.builder()
121103
.title("PaperCache Timer")
122104
.body(&format!("⏱ Timer finished: {}", label))
123105
.show();
124-
125106
let _ = app_clone.emit("timer-complete", &id_clone);
126107
});
127108

128-
state
129-
.timer_handles
130-
.write()
131-
.map_err(|e| e.to_string())?
132-
.insert(id, handle);
109+
{
110+
let mut handles = state
111+
.timer_handles
112+
.write()
113+
.map_err(|e| e.to_string())?;
114+
if let Some(existing) = handles.remove(&id) {
115+
existing.abort();
116+
}
117+
handles.insert(id, handle);
118+
}
133119

134120
Ok(())
135121
}

src-tauri/src/lib.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod tray;
1010

1111
use commands::shortcuts::GlobalShortcutState;
1212
use commands::notifications::NotificationState;
13-
use std::sync::atomic::{AtomicBool, Ordering};
13+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1414
use std::sync::Arc;
1515

1616
pub struct DialogState {
@@ -69,12 +69,12 @@ pub fn run() {
6969
let dialog_state = app.state::<crate::DialogState>();
7070
let is_dialog_open = dialog_state.is_open.clone();
7171
#[cfg(not(target_os = "macos"))]
72-
let pending_hide = Arc::new(AtomicBool::new(false));
72+
let focus_gen = Arc::new(AtomicU64::new(0));
7373

7474
window.on_window_event({
7575
let w = window.clone();
7676
#[cfg(not(target_os = "macos"))]
77-
let pending = pending_hide.clone();
77+
let gen = focus_gen.clone();
7878
move |event| match event {
7979
tauri::WindowEvent::CloseRequested { api, .. } => {
8080
api.prevent_close();
@@ -83,21 +83,21 @@ pub fn run() {
8383
tauri::WindowEvent::Focused(focused) => {
8484
if *focused {
8585
#[cfg(not(target_os = "macos"))]
86-
pending.store(false, Ordering::SeqCst);
86+
{ gen.fetch_add(1, Ordering::SeqCst); }
8787
} else if !is_dialog_open.load(Ordering::SeqCst) {
8888
#[cfg(target_os = "macos")]
8989
let _ = w.hide();
9090

9191
#[cfg(not(target_os = "macos"))]
9292
{
93-
pending.store(true, Ordering::SeqCst);
93+
let gen_at_spawn = gen.fetch_add(1, Ordering::SeqCst) + 1;
9494
let w2 = w.clone();
95-
let p2 = pending.clone();
95+
let g2 = gen.clone();
9696
std::thread::spawn(move || {
9797
std::thread::sleep(
9898
std::time::Duration::from_millis(200),
9999
);
100-
if p2.swap(false, Ordering::SeqCst) {
100+
if g2.load(Ordering::SeqCst) == gen_at_spawn {
101101
let _ = w2.hide();
102102
}
103103
});

src/App.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,28 @@ function App() {
5959
}, [])
6060

6161
// Auto-dismiss toasts after 5 seconds
62+
const toastTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
6263
useEffect(() => {
63-
if (toasts.length === 0) return undefined
64-
const ids = toasts.map((t) => t.id)
65-
const timers = ids.map((id) => setTimeout(() => removeToast(id), 5000))
66-
return () => timers.forEach(clearTimeout)
64+
const timers = toastTimersRef.current
65+
const currentIds = new Set(toasts.map((t) => t.id))
66+
67+
// Clear timers for removed toasts
68+
for (const [id, timer] of timers) {
69+
if (!currentIds.has(id)) {
70+
clearTimeout(timer)
71+
timers.delete(id)
72+
}
73+
}
74+
75+
// Set timers for new toasts
76+
for (const toast of toasts) {
77+
if (!timers.has(toast.id)) {
78+
timers.set(
79+
toast.id,
80+
setTimeout(() => removeToast(toast.id), 5000)
81+
)
82+
}
83+
}
6784
}, [toasts, removeToast])
6885

6986
useEffect(() => {

src/GraphView.tsx

Lines changed: 47 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -142,41 +142,44 @@ export default function GraphView({
142142
}, [notes])
143143

144144
useEffect(() => {
145-
const fg = fgRef.current
146-
if (!fg) return
147-
148-
const folders = Array.from(new Set(graphData.nodes.map((n) => n.folder).filter(Boolean)))
149-
const centroids = buildFolderCentroids(folders)
150-
151-
fg.d3Force('centerX', d3.forceX<GraphNode>(0).strength(0.008))
152-
153-
fg.d3Force('centerY', d3.forceY<GraphNode>(0).strength(0.008))
154-
155-
fg.d3Force(
156-
'folderX',
157-
d3
158-
.forceX<GraphNode>((node) => {
159-
const c = centroids.get(node.folder)
160-
return c ? c.cx : 0
161-
})
162-
.strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0))
163-
)
164-
165-
fg.d3Force(
166-
'folderY',
167-
d3
168-
.forceY<GraphNode>((node) => {
169-
const c = centroids.get(node.folder)
170-
return c ? c.cy : 0
171-
})
172-
.strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0))
173-
)
174-
175-
fg.d3Force('charge')?.strength(-120)
176-
177-
fg.d3Force('collision', d3.forceCollide<GraphNode>(22))
178-
179-
fg.d3ReheatSimulation()
145+
let attempts = 0
146+
const id = setInterval(() => {
147+
const fg = fgRef.current
148+
if (!fg) {
149+
attempts++
150+
if (attempts > 20) clearInterval(id)
151+
return
152+
}
153+
clearInterval(id)
154+
155+
const folders = Array.from(new Set(graphData.nodes.map((n) => n.folder).filter(Boolean)))
156+
const centroids = buildFolderCentroids(folders)
157+
158+
fg.d3Force('centerX', d3.forceX<GraphNode>(0).strength(0.008))
159+
fg.d3Force('centerY', d3.forceY<GraphNode>(0).strength(0.008))
160+
fg.d3Force(
161+
'folderX',
162+
d3
163+
.forceX<GraphNode>((node) => {
164+
const c = centroids.get(node.folder)
165+
return c ? c.cx : 0
166+
})
167+
.strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0))
168+
)
169+
fg.d3Force(
170+
'folderY',
171+
d3
172+
.forceY<GraphNode>((node) => {
173+
const c = centroids.get(node.folder)
174+
return c ? c.cy : 0
175+
})
176+
.strength((node) => (node.folder && !draggedNodesRef.current.has(node.id) ? 0.008 : 0))
177+
)
178+
fg.d3Force('charge')?.strength(-120)
179+
fg.d3Force('collision', d3.forceCollide<GraphNode>(22))
180+
fg.d3ReheatSimulation()
181+
}, 50)
182+
return () => clearInterval(id)
180183
}, [graphData])
181184

182185
const handleNodeClick = useCallback(
@@ -202,10 +205,7 @@ export default function GraphView({
202205
if (!fg) return
203206
const node = fg.graphData().nodes.find((n: GraphNode) => n.id === nodeId)
204207
if (!node || node.x == null || node.y == null) return
205-
fg.centerAt(node.x, node.y, 400)
206-
setTimeout(() => {
207-
fg.cameraPosition({ x: node.x, y: node.y, z: 120 }, { x: node.x, y: node.y, z: 0 }, 400)
208-
}, 200)
208+
fg.cameraPosition({ x: node.x, y: node.y, z: 120 }, { x: node.x, y: node.y, z: 0 }, 400)
209209
}, [])
210210

211211
const nodeThreeObject = useCallback(
@@ -376,7 +376,14 @@ export default function GraphView({
376376
</h2>
377377
)}
378378
<button
379-
onClick={onClose}
379+
onClick={() => {
380+
if (showSearch) {
381+
setShowSearch(false)
382+
setSearchQuery('')
383+
} else {
384+
onClose()
385+
}
386+
}}
380387
style={{
381388
background: 'transparent',
382389
border: 'none',

src/api.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { invoke } from '@tauri-apps/api/core'
22
import { listen } from '@tauri-apps/api/event'
3-
import type { ElectronAPI } from './types'
3+
import type { ElectronAPI, ReminderPayload } from './types'
44

55
export const tauriApi: ElectronAPI = {
66
// Implemented Phase 2 Commands
@@ -23,8 +23,7 @@ export const tauriApi: ElectronAPI = {
2323
quitApp: () => invoke('quit_app'),
2424
openExternal: (url) => invoke('open_external', { url }),
2525
openFile: (path) => invoke('open_file', { path }),
26-
scheduleReminders: (reminders) =>
27-
invoke('schedule_reminders', { reminders: reminders as unknown[] }),
26+
scheduleReminders: (reminders: ReminderPayload[]) => invoke('schedule_reminders', { reminders }),
2827
cancelReminders: () => invoke('cancel_all_reminders'),
2928
scheduleTimer: (id, durationMs, label) => invoke('schedule_timer', { id, durationMs, label }),
3029
cancelTimer: (id) => invoke('cancel_timer', { id }),

src/components/TimersPage.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export function TimersPage({ onClose }: TimersPageProps) {
157157
return () => window.removeEventListener('keydown', handler)
158158
}, [onClose])
159159

160-
const handleCreate = () => {
160+
const handleCreate = async () => {
161161
const h = parseInt(hInput) || 0
162162
const m = parseInt(mInput) || 0
163163
const s = parseInt(sInput) || 0
@@ -168,18 +168,26 @@ export function TimersPage({ onClose }: TimersPageProps) {
168168
labelInput.trim() ||
169169
`${h > 0 ? `${h}h ` : ''}${m > 0 ? `${m}m ` : ''}${s > 0 ? `${s}s` : ''}`.trim()
170170
const id = addTimer(label, durationMs)
171-
window.electronAPI.scheduleTimer(id, durationMs, label)
172-
setLabelInput('')
171+
try {
172+
await window.electronAPI.scheduleTimer(id, durationMs, label)
173+
setLabelInput('')
174+
} catch {
175+
removeTimer(id)
176+
}
173177
}
174178

175179
const handleRemove = (id: string) => {
176180
window.electronAPI.cancelTimer(id).catch(() => {})
177181
removeTimer(id)
178182
}
179183

180-
const handlePreset = (ms: number, presetLabel: string) => {
184+
const handlePreset = async (ms: number, presetLabel: string) => {
181185
const id = addTimer(presetLabel, ms)
182-
window.electronAPI.scheduleTimer(id, ms, presetLabel)
186+
try {
187+
await window.electronAPI.scheduleTimer(id, ms, presetLabel)
188+
} catch {
189+
removeTimer(id)
190+
}
183191
}
184192

185193
return (

0 commit comments

Comments
 (0)