Extracts the repo-relative paths edited in a Cursor `afterFileEdit` event. Cursor sends an absolute `file_path` (plus an `edits` array). We strip the resolved `project_root` prefix and normalize to forward slashes so the hook can notify the daemon about only the changed files. Paths outside the project root are skipped.
(event_json: &str, project_root: &Path)
| 522 | /// can notify the daemon about only the changed files. Paths outside the project |
| 523 | /// root are skipped. |
| 524 | pub fn cursor_after_file_edit_rel_paths(event_json: &str, project_root: &Path) -> Vec<String> { |
| 525 | let Ok(parsed) = serde_json::from_str::<Value>(event_json) else { |
| 526 | return Vec::new(); |
| 527 | }; |
| 528 | |
| 529 | let mut abs_paths: Vec<String> = Vec::new(); |
| 530 | if let Some(p) = parsed |
| 531 | .get("file_path") |
| 532 | .and_then(Value::as_str) |
| 533 | .filter(|s| !s.is_empty()) |
| 534 | { |
| 535 | abs_paths.push(p.to_string()); |
| 536 | } |
| 537 | // Defensive: some edit payloads may carry per-edit file paths. |
| 538 | if let Some(edits) = parsed.get("edits").and_then(Value::as_array) { |
| 539 | for edit in edits { |
| 540 | if let Some(p) = edit |
| 541 | .get("file_path") |
| 542 | .and_then(Value::as_str) |
| 543 | .filter(|s| !s.is_empty()) |
| 544 | { |
| 545 | abs_paths.push(p.to_string()); |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | let mut rels: Vec<String> = Vec::new(); |
| 551 | for abs in abs_paths { |
| 552 | if let Some(rel) = rel_under_root(project_root, Path::new(&abs)) { |
| 553 | if !rels.contains(&rel) { |
| 554 | rels.push(rel); |
| 555 | } |
| 556 | } |
| 557 | } |
| 558 | rels |
| 559 | } |
| 560 | |
| 561 | /// Returns `true` when a sync should run given the last marker time and a |
| 562 | /// debounce window. Used to coalesce back-to-back `afterShellExecution` syncs. |