From 57745df31ed3fa3a1cb94e49cdc7151fe1b72523 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Wed, 5 Nov 2025 10:38:23 +0900 Subject: [PATCH 1/5] wip --- patto-preview-next/src/app/globals.css | 15 ++ patto-preview-next/src/app/page.js | 141 ++++++++++- .../src/components/TaskPanel.jsx | 164 +++++++++++++ .../src/components/TaskPanel.module.css | 219 ++++++++++++++++++ src/bin/patto-preview.rs | 19 +- src/repository.rs | 109 ++++++++- 6 files changed, 657 insertions(+), 10 deletions(-) create mode 100644 patto-preview-next/src/components/TaskPanel.jsx create mode 100644 patto-preview-next/src/components/TaskPanel.module.css diff --git a/patto-preview-next/src/app/globals.css b/patto-preview-next/src/app/globals.css index 5c811b3..6a5afbf 100644 --- a/patto-preview-next/src/app/globals.css +++ b/patto-preview-next/src/app/globals.css @@ -181,6 +181,21 @@ code { } } +/* Highlight animation for clicked task lines */ +:global(.highlighted) { + animation: highlight-flash 2s ease-in-out; + border-radius: 4px; +} + +@keyframes highlight-flash { + 0%, 100% { + background-color: transparent; + } + 10%, 90% { + background-color: #fff3cd; + } +} + /* :root { --background: #ffffff; diff --git a/patto-preview-next/src/app/page.js b/patto-preview-next/src/app/page.js index 79b28c8..5fb7d05 100644 --- a/patto-preview-next/src/app/page.js +++ b/patto-preview-next/src/app/page.js @@ -5,6 +5,7 @@ import { useClientRouter } from '../lib/router'; import { usePattoWebSocket } from '../lib/websocket'; import Sidebar from '../components/Sidebar'; import Preview from '../components/Preview'; +import TaskPanel from '../components/TaskPanel'; import styles from './page.module.css'; export default function PattoApp() { @@ -18,6 +19,9 @@ export default function PattoApp() { const [twoHopLinks, setTwoHopLinks] = useState([]); const [sortBy, setSortBy] = useState('modified'); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [tasks, setTasks] = useState([]); + const [taskPanelOpen, setTaskPanelOpen] = useState(false); + const [targetLineId, setTargetLineId] = useState(null); // Initialize from localStorage after mount useEffect(() => { @@ -27,6 +31,9 @@ export default function PattoApp() { const savedCollapsed = localStorage.getItem('sidebar-collapsed'); if (savedCollapsed === 'true') setSidebarCollapsed(true); + + const savedTaskPanelOpen = localStorage.getItem('task-panel-open'); + if (savedTaskPanelOpen === 'true') setTaskPanelOpen(true); } }, []); @@ -92,6 +99,10 @@ export default function PattoApp() { setTwoHopLinks(data.data.two_hop_links || []); } break; + + case 'TasksUpdated': + setTasks(data.data.tasks || []); + break; } }); @@ -131,6 +142,50 @@ export default function PattoApp() { } }, []); // Run only once on mount + // Scroll to target line after content loads + useEffect(() => { + if (previewHtml && targetLineId !== null) { + setTimeout(() => { + const { stableId, row } = typeof targetLineId === 'object' ? targetLineId : { stableId: targetLineId, row: null }; + console.log('Attempting to scroll after navigation:', { stableId, row }); + + let element = null; + + // Try stable ID first + if (stableId !== null && stableId !== undefined) { + element = document.querySelector(`[data-line-id="${stableId}"]`); + if (element) { + console.log('Found element by stable ID'); + } + } + + // Fall back to row position + if (!element && row !== null && row !== undefined) { + const preview = document.querySelector('#preview-content'); + if (preview) { + const lines = preview.querySelectorAll('li'); + if (row < lines.length) { + element = lines[row]; + console.log(`Found element by row position: ${row}`); + } + } + } + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + element.classList.add('highlighted'); + setTimeout(() => element.classList.remove('highlighted'), 2000); + } else { + console.warn('Element not found after navigation:', { stableId, row }); + } + setTargetLineId(null); + }, 500); + } + }, [previewHtml, targetLineId]); + // Handle sort preference changes const handleSortChange = useCallback((newSort) => { setSortBy(newSort); @@ -150,6 +205,66 @@ export default function PattoApp() { }); }, []); + // Handle task panel toggle + const handleToggleTaskPanel = useCallback(() => { + setTaskPanelOpen(prev => { + const newValue = !prev; + if (typeof window !== 'undefined') { + localStorage.setItem('task-panel-open', newValue.toString()); + } + return newValue; + }); + }, []); + + // Handle task click - navigate to file and scroll to line + const handleTaskClick = useCallback((filePath, stableId, row) => { + console.log('Task clicked:', { filePath, stableId, row, currentNote }); + + // If clicking the same file, just scroll + if (currentNote === filePath) { + let element = null; + + // Try stable ID first (if available from line-tracked rendering) + if (stableId !== null && stableId !== undefined) { + element = document.querySelector(`[data-line-id="${stableId}"]`); + if (element) { + console.log('Found element by stable ID'); + } + } + + // Fall back to finding by position (nth li in preview) + if (!element && row !== null && row !== undefined) { + const preview = document.querySelector('#preview-content'); + if (preview) { + const lines = preview.querySelectorAll('li'); + // Row is 0-indexed in parser, find the matching line + if (row < lines.length) { + element = lines[row]; + console.log(`Found element by row position: ${row}`); + } + } + } + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + element.classList.add('highlighted'); + setTimeout(() => element.classList.remove('highlighted'), 2000); + } else { + console.warn('Could not find element to scroll to', { stableId, row }); + } + } else { + // Navigate to different file + navigate(filePath); + // Store both stableId and row for after navigation + if (stableId !== null || row !== null) { + setTargetLineId({ stableId, row }); + } + } + }, [navigate, currentNote]); + return (
{/* Header */} @@ -189,14 +304,24 @@ export default function PattoApp() { onToggle={handleToggleSidebar} /> -
- +
+ +
+ + {/* Task Panel */} +
diff --git a/patto-preview-next/src/components/TaskPanel.jsx b/patto-preview-next/src/components/TaskPanel.jsx new file mode 100644 index 0000000..a91696e --- /dev/null +++ b/patto-preview-next/src/components/TaskPanel.jsx @@ -0,0 +1,164 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import styles from './TaskPanel.module.css'; + +export default function TaskPanel({ tasks, isOpen, onToggle, onTaskClick }) { + const [sortBy, setSortBy] = useState('deadline'); + const [filter, setFilter] = useState('all'); + + // Load sort preference from localStorage + useEffect(() => { + if (typeof window !== 'undefined') { + const savedSort = localStorage.getItem('task-sort-by'); + if (savedSort) setSortBy(savedSort); + + const savedFilter = localStorage.getItem('task-filter'); + if (savedFilter) setFilter(savedFilter); + } + }, []); + + // Save sort preference + const handleSortChange = (newSort) => { + setSortBy(newSort); + if (typeof window !== 'undefined') { + localStorage.setItem('task-sort-by', newSort); + } + }; + + // Save filter preference + const handleFilterChange = (newFilter) => { + setFilter(newFilter); + if (typeof window !== 'undefined') { + localStorage.setItem('task-filter', newFilter); + } + }; + + // Filter tasks + const filteredTasks = tasks.filter(task => { + if (filter === 'all') return true; + + if (!task.deadline) return filter === 'no-deadline'; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const taskDate = new Date(task.deadline); + taskDate.setHours(0, 0, 0, 0); + + if (filter === 'overdue') { + return taskDate < today; + } else if (filter === 'today') { + return taskDate.getTime() === today.getTime(); + } else if (filter === 'week') { + const weekFromNow = new Date(today); + weekFromNow.setDate(weekFromNow.getDate() + 7); + return taskDate >= today && taskDate <= weekFromNow; + } else if (filter === 'no-deadline') { + return false; + } + + return true; + }); + + // Sort tasks + const sortedTasks = [...filteredTasks].sort((a, b) => { + if (sortBy === 'deadline') { + if (!a.deadline && !b.deadline) return a.file_path.localeCompare(b.file_path); + if (!a.deadline) return 1; + if (!b.deadline) return -1; + return a.deadline.localeCompare(b.deadline); + } else if (sortBy === 'file') { + return a.file_path.localeCompare(b.file_path); + } else if (sortBy === 'status') { + const statusOrder = { 'Todo': 0, 'Doing': 1, 'Pending': 2 }; + return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0); + } + return 0; + }); + + const getStatusBadgeClass = (status) => { + switch (status) { + case 'Todo': return styles.statusTodo; + case 'Doing': return styles.statusDoing; + case 'Pending': return styles.statusPending; + default: return styles.statusTodo; + } + }; + + const formatDeadline = (deadline) => { + if (!deadline) return '-'; + + const date = new Date(deadline); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const taskDate = new Date(date); + taskDate.setHours(0, 0, 0, 0); + + if (taskDate < today) { + return {deadline}; + } else if (taskDate.getTime() === today.getTime()) { + return {deadline}; + } + + return deadline; + }; + + return ( +
+
+
+ {isOpen ? '▼' : '▲'} +

Tasks ({sortedTasks.length})

+
+ {isOpen && ( +
e.stopPropagation()}> +
+ + +
+
+ + +
+
+ )} +
+ + {isOpen && ( +
+ {sortedTasks.length === 0 ? ( +
No tasks found
+ ) : ( + sortedTasks.map((task, idx) => ( +
onTaskClick(task.file_path, task.stable_id, task.row)} + title={`${task.file_path} - Line ${task.row}`} + > +
{task.file_path}
+
{task.line_text}
+
+ {task.status} +
+
{formatDeadline(task.deadline)}
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/patto-preview-next/src/components/TaskPanel.module.css b/patto-preview-next/src/components/TaskPanel.module.css new file mode 100644 index 0000000..ab79d0d --- /dev/null +++ b/patto-preview-next/src/components/TaskPanel.module.css @@ -0,0 +1,219 @@ +.taskPanel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: white; + border-top: 2px solid #ccc; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease-in-out, height 0.3s ease-in-out; + z-index: 100; + display: flex; + flex-direction: column; +} + +.taskPanel.collapsed { + height: 45px; + transform: translateY(0); +} + +.taskPanel.expanded { + height: 40vh; + transform: translateY(0); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 15px; + background: #f5f5f5; + border-bottom: 1px solid #ddd; + cursor: pointer; + user-select: none; + min-height: 45px; +} + +.header:hover { + background: #ebebeb; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 10px; +} + +.headerLeft h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.toggleIcon { + font-size: 14px; + color: #666; +} + +.controls { + display: flex; + gap: 20px; + align-items: center; +} + +.controlGroup { + display: flex; + align-items: center; + gap: 8px; +} + +.controlGroup label { + font-size: 14px; + color: #666; + font-weight: 500; +} + +.controlGroup select { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + background: white; + cursor: pointer; +} + +.controlGroup select:hover { + border-color: #999; +} + +.taskList { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.taskItem { + display: grid; + grid-template-columns: 2fr 4fr 1fr 1.5fr; + gap: 12px; + padding: 10px 15px; + border-bottom: 1px solid #e0e0e0; + cursor: pointer; + transition: background-color 0.15s; + align-items: center; +} + +.taskItem:hover { + background-color: #f9f9f9; +} + +.taskItem:active { + background-color: #f0f0f0; +} + +.fileName { + font-size: 13px; + color: #0066cc; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.taskText { + font-size: 14px; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusBadge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-align: center; + white-space: nowrap; +} + +.statusTodo { + background-color: #e3f2fd; + color: #1976d2; +} + +.statusDoing { + background-color: #fff9c4; + color: #f57c00; +} + +.statusPending { + background-color: #ffe0b2; + color: #e65100; +} + +.deadline { + font-size: 13px; + color: #666; + text-align: right; +} + +.overdue { + color: #d32f2f; + font-weight: 600; +} + +.today { + color: #f57c00; + font-weight: 600; +} + +.emptyState { + padding: 40px; + text-align: center; + color: #999; + font-size: 14px; +} + +/* Scrollbar styling */ +.taskList::-webkit-scrollbar { + width: 8px; +} + +.taskList::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.taskList::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.taskList::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .taskItem { + grid-template-columns: 1.5fr 3fr 0.8fr 1fr; + gap: 8px; + padding: 8px 10px; + font-size: 13px; + } + + .controls { + gap: 10px; + } + + .controlGroup { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .taskPanel.expanded { + height: 50vh; + } +} diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index e0940d3..24fe974 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -12,7 +12,7 @@ use patto::{ line_tracker::LineTracker, parser, renderer::{HtmlRenderer, HtmlRendererOptions, Renderer}, - repository::{FileMetadata, Repository, RepositoryMessage}, + repository::{FileMetadata, Repository, RepositoryMessage, TaskInfo}, }; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; @@ -86,6 +86,9 @@ enum WsMessage { path: String, two_hop_links: Vec<(String, Vec)>, }, + TasksUpdated { + tasks: Vec, + }, } // Helper function to get file extension @@ -495,6 +498,17 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { return; } } + + // Send initial task list + let tasks = state.repository.aggregate_tasks(); + let tasks_message = WsMessage::TasksUpdated { tasks }; + + if let Ok(json) = serde_json::to_string(&tasks_message) { + if let Err(e) = socket.send(axum::extract::ws::Message::Text(json)).await { + eprintln!("Error sending initial tasks: {}", e); + } + } + //let root_dir = state.repository.root_dir.clone(); // Main loop - handle both broadcast messages and websocket messages loop { @@ -560,6 +574,9 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { two_hop_links, } } + RepositoryMessage::TasksUpdated { tasks } => { + WsMessage::TasksUpdated { tasks } + } // Ignore scan progress messages in preview RepositoryMessage::ScanStarted { .. } | RepositoryMessage::ScanProgress { .. } | diff --git a/src/repository.rs b/src/repository.rs index e93eb55..75a13d3 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -12,7 +12,7 @@ use tokio::time::sleep; use tower_lsp::lsp_types::Url; use urlencoding::encode; -use crate::parser::{self, AstNode}; +use crate::parser::{self, AstNode, AstNodeKind, Deadline, Property, TaskStatus}; /// File metadata for sorting and display #[derive(Serialize, Deserialize, Clone, Debug)] @@ -23,6 +23,17 @@ pub struct FileMetadata { pub link_count: u32, } +/// Task information for task aggregation +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TaskInfo { + pub file_path: String, + pub line_text: String, + pub status: String, + pub deadline: Option, + pub row: usize, + pub stable_id: Option, +} + /// Messages for repository change notifications #[derive(Clone, Debug)] pub enum RepositoryMessage { @@ -34,6 +45,7 @@ pub enum RepositoryMessage { ScanStarted { total_files: usize }, ScanProgress { scanned: usize, total: usize }, ScanCompleted { total_files: usize }, + TasksUpdated { tasks: Vec }, } /// Repository manages the collection of notes and their relationships @@ -583,6 +595,10 @@ impl Repository { rel_path.to_path_buf(), metadata, )); + + // Broadcast updated tasks + let tasks = repository.aggregate_tasks(); + let _ = repo_tx.send(RepositoryMessage::TasksUpdated { tasks }); } else if event.kind.is_remove() { let Ok(rel_path) = path.strip_prefix(&root_dir) else { continue; @@ -591,6 +607,10 @@ impl Repository { repository.remove_file_from_graph(&path); let _ = repo_tx .send(RepositoryMessage::FileRemoved(rel_path.to_path_buf())); + + // Broadcast updated tasks + let tasks = repository.aggregate_tasks(); + let _ = repo_tx.send(RepositoryMessage::TasksUpdated { tasks }); } else if event.kind.is_modify() && path.is_file() { { let mut changes = pending_changes.lock().unwrap(); @@ -662,6 +682,10 @@ impl Repository { let _ = repo_tx_clone.send(RepositoryMessage::FileChanged( path_clone, metadata, content, )); + + // Broadcast updated tasks + let tasks = repository_clone.aggregate_tasks(); + let _ = repo_tx_clone.send(RepositoryMessage::TasksUpdated { tasks }); } }); } @@ -671,4 +695,87 @@ impl Repository { Ok(()) } + + /// Gather tasks from an AST node recursively + fn gather_tasks_from_ast(parent: &AstNode, tasklines: &mut Vec<(AstNode, Deadline)>) { + if let AstNodeKind::Line { ref properties } = &parent.kind() { + for prop in properties { + if let Property::Task { status, due } = prop { + if !matches!(status, TaskStatus::Done) { + tasklines.push((parent.clone(), due.clone())); + break; + } + } + } + } + + for child in parent.value().children.lock().unwrap().iter() { + Self::gather_tasks_from_ast(child, tasklines); + } + } + + /// Aggregate all tasks from the workspace + pub fn aggregate_tasks(&self) -> Vec { + let mut tasks: Vec = Vec::new(); + + self.ast_map.iter().for_each(|entry| { + let mut tasklines = vec![]; + Self::gather_tasks_from_ast(entry.value(), &mut tasklines); + + for (line, due) in tasklines { + let file_path = entry.key().to_file_path().ok() + .and_then(|p| p.strip_prefix(&self.root_dir).ok().map(|p| p.to_path_buf())) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let line_text = line.extract_str().trim_start().to_string(); + + let status = if let AstNodeKind::Line { ref properties } = &line.kind() { + properties.iter().find_map(|prop| { + if let Property::Task { status, .. } = prop { + Some(match status { + TaskStatus::Todo => "Todo", + TaskStatus::Doing => "Doing", + TaskStatus::Done => "Done", + }.to_string()) + } else { + None + } + }).unwrap_or_else(|| "Todo".to_string()) + } else { + "Todo".to_string() + }; + + let deadline = match due { + Deadline::Date(d) => Some(d.format("%Y-%m-%d").to_string()), + Deadline::DateTime(dt) => Some(dt.format("%Y-%m-%d %H:%M").to_string()), + Deadline::Uninterpretable(_) => None, + }; + + let row = line.location().row; + let stable_id = *line.value().stable_id.lock().unwrap(); + + tasks.push(TaskInfo { + file_path, + line_text, + status, + deadline, + row, + stable_id, + }); + } + }); + + // Sort by deadline + tasks.sort_by(|a, b| { + match (&a.deadline, &b.deadline) { + (Some(d1), Some(d2)) => d1.cmp(d2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.file_path.cmp(&b.file_path), + } + }); + + tasks + } } From b9ed4e15e72b732c36120e490a570e8eea7c7786 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Wed, 5 Nov 2025 15:15:26 +0900 Subject: [PATCH 2/5] fix printable area --- patto-preview-next/src/app/globals.css | 50 ++++++++++++++++++-------- patto-preview-next/src/app/page.js | 14 ++++---- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/patto-preview-next/src/app/globals.css b/patto-preview-next/src/app/globals.css index 6a5afbf..1c9c0df 100644 --- a/patto-preview-next/src/app/globals.css +++ b/patto-preview-next/src/app/globals.css @@ -53,26 +53,45 @@ code { tab-size: 2; } -/* Print styles - only show title and preview content */ +/* Print styles - only show preview content */ @media print { - /* Hide everything by default */ - * { - visibility: hidden; + /* Hide header, sidebar, and task panel */ + .patto-header, + .patto-sidebar, + [class*="Sidebar"], + [class*="taskPanel"] { + display: none !important; } - /* Show only the preview content */ - #preview-content, - #preview-content * { - visibility: visible; + /* Make all containers allow overflow for printing full content */ + .patto-app, + .patto-main, + .patto-preview-area, + .patto-preview-scroll { + display: block !important; + position: static !important; + overflow: visible !important; + height: auto !important; + max-height: none !important; + flex: none !important; + padding: 0 !important; + margin: 0 !important; } + /* Ensure preview content is fully visible */ #preview-content { - position: absolute; - top: 0; - left: 0; - width: 100%; - margin: 0; - padding: 0; + position: static !important; + width: 100% !important; + margin: 0 !important; + padding: 20px !important; + overflow: visible !important; + height: auto !important; + max-height: none !important; + } + + #preview-content, + #preview-content * { + overflow: visible !important; } /* Remove unnecessary spacing and ensure good print layout */ @@ -82,6 +101,9 @@ code { line-height: 1.4; color: black; background: white; + overflow: visible !important; + margin: 0 !important; + padding: 0 !important; } /* Ensure images fit on the page */ diff --git a/patto-preview-next/src/app/page.js b/patto-preview-next/src/app/page.js index 5fb7d05..315757a 100644 --- a/patto-preview-next/src/app/page.js +++ b/patto-preview-next/src/app/page.js @@ -174,7 +174,7 @@ export default function PattoApp() { if (element) { element.scrollIntoView({ behavior: 'smooth', - block: 'center' + block: 'start' }); element.classList.add('highlighted'); setTimeout(() => element.classList.remove('highlighted'), 2000); @@ -248,7 +248,7 @@ export default function PattoApp() { if (element) { element.scrollIntoView({ behavior: 'smooth', - block: 'center' + block: 'start' }); element.classList.add('highlighted'); setTimeout(() => element.classList.remove('highlighted'), 2000); @@ -266,9 +266,9 @@ export default function PattoApp() { }, [navigate, currentNote]); return ( -
+
{/* Header */} -
{/* Main content */} -
+
-
-
+
+
Date: Thu, 4 Dec 2025 09:58:22 +0900 Subject: [PATCH 3/5] fmt --- src/bin/patto-preview.rs | 2 +- src/repository.rs | 60 +++++++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/bin/patto-preview.rs b/src/bin/patto-preview.rs index 467faa0..12d189b 100644 --- a/src/bin/patto-preview.rs +++ b/src/bin/patto-preview.rs @@ -696,7 +696,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) { // Send initial task list let tasks = state.repository.aggregate_tasks(); let tasks_message = WsMessage::TasksUpdated { tasks }; - + if let Ok(json) = serde_json::to_string(&tasks_message) { if let Err(e) = socket.send(axum::extract::ws::Message::Text(json)).await { eprintln!("Error sending initial tasks: {}", e); diff --git a/src/repository.rs b/src/repository.rs index 3c6f78d..1ef3bf1 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -723,7 +723,7 @@ impl Repository { rel_path.to_path_buf(), metadata, )); - + // Broadcast updated tasks let tasks = repository.aggregate_tasks(); let _ = repo_tx.send(RepositoryMessage::TasksUpdated { tasks }); @@ -733,9 +733,9 @@ impl Repository { }; // Remove from graph repository.remove_file_from_graph(&path); - let _ = repo_tx - .send(RepositoryMessage::FileRemoved(rel_path.to_path_buf())); - + let _ = + repo_tx.send(RepositoryMessage::FileRemoved(rel_path.to_path_buf())); + // Broadcast updated tasks let tasks = repository.aggregate_tasks(); let _ = repo_tx.send(RepositoryMessage::TasksUpdated { tasks }); @@ -792,10 +792,11 @@ impl Repository { let _ = repo_tx_clone.send(RepositoryMessage::FileChanged( path_clone, metadata, content, )); - + // Broadcast updated tasks let tasks = repository_clone.aggregate_tasks(); - let _ = repo_tx_clone.send(RepositoryMessage::TasksUpdated { tasks }); + let _ = + repo_tx_clone.send(RepositoryMessage::TasksUpdated { tasks }); } }); } @@ -833,25 +834,34 @@ impl Repository { Self::gather_tasks_from_ast(entry.value(), &mut tasklines); for (line, due) in tasklines { - let file_path = entry.key().to_file_path().ok() + let file_path = entry + .key() + .to_file_path() + .ok() .and_then(|p| p.strip_prefix(&self.root_dir).ok().map(|p| p.to_path_buf())) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let line_text = line.extract_str().trim_start().to_string(); - + let status = if let AstNodeKind::Line { ref properties } = &line.kind() { - properties.iter().find_map(|prop| { - if let Property::Task { status, .. } = prop { - Some(match status { - TaskStatus::Todo => "Todo", - TaskStatus::Doing => "Doing", - TaskStatus::Done => "Done", - }.to_string()) - } else { - None - } - }).unwrap_or_else(|| "Todo".to_string()) + properties + .iter() + .find_map(|prop| { + if let Property::Task { status, .. } = prop { + Some( + match status { + TaskStatus::Todo => "Todo", + TaskStatus::Doing => "Doing", + TaskStatus::Done => "Done", + } + .to_string(), + ) + } else { + None + } + }) + .unwrap_or_else(|| "Todo".to_string()) } else { "Todo".to_string() }; @@ -877,13 +887,11 @@ impl Repository { }); // Sort by deadline - tasks.sort_by(|a, b| { - match (&a.deadline, &b.deadline) { - (Some(d1), Some(d2)) => d1.cmp(d2), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.file_path.cmp(&b.file_path), - } + tasks.sort_by(|a, b| match (&a.deadline, &b.deadline) { + (Some(d1), Some(d2)) => d1.cmp(d2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.file_path.cmp(&b.file_path), }); tasks From 94ca6d6a9e24b37ec564751520faa6432863bda4 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 4 Dec 2025 10:11:09 +0900 Subject: [PATCH 4/5] Ensure live file change notifications clone repo_tx/content, include fallback metadata, and ignore task location field. --- src/repository.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 1ef3bf1..54e374e 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -749,6 +749,7 @@ impl Repository { let pending_changes_clone = Arc::clone(&pending_changes); let repository_clone = repository.clone(); + let repo_tx_clone = repo_tx.clone(); tokio::spawn(async move { sleep(debounce_duration).await; @@ -776,7 +777,7 @@ impl Repository { let start = Instant::now(); repository_clone - .handle_live_file_change(path_clone.clone(), content) + .handle_live_file_change(path_clone.clone(), content.clone()) .await; if let Ok(rel_path) = @@ -788,6 +789,22 @@ impl Repository { start.elapsed().as_millis() ); } + let link_count = repository_clone.calculate_back_links(&path_clone).len(); + + let metadata = repository_clone + .collect_file_metadata(&path_clone) + .unwrap_or_else(|_| { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + FileMetadata { + modified: now, + created: now, + link_count: link_count.try_into().unwrap(), + } + }); let _ = repo_tx_clone.send(RepositoryMessage::FileChanged( path_clone, metadata, content, @@ -811,7 +828,7 @@ impl Repository { fn gather_tasks_from_ast(parent: &AstNode, tasklines: &mut Vec<(AstNode, Deadline)>) { if let AstNodeKind::Line { ref properties } = &parent.kind() { for prop in properties { - if let Property::Task { status, due } = prop { + if let Property::Task { status, due, location: _location } = prop { if !matches!(status, TaskStatus::Done) { tasklines.push((parent.clone(), due.clone())); break; From 64c31bbebf4f6fbae42f6655cd0d1dbfabef1fe4 Mon Sep 17 00:00:00 2001 From: Shohei Fujii Date: Thu, 4 Dec 2025 10:13:08 +0900 Subject: [PATCH 5/5] fmt --- src/repository.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 54e374e..e7996a8 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -789,7 +789,8 @@ impl Repository { start.elapsed().as_millis() ); } - let link_count = repository_clone.calculate_back_links(&path_clone).len(); + let link_count = + repository_clone.calculate_back_links(&path_clone).len(); let metadata = repository_clone .collect_file_metadata(&path_clone) @@ -828,7 +829,12 @@ impl Repository { fn gather_tasks_from_ast(parent: &AstNode, tasklines: &mut Vec<(AstNode, Deadline)>) { if let AstNodeKind::Line { ref properties } = &parent.kind() { for prop in properties { - if let Property::Task { status, due, location: _location } = prop { + if let Property::Task { + status, + due, + location: _location, + } = prop + { if !matches!(status, TaskStatus::Done) { tasklines.push((parent.clone(), due.clone())); break;