diff --git a/src/development_hub/models/goal.py b/src/development_hub/models/goal.py
index cfd902c..8268ad9 100644
--- a/src/development_hub/models/goal.py
+++ b/src/development_hub/models/goal.py
@@ -55,6 +55,7 @@ class Milestone:
progress: int = 0 # 0-100
deliverables: list[Deliverable] = field(default_factory=list)
notes: str = ""
+ description: str = "" # Free-form description text
@property
def is_complete(self) -> bool:
@@ -80,9 +81,16 @@ class Milestone:
@dataclass
class Goal:
- """A goal item with priority and status."""
+ """A goal item with priority and status.
+
+ Goals support three states:
+ - Not achieved: completed=False, partial=False
+ - Partially achieved: completed=False, partial=True
+ - Achieved: completed=True, partial=False
+ """
text: str
completed: bool = False
+ partial: bool = False # Partially achieved (orange with half-moon)
priority: str = "medium" # "high", "medium", "low"
tags: list[str] = field(default_factory=list)
project: str | None = None
@@ -99,7 +107,7 @@ class GoalList:
"""Collection of goals organized by status."""
active: list[Goal] = field(default_factory=list)
future: list[Goal] = field(default_factory=list)
- non_goals: list[str] = field(default_factory=list)
+ non_goals: list[Goal] = field(default_factory=list)
project: str | None = None
updated: str | None = None
diff --git a/src/development_hub/models/todo.py b/src/development_hub/models/todo.py
index 39c4c62..ec41c52 100644
--- a/src/development_hub/models/todo.py
+++ b/src/development_hub/models/todo.py
@@ -43,6 +43,9 @@ class Todo:
parts.append(f"@{self.project}")
for tag in self.tags:
parts.append(f"#{tag}")
+ # For completed items, add priority tag so it's preserved when unchecking
+ if self.completed and self.priority in ("high", "medium", "low"):
+ parts.append(f"#{self.priority}")
if self.completed_date:
parts.append(f"({self.completed_date})")
if self.blocker_reason:
diff --git a/src/development_hub/parsers/base.py b/src/development_hub/parsers/base.py
index 9b874c2..0ef7365 100644
--- a/src/development_hub/parsers/base.py
+++ b/src/development_hub/parsers/base.py
@@ -1,10 +1,45 @@
"""Base parser class with common utilities."""
+import os
import re
+import tempfile
from pathlib import Path
from typing import Any
+def atomic_write(path: Path, content: str) -> None:
+ """Write content to file atomically.
+
+ Writes to a temp file first, then renames to target.
+ This prevents data loss if write is interrupted.
+
+ Args:
+ path: Target file path
+ content: Content to write
+ """
+ # Ensure parent directory exists
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Write to temp file in same directory (for atomic rename)
+ fd, tmp_path = tempfile.mkstemp(
+ dir=path.parent,
+ prefix=f".{path.name}.",
+ suffix=".tmp"
+ )
+ try:
+ with os.fdopen(fd, 'w') as f:
+ f.write(content)
+ # Atomic rename
+ os.replace(tmp_path, path)
+ except Exception:
+ # Clean up temp file on failure
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content.
@@ -127,6 +162,29 @@ class BaseParser:
return checked, text
return False, line.strip()
+ @staticmethod
+ def parse_checkbox_triple(line: str) -> tuple[bool, bool, str]:
+ """Parse a checkbox line with three states.
+
+ Args:
+ line: Line like "- [ ] Task", "- [~] Partial", or "- [x] Done task"
+
+ Returns:
+ Tuple of (is_completed, is_partial, task_text)
+ - [ ] -> (False, False, text)
+ - [~] -> (False, True, text)
+ - [x] -> (True, False, text)
+ """
+ # Match checkbox pattern with three states
+ match = re.match(r"^-\s*\[([ xX~])\]\s*(.+)$", line.strip())
+ if match:
+ marker = match.group(1).lower()
+ text = match.group(2).strip()
+ completed = marker == "x"
+ partial = marker == "~"
+ return completed, partial, text
+ return False, False, line.strip()
+
@staticmethod
def extract_milestone_tag(text: str) -> tuple[str | None, str]:
"""Extract @M1, @M2, etc. milestone tag from text.
diff --git a/src/development_hub/parsers/goals_parser.py b/src/development_hub/parsers/goals_parser.py
index 5cc0174..ce3e00d 100644
--- a/src/development_hub/parsers/goals_parser.py
+++ b/src/development_hub/parsers/goals_parser.py
@@ -3,7 +3,7 @@
import re
from pathlib import Path
-from development_hub.parsers.base import BaseParser
+from development_hub.parsers.base import BaseParser, atomic_write
from development_hub.models.goal import (
Goal,
GoalList,
@@ -63,27 +63,35 @@ class GoalsParser(BaseParser):
current_section = "non-goals"
continue
- # Handle checkbox items (Active and Future)
- if line_stripped.startswith("- [") and current_section in ("active", "future"):
+ # Handle checkbox items (Active, Future, and Non-Goals)
+ if line_stripped.startswith("- ["):
goal = self._parse_goal_line(line_stripped)
if goal:
if current_section == "active":
goal_list.active.append(goal)
- else:
+ elif current_section == "future":
goal_list.future.append(goal)
- continue
-
- # Handle non-goals (simple bullets)
- if current_section == "non-goals" and line_stripped.startswith("- "):
+ else: # non-goals
+ goal_list.non_goals.append(goal)
+ # Handle plain bullet items in non-goals section (backwards compatibility)
+ elif current_section == "non-goals" and line_stripped.startswith("- "):
text = line_stripped[2:].strip()
if text:
- goal_list.non_goals.append(text)
+ # Extract priority from hashtags
+ priority = "medium"
+ tags, text = self.extract_hashtags(text)
+ for tag in tags:
+ if tag in ("high", "medium", "low"):
+ priority = tag
+ break
+ goal = Goal(text=text, completed=False, priority=priority, tags=tags)
+ goal_list.non_goals.append(goal)
return goal_list
def _parse_goal_line(self, line: str) -> Goal | None:
- """Parse a single goal line."""
- completed, text = self.parse_checkbox(line)
+ """Parse a single goal line with three states."""
+ completed, partial, text = self.parse_checkbox_triple(line)
if not text:
return None
@@ -103,6 +111,7 @@ class GoalsParser(BaseParser):
return Goal(
text=text,
completed=completed,
+ partial=partial,
priority=priority,
tags=tags,
completed_date=date,
@@ -115,14 +124,18 @@ class MilestonesParser(BaseParser):
def parse(self) -> list[Milestone]:
"""Parse milestones.md file.
- Expected format:
+ Expected format (flat list, no section headers):
#### M1: Milestone Name
**Target**: January 2026
**Status**: In Progress (80%)
- | Deliverable | Status |
- |-------------|--------|
- | Item 1 | Done |
+ Description text here...
+
+ ---
+
+ #### M2: Another Milestone
+ **Target**: February 2026
+ **Status**: Completed (100%)
Returns:
List of Milestone instances
@@ -132,32 +145,50 @@ class MilestonesParser(BaseParser):
if not self.body:
return milestones
- # Split by milestone headers (#### M#:)
- milestone_pattern = r"####\s+(M\d+):\s*(.+)"
- parts = re.split(f"({milestone_pattern})", self.body)
+ milestone_pattern = r"####\s+(M[\d.]+):\s*(.+)"
- i = 0
- while i < len(parts):
- part = parts[i]
+ lines = self.body.split("\n")
+ current_milestone_lines = []
+ current_milestone_id = None
+ current_milestone_name = None
- # Check for milestone header match
- match = re.match(milestone_pattern, part)
+ for line in lines:
+ line_stripped = line.strip()
+
+ # Skip section headers (legacy support)
+ if line_stripped.startswith("## "):
+ continue
+
+ # Check for milestone header
+ match = re.match(milestone_pattern, line_stripped)
if match:
- milestone_id = match.group(1)
- milestone_name = match.group(2).strip()
+ # Save previous milestone if exists
+ if current_milestone_id:
+ milestone = self._parse_milestone_content(
+ current_milestone_id,
+ current_milestone_name,
+ "\n".join(current_milestone_lines),
+ )
+ if milestone:
+ milestones.append(milestone)
- # Get content until next milestone or section
- content = ""
- if i + 1 < len(parts):
- content = parts[i + 1]
+ # Start new milestone
+ current_milestone_id = match.group(1)
+ current_milestone_name = match.group(2).strip()
+ current_milestone_lines = []
+ elif current_milestone_id:
+ # Accumulate lines for current milestone
+ current_milestone_lines.append(line)
- milestone = self._parse_milestone_content(
- milestone_id, milestone_name, content
- )
- if milestone:
- milestones.append(milestone)
-
- i += 1
+ # Don't forget the last milestone
+ if current_milestone_id:
+ milestone = self._parse_milestone_content(
+ current_milestone_id,
+ current_milestone_name,
+ "\n".join(current_milestone_lines),
+ )
+ if milestone:
+ milestones.append(milestone)
return milestones
@@ -179,6 +210,7 @@ class MilestonesParser(BaseParser):
progress = 0
deliverables = []
notes = ""
+ description_lines = []
lines = content.split("\n")
table_lines = []
@@ -187,6 +219,10 @@ class MilestonesParser(BaseParser):
for line in lines:
line_stripped = line.strip()
+ # Skip separators
+ if line_stripped == "---":
+ continue
+
# Parse **Target**: value
target_match = re.match(r"\*\*Target\*\*:\s*(.+)", line_stripped)
if target_match:
@@ -210,15 +246,16 @@ class MilestonesParser(BaseParser):
if line_stripped.startswith("|"):
in_table = True
table_lines.append(line_stripped)
+ continue
elif in_table and not line_stripped.startswith("|"):
# Table ended
deliverables = self._parse_deliverables_table(table_lines)
table_lines = []
in_table = False
- # Stop at next section marker
- if line_stripped.startswith("---") or line_stripped.startswith("## "):
- break
+ # Collect description lines (non-empty, non-field lines)
+ if line_stripped and not in_table:
+ description_lines.append(line_stripped)
# Handle any remaining table
if table_lines:
@@ -232,6 +269,7 @@ class MilestonesParser(BaseParser):
progress=progress,
deliverables=deliverables,
notes=notes,
+ description=" ".join(description_lines),
)
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
@@ -323,21 +361,18 @@ class MilestonesParser(BaseParser):
# Write active milestones
lines.append("## Active")
lines.append("")
-
for milestone in active:
lines.extend(self._format_milestone(milestone))
lines.append("")
# Write completed milestones
- if completed:
- lines.append("## Completed")
+ lines.append("## Completed")
+ lines.append("")
+ for milestone in completed:
+ lines.extend(self._format_milestone(milestone))
lines.append("")
- for milestone in completed:
- lines.extend(self._format_milestone(milestone))
- lines.append("")
-
- self.path.write_text("\n".join(lines))
+ atomic_write(self.file_path, "\n".join(lines))
def _format_milestone(self, milestone: Milestone) -> list[str]:
"""Format a single milestone as markdown lines.
@@ -360,7 +395,7 @@ class MilestonesParser(BaseParser):
# Status with progress
progress = milestone.calculate_progress()
if milestone.status == MilestoneStatus.COMPLETE:
- lines.append("**Status**: Complete")
+ lines.append(f"**Status**: Completed ({progress}%)")
elif milestone.status == MilestoneStatus.IN_PROGRESS:
lines.append(f"**Status**: In Progress ({progress}%)")
elif milestone.status == MilestoneStatus.PLANNING:
@@ -372,10 +407,14 @@ class MilestonesParser(BaseParser):
if milestone.notes:
lines.append(f"**Notes**: {milestone.notes}")
- lines.append("")
+ # Description (after fields, before table)
+ if milestone.description:
+ lines.append("")
+ lines.append(milestone.description)
# Deliverables table
if milestone.deliverables:
+ lines.append("")
lines.append("| Deliverable | Status |")
lines.append("|-------------|--------|")
@@ -422,7 +461,7 @@ class GoalsSaver:
lines.append("## Active")
lines.append("")
for goal in goal_list.active:
- checkbox = "[x]" if goal.completed else "[ ]"
+ checkbox = self._get_checkbox(goal)
priority_tag = f" #{goal.priority}" if goal.priority else ""
lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("")
@@ -432,16 +471,36 @@ class GoalsSaver:
lines.append("## Future")
lines.append("")
for goal in goal_list.future:
- checkbox = "[x]" if goal.completed else "[ ]"
- lines.append(f"- {checkbox} {goal.text}")
+ checkbox = self._get_checkbox(goal)
+ priority_tag = f" #{goal.priority}" if goal.priority else ""
+ lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("")
- # Non-goals
+ # Non-goals (also with checkboxes and priority)
if goal_list.non_goals:
lines.append("## Non-Goals")
lines.append("")
- for non_goal in goal_list.non_goals:
- lines.append(f"- {non_goal}")
+ for goal in goal_list.non_goals:
+ checkbox = self._get_checkbox(goal)
+ priority_tag = f" #{goal.priority}" if goal.priority else ""
+ lines.append(f"- {checkbox} {goal.text}{priority_tag}")
lines.append("")
- self.path.write_text("\n".join(lines))
+ atomic_write(self.path, "\n".join(lines))
+
+ @staticmethod
+ def _get_checkbox(goal: Goal) -> str:
+ """Get the checkbox marker for a goal's state.
+
+ Args:
+ goal: Goal to get checkbox for
+
+ Returns:
+ "[x]" for completed, "[~]" for partial, "[ ]" for not achieved
+ """
+ if goal.completed:
+ return "[x]"
+ elif getattr(goal, 'partial', False):
+ return "[~]"
+ else:
+ return "[ ]"
diff --git a/src/development_hub/parsers/todos_parser.py b/src/development_hub/parsers/todos_parser.py
index 0e7da62..21adbfd 100644
--- a/src/development_hub/parsers/todos_parser.py
+++ b/src/development_hub/parsers/todos_parser.py
@@ -3,7 +3,7 @@
import re
from pathlib import Path
-from development_hub.parsers.base import BaseParser
+from development_hub.parsers.base import BaseParser, atomic_write
from development_hub.models.todo import Todo, TodoList
@@ -134,10 +134,23 @@ class TodosParser(BaseParser):
if section == "completed":
completed = True
+ # For completed items, check if priority is stored in tags
+ # This preserves priority when items are unchecked
+ effective_priority = priority
+ if section == "completed":
+ for tag in tags:
+ if tag in ("high", "medium", "low"):
+ effective_priority = tag
+ tags.remove(tag)
+ break
+ else:
+ # Default to medium if no priority tag found
+ effective_priority = "medium"
+
todo = Todo(
text=text,
completed=completed,
- priority=priority if not completed else "completed",
+ priority=effective_priority,
project=project,
milestone=milestone,
tags=tags,
@@ -262,4 +275,4 @@ class TodosParser(BaseParser):
todo_list: TodoList to save
"""
content = self.write(todo_list)
- self.file_path.write_text(content)
+ atomic_write(self.file_path, content)
diff --git a/src/development_hub/project_list.py b/src/development_hub/project_list.py
index 0c333fc..583b352 100644
--- a/src/development_hub/project_list.py
+++ b/src/development_hub/project_list.py
@@ -193,6 +193,13 @@ class ProjectListWidget(QWidget):
progress_bar.setObjectName("progress_bar")
layout.addWidget(progress_bar)
+ # OK button (hidden until complete)
+ ok_button = QPushButton("OK")
+ ok_button.setObjectName("ok_button")
+ ok_button.clicked.connect(dialog.accept)
+ ok_button.hide()
+ layout.addWidget(ok_button)
+
return dialog
def _deploy_docs(self, project: Project):
@@ -350,6 +357,11 @@ class ProjectListWidget(QWidget):
"Deploy Complete" if success else "Deploy Failed"
)
+ # Show OK button
+ ok_button = self._progress_dialog.findChild(QPushButton, "ok_button")
+ if ok_button:
+ ok_button.show()
+
self._deploy_thread = None
def _rebuild_main_docs(self):
@@ -394,6 +406,11 @@ class ProjectListWidget(QWidget):
"Rebuild Complete" if success else "Rebuild Failed"
)
+ # Show OK button
+ ok_button = self._progress_dialog.findChild(QPushButton, "ok_button")
+ if ok_button:
+ ok_button.show()
+
self._rebuild_thread = None
def _update_docs(self, project: Project):
diff --git a/src/development_hub/views/dashboard.py b/src/development_hub/views/dashboard.py
index cb94660..44cd832 100644
--- a/src/development_hub/views/dashboard.py
+++ b/src/development_hub/views/dashboard.py
@@ -4,7 +4,9 @@ import subprocess
import shutil
from pathlib import Path
-from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher, QTimer
+from development_hub.parsers.base import atomic_write
+
+from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher, QTimer, QThread, QObject
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
@@ -15,11 +17,75 @@ from PyQt6.QtWidgets import (
QPushButton,
QLineEdit,
QComboBox,
+ QDialog,
+ QProgressBar,
+ QMessageBox,
+ QApplication,
)
from development_hub.project_discovery import Project
+from development_hub.services.health_checker import HealthChecker
+from development_hub.parsers.progress_parser import ProgressLogManager
+from development_hub.widgets.health_card import HealthCardCompact
+
+
+class AuditWorker(QObject):
+ """Background worker for running goals audit."""
+
+ finished = pyqtSignal(str, bool) # output, success
+ error = pyqtSignal(str)
+
+ def __init__(self, goals_content: str, project_path: Path | None = None):
+ super().__init__()
+ self.goals_content = goals_content
+ self.project_path = project_path
+ self._process: subprocess.Popen | None = None
+ self._cancelled = False
+
+ def run(self):
+ """Execute the audit command."""
+ cmdforge_path = Path.home() / "PycharmProjects" / "CmdForge" / ".venv" / "bin" / "cmdforge"
+ if not cmdforge_path.exists():
+ cmdforge_path = Path("cmdforge")
+
+ try:
+ self._process = subprocess.Popen(
+ [str(cmdforge_path), "run", "audit-goals"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None,
+ )
+
+ stdout, stderr = self._process.communicate(input=self.goals_content)
+
+ if self._cancelled:
+ return
+
+ if self._process.returncode == 0:
+ self.finished.emit(stdout, True)
+ else:
+ self.finished.emit(stderr or stdout, False)
+
+ except Exception as e:
+ if not self._cancelled:
+ self.error.emit(str(e))
+
+ def cancel(self):
+ """Cancel the running process."""
+ self._cancelled = True
+ if self._process:
+ self._process.terminate()
+ try:
+ self._process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ self._process.kill()
+
+
from development_hub.services.git_service import GitService
from development_hub.services.health_checker import HealthChecker
+from development_hub.models.health import HealthStatus
from development_hub.parsers.todos_parser import TodosParser
from development_hub.parsers.goals_parser import MilestonesParser, GoalsParser
from development_hub.widgets.progress_bar import MilestoneProgressBar
@@ -35,7 +101,7 @@ from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable,
class ProjectDashboard(QWidget):
- """Dashboard view for a single project.
+ """Dashboard view for a single project or global overview.
Displays:
- Project health status and stats
@@ -43,26 +109,38 @@ class ProjectDashboard(QWidget):
- Milestones (time-bound checkpoints)
- Recent activity, completed, blocked
- TODOs by priority
+
+ When project is None, displays global dashboard with:
+ - All projects health status
+ - Global goals/milestones/todos
+ - Needs attention section
+ - Today's progress
"""
terminal_requested = pyqtSignal(object) # Emits Project
+ project_selected = pyqtSignal(str) # Emits project_key (global mode only)
+ standup_requested = pyqtSignal() # Global mode only
- def __init__(self, project: Project, parent: QWidget | None = None):
+ def __init__(self, project: Project | None = None, parent: QWidget | None = None):
"""Initialize project dashboard.
Args:
- project: Project to display
+ project: Project to display, or None for global dashboard
parent: Parent widget
"""
super().__init__(parent)
self.project = project
+ self.is_global = project is None
self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
# Todos state
self._todos_parser = None
self._todo_list = None
self._todos_path = None
- self._undo_stack = [] # List of (action_type, todo_text, priority, was_completed)
+ # Undo/redo stacks support todos, goals, and ideas
+ # Format: (action_type, ...action_specific_data)
+ # action_type: toggle, delete, edit (todos), goal_toggle, goal_delete, goal_edit, idea_toggle, idea_delete, idea_edit
+ self._undo_stack = []
self._redo_stack = []
self._ignoring_file_change = False # To avoid reload loops when we save
@@ -76,6 +154,13 @@ class ProjectDashboard(QWidget):
self._milestones_list = None
self._milestones_path = None
+ # Audit worker state
+ self._audit_worker: AuditWorker | None = None
+ self._audit_thread: QThread | None = None
+ self._audit_dialog: QDialog | None = None
+ self._audit_elapsed_seconds = 0
+ self._audit_timer: QTimer | None = None
+
# Filter state
self._current_filter = "priority" # "priority", "milestone", or "all"
self._milestone_sections = {} # For milestone filter view
@@ -90,9 +175,34 @@ class ProjectDashboard(QWidget):
self._reload_timer.setSingleShot(True)
self._reload_timer.timeout.connect(self._do_reload_todos)
+ # Debounce timers for goals and milestones
+ self._reload_goals_timer = QTimer(self)
+ self._reload_goals_timer.setSingleShot(True)
+ self._reload_goals_timer.timeout.connect(self._do_reload_goals)
+
+ self._reload_milestones_timer = QTimer(self)
+ self._reload_milestones_timer.setSingleShot(True)
+ self._reload_milestones_timer.timeout.connect(self._do_reload_milestones)
+
+ # Git status refresh timer (every 3 minutes)
+ self._git_status_timer = QTimer(self)
+ self._git_status_timer.timeout.connect(self._refresh_git_status)
+ self._git_status_timer.start(180000) # 3 minutes in ms
+
+ # Track last known git status for change detection
+ self._last_git_status = None
+
self._setup_ui()
self._load_data()
+ def closeEvent(self, event):
+ """Clean up resources when widget is closed."""
+ # Cancel any running audit
+ if self._audit_worker:
+ self._audit_worker.cancel()
+ self._cleanup_audit()
+ super().closeEvent(event)
+
def _setup_ui(self):
"""Set up the UI."""
# Main scroll area
@@ -111,26 +221,89 @@ class ProjectDashboard(QWidget):
# Header
header_layout = QHBoxLayout()
- title = QLabel(f"{self.project.title} Dashboard")
+ if self.is_global:
+ title = QLabel("Development Hub")
+ else:
+ title = QLabel(f"{self.project.title} Dashboard")
title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;")
header_layout.addWidget(title)
header_layout.addStretch()
- # Health indicator
+ # Daily Standup button (global only)
+ if self.is_global:
+ standup_btn = QPushButton("Daily Standup")
+ standup_btn.setStyleSheet("""
+ QPushButton {
+ background-color: #3d6a99;
+ color: #e0e0e0;
+ border: none;
+ border-radius: 4px;
+ padding: 8px 16px;
+ font-size: 13px;
+ }
+ QPushButton:hover {
+ background-color: #4a7ab0;
+ }
+ """)
+ standup_btn.clicked.connect(self.standup_requested.emit)
+ header_layout.addWidget(standup_btn)
+
+ # Health indicator (project only)
self.health_label = QLabel()
self.health_label.setStyleSheet("font-size: 14px; color: #888888;")
- header_layout.addWidget(self.health_label)
+ if not self.is_global:
+ header_layout.addWidget(self.health_label)
layout.addLayout(header_layout)
+ # Overall milestones progress (global only)
+ if self.is_global:
+ milestone_section = QLabel("MILESTONES")
+ milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;")
+ layout.addWidget(milestone_section)
+
+ self.global_milestone_progress = MilestoneProgressBar()
+ layout.addWidget(self.global_milestone_progress)
+
# Stats row
self.stats_row = StatCardRow()
- self.stats_row.add_stat("goals", 0, "Active\nGoals", "#4a9eff")
- self.stats_row.add_stat("blocked", 0, "Blocked\nItems", "#ff9800")
- self.stats_row.add_stat("todos", 0, "Open\nTODOs", "#e0e0e0")
+ if self.is_global:
+ self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50")
+ self.stats_row.add_stat("warning", 0, "Warning", "#ff9800")
+ self.stats_row.add_stat("todos", 0, "Total TODOs", "#e0e0e0")
+ else:
+ self.stats_row.add_stat("goals", 0, "Active\nGoals", "#4a9eff")
+ self.stats_row.add_stat("blocked", 0, "Blocked\nItems", "#ff9800")
+ self.stats_row.add_stat("todos", 0, "Open\nTODOs", "#e0e0e0")
layout.addWidget(self.stats_row)
+ # Health alert banner (hidden by default)
+ self.alert_banner = QFrame()
+ self.alert_banner.setStyleSheet("""
+ QFrame {
+ background-color: #4a3000;
+ border: 1px solid #ff9800;
+ border-radius: 6px;
+ padding: 8px;
+ }
+ """)
+ alert_layout = QHBoxLayout(self.alert_banner)
+ alert_layout.setContentsMargins(12, 8, 12, 8)
+ alert_layout.setSpacing(8)
+
+ self.alert_icon = QLabel()
+ self.alert_icon.setStyleSheet("font-size: 16px;")
+ alert_layout.addWidget(self.alert_icon)
+
+ self.alert_text = QLabel()
+ self.alert_text.setStyleSheet("color: #e0e0e0; font-size: 13px;")
+ self.alert_text.setWordWrap(True)
+ alert_layout.addWidget(self.alert_text, stretch=1)
+
+ self.alert_banner.hide()
+ layout.addWidget(self.alert_banner)
+
# === GOALS SECTION ===
goals_header_layout = QHBoxLayout()
goals_header_layout.setSpacing(8)
@@ -165,6 +338,11 @@ class ProjectDashboard(QWidget):
goals_header_layout.addStretch()
+ audit_goals_btn = QPushButton("Audit")
+ audit_goals_btn.setStyleSheet(self._button_style())
+ audit_goals_btn.clicked.connect(self._audit_goals)
+ goals_header_layout.addWidget(audit_goals_btn)
+
edit_goals_btn = QPushButton("Edit")
edit_goals_btn.setStyleSheet(self._button_style())
edit_goals_btn.clicked.connect(self._open_goals)
@@ -172,12 +350,15 @@ class ProjectDashboard(QWidget):
layout.addLayout(goals_header_layout)
- # Goals container (will be populated by _load_goals)
- self.goals_container = QWidget()
- self.goals_layout = QVBoxLayout(self.goals_container)
- self.goals_layout.setContentsMargins(16, 4, 0, 4)
- self.goals_layout.setSpacing(4)
- layout.addWidget(self.goals_container)
+ # Goals subsections (Active, Future, Non-Goals)
+ self.goals_active_section = CollapsibleSection("Active", expanded=True)
+ layout.addWidget(self.goals_active_section)
+
+ self.goals_future_section = CollapsibleSection("Future", expanded=False)
+ layout.addWidget(self.goals_future_section)
+
+ self.goals_nongoals_section = CollapsibleSection("Non-Goals", expanded=False)
+ layout.addWidget(self.goals_nongoals_section)
# Add goal input
goals_add_layout = QHBoxLayout()
@@ -202,6 +383,57 @@ class ProjectDashboard(QWidget):
self.goal_input.returnPressed.connect(self._add_goal)
goals_add_layout.addWidget(self.goal_input)
+ # Section dropdown for goals
+ self.goal_section_combo = QComboBox()
+ self.goal_section_combo.addItem("active", "active")
+ self.goal_section_combo.addItem("future", "future")
+ self.goal_section_combo.addItem("non-goal", "non-goal")
+ self.goal_section_combo.setFixedWidth(70)
+ self.goal_section_combo.setStyleSheet("""
+ QComboBox {
+ background-color: #2d2d2d;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 4px;
+ color: #e0e0e0;
+ font-size: 11px;
+ }
+ QComboBox::drop-down { border: none; }
+ QComboBox::down-arrow {
+ image: none;
+ border-left: 3px solid transparent;
+ border-right: 3px solid transparent;
+ border-top: 4px solid #888888;
+ }
+ """)
+ goals_add_layout.addWidget(self.goal_section_combo)
+
+ # Priority dropdown for goals
+ self.goal_priority_combo = QComboBox()
+ self.goal_priority_combo.addItem("H", "high")
+ self.goal_priority_combo.addItem("M", "medium")
+ self.goal_priority_combo.addItem("L", "low")
+ self.goal_priority_combo.setCurrentIndex(0)
+ self.goal_priority_combo.setFixedWidth(40)
+ self.goal_priority_combo.setStyleSheet("""
+ QComboBox {
+ background-color: #2d2d2d;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 4px;
+ color: #e0e0e0;
+ font-size: 11px;
+ }
+ QComboBox::drop-down { border: none; }
+ QComboBox::down-arrow {
+ image: none;
+ border-left: 3px solid transparent;
+ border-right: 3px solid transparent;
+ border-top: 4px solid #888888;
+ }
+ """)
+ goals_add_layout.addWidget(self.goal_priority_combo)
+
goal_add_btn = QPushButton("+")
goal_add_btn.setFixedSize(28, 28)
goal_add_btn.setStyleSheet(self._button_style())
@@ -287,14 +519,14 @@ class ProjectDashboard(QWidget):
layout.addLayout(milestone_add_layout)
- # === RECENT ACTIVITY SECTION ===
- self.activity_section = CollapsibleSection("RECENT ACTIVITY", expanded=False)
- layout.addWidget(self.activity_section)
-
# === BLOCKED SECTION ===
self.blocked_section = CollapsibleSection("BLOCKED", expanded=False)
layout.addWidget(self.blocked_section)
+ # === RECENT ACTIVITY SECTION ===
+ self.activity_section = CollapsibleSection("RECENT ACTIVITY", expanded=False)
+ layout.addWidget(self.activity_section)
+
# === SEPARATOR ===
separator = QFrame()
separator.setFrameShape(QFrame.Shape.HLine)
@@ -432,6 +664,119 @@ class ProjectDashboard(QWidget):
layout.addLayout(add_layout)
+ # === SEPARATOR (after TODOs) ===
+ separator2 = QFrame()
+ separator2.setFrameShape(QFrame.Shape.HLine)
+ separator2.setStyleSheet("background-color: #3d3d3d;")
+ separator2.setFixedHeight(1)
+ layout.addWidget(separator2)
+
+ # === IDEAS & EXPLORATION SECTION ===
+ ideas_header_layout = QHBoxLayout()
+ ideas_header_layout.setSpacing(8)
+ ideas_label = QLabel("IDEAS & EXPLORATION")
+ ideas_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #e0e0e0;")
+ ideas_header_layout.addWidget(ideas_label)
+ ideas_header_layout.addStretch()
+
+ edit_ideas_btn = QPushButton("Edit")
+ edit_ideas_btn.setStyleSheet(self._button_style())
+ edit_ideas_btn.clicked.connect(self._open_ideas)
+ ideas_header_layout.addWidget(edit_ideas_btn)
+
+ layout.addLayout(ideas_header_layout)
+
+ self.ideas_section = CollapsibleSection("Ideas", expanded=False)
+ layout.addWidget(self.ideas_section)
+
+ # Add idea input
+ ideas_add_layout = QHBoxLayout()
+ ideas_add_layout.setContentsMargins(16, 0, 0, 0)
+ ideas_add_layout.setSpacing(4)
+
+ self.idea_input = QLineEdit()
+ self.idea_input.setPlaceholderText("Add new idea...")
+ self.idea_input.setStyleSheet("""
+ QLineEdit {
+ background-color: #2d2d2d;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 6px 8px;
+ color: #e0e0e0;
+ font-size: 12px;
+ }
+ QLineEdit:focus {
+ border-color: #4a9eff;
+ }
+ """)
+ self.idea_input.returnPressed.connect(self._add_idea)
+ ideas_add_layout.addWidget(self.idea_input)
+
+ # Priority dropdown for ideas
+ self.idea_priority_combo = QComboBox()
+ self.idea_priority_combo.addItem("H", "high")
+ self.idea_priority_combo.addItem("M", "medium")
+ self.idea_priority_combo.addItem("L", "low")
+ self.idea_priority_combo.setCurrentIndex(0)
+ self.idea_priority_combo.setFixedWidth(40)
+ self.idea_priority_combo.setStyleSheet("""
+ QComboBox {
+ background-color: #2d2d2d;
+ border: 1px solid #3d3d3d;
+ border-radius: 4px;
+ padding: 4px;
+ color: #e0e0e0;
+ font-size: 11px;
+ }
+ QComboBox::drop-down { border: none; }
+ QComboBox::down-arrow {
+ image: none;
+ border-left: 3px solid transparent;
+ border-right: 3px solid transparent;
+ border-top: 4px solid #888888;
+ }
+ """)
+ ideas_add_layout.addWidget(self.idea_priority_combo)
+
+ idea_add_btn = QPushButton("+")
+ idea_add_btn.setFixedSize(28, 28)
+ idea_add_btn.setStyleSheet(self._button_style())
+ idea_add_btn.clicked.connect(self._add_idea)
+ ideas_add_layout.addWidget(idea_add_btn)
+
+ layout.addLayout(ideas_add_layout)
+
+ # === GLOBAL-ONLY SECTIONS ===
+ if self.is_global:
+ # Project health section
+ health_label = QLabel("PROJECT HEALTH")
+ health_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
+ layout.addWidget(health_label)
+
+ self.health_container = QVBoxLayout()
+ self.health_container.setSpacing(4)
+ layout.addLayout(self.health_container)
+
+ # Needs attention section
+ attention_label = QLabel("NEEDS ATTENTION")
+ attention_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
+ layout.addWidget(attention_label)
+
+ self.attention_list = QLabel()
+ self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
+ self.attention_list.setWordWrap(True)
+ layout.addWidget(self.attention_list)
+
+ # Today's progress section
+ progress_label = QLabel("TODAY'S PROGRESS")
+ progress_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
+ layout.addWidget(progress_label)
+
+ self.progress_list = QLabel()
+ self.progress_list.setStyleSheet("color: #b0b0b0; line-height: 1.6;")
+ self.progress_list.setWordWrap(True)
+ layout.addWidget(self.progress_list)
+
layout.addStretch()
scroll.setWidget(content)
@@ -448,8 +793,126 @@ class ProjectDashboard(QWidget):
def _load_data(self):
"""Load project data and update display."""
- # Get health info
checker = HealthChecker()
+
+ if self.is_global:
+ self._load_global_data(checker)
+ else:
+ self._load_project_data(checker)
+
+ # Load common sections
+ self._load_goals()
+ self._load_milestones()
+ self._load_ideas()
+ self._load_todos()
+
+ # Load project-only sections
+ if not self.is_global:
+ self._load_activity()
+
+ def _load_global_data(self, checker: HealthChecker):
+ """Load global dashboard data."""
+ ecosystem = checker.check_all_projects()
+
+ # Update stats
+ self.stats_row.update_stat("healthy", ecosystem.healthy_count)
+ self.stats_row.update_stat("warning", ecosystem.warning_count + ecosystem.critical_count)
+ self.stats_row.update_stat("todos", ecosystem.total_active_todos)
+
+ # Hide alert banner in global mode
+ self.alert_banner.hide()
+
+ # Load global milestone progress (shows completion ratio, not single milestone)
+ milestones_path = self._docs_root / "goals" / "milestones.md"
+ if milestones_path.exists():
+ parser = MilestonesParser(milestones_path)
+ all_milestones = parser.parse()
+ if all_milestones:
+ completed = sum(1 for m in all_milestones if m.is_complete)
+ total = len(all_milestones)
+ progress = int((completed / total) * 100) if total > 0 else 0
+ self.global_milestone_progress.set_milestone(
+ f"{completed} of {total} Complete",
+ progress,
+ "", # No target date for overall progress
+ )
+ else:
+ self.global_milestone_progress.clear()
+ else:
+ self.global_milestone_progress.clear()
+
+ # Build project milestone progress lookup
+ project_milestone_progress = {}
+ for health in ecosystem.projects:
+ project_milestones_path = self._docs_root / "projects" / health.project_key / "milestones.md"
+ if project_milestones_path.exists():
+ try:
+ proj_parser = MilestonesParser(project_milestones_path)
+ current_milestone = proj_parser.get_current_milestone()
+ if current_milestone:
+ project_milestone_progress[health.project_key] = current_milestone.progress
+ elif proj_parser.parse():
+ milestones = proj_parser.parse()
+ total_progress = sum(m.progress for m in milestones)
+ project_milestone_progress[health.project_key] = total_progress // len(milestones)
+ except Exception:
+ pass
+
+ # Clear and rebuild health cards
+ while self.health_container.count():
+ item = self.health_container.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+
+ for health in ecosystem.projects:
+ card = HealthCardCompact()
+ milestone_prog = project_milestone_progress.get(health.project_key, 0)
+ card.set_project(
+ health.project_key,
+ health.project_name,
+ health.status_icon,
+ health.git_info.time_since_commit,
+ health.active_todos,
+ milestone_prog,
+ )
+ card.clicked.connect(self._on_project_clicked)
+ self.health_container.addWidget(card)
+
+ # Needs attention
+ needing = ecosystem.get_needing_attention()
+ if needing:
+ attention_html = "
".join(
+ f"⚠ {h.project_name}: {', '.join(h.attention_reasons[:2])}"
+ for h in needing[:5]
+ )
+ self.attention_list.setText(attention_html)
+ self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
+ else:
+ self.attention_list.setText("All projects healthy!")
+ self.attention_list.setStyleSheet("color: #4caf50; line-height: 1.6;")
+
+ # Today's progress
+ progress_dir = self._docs_root / "progress"
+ if progress_dir.exists():
+ manager = ProgressLogManager(progress_dir)
+ today = manager.get_today()
+ if today:
+ items = []
+ for task in today.completed[:5]:
+ items.append(f"☑ {task}")
+ for task in today.in_progress[:3]:
+ items.append(f"☐ {task} (in progress)")
+ if items:
+ self.progress_list.setText("
".join(items))
+ else:
+ self.progress_list.setText("No progress logged yet today")
+ else:
+ self.progress_list.setText("No progress log for today")
+ else:
+ self.progress_list.setText("Progress directory not found")
+
+ def _load_project_data(self, checker: HealthChecker):
+ """Load project-specific dashboard data."""
health = checker.check_project(self.project)
# Update health label
@@ -463,28 +926,58 @@ class ProjectDashboard(QWidget):
self.stats_row.update_stat("blocked", health.blocked_todos)
self.stats_row.update_stat("todos", health.active_todos)
- # Load all sections
- self._load_goals()
- self._load_milestones()
- self._load_activity()
- self._load_todos()
+ # Update alert banner
+ if health.needs_attention:
+ reasons = health.attention_reasons
+ if health.status == HealthStatus.CRITICAL:
+ self.alert_banner.setStyleSheet("""
+ QFrame {
+ background-color: #4a1a1a;
+ border: 1px solid #ff5252;
+ border-radius: 6px;
+ padding: 8px;
+ }
+ """)
+ self.alert_icon.setText("🔴")
+ self.alert_text.setText(f"Critical: {' • '.join(reasons)}")
+ else:
+ self.alert_banner.setStyleSheet("""
+ QFrame {
+ background-color: #4a3000;
+ border: 1px solid #ff9800;
+ border-radius: 6px;
+ padding: 8px;
+ }
+ """)
+ self.alert_icon.setText("🟡")
+ self.alert_text.setText(f"Attention: {' • '.join(reasons)}")
+
+ self.alert_banner.show()
+ else:
+ self.alert_banner.hide()
+
+ def _on_project_clicked(self, project_key: str):
+ """Handle project card click (global mode only)."""
+ self.project_selected.emit(project_key)
def _load_goals(self):
- """Load and display goals."""
- # Clear existing goal widgets
- while self.goals_layout.count():
- item = self.goals_layout.takeAt(0)
- if item.widget():
- item.widget().deleteLater()
+ """Load and display goals in Active, Future, and Non-Goals subsections."""
+ # Clear all goal subsections
+ self.goals_active_section.clear_content()
+ self.goals_future_section.clear_content()
+ self.goals_nongoals_section.clear_content()
- goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
+ if self.is_global:
+ goals_path = self._docs_root / "goals" / "goals.md"
+ else:
+ goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
self._goals_path = goals_path
if not goals_path.exists():
no_goals = QLabel("No goals defined yet. Add one below!")
no_goals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;")
no_goals.setWordWrap(True)
- self.goals_layout.addWidget(no_goals)
+ self.goals_active_section.add_widget(no_goals)
self.goals_progress.setValue(0)
self._goals_parser = None
self._goal_list = None
@@ -493,6 +986,7 @@ class ProjectDashboard(QWidget):
self._goals_parser = GoalsParser(goals_path)
self._goal_list = self._goals_parser.parse()
+ # Active goals
completed_count = 0
total_count = len(self._goal_list.active)
@@ -500,29 +994,73 @@ class ProjectDashboard(QWidget):
widget = GoalItemWidget(goal)
widget.toggled.connect(self._on_goal_toggled)
widget.deleted.connect(self._on_goal_deleted)
- self.goals_layout.addWidget(widget)
+ widget.edited.connect(self._on_goal_edited)
+ self.goals_active_section.add_widget(widget)
if goal.completed:
completed_count += 1
if not self._goal_list.active:
- no_goals = QLabel("No active goals defined. Add one below!")
+ no_goals = QLabel("No active goals")
no_goals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;")
- self.goals_layout.addWidget(no_goals)
+ self.goals_active_section.add_widget(no_goals)
- # Update progress bar
+ self.goals_active_section.set_count_ratio(completed_count, total_count)
+
+ # Future goals (turn green when checked, no strikethrough)
+ future_checked = 0
+ for goal in self._goal_list.future:
+ widget = GoalItemWidget(goal, is_non_goal=True)
+ widget.toggled.connect(self._on_goal_toggled)
+ widget.deleted.connect(self._on_goal_deleted)
+ widget.edited.connect(self._on_goal_edited)
+ self.goals_future_section.add_widget(widget)
+ if goal.completed:
+ future_checked += 1
+
+ if not self._goal_list.future:
+ no_future = QLabel("No future goals")
+ no_future.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;")
+ self.goals_future_section.add_widget(no_future)
+
+ self.goals_future_section.set_count_ratio(future_checked, len(self._goal_list.future))
+
+ # Non-goals (checkable, turn green when checked)
+ nongoals_checked = 0
+ for goal in self._goal_list.non_goals:
+ widget = GoalItemWidget(goal, is_non_goal=True)
+ widget.toggled.connect(self._on_goal_toggled)
+ widget.deleted.connect(self._on_goal_deleted)
+ widget.edited.connect(self._on_goal_edited)
+ self.goals_nongoals_section.add_widget(widget)
+ if goal.completed:
+ nongoals_checked += 1
+
+ if not self._goal_list.non_goals:
+ no_nongoals = QLabel("No non-goals defined")
+ no_nongoals.setStyleSheet("color: #666666; font-style: italic; font-size: 13px;")
+ self.goals_nongoals_section.add_widget(no_nongoals)
+
+ self.goals_nongoals_section.set_count_ratio(nongoals_checked, len(self._goal_list.non_goals))
+
+ # Update progress bar (active goals only)
progress = int((completed_count / total_count) * 100) if total_count > 0 else 0
self.goals_progress.setValue(progress)
def _load_milestones(self):
"""Load and display milestones with linked todos."""
+ from development_hub.models.goal import MilestoneStatus
+
# Clear existing milestone widgets
while self.milestones_layout.count():
item = self.milestones_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
- # Use project-specific milestones
- milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
+ # Use appropriate milestones path
+ if self.is_global:
+ milestones_path = self._docs_root / "goals" / "milestones.md"
+ else:
+ milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
self._milestones_path = milestones_path
if not milestones_path.exists():
@@ -538,20 +1076,22 @@ class ProjectDashboard(QWidget):
self._milestones_list = self._milestones_parser.parse()
# Load todos to get linked items for each milestone
- todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
+ if self.is_global:
+ todos_path = self._docs_root / "todos.md"
+ else:
+ todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
todo_list = None
if todos_path.exists():
parser = TodosParser(todos_path)
todo_list = parser.parse()
- # Create MilestoneWidget for each milestone with linked todos
- for i, milestone in enumerate(self._milestones_list):
- # First active milestone is expanded by default
- from development_hub.models.goal import MilestoneStatus
- is_first_active = (
- milestone.status in (MilestoneStatus.IN_PROGRESS, MilestoneStatus.NOT_STARTED, MilestoneStatus.PLANNING)
- and i == 0
- )
+ # Create MilestoneWidget for each milestone (keep original file order)
+ first_active_shown = False
+ for milestone in self._milestones_list:
+ # First in-progress milestone is expanded by default
+ is_first_active = not first_active_shown and milestone.status == MilestoneStatus.IN_PROGRESS
+ if is_first_active:
+ first_active_shown = True
# Get todos linked to this milestone
linked_todos = []
@@ -559,15 +1099,7 @@ class ProjectDashboard(QWidget):
linked_todos = todo_list.get_by_milestone(milestone.id)
widget = MilestoneWidget(milestone, expanded=is_first_active, todos=linked_todos)
- # Connect signals for todo mode
- widget.todo_toggled.connect(self._on_milestone_todo_toggled)
- widget.todo_deleted.connect(self._on_milestone_todo_deleted)
- widget.todo_added.connect(self._on_milestone_todo_added)
- widget.todo_start_discussion.connect(self._on_todo_start_discussion)
- # Keep legacy signals for deliverables mode (fallback)
- widget.deliverable_toggled.connect(self._on_deliverable_toggled)
- widget.deliverable_added.connect(self._on_deliverable_added)
- widget.deliverable_deleted.connect(self._on_deliverable_deleted)
+ self._connect_milestone_signals(widget)
self.milestones_layout.addWidget(widget)
if not self._milestones_list:
@@ -577,22 +1109,100 @@ class ProjectDashboard(QWidget):
self.milestones_progress.setValue(0)
return
- # Calculate overall milestones progress based on linked todos
- total_todos = 0
- completed_todos = 0
- if todo_list:
- for milestone in self._milestones_list:
- linked = todo_list.get_by_milestone(milestone.id)
- total_todos += len(linked)
- completed_todos += sum(1 for t in linked if t.completed)
-
- progress = int((completed_todos / total_todos) * 100) if total_todos > 0 else 0
+ # Calculate overall milestones progress as ratio of complete milestones
+ total_milestones = len(self._milestones_list)
+ completed_milestones = sum(1 for m in self._milestones_list if m.is_complete)
+ progress = int((completed_milestones / total_milestones) * 100) if total_milestones > 0 else 0
self.milestones_progress.setValue(progress)
+ def _connect_milestone_signals(self, widget: MilestoneWidget):
+ """Connect signals for a milestone widget."""
+ widget.todo_toggled.connect(self._on_milestone_todo_toggled)
+ widget.todo_deleted.connect(self._on_milestone_todo_deleted)
+ widget.todo_added.connect(self._on_milestone_todo_added)
+ widget.todo_start_discussion.connect(self._on_todo_start_discussion)
+ widget.todo_edited.connect(self._on_todo_edited)
+ # Keep legacy signals for deliverables mode (fallback)
+ widget.deliverable_toggled.connect(self._on_deliverable_toggled)
+ widget.deliverable_added.connect(self._on_deliverable_added)
+ widget.deliverable_deleted.connect(self._on_deliverable_deleted)
+
+ def _load_ideas(self):
+ """Load and display ideas from ideas-and-exploration.md."""
+ self.ideas_section.clear_content()
+
+ if self.is_global:
+ ideas_path = self._docs_root / "goals" / "ideas-and-exploration.md"
+ else:
+ ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md"
+ self._ideas_path = ideas_path
+
+ if not ideas_path.exists():
+ no_ideas = QLabel("No ideas file found")
+ no_ideas.setStyleSheet("color: #666666; font-style: italic;")
+ self.ideas_section.add_widget(no_ideas)
+ self.ideas_section.set_count(0)
+ self._ideas_list = []
+ return
+
+ # Parse the ideas file - look for checkbox items in Ideas section
+ content = ideas_path.read_text()
+ self._ideas_list = []
+ in_ideas_section = False
+
+ for line in content.split("\n"):
+ stripped = line.strip()
+ if stripped.startswith("## Ideas"):
+ in_ideas_section = True
+ continue
+ elif stripped.startswith("## "):
+ in_ideas_section = False
+ continue
+
+ if in_ideas_section and stripped.startswith("- ["):
+ # Parse checkbox: - [ ] or - [x]
+ completed = stripped.startswith("- [x]")
+ text = stripped[6:].strip() if completed else stripped[5:].strip()
+ if text:
+ # Extract priority from hashtag
+ priority = "medium"
+ for tag in ["#high", "#medium", "#low"]:
+ if tag in text:
+ priority = tag[1:] # Remove the #
+ text = text.replace(tag, "").strip()
+ break
+ idea = Goal(text=text, completed=completed, priority=priority)
+ self._ideas_list.append(idea)
+
+ if self._ideas_list:
+ checked_count = 0
+ for idea in self._ideas_list:
+ widget = GoalItemWidget(idea, is_non_goal=True)
+ widget.toggled.connect(self._on_idea_toggled)
+ widget.deleted.connect(self._on_idea_deleted)
+ widget.edited.connect(self._on_idea_edited)
+ self.ideas_section.add_widget(widget)
+ if idea.completed:
+ checked_count += 1
+ self.ideas_section.set_count_ratio(checked_count, len(self._ideas_list))
+ else:
+ no_ideas = QLabel("No ideas yet")
+ no_ideas.setStyleSheet("color: #666666; font-style: italic;")
+ self.ideas_section.add_widget(no_ideas)
+ self.ideas_section.set_count(0)
+
def _load_activity(self):
"""Load recent git activity."""
self.activity_section.clear_content()
+ if self.is_global:
+ # No git activity in global mode (no single project context)
+ no_activity = QLabel("Select a project to view git activity")
+ no_activity.setStyleSheet("color: #666666; font-style: italic;")
+ self.activity_section.add_widget(no_activity)
+ self.activity_section.set_count(0)
+ return
+
if self.project.exists:
git_service = GitService(self.project.path)
commits = git_service.get_recent_commits(5)
@@ -629,10 +1239,13 @@ class ProjectDashboard(QWidget):
section.deleteLater()
self._milestone_sections.clear()
- todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
+ if self.is_global:
+ todos_path = self._docs_root / "todos.md"
+ else:
+ todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
self._todos_path = todos_path
- # Set up file watching - watch both file and directory for robustness
+ # Set up file watching - watch todos, goals, milestones and directory
watched_files = self._file_watcher.files()
watched_dirs = self._file_watcher.directories()
if watched_files:
@@ -640,8 +1253,12 @@ class ProjectDashboard(QWidget):
if watched_dirs:
self._file_watcher.removePaths(watched_dirs)
- if todos_path.exists():
- self._file_watcher.addPath(str(todos_path))
+ # Watch all doc files
+ for file_path in [self._todos_path, self._goals_path, self._milestones_path]:
+ if file_path and file_path.exists():
+ self._file_watcher.addPath(str(file_path))
+
+ # Watch directory for file recreation
if todos_path.parent.exists():
self._file_watcher.addPath(str(todos_path.parent))
@@ -679,6 +1296,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.blocked_section.add_widget(widget)
self.blocked_section.set_count(len(self._todo_list.blocked))
@@ -687,6 +1305,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.completed_section.add_widget(widget)
self.completed_section.set_count(len(self._todo_list.completed))
@@ -704,6 +1323,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.high_section.add_widget(widget)
high_active += 1
@@ -718,6 +1338,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.medium_section.add_widget(widget)
med_active += 1
@@ -732,6 +1353,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.low_section.add_widget(widget)
low_active += 1
@@ -767,6 +1389,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
section.add_widget(widget)
section.set_count(len(todos_for_milestone))
@@ -783,6 +1406,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
section.add_widget(widget)
section.set_count(len(no_milestone_todos))
@@ -818,6 +1442,7 @@ class ProjectDashboard(QWidget):
widget = TodoItemWidget(todo)
widget.toggled.connect(self._on_todo_toggled)
widget.deleted.connect(self._on_todo_deleted)
+ widget.edited.connect(self._on_todo_edited)
widget.start_discussion.connect(self._on_todo_start_discussion)
self.high_section.add_widget(widget)
@@ -835,6 +1460,12 @@ class ProjectDashboard(QWidget):
if not text:
return
+ # Cannot add todos in global mode (no project context)
+ if self.is_global:
+ self.toast.show_message("Cannot add todos in global view")
+ self._position_toast()
+ return
+
# Create parser if needed
todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
if not self._todos_parser:
@@ -861,13 +1492,26 @@ class ProjectDashboard(QWidget):
self._undo_stack.append(("toggle", todo.text, todo.priority, not completed))
self._redo_stack.clear() # New action clears redo stack
- # Update the todo in self._todo_list (may be different instance)
+ # Find and update the todo, moving it between lists as needed
for t in self._todo_list.all_todos:
if t.text == todo.text and t.priority == todo.priority:
- t.completed = completed
+ # Remove from current list
+ self._todo_list.remove_todo(t)
+
+ if completed:
+ # Mark as complete and add to completed list
+ t.mark_complete()
+ self._todo_list.completed.append(t)
+ else:
+ # Uncompleting - restore to active list
+ # Priority is preserved (stored as #high/#medium/#low tag)
+ t.completed = False
+ t.completed_date = None
+ self._todo_list.add_todo(t)
break
self._save_todos()
+ self._sync_milestone_status_from_todos() # Update milestone status in file
self._load_todos()
self._load_milestones() # Sync milestone view
@@ -884,8 +1528,8 @@ class ProjectDashboard(QWidget):
def _on_todo_deleted(self, todo):
"""Handle todo deletion."""
if self._todo_list and self._todos_parser:
- # Store for undo: (action, text, priority, was_completed)
- self._undo_stack.append(("delete", todo.text, todo.priority, todo.completed))
+ # Store for undo: (action, text, priority, was_completed, milestone)
+ self._undo_stack.append(("delete", todo.text, todo.priority, todo.completed, todo.milestone))
self._redo_stack.clear()
self._todo_list.remove_todo(todo)
@@ -901,10 +1545,49 @@ class ProjectDashboard(QWidget):
)
self._position_toast()
+ def _on_todo_edited(self, todo, old_text: str, new_text: str):
+ """Handle todo text edited inline."""
+ if self._todo_list and self._todos_parser:
+ # Store for undo: (action, old_text, new_text, priority, was_completed)
+ self._undo_stack.append(("edit", old_text, todo.priority, todo.completed, new_text))
+ self._redo_stack.clear()
+
+ # Update the todo in self._todo_list
+ for t in self._todo_list.all_todos:
+ if t.text == old_text and t.priority == todo.priority:
+ t.text = new_text
+ break
+
+ # Also update the passed todo object
+ todo.text = new_text
+
+ self._save_todos()
+ self._load_todos()
+
+ # Show toast with undo option
+ truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text
+ truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text
+ self.toast.show_message(
+ f"Edited: {truncated_old} → {truncated_new}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
def _on_todo_start_discussion(self, todo):
"""Handle starting a discussion for a todo item."""
from PyQt6.QtWidgets import QMessageBox
+ # Cannot start discussion in global mode (no project context)
+ if self.is_global:
+ QMessageBox.warning(
+ self,
+ "Not Available",
+ "Cannot start discussion in global view.\n\n"
+ "Open a project dashboard to start discussions."
+ )
+ return
+
# Generate title from todo text
title = todo.text.lower().replace(" ", "-")
title = "".join(c for c in title if c.isalnum() or c == "-")
@@ -971,8 +1654,14 @@ class ProjectDashboard(QWidget):
self._ensure_file_watched()
return
- # Debounce - some editors trigger multiple events
- self._reload_timer.start(100)
+ # Determine which file changed and trigger appropriate reload
+ path_obj = Path(path)
+ if path_obj.name == "todos.md":
+ self._reload_timer.start(100)
+ elif path_obj.name == "goals.md":
+ self._reload_goals_timer.start(100)
+ elif path_obj.name == "milestones.md":
+ self._reload_milestones_timer.start(100)
# Re-add path since QFileSystemWatcher removes it after change
self._ensure_file_watched()
@@ -984,19 +1673,23 @@ class ProjectDashboard(QWidget):
self._ensure_file_watched()
return
- # Check if our file was recreated
- if self._todos_path and self._todos_path.exists():
- # Re-add the file to the watcher
- if str(self._todos_path) not in self._file_watcher.files():
- self._file_watcher.addPath(str(self._todos_path))
- # Debounce reload
- self._reload_timer.start(100)
+ # Check if any of our files were recreated
+ for file_path, timer in [
+ (self._todos_path, self._reload_timer),
+ (self._goals_path, self._reload_goals_timer),
+ (self._milestones_path, self._reload_milestones_timer),
+ ]:
+ if file_path and file_path.exists():
+ if str(file_path) not in self._file_watcher.files():
+ self._file_watcher.addPath(str(file_path))
+ timer.start(100)
def _ensure_file_watched(self):
- """Ensure the todos file is being watched."""
- if self._todos_path and self._todos_path.exists():
- if str(self._todos_path) not in self._file_watcher.files():
- self._file_watcher.addPath(str(self._todos_path))
+ """Ensure all doc files are being watched."""
+ for file_path in [self._todos_path, self._goals_path, self._milestones_path]:
+ if file_path and file_path.exists():
+ if str(file_path) not in self._file_watcher.files():
+ self._file_watcher.addPath(str(file_path))
def _do_reload_todos(self):
"""Actually reload todos after debounce timer expires."""
@@ -1009,45 +1702,211 @@ class ProjectDashboard(QWidget):
)
self._position_toast()
- def _undo(self):
- """Undo the last action."""
- if not self._undo_stack or not self._todos_parser:
+ def _do_reload_goals(self):
+ """Reload goals after debounce timer expires."""
+ self._load_goals()
+ self.toast.show_message(
+ "Goals reloaded (external change)",
+ can_undo=False,
+ can_redo=False,
+ duration_ms=3000,
+ )
+ self._position_toast()
+
+ def _do_reload_milestones(self):
+ """Reload milestones after debounce timer expires."""
+ self._load_milestones()
+ self.toast.show_message(
+ "Milestones reloaded (external change)",
+ can_undo=False,
+ can_redo=False,
+ duration_ms=3000,
+ )
+ self._position_toast()
+
+ def _refresh_git_status(self):
+ """Refresh git status and update health display if changed."""
+ # Skip git status refresh in global mode
+ if self.is_global:
return
- # Reload fresh from file to ensure we have current state
- self._todo_list = self._todos_parser.parse()
+ checker = HealthChecker()
+ health = checker.check_project(self.project)
- action_type, text, priority, was_completed = self._undo_stack.pop()
+ # Build a status tuple to compare
+ current_status = (
+ health.status,
+ health.git_info.last_commit_date,
+ health.git_info.uncommitted_changes,
+ )
- if action_type == "toggle":
- # Find the todo by text
- for todo in self._todo_list.all_todos:
- if todo.text == text:
- # Store current state for redo
- self._redo_stack.append(("toggle", text, priority, todo.completed))
- # Remove from current location
- self._todo_list.remove_todo(todo)
- # Restore BOTH completion state AND priority
- todo.completed = was_completed
- todo.priority = priority # Critical: restore original priority!
- if was_completed:
- todo.mark_complete()
- else:
- todo.completed_date = None
- # Re-add to correct list based on restored state
- self._todo_list.add_todo(todo)
- break
+ # Only update UI if something changed
+ if current_status != self._last_git_status:
+ self._last_git_status = current_status
- elif action_type == "delete":
- # Restore deleted todo with original priority
- from development_hub.models.todo import Todo
- restored = Todo(text=text, priority=priority, completed=was_completed)
- self._todo_list.add_todo(restored)
- self._redo_stack.append(("delete", text, priority, was_completed))
+ # Update health label
+ self.health_label.setText(
+ f"{health.status_icon} {health.status.value.title()} "
+ f"Last commit: {health.git_info.time_since_commit}"
+ )
- self._save_todos()
- self._load_todos()
- self._load_milestones() # Sync milestone view
+ # Update alert banner
+ if health.needs_attention:
+ reasons = health.attention_reasons
+ if health.status == HealthStatus.CRITICAL:
+ self.alert_banner.setStyleSheet("""
+ QFrame {
+ background-color: #4a1a1a;
+ border: 1px solid #ff5252;
+ border-radius: 6px;
+ padding: 8px;
+ }
+ """)
+ self.alert_icon.setText("🔴")
+ self.alert_text.setText(f"Critical: {' • '.join(reasons)}")
+ else:
+ self.alert_banner.setStyleSheet("""
+ QFrame {
+ background-color: #4a3000;
+ border: 1px solid #ff9800;
+ border-radius: 6px;
+ padding: 8px;
+ }
+ """)
+ self.alert_icon.setText("🟡")
+ self.alert_text.setText(f"Attention: {' • '.join(reasons)}")
+ self.alert_banner.show()
+ else:
+ self.alert_banner.hide()
+
+ def showEvent(self, event):
+ """Handle show event - refresh git status when tab becomes visible."""
+ super().showEvent(event)
+ # Check git status when dashboard becomes visible (e.g., switching tabs)
+ self._refresh_git_status()
+
+ def _undo(self):
+ """Undo the last action."""
+ if not self._undo_stack:
+ return
+
+ action = self._undo_stack.pop()
+ action_type = action[0]
+
+ # Handle todo actions
+ if action_type in ("toggle", "delete", "edit"):
+ if not self._todos_parser:
+ return
+ # Reload fresh from file to ensure we have current state
+ self._todo_list = self._todos_parser.parse()
+
+ if action_type == "toggle":
+ _, text, priority, was_completed = action
+ for todo in self._todo_list.all_todos:
+ if todo.text == text:
+ self._redo_stack.append(("toggle", text, priority, todo.completed))
+ self._todo_list.remove_todo(todo)
+ todo.completed = was_completed
+ todo.priority = priority
+ if was_completed:
+ todo.mark_complete()
+ else:
+ todo.completed_date = None
+ self._todo_list.add_todo(todo)
+ break
+
+ elif action_type == "delete":
+ _, text, priority, was_completed = action[:4]
+ milestone = action[4] if len(action) > 4 else None
+ from development_hub.models.todo import Todo
+ restored = Todo(text=text, priority=priority, completed=was_completed, milestone=milestone)
+ self._todo_list.add_todo(restored)
+ self._redo_stack.append(("delete", text, priority, was_completed, milestone))
+
+ elif action_type == "edit":
+ _, old_text, priority, was_completed, new_text = action
+ for todo in self._todo_list.all_todos:
+ if todo.text == new_text and todo.priority == priority:
+ self._redo_stack.append(("edit", old_text, priority, was_completed, new_text))
+ todo.text = old_text
+ break
+
+ self._save_todos()
+ self._load_todos()
+ self._load_milestones()
+
+ # Handle goal actions
+ elif action_type in ("goal_toggle", "goal_delete", "goal_edit"):
+ if not self._goals_parser or not self._goal_list:
+ return
+
+ if action_type == "goal_toggle":
+ _, text, priority, was_completed, was_partial, section = action
+ # Find goal and restore state
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ for goal in section_list:
+ if goal.text == text:
+ # Store current state for redo
+ self._redo_stack.append(("goal_toggle", text, priority, goal.completed, getattr(goal, 'partial', False), section))
+ goal.completed = was_completed
+ goal.partial = was_partial
+ break
+
+ elif action_type == "goal_delete":
+ _, text, priority, was_completed, was_partial, section = action
+ # Restore deleted goal
+ from development_hub.models.goal import Goal
+ restored = Goal(text=text, priority=priority, completed=was_completed, partial=was_partial)
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ section_list.append(restored)
+ self._redo_stack.append(("goal_delete", text, priority, was_completed, was_partial, section))
+
+ elif action_type == "goal_edit":
+ _, old_text, new_text, priority, completed, partial, section = action
+ # Find goal by new_text and restore old_text
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ for goal in section_list:
+ if goal.text == new_text:
+ self._redo_stack.append(("goal_edit", old_text, new_text, priority, completed, partial, section))
+ goal.text = old_text
+ break
+
+ self._save_goals()
+ self._load_goals()
+
+ # Handle idea actions
+ elif action_type in ("idea_toggle", "idea_delete", "idea_edit"):
+ if not hasattr(self, '_ideas_list') or not self._ideas_list:
+ # For idea_delete undo, we need to restore even if list is empty
+ if action_type != "idea_delete":
+ return
+ self._ideas_list = []
+
+ if action_type == "idea_toggle":
+ _, text, priority, was_completed = action
+ for idea in self._ideas_list:
+ if idea.text == text:
+ self._redo_stack.append(("idea_toggle", text, priority, idea.completed))
+ idea.completed = was_completed
+ break
+
+ elif action_type == "idea_delete":
+ _, text, priority, was_completed = action
+ from development_hub.models.goal import Goal
+ restored = Goal(text=text, priority=priority, completed=was_completed)
+ self._ideas_list.append(restored)
+ self._redo_stack.append(("idea_delete", text, priority, was_completed))
+
+ elif action_type == "idea_edit":
+ _, old_text, new_text, priority, completed = action
+ for idea in self._ideas_list:
+ if idea.text == new_text:
+ self._redo_stack.append(("idea_edit", old_text, new_text, priority, completed))
+ idea.text = old_text
+ break
+
+ self._save_ideas()
+ self._load_ideas()
self.toast.show_message(
"Undone",
@@ -1058,42 +1917,122 @@ class ProjectDashboard(QWidget):
def _redo(self):
"""Redo the last undone action."""
- if not self._redo_stack or not self._todos_parser:
+ if not self._redo_stack:
return
- # Reload fresh from file
- self._todo_list = self._todos_parser.parse()
+ action = self._redo_stack.pop()
+ action_type = action[0]
- action_type, text, priority, was_completed = self._redo_stack.pop()
+ # Handle todo actions
+ if action_type in ("toggle", "delete", "edit"):
+ if not self._todos_parser:
+ return
+ self._todo_list = self._todos_parser.parse()
- if action_type == "toggle":
- # Find the todo by text
- for todo in self._todo_list.all_todos:
- if todo.text == text:
- # Store current state for undo
- self._undo_stack.append(("toggle", text, priority, todo.completed))
- self._todo_list.remove_todo(todo)
- # Redo restores to the state before undo (was_completed)
- todo.completed = was_completed
- todo.priority = priority
- if todo.completed:
- todo.mark_complete()
- else:
- todo.completed_date = None
- self._todo_list.add_todo(todo)
- break
+ if action_type == "toggle":
+ _, text, priority, was_completed = action
+ for todo in self._todo_list.all_todos:
+ if todo.text == text:
+ self._undo_stack.append(("toggle", text, priority, todo.completed))
+ self._todo_list.remove_todo(todo)
+ todo.completed = was_completed
+ todo.priority = priority
+ if todo.completed:
+ todo.mark_complete()
+ else:
+ todo.completed_date = None
+ self._todo_list.add_todo(todo)
+ break
- elif action_type == "delete":
- # Delete again
- for todo in self._todo_list.all_todos:
- if todo.text == text:
- self._undo_stack.append(("delete", text, priority, todo.completed))
- self._todo_list.remove_todo(todo)
- break
+ elif action_type == "delete":
+ _, text, priority, was_completed = action[:4]
+ milestone = action[4] if len(action) > 4 else None
+ for todo in self._todo_list.all_todos:
+ if todo.text == text:
+ self._undo_stack.append(("delete", text, priority, todo.completed, todo.milestone))
+ self._todo_list.remove_todo(todo)
+ break
- self._save_todos()
- self._load_todos()
- self._load_milestones() # Sync milestone view
+ elif action_type == "edit":
+ _, old_text, priority, was_completed, new_text = action
+ for todo in self._todo_list.all_todos:
+ if todo.text == old_text and todo.priority == priority:
+ self._undo_stack.append(("edit", old_text, priority, was_completed, new_text))
+ todo.text = new_text
+ break
+
+ self._save_todos()
+ self._load_todos()
+ self._load_milestones()
+
+ # Handle goal actions
+ elif action_type in ("goal_toggle", "goal_delete", "goal_edit"):
+ if not self._goals_parser or not self._goal_list:
+ return
+
+ if action_type == "goal_toggle":
+ _, text, priority, was_completed, was_partial, section = action
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ for goal in section_list:
+ if goal.text == text:
+ self._undo_stack.append(("goal_toggle", text, priority, goal.completed, getattr(goal, 'partial', False), section))
+ goal.completed = was_completed
+ goal.partial = was_partial
+ break
+
+ elif action_type == "goal_delete":
+ _, text, priority, was_completed, was_partial, section = action
+ # Delete again
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ for goal in section_list[:]: # Copy list to allow modification
+ if goal.text == text:
+ self._undo_stack.append(("goal_delete", text, priority, goal.completed, getattr(goal, 'partial', False), section))
+ section_list.remove(goal)
+ break
+
+ elif action_type == "goal_edit":
+ _, old_text, new_text, priority, completed, partial, section = action
+ section_list = getattr(self._goal_list, section, self._goal_list.active)
+ for goal in section_list:
+ if goal.text == old_text:
+ self._undo_stack.append(("goal_edit", old_text, new_text, priority, completed, partial, section))
+ goal.text = new_text
+ break
+
+ self._save_goals()
+ self._load_goals()
+
+ # Handle idea actions
+ elif action_type in ("idea_toggle", "idea_delete", "idea_edit"):
+ if not hasattr(self, '_ideas_list') or not self._ideas_list:
+ return
+
+ if action_type == "idea_toggle":
+ _, text, priority, was_completed = action
+ for idea in self._ideas_list:
+ if idea.text == text:
+ self._undo_stack.append(("idea_toggle", text, priority, idea.completed))
+ idea.completed = was_completed
+ break
+
+ elif action_type == "idea_delete":
+ _, text, priority, was_completed = action
+ for idea in self._ideas_list[:]:
+ if idea.text == text:
+ self._undo_stack.append(("idea_delete", text, priority, idea.completed))
+ self._ideas_list.remove(idea)
+ break
+
+ elif action_type == "idea_edit":
+ _, old_text, new_text, priority, completed = action
+ for idea in self._ideas_list:
+ if idea.text == old_text:
+ self._undo_stack.append(("idea_edit", old_text, new_text, priority, completed))
+ idea.text = new_text
+ break
+
+ self._save_ideas()
+ self._load_ideas()
self.toast.show_message(
"Redone",
@@ -1124,6 +2063,108 @@ class ProjectDashboard(QWidget):
if self.toast.isVisible():
self._position_toast()
+ def keyPressEvent(self, event):
+ """Handle keyboard shortcuts for the dashboard.
+
+ Shortcuts:
+ / - Focus add todo input
+ Ctrl+1 - Priority filter view
+ Ctrl+2 - Milestone filter view
+ Ctrl+3 - Show all todos
+ Ctrl+[ - Collapse all sections
+ Ctrl+] - Expand all sections
+ """
+ key = event.key()
+ modifiers = event.modifiers()
+ ctrl = modifiers & Qt.KeyboardModifier.ControlModifier
+
+ # / - Focus add todo input
+ if key == Qt.Key.Key_Slash and not ctrl:
+ self.todo_input.setFocus()
+ self.todo_input.selectAll()
+ event.accept()
+ return
+
+ # Ctrl+1 - Priority filter
+ if ctrl and key == Qt.Key.Key_1:
+ self.todo_filter.setCurrentIndex(0) # "By Priority"
+ event.accept()
+ return
+
+ # Ctrl+2 - Milestone filter
+ if ctrl and key == Qt.Key.Key_2:
+ self.todo_filter.setCurrentIndex(1) # "By Milestone"
+ event.accept()
+ return
+
+ # Ctrl+3 - Show all
+ if ctrl and key == Qt.Key.Key_3:
+ self.todo_filter.setCurrentIndex(2) # "Show All"
+ event.accept()
+ return
+
+ # Ctrl+[ - Collapse all sections
+ if ctrl and key == Qt.Key.Key_BracketLeft:
+ self._collapse_all_sections()
+ event.accept()
+ return
+
+ # Ctrl+] - Expand all sections
+ if ctrl and key == Qt.Key.Key_BracketRight:
+ self._expand_all_sections()
+ event.accept()
+ return
+
+ super().keyPressEvent(event)
+
+ def _expand_all_sections(self):
+ """Expand all collapsible sections."""
+ sections = [
+ self.goals_active_section,
+ self.goals_future_section,
+ self.goals_nongoals_section,
+ self.ideas_section,
+ self.activity_section,
+ self.blocked_section,
+ self.high_section,
+ self.medium_section,
+ self.low_section,
+ self.completed_section,
+ ]
+ # Also include milestone sections if in milestone view
+ sections.extend(self._milestone_sections.values())
+
+ for section in sections:
+ if section.isVisible():
+ section.set_expanded(True)
+
+ self.toast.show_message("All sections expanded", can_undo=False, can_redo=False)
+ self._position_toast()
+
+ def _collapse_all_sections(self):
+ """Collapse all collapsible sections."""
+ sections = [
+ self.goals_active_section,
+ self.goals_future_section,
+ self.goals_nongoals_section,
+ self.ideas_section,
+ self.activity_section,
+ self.blocked_section,
+ self.high_section,
+ self.medium_section,
+ self.low_section,
+ self.completed_section,
+ ]
+ # Also include milestone sections if in milestone view
+ sections.extend(self._milestone_sections.values())
+
+ for section in sections:
+ if section.isVisible():
+ section.set_expanded(False)
+
+ self.toast.show_message("All sections collapsed", can_undo=False, can_redo=False)
+ self._position_toast()
+
def _button_style(self) -> str:
"""Get button stylesheet."""
return """
@@ -1204,20 +2245,378 @@ class ProjectDashboard(QWidget):
)
def _open_todos(self):
- """Open the project's todos.md file."""
- todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
+ """Open the todos.md file."""
+ if self.is_global:
+ todos_path = self._docs_root / "todos.md"
+ else:
+ todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
self._open_file_in_editor(todos_path)
+ def _audit_goals(self):
+ """Run CmdForge audit-goals tool to check if goals have been met."""
+ if self.is_global:
+ goals_path = self._docs_root / "goals" / "goals.md"
+ else:
+ goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
+
+ if not goals_path.exists():
+ QMessageBox.warning(self, "No Goals", "No goals.md file found.")
+ return
+
+ # Confirmation dialog
+ confirm = QMessageBox.question(
+ self,
+ "Run Goals Audit",
+ "This operation uses AI to analyze your goals against the codebase.\n\n"
+ "It may take a minute or two to complete.\n\n"
+ "Do you want to continue?",
+ QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Ok
+ )
+ if confirm != QMessageBox.StandardButton.Ok:
+ return
+
+ # Read the goals content
+ goals_content = goals_path.read_text()
+
+ # Get the project root for context
+ if self.is_global:
+ project_root = None
+ else:
+ project_root = Path(self.project.path) if self.project.path else None
+
+ # Create progress dialog
+ self._audit_dialog = QDialog(self)
+ self._audit_dialog.setWindowTitle("Running Goals Audit")
+ self._audit_dialog.setMinimumWidth(400)
+ self._audit_dialog.setModal(True)
+
+ layout = QVBoxLayout(self._audit_dialog)
+
+ status_label = QLabel("Analyzing goals with AI...")
+ status_label.setStyleSheet("font-size: 14px; padding: 10px;")
+ layout.addWidget(status_label)
+
+ self._audit_time_label = QLabel("Elapsed: 0:00")
+ self._audit_time_label.setStyleSheet("color: #888; font-size: 12px; padding: 5px;")
+ layout.addWidget(self._audit_time_label)
+
+ progress = QProgressBar()
+ progress.setRange(0, 0) # Indeterminate
+ progress.setStyleSheet("""
+ QProgressBar {
+ border: 1px solid #444;
+ border-radius: 3px;
+ background-color: #2d2d2d;
+ height: 20px;
+ }
+ QProgressBar::chunk {
+ background-color: #4a90d9;
+ }
+ """)
+ layout.addWidget(progress)
+
+ cancel_btn = QPushButton("Cancel")
+ cancel_btn.clicked.connect(self._cancel_audit)
+ layout.addWidget(cancel_btn)
+
+ # Store goals path for later use
+ self._audit_goals_path = goals_path
+
+ # Create worker and thread
+ self._audit_thread = QThread()
+ self._audit_worker = AuditWorker(goals_content, project_root)
+ self._audit_worker.moveToThread(self._audit_thread)
+
+ # Connect signals
+ self._audit_thread.started.connect(self._audit_worker.run)
+ self._audit_worker.finished.connect(self._on_audit_finished)
+ self._audit_worker.error.connect(self._on_audit_error)
+
+ # Start elapsed time timer
+ self._audit_elapsed_seconds = 0
+ self._audit_timer = QTimer(self)
+ self._audit_timer.timeout.connect(self._update_audit_time)
+ self._audit_timer.start(1000) # Update every second
+
+ # Start the thread
+ self._audit_thread.start()
+
+ # Show the dialog (non-blocking)
+ self._audit_dialog.show()
+
+ def _update_audit_time(self):
+ """Update elapsed time display and check for timeout."""
+ self._audit_elapsed_seconds += 1
+ minutes = self._audit_elapsed_seconds // 60
+ seconds = self._audit_elapsed_seconds % 60
+ self._audit_time_label.setText(f"Elapsed: {minutes}:{seconds:02d}")
+
+ # Check for 5-minute warning
+ if self._audit_elapsed_seconds == 300: # 5 minutes
+ self._show_timeout_warning()
+
+ def _show_timeout_warning(self):
+ """Show timeout warning after 5 minutes."""
+ result = QMessageBox.question(
+ self._audit_dialog,
+ "Audit Taking Long",
+ "The audit has been running for 5 minutes.\n\n"
+ "Do you want to continue waiting (5 more minutes) or cancel?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.Yes
+ )
+ if result == QMessageBox.StandardButton.No:
+ self._cancel_audit()
+ else:
+ # Reset the timer check for another 5 minutes
+ self._audit_elapsed_seconds = 0
+
+ def _cancel_audit(self):
+ """Cancel the running audit."""
+ if self._audit_worker:
+ self._audit_worker.cancel()
+ self._cleanup_audit()
+ if self._audit_dialog:
+ self._audit_dialog.close()
+
+ def _cleanup_audit(self):
+ """Clean up audit resources."""
+ if self._audit_timer:
+ self._audit_timer.stop()
+ self._audit_timer = None
+
+ if self._audit_thread:
+ self._audit_thread.quit()
+ self._audit_thread.wait(3000)
+ self._audit_thread = None
+
+ self._audit_worker = None
+
+ def _on_audit_finished(self, output: str, success: bool):
+ """Handle audit completion."""
+ import json
+ from datetime import datetime
+ from PyQt6.QtWidgets import QTextEdit, QDialogButtonBox
+
+ self._cleanup_audit()
+
+ if self._audit_dialog:
+ self._audit_dialog.close()
+ self._audit_dialog = None
+
+ goals_path = self._audit_goals_path
+
+ # Try to parse as JSON
+ try:
+ # Clean up output - remove any markdown code blocks
+ json_str = output.strip()
+ if json_str.startswith("```"):
+ json_str = json_str.split("```")[1]
+ if json_str.startswith("json"):
+ json_str = json_str[4:]
+ json_str = json_str.strip()
+
+ audit_data = json.loads(json_str)
+
+ # Update the goals.md file with new checkbox states
+ self._update_goals_from_audit(goals_path, audit_data)
+
+ # Build a human-readable report
+ report = self._build_audit_report(audit_data)
+
+ # Reload the goals section
+ self._load_goals()
+
+ except json.JSONDecodeError:
+ # If JSON parsing fails, just show the raw output
+ report = f"Could not parse audit response as JSON. Raw output:\n\n{output}"
+
+ # Save report to file
+ if self.is_global:
+ report_path = self._docs_root / "goals" / "last_goals_audit.md"
+ project_key = "global"
+ dialog_title = "Goals Audit - Global"
+ else:
+ report_path = self._docs_root / "projects" / self.project.key / "last_goals_audit.md"
+ project_key = self.project.key
+ dialog_title = f"Goals Audit - {self.project.title}"
+
+ report_content = f"""---
+type: audit
+project: {project_key}
+generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+---
+
+# Goals Audit Report
+
+{report}
+"""
+ atomic_write(report_path, report_content)
+
+ # Show result in a dialog
+ result_dialog = QDialog(self)
+ result_dialog.setWindowTitle(dialog_title)
+ result_dialog.setMinimumSize(700, 500)
+
+ layout = QVBoxLayout(result_dialog)
+
+ # Add note about saved report
+ saved_label = QLabel(f"Report saved to: {report_path.name}")
+ saved_label.setStyleSheet("color: #888; font-size: 11px; padding: 5px;")
+ layout.addWidget(saved_label)
+
+ text_edit = QTextEdit()
+ text_edit.setReadOnly(True)
+ text_edit.setPlainText(report)
+ text_edit.setStyleSheet("""
+ QTextEdit {
+ background-color: #1e1e1e;
+ color: #e0e0e0;
+ font-family: monospace;
+ font-size: 12px;
+ border: none;
+ padding: 10px;
+ }
+ """)
+ layout.addWidget(text_edit)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
+ buttons.accepted.connect(result_dialog.accept)
+ layout.addWidget(buttons)
+
+ result_dialog.exec()
+
+ def _on_audit_error(self, error_msg: str):
+ """Handle audit error."""
+ self._cleanup_audit()
+
+ if self._audit_dialog:
+ self._audit_dialog.close()
+ self._audit_dialog = None
+
+ QMessageBox.critical(self, "Audit Error", f"Failed to run audit: {error_msg}")
+
+ def _update_goals_from_audit(self, goals_path: Path, audit_data: dict):
+ """Update goals.md file with checkbox states from audit."""
+ from development_hub.parsers.goals_parser import GoalsParser, GoalsSaver
+ from development_hub.models.goal import Goal, GoalList
+
+ # Parse existing goals to get frontmatter
+ parser = GoalsParser(goals_path)
+ frontmatter = parser.frontmatter
+
+ # Build new GoalList from audit data
+ goal_list = GoalList(
+ project=frontmatter.get("project"),
+ updated=frontmatter.get("updated"),
+ )
+
+ # Process active goals
+ for item in audit_data.get("active", []):
+ goal = Goal(
+ text=item.get("text", ""),
+ completed=item.get("checked", False),
+ priority=item.get("priority", "medium"),
+ )
+ goal_list.active.append(goal)
+
+ # Process future goals
+ for item in audit_data.get("future", []):
+ goal = Goal(
+ text=item.get("text", ""),
+ completed=item.get("checked", False),
+ priority=item.get("priority", "medium"),
+ )
+ goal_list.future.append(goal)
+
+ # Process non-goals
+ for item in audit_data.get("non_goals", []):
+ goal = Goal(
+ text=item.get("text", ""),
+ completed=item.get("checked", False),
+ priority=item.get("priority", "medium"),
+ )
+ goal_list.non_goals.append(goal)
+
+ # Save updated goals
+ saver = GoalsSaver(goals_path, frontmatter)
+ saver.save(goal_list)
+
+ def _build_audit_report(self, audit_data: dict) -> str:
+ """Build a human-readable report from audit data."""
+ lines = []
+ lines.append("=" * 60)
+ lines.append("GOALS AUDIT REPORT")
+ lines.append("=" * 60)
+ lines.append("")
+
+ # Active goals
+ lines.append("ACTIVE GOALS")
+ lines.append("-" * 40)
+ for item in audit_data.get("active", []):
+ status = "[x]" if item.get("checked") else "[ ]"
+ lines.append(f"{status} {item.get('text', '')}")
+ lines.append(f" -> {item.get('reason', 'No reason provided')}")
+ lines.append("")
+
+ # Future goals
+ lines.append("FUTURE GOALS")
+ lines.append("-" * 40)
+ for item in audit_data.get("future", []):
+ status = "[x]" if item.get("checked") else "[ ]"
+ lines.append(f"{status} {item.get('text', '')}")
+ lines.append(f" -> {item.get('reason', 'No reason provided')}")
+ lines.append("")
+
+ # Non-goals
+ lines.append("NON-GOALS")
+ lines.append("-" * 40)
+ for item in audit_data.get("non_goals", []):
+ status = "[x]" if item.get("checked") else "[ ]"
+ lines.append(f"{status} {item.get('text', '')}")
+ lines.append(f" -> {item.get('reason', 'No reason provided')}")
+ lines.append("")
+
+ # Summary
+ summary = audit_data.get("summary", {})
+ lines.append("=" * 60)
+ lines.append("SUMMARY")
+ lines.append("=" * 60)
+ lines.append(f"Active Goals: {summary.get('active_met', 0)}/{summary.get('active_total', 0)} met")
+ lines.append(f"Future Goals: {summary.get('future_started', 0)}/{summary.get('future_total', 0)} started")
+ lines.append(f"Non-Goals: {summary.get('non_goals_confirmed', 0)}/{summary.get('non_goals_total', 0)} confirmed")
+ lines.append("")
+ lines.append(f"Overall Health: {summary.get('health', 'UNKNOWN')}")
+ lines.append("")
+ lines.append("Goals file has been updated with the audit results.")
+
+ return "\n".join(lines)
+
def _open_goals(self):
- """Open the project's goals.md file."""
- goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
+ """Open the goals.md file."""
+ if self.is_global:
+ goals_path = self._docs_root / "goals" / "goals.md"
+ else:
+ goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
self._open_file_in_editor(goals_path)
def _open_milestones(self):
- """Open the project's milestones.md file."""
- milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
+ """Open the milestones.md file."""
+ if self.is_global:
+ milestones_path = self._docs_root / "goals" / "milestones.md"
+ else:
+ milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
self._open_file_in_editor(milestones_path)
+ def _open_ideas(self):
+ """Open the ideas-and-exploration.md file."""
+ if self.is_global:
+ ideas_path = self._docs_root / "goals" / "ideas-and-exploration.md"
+ else:
+ ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md"
+ self._open_file_in_editor(ideas_path)
+
def _wrap_layout(self, layout: QHBoxLayout) -> QWidget:
"""Wrap a layout in a widget."""
widget = QWidget()
@@ -1232,6 +2631,12 @@ class ProjectDashboard(QWidget):
if not text:
return
+ # Cannot add goals in global mode (no project context)
+ if self.is_global:
+ self.toast.show_message("Cannot add goals in global view")
+ self._position_toast()
+ return
+
goals_path = self._docs_root / "projects" / self.project.key / "goals.md"
# Create file if needed
@@ -1255,28 +2660,124 @@ class ProjectDashboard(QWidget):
self._goals_parser = GoalsParser(goals_path)
self._goal_list = self._goals_parser.parse()
- # Add the goal
- new_goal = Goal(text=text, completed=False, priority="high")
- self._goal_list.active.append(new_goal)
+ # Get section and priority from dropdowns
+ section = self.goal_section_combo.currentData()
+ priority = self.goal_priority_combo.currentData()
+
+ new_goal = Goal(text=text, completed=False, priority=priority)
+ if section == "non-goal":
+ self._goal_list.non_goals.append(new_goal)
+ elif section == "future":
+ self._goal_list.future.append(new_goal)
+ else: # active
+ self._goal_list.active.append(new_goal)
# Save
self._save_goals()
self.goal_input.clear()
self._load_goals()
- def _on_goal_toggled(self, goal, completed):
- """Handle goal checkbox toggle."""
+ def _find_goal_section(self, goal) -> str:
+ """Find which section a goal belongs to."""
+ if self._goal_list:
+ for g in self._goal_list.active:
+ if g.text == goal.text:
+ return "active"
+ for g in self._goal_list.future:
+ if g.text == goal.text:
+ return "future"
+ for g in self._goal_list.non_goals:
+ if g.text == goal.text:
+ return "non_goals"
+ return "active" # default
+
+ def _on_goal_toggled(self, goal, completed, was_completed, was_partial):
+ """Handle goal checkbox toggle.
+
+ Args:
+ goal: The goal object (already updated with new state)
+ completed: New completed state
+ was_completed: Previous completed state
+ was_partial: Previous partial state
+ """
if self._goals_parser and self._goal_list:
+ section = self._find_goal_section(goal)
+ # Store for undo: previous state before toggle (now passed correctly from widget)
+ self._undo_stack.append(("goal_toggle", goal.text, goal.priority, was_completed, was_partial, section))
+ self._redo_stack.clear()
+
self._save_goals()
self._load_goals()
+ # Describe the state change
+ if completed:
+ action = "achieved"
+ elif getattr(goal, 'partial', False):
+ action = "marked partial"
+ else:
+ action = "marked not achieved"
+ truncated = goal.text[:30] + "..." if len(goal.text) > 30 else goal.text
+ self.toast.show_message(
+ f"Goal {action}: {truncated}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
def _on_goal_deleted(self, goal):
"""Handle goal deletion."""
if self._goal_list:
+ section = self._find_goal_section(goal)
+ # Store for undo
+ self._undo_stack.append(("goal_delete", goal.text, goal.priority, goal.completed, getattr(goal, 'partial', False), section))
+ self._redo_stack.clear()
+
+ # Remove from all sections (active, future, non_goals)
self._goal_list.active = [g for g in self._goal_list.active if g.text != goal.text]
+ self._goal_list.future = [g for g in self._goal_list.future if g.text != goal.text]
+ self._goal_list.non_goals = [g for g in self._goal_list.non_goals if g.text != goal.text]
self._save_goals()
self._load_goals()
+ truncated = goal.text[:30] + "..." if len(goal.text) > 30 else goal.text
+ self.toast.show_message(
+ f"Deleted goal: {truncated}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
+ def _on_goal_edited(self, goal, old_text: str, new_text: str):
+ """Handle goal text edited inline."""
+ if self._goal_list and self._goals_parser:
+ section = self._find_goal_section(goal)
+ # Store for undo
+ self._undo_stack.append(("goal_edit", old_text, new_text, goal.priority, goal.completed, getattr(goal, 'partial', False), section))
+ self._redo_stack.clear()
+
+ # Update the goal in all sections (active, future, non_goals)
+ for goal_list in [self._goal_list.active, self._goal_list.future, self._goal_list.non_goals]:
+ for g in goal_list:
+ if g.text == old_text:
+ g.text = new_text
+ break
+
+ # Also update the passed goal object
+ goal.text = new_text
+
+ self._save_goals()
+ self._load_goals()
+
+ # Show toast notification
+ truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text
+ truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text
+ self.toast.show_message(
+ f"Goal edited: {truncated_old} → {truncated_new}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
def _save_goals(self):
"""Save goals to file."""
if self._goals_parser and self._goal_list:
@@ -1284,6 +2785,146 @@ class ProjectDashboard(QWidget):
saver = GoalsSaver(self._goals_path, self._goals_parser.frontmatter)
saver.save(self._goal_list)
+ # === Idea handlers ===
+
+ def _add_idea(self):
+ """Add a new idea from the input field."""
+ text = self.idea_input.text().strip()
+ if not text:
+ return
+
+ # Cannot add ideas in global mode (no project context)
+ if self.is_global:
+ self.toast.show_message("Cannot add ideas in global view")
+ self._position_toast()
+ return
+
+ ideas_path = self._docs_root / "projects" / self.project.key / "ideas-and-exploration.md"
+
+ # Create file if needed
+ if not ideas_path.exists():
+ ideas_path.parent.mkdir(parents=True, exist_ok=True)
+ ideas_path.write_text(
+ "---\n"
+ f"type: ideas\n"
+ f"project: {self.project.key}\n"
+ f"updated: 2026-01-08\n"
+ "public: True\n"
+ "---\n\n"
+ "# Ideas & Exploration\n\n"
+ "## Ideas\n\n"
+ )
+ self._ideas_list = []
+
+ if not hasattr(self, '_ideas_list') or self._ideas_list is None:
+ self._ideas_list = []
+
+ # Add the idea with selected priority
+ priority = self.idea_priority_combo.currentData()
+ new_idea = Goal(text=text, completed=False, priority=priority)
+ self._ideas_list.append(new_idea)
+
+ # Save
+ self._save_ideas()
+ self.idea_input.clear()
+ self._load_ideas()
+
+ def _on_idea_toggled(self, idea, completed):
+ """Handle idea checkbox toggle."""
+ # Store for undo: previous state before toggle
+ was_completed = not completed
+ self._undo_stack.append(("idea_toggle", idea.text, idea.priority, was_completed))
+ self._redo_stack.clear()
+
+ self._save_ideas()
+ self._load_ideas()
+
+ action = "completed" if completed else "uncompleted"
+ truncated = idea.text[:30] + "..." if len(idea.text) > 30 else idea.text
+ self.toast.show_message(
+ f"Idea {action}: {truncated}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
+ def _on_idea_deleted(self, idea):
+ """Handle idea deletion."""
+ if hasattr(self, '_ideas_list') and self._ideas_list:
+ # Store for undo
+ self._undo_stack.append(("idea_delete", idea.text, idea.priority, idea.completed))
+ self._redo_stack.clear()
+
+ self._ideas_list = [i for i in self._ideas_list if i.text != idea.text]
+ self._save_ideas()
+ self._load_ideas()
+
+ truncated = idea.text[:30] + "..." if len(idea.text) > 30 else idea.text
+ self.toast.show_message(
+ f"Deleted idea: {truncated}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
+ def _on_idea_edited(self, idea, old_text: str, new_text: str):
+ """Handle idea text edited inline."""
+ if hasattr(self, '_ideas_list') and self._ideas_list:
+ # Store for undo
+ self._undo_stack.append(("idea_edit", old_text, new_text, idea.priority, idea.completed))
+ self._redo_stack.clear()
+
+ # Update the idea in the list
+ for i in self._ideas_list:
+ if i.text == old_text:
+ i.text = new_text
+ break
+
+ # Also update the passed idea object
+ idea.text = new_text
+
+ self._save_ideas()
+ self._load_ideas()
+
+ truncated_old = old_text[:20] + "..." if len(old_text) > 20 else old_text
+ truncated_new = new_text[:20] + "..." if len(new_text) > 20 else new_text
+ self.toast.show_message(
+ f"Idea edited: {truncated_old} → {truncated_new}",
+ can_undo=bool(self._undo_stack),
+ can_redo=bool(self._redo_stack),
+ )
+ self._position_toast()
+
+ def _save_ideas(self):
+ """Save ideas to file."""
+ if not hasattr(self, '_ideas_path') or not hasattr(self, '_ideas_list'):
+ return
+
+ # Cannot save ideas in global mode (no project context)
+ if self.is_global:
+ return
+
+ lines = []
+ lines.append("---")
+ lines.append("type: ideas")
+ lines.append(f"project: {self.project.key}")
+ lines.append("updated: 2026-01-08")
+ lines.append("public: True")
+ lines.append("---")
+ lines.append("")
+ lines.append("# Ideas & Exploration")
+ lines.append("")
+ lines.append("## Ideas")
+ lines.append("")
+
+ for idea in self._ideas_list:
+ checkbox = "[x]" if idea.completed else "[ ]"
+ priority_tag = f" #{idea.priority}" if idea.priority else ""
+ lines.append(f"- {checkbox} {idea.text}{priority_tag}")
+
+ lines.append("")
+ atomic_write(self._ideas_path, "\n".join(lines))
+
# === Milestone handlers ===
def _add_milestone(self):
@@ -1292,6 +2933,12 @@ class ProjectDashboard(QWidget):
if not text:
return
+ # Cannot add milestones in global mode (no project context)
+ if self.is_global:
+ self.toast.show_message("Cannot add milestones in global view")
+ self._position_toast()
+ return
+
milestones_path = self._docs_root / "projects" / self.project.key / "milestones.md"
# Create file if needed
@@ -1379,38 +3026,57 @@ class ProjectDashboard(QWidget):
if self._milestones_parser and self._milestones_list:
self._milestones_parser.save(self._milestones_list)
+ def _sync_milestone_status_from_todos(self):
+ """Update milestone status based on linked todos completion."""
+ if not self._milestones_list or not self._todo_list:
+ return
+
+ from development_hub.models.goal import MilestoneStatus
+
+ changed = False
+ for milestone in self._milestones_list:
+ linked_todos = self._todo_list.get_by_milestone(milestone.id)
+ if not linked_todos:
+ continue
+
+ total = len(linked_todos)
+ done = sum(1 for t in linked_todos if t.completed)
+
+ if done == total:
+ if milestone.status != MilestoneStatus.COMPLETE:
+ milestone.status = MilestoneStatus.COMPLETE
+ milestone.progress = 100
+ changed = True
+ elif done > 0:
+ if milestone.status not in (MilestoneStatus.IN_PROGRESS, MilestoneStatus.COMPLETE):
+ milestone.status = MilestoneStatus.IN_PROGRESS
+ progress = int((done / total) * 100)
+ if milestone.progress != progress:
+ milestone.progress = progress
+ changed = True
+ else:
+ # No todos done - only downgrade if currently complete
+ if milestone.status == MilestoneStatus.COMPLETE:
+ milestone.status = MilestoneStatus.IN_PROGRESS
+ milestone.progress = 0
+ changed = True
+
+ if changed:
+ self._save_milestones()
+
def _on_milestone_todo_toggled(self, todo, completed):
"""Handle todo toggle from within milestone widget.
- This saves the todo and reloads the view.
+ Delegates to _on_todo_toggled for consistent behavior and undo support.
"""
- if self._todos_parser and self._todo_list:
- # Update the todo in self._todo_list (milestone has different instance)
- for t in self._todo_list.all_todos:
- if t.text == todo.text and t.priority == todo.priority:
- t.completed = completed
- break
-
- self._save_todos()
- self._load_todos()
- self._load_milestones() # Sync milestone view
- # Show toast
- action = "completed" if completed else "uncompleted"
- truncated = todo.text[:30] + "..." if len(todo.text) > 30 else todo.text
- self.toast.show_message(f"Todo {action}: {truncated}")
- self._position_toast()
+ self._on_todo_toggled(todo, completed)
def _on_milestone_todo_deleted(self, todo):
- """Handle todo deletion from within milestone widget."""
- if self._todo_list and self._todos_parser:
- self._todo_list.remove_todo(todo)
- self._save_todos()
- self._load_todos()
- self._load_milestones() # Refresh milestone views
- # Show toast
- truncated = todo.text[:30] + "..." if len(todo.text) > 30 else todo.text
- self.toast.show_message(f"Deleted: {truncated}")
- self._position_toast()
+ """Handle todo deletion from within milestone widget.
+
+ Delegates to _on_todo_deleted for consistent behavior and undo support.
+ """
+ self._on_todo_deleted(todo)
def _on_milestone_todo_added(self, text, priority, milestone_id):
"""Handle adding a new todo from within a milestone widget.
@@ -1420,6 +3086,12 @@ class ProjectDashboard(QWidget):
priority: Priority level (high, medium, low)
milestone_id: Milestone ID to tag (e.g., "M1")
"""
+ # Cannot add todos in global mode (no project context)
+ if self.is_global:
+ self.toast.show_message("Cannot add todos in global view")
+ self._position_toast()
+ return
+
# Ensure parser exists
todos_path = self._docs_root / "projects" / self.project.key / "todos.md"
if not self._todos_parser:
@@ -1443,3 +3115,56 @@ class ProjectDashboard(QWidget):
truncated = text[:30] + "..." if len(text) > 30 else text
self.toast.show_message(f"Added to {milestone_id}: {truncated}")
self._position_toast()
+
+ # === Session state persistence ===
+
+ def get_state(self) -> dict:
+ """Get the dashboard state for session persistence.
+
+ Returns:
+ Dict with filter selection and section expanded states
+ """
+ return {
+ "filter": self.todo_filter.currentIndex(),
+ "sections": {
+ "goals_active": self.goals_active_section._expanded,
+ "goals_future": self.goals_future_section._expanded,
+ "goals_nongoals": self.goals_nongoals_section._expanded,
+ "ideas": self.ideas_section._expanded,
+ "activity": self.activity_section._expanded,
+ "blocked": self.blocked_section._expanded,
+ "high": self.high_section._expanded,
+ "medium": self.medium_section._expanded,
+ "low": self.low_section._expanded,
+ "completed": self.completed_section._expanded,
+ },
+ }
+
+ def restore_state(self, state: dict):
+ """Restore the dashboard state from session.
+
+ Args:
+ state: Dict with filter and sections keys
+ """
+ # Restore filter selection
+ filter_index = state.get("filter", 0)
+ if 0 <= filter_index < self.todo_filter.count():
+ self.todo_filter.setCurrentIndex(filter_index)
+
+ # Restore section expanded states
+ sections_state = state.get("sections", {})
+ section_map = {
+ "goals_active": self.goals_active_section,
+ "goals_future": self.goals_future_section,
+ "goals_nongoals": self.goals_nongoals_section,
+ "ideas": self.ideas_section,
+ "activity": self.activity_section,
+ "blocked": self.blocked_section,
+ "high": self.high_section,
+ "medium": self.medium_section,
+ "low": self.low_section,
+ "completed": self.completed_section,
+ }
+ for key, section in section_map.items():
+ if key in sections_state:
+ section.set_expanded(sections_state[key])
diff --git a/src/development_hub/views/global_dashboard.py b/src/development_hub/views/global_dashboard.py
index b474edc..406606e 100644
--- a/src/development_hub/views/global_dashboard.py
+++ b/src/development_hub/views/global_dashboard.py
@@ -1,284 +1,36 @@
-"""Global dashboard view showing cross-project status."""
+"""Global dashboard view - wrapper around ProjectDashboard in global mode."""
-import subprocess
-import shutil
-from pathlib import Path
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QWidget, QVBoxLayout
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtWidgets import (
- QWidget,
- QVBoxLayout,
- QHBoxLayout,
- QLabel,
- QScrollArea,
- QFrame,
- QPushButton,
-)
-
-from development_hub.services.health_checker import HealthChecker
-from development_hub.parsers.goals_parser import MilestonesParser
-from development_hub.parsers.progress_parser import ProgressLogManager
-from development_hub.widgets.progress_bar import MilestoneProgressBar
-from development_hub.widgets.stat_card import StatCardRow
-from development_hub.widgets.health_card import HealthCardCompact
+from development_hub.views.dashboard import ProjectDashboard
class GlobalDashboard(QWidget):
"""Global dashboard showing status across all projects.
- Displays:
- - Current milestone progress (global)
- - Project health summary
- - Projects needing attention
- - Today's progress
+ This is a thin wrapper around ProjectDashboard in global mode.
"""
project_selected = pyqtSignal(str) # Emits project_key
standup_requested = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
- """Initialize global dashboard.
-
- Args:
- parent: Parent widget
- """
+ """Initialize global dashboard."""
super().__init__(parent)
- self._docs_root = Path.home() / "PycharmProjects" / "project-docs" / "docs"
- self._setup_ui()
- self._load_data()
- def _setup_ui(self):
- """Set up the UI."""
- # Main scroll area
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setFrameStyle(QFrame.Shape.NoFrame)
- scroll.setStyleSheet("QScrollArea { background-color: #1e1e1e; border: none; }")
+ # Create ProjectDashboard in global mode
+ self._dashboard = ProjectDashboard(project=None, parent=self)
- # Content widget
- content = QWidget()
- content.setStyleSheet("background-color: #1e1e1e;")
- layout = QVBoxLayout(content)
- layout.setContentsMargins(24, 24, 24, 24)
- layout.setSpacing(20)
+ # Forward signals
+ self._dashboard.project_selected.connect(self.project_selected.emit)
+ self._dashboard.standup_requested.connect(self.standup_requested.emit)
- # Header
- header_layout = QHBoxLayout()
-
- title = QLabel("Development Hub")
- title.setStyleSheet("font-size: 24px; font-weight: bold; color: #e0e0e0;")
- header_layout.addWidget(title)
-
- header_layout.addStretch()
-
- # Daily standup button
- standup_btn = QPushButton("Daily Standup")
- standup_btn.setStyleSheet("""
- QPushButton {
- background-color: #3d6a99;
- color: #e0e0e0;
- border: none;
- border-radius: 4px;
- padding: 8px 16px;
- font-size: 13px;
- }
- QPushButton:hover {
- background-color: #4a7ab0;
- }
- """)
- standup_btn.clicked.connect(self.standup_requested.emit)
- header_layout.addWidget(standup_btn)
-
- layout.addLayout(header_layout)
-
- # Milestone progress
- milestone_section = QLabel("CURRENT MILESTONE")
- milestone_section.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888;")
- layout.addWidget(milestone_section)
-
- self.milestone_progress = MilestoneProgressBar()
- layout.addWidget(self.milestone_progress)
-
- # Stats row
- self.stats_row = StatCardRow()
- self.stats_row.add_stat("healthy", 0, "Healthy", "#4caf50")
- self.stats_row.add_stat("warning", 0, "Warning", "#ff9800")
- self.stats_row.add_stat("total_todos", 0, "Total TODOs", "#e0e0e0")
- layout.addWidget(self.stats_row)
-
- # Action buttons
- actions_layout = QHBoxLayout()
- actions_layout.setSpacing(8)
-
- view_todos_btn = QPushButton("View Global TODOs")
- view_todos_btn.setStyleSheet(self._button_style())
- view_todos_btn.clicked.connect(self._open_todos)
- actions_layout.addWidget(view_todos_btn)
-
- view_milestones_btn = QPushButton("View Milestones")
- view_milestones_btn.setStyleSheet(self._button_style())
- view_milestones_btn.clicked.connect(self._open_milestones)
- actions_layout.addWidget(view_milestones_btn)
-
- actions_layout.addStretch()
- layout.addLayout(actions_layout)
-
- # Project health section
- health_label = QLabel("PROJECT HEALTH")
- health_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
- layout.addWidget(health_label)
-
- self.health_container = QVBoxLayout()
- self.health_container.setSpacing(4)
- layout.addLayout(self.health_container)
-
- # Needs attention section
- attention_label = QLabel("NEEDS ATTENTION")
- attention_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
- layout.addWidget(attention_label)
-
- self.attention_list = QLabel()
- self.attention_list.setStyleSheet("color: #ff9800; line-height: 1.6;")
- self.attention_list.setWordWrap(True)
- layout.addWidget(self.attention_list)
-
- # Today's progress section
- progress_label = QLabel("TODAY'S PROGRESS")
- progress_label.setStyleSheet("font-size: 12px; font-weight: bold; color: #888888; margin-top: 12px;")
- layout.addWidget(progress_label)
-
- self.progress_list = QLabel()
- self.progress_list.setStyleSheet("color: #b0b0b0; line-height: 1.6;")
- self.progress_list.setWordWrap(True)
- layout.addWidget(self.progress_list)
-
- layout.addStretch()
-
- scroll.setWidget(content)
-
- # Main layout
- main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(0, 0, 0, 0)
- main_layout.addWidget(scroll)
-
- def _load_data(self):
- """Load data and update display."""
- # Get ecosystem health
- checker = HealthChecker()
- ecosystem = checker.check_all_projects()
-
- # Update stats
- self.stats_row.update_stat("healthy", ecosystem.healthy_count)
- self.stats_row.update_stat("warning", ecosystem.warning_count + ecosystem.critical_count)
- self.stats_row.update_stat("total_todos", ecosystem.total_active_todos)
-
- # Load milestone
- milestones_path = self._docs_root / "goals" / "milestones.md"
- if milestones_path.exists():
- parser = MilestonesParser(milestones_path)
- current = parser.get_current_milestone()
- if current:
- self.milestone_progress.set_milestone(
- f"{current.id}: {current.name}",
- current.progress,
- current.target,
- )
- else:
- self.milestone_progress.clear()
- else:
- self.milestone_progress.clear()
-
- # Clear and rebuild health cards
- while self.health_container.count():
- item = self.health_container.takeAt(0)
- if item.widget():
- item.widget().deleteLater()
-
- for health in ecosystem.projects:
- card = HealthCardCompact()
- card.set_project(
- health.project_key,
- health.project_name,
- health.status_icon,
- health.git_info.time_since_commit,
- health.active_todos,
- )
- card.clicked.connect(self._on_project_clicked)
- self.health_container.addWidget(card)
-
- # Needs attention
- needing = ecosystem.get_needing_attention()
- if needing:
- attention_html = "
".join(
- f"⚠ {h.project_name}: {', '.join(h.attention_reasons[:2])}"
- for h in needing[:5]
- )
- self.attention_list.setText(attention_html)
- else:
- self.attention_list.setText("All projects healthy!")
- self.attention_list.setStyleSheet("color: #4caf50; line-height: 1.6;")
-
- # Today's progress
- progress_dir = self._docs_root / "progress"
- if progress_dir.exists():
- manager = ProgressLogManager(progress_dir)
- today = manager.get_today()
- if today:
- items = []
- for task in today.completed[:5]:
- items.append(f"☑ {task}")
- for task in today.in_progress[:3]:
- items.append(f"☐ {task} (in progress)")
- if items:
- self.progress_list.setText("
".join(items))
- else:
- self.progress_list.setText("No progress logged yet today")
- else:
- self.progress_list.setText("No progress log for today")
- else:
- self.progress_list.setText("Progress directory not found")
-
- def _on_project_clicked(self, project_key: str):
- """Handle project card click."""
- self.project_selected.emit(project_key)
+ # Layout
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._dashboard)
def refresh(self):
"""Refresh the dashboard data."""
- self._load_data()
-
- def _button_style(self) -> str:
- """Get button stylesheet."""
- return """
- QPushButton {
- background-color: #3d3d3d;
- color: #e0e0e0;
- border: 1px solid #4d4d4d;
- border-radius: 4px;
- padding: 6px 12px;
- font-size: 12px;
- }
- QPushButton:hover {
- background-color: #4d4d4d;
- }
- """
-
- def _open_file_in_editor(self, path: Path):
- """Open a file in the default editor."""
- if not path.exists():
- return
-
- editors = ["pycharm", "code", "subl", "gedit", "xdg-open"]
- for editor in editors:
- if shutil.which(editor):
- subprocess.Popen([editor, str(path)])
- return
-
- def _open_todos(self):
- """Open the global todos.md file."""
- todos_path = self._docs_root / "todos.md"
- self._open_file_in_editor(todos_path)
-
- def _open_milestones(self):
- """Open the milestones.md file."""
- milestones_path = self._docs_root / "goals" / "milestones.md"
- self._open_file_in_editor(milestones_path)
+ self._dashboard._load_data()
diff --git a/src/development_hub/widgets/collapsible_section.py b/src/development_hub/widgets/collapsible_section.py
index 1546966..91cc635 100644
--- a/src/development_hub/widgets/collapsible_section.py
+++ b/src/development_hub/widgets/collapsible_section.py
@@ -182,6 +182,7 @@ class TodoItemWidget(QWidget):
toggled = pyqtSignal(object, bool) # Emits (todo, completed)
deleted = pyqtSignal(object) # Emits todo
start_discussion = pyqtSignal(object) # Emits todo
+ edited = pyqtSignal(object, str, str) # Emits (todo, old_text, new_text)
def __init__(self, todo, parent: QWidget | None = None, show_priority: bool = False):
"""Initialize todo item widget.
@@ -194,6 +195,8 @@ class TodoItemWidget(QWidget):
super().__init__(parent)
self.todo = todo
self._show_priority = show_priority
+ self._editing = False
+ self._edit_widget = None
self._setup_ui()
def _setup_ui(self):
@@ -210,9 +213,11 @@ class TodoItemWidget(QWidget):
self.checkbox.setFixedWidth(20)
layout.addWidget(self.checkbox)
- # Text
+ # Text (double-click to edit)
self.text_label = QLabel(self.todo.text)
self.text_label.setWordWrap(True)
+ self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
+ self.text_label.mouseDoubleClickEvent = self._start_editing
self._update_text_style()
layout.addWidget(self.text_label, 1)
@@ -294,6 +299,91 @@ class TodoItemWidget(QWidget):
self.delete_btn.hide()
super().leaveEvent(event)
+ def _start_editing(self, event):
+ """Start inline editing of the todo text."""
+ if self._editing:
+ return
+
+ self._editing = True
+ self._original_text = self.todo.text
+
+ # Create edit widget in place of label
+ self._edit_widget = QLineEdit(self.todo.text)
+ self._edit_widget.setStyleSheet("""
+ QLineEdit {
+ background-color: #2d2d2d;
+ border: 1px solid #4a9eff;
+ border-radius: 2px;
+ padding: 2px 4px;
+ color: #e0e0e0;
+ }
+ """)
+
+ # Connect signals - Enter to save, event filter handles Escape and focus out
+ self._edit_widget.returnPressed.connect(self._finish_editing)
+ self._edit_widget.installEventFilter(self)
+
+ # Replace label with edit widget
+ layout = self.layout()
+ label_index = layout.indexOf(self.text_label)
+ self.text_label.hide()
+ layout.insertWidget(label_index, self._edit_widget)
+
+ # Focus and select all
+ self._edit_widget.setFocus()
+ self._edit_widget.selectAll()
+
+ def _finish_editing(self):
+ """Finish editing and save changes."""
+ if not self._editing or not self._edit_widget:
+ return
+
+ new_text = self._edit_widget.text().strip()
+ self._editing = False
+
+ # Remove edit widget
+ self._edit_widget.hide()
+ self._edit_widget.deleteLater()
+ self._edit_widget = None
+
+ # Show label again
+ self.text_label.show()
+
+ # Only emit if text changed and not empty
+ if new_text and new_text != self._original_text:
+ self.text_label.setText(new_text)
+ self.edited.emit(self.todo, self._original_text, new_text)
+
+ def _cancel_editing(self):
+ """Cancel editing and restore original text."""
+ if not self._editing or not self._edit_widget:
+ return
+
+ self._editing = False
+
+ # Remove edit widget
+ self._edit_widget.hide()
+ self._edit_widget.deleteLater()
+ self._edit_widget = None
+
+ # Show label again (with original text)
+ self.text_label.show()
+
+ def eventFilter(self, obj, event):
+ """Handle Escape key to cancel editing, focus out to save."""
+ from PyQt6.QtCore import QEvent
+
+ if obj == self._edit_widget:
+ if event.type() == QEvent.Type.KeyPress:
+ if event.key() == Qt.Key.Key_Escape:
+ self._cancel_editing()
+ return True
+ elif event.type() == QEvent.Type.FocusOut:
+ # Save on focus loss
+ self._finish_editing()
+ return False
+ return super().eventFilter(obj, event)
+
def contextMenuEvent(self, event):
"""Show context menu on right-click."""
menu = QMenu(self)
@@ -326,18 +416,23 @@ class TodoItemWidget(QWidget):
class GoalItemWidget(QWidget):
"""Widget for displaying a single goal item with checkbox."""
- toggled = pyqtSignal(object, bool) # Emits (goal, completed)
+ toggled = pyqtSignal(object, bool, bool, bool) # Emits (goal, new_completed, was_completed, was_partial)
deleted = pyqtSignal(object) # Emits goal
+ edited = pyqtSignal(object, str, str) # Emits (goal, old_text, new_text)
- def __init__(self, goal, parent: QWidget | None = None):
+ def __init__(self, goal, parent: QWidget | None = None, is_non_goal: bool = False):
"""Initialize goal item widget.
Args:
goal: Goal model instance
parent: Parent widget
+ is_non_goal: If True, show green when checked instead of strikethrough
"""
super().__init__(parent)
self.goal = goal
+ self._is_non_goal = is_non_goal
+ self._editing = False
+ self._edit_widget = None
self._setup_ui()
def _setup_ui(self):
@@ -354,9 +449,11 @@ class GoalItemWidget(QWidget):
self.checkbox.setFixedWidth(20)
layout.addWidget(self.checkbox)
- # Text
+ # Text (double-click to edit)
self.text_label = QLabel(self.goal.text)
self.text_label.setWordWrap(True)
+ self.text_label.setCursor(Qt.CursorShape.IBeamCursor)
+ self.text_label.mouseDoubleClickEvent = self._start_editing
self._update_text_style()
layout.addWidget(self.text_label, 1)
@@ -394,29 +491,56 @@ class GoalItemWidget(QWidget):
layout.addWidget(self.delete_btn)
def _update_checkbox(self):
- """Update checkbox display."""
+ """Update checkbox display for three states."""
if self.goal.completed:
self.checkbox.setText("☑")
self.checkbox.setStyleSheet("color: #4caf50; font-size: 16px;")
+ elif getattr(self.goal, 'partial', False):
+ self.checkbox.setText("◐")
+ self.checkbox.setStyleSheet("color: #ff9800; font-size: 16px;")
else:
self.checkbox.setText("☐")
self.checkbox.setStyleSheet("color: #888888; font-size: 16px;")
def _update_text_style(self):
- """Update text style based on completion."""
+ """Update text style based on goal state.
+
+ Goals use green when achieved (no strikethrough) since they represent
+ ongoing commitments that require continued focus even after achievement.
+ """
if self.goal.completed:
- self.text_label.setStyleSheet(
- "color: #666666; text-decoration: line-through; font-size: 13px;"
- )
+ # Achieved: green text, no strikethrough
+ self.text_label.setStyleSheet("color: #4caf50; font-size: 13px;")
+ elif getattr(self.goal, 'partial', False):
+ # Partially achieved: orange text
+ self.text_label.setStyleSheet("color: #ff9800; font-size: 13px;")
else:
+ # Not achieved: white text
self.text_label.setStyleSheet("color: #e0e0e0; font-size: 13px;")
def _on_checkbox_clicked(self, event):
- """Handle checkbox click."""
- self.goal.completed = not self.goal.completed
+ """Handle checkbox click - cycle through states.
+
+ Cycle: Not achieved -> Partial -> Achieved -> Not achieved
+ """
+ # Capture previous state before modifying
+ was_completed = self.goal.completed
+ was_partial = getattr(self.goal, 'partial', False)
+
+ if self.goal.completed:
+ # Achieved -> Not achieved
+ self.goal.completed = False
+ self.goal.partial = False
+ elif getattr(self.goal, 'partial', False):
+ # Partial -> Achieved
+ self.goal.completed = True
+ self.goal.partial = False
+ else:
+ # Not achieved -> Partial
+ self.goal.partial = True
self._update_checkbox()
self._update_text_style()
- self.toggled.emit(self.goal, self.goal.completed)
+ self.toggled.emit(self.goal, self.goal.completed, was_completed, was_partial)
def enterEvent(self, event):
"""Show delete button on hover."""
@@ -428,6 +552,91 @@ class GoalItemWidget(QWidget):
self.delete_btn.hide()
super().leaveEvent(event)
+ def _start_editing(self, event):
+ """Start inline editing of the goal text."""
+ if self._editing:
+ return
+
+ self._editing = True
+ self._original_text = self.goal.text
+
+ # Create edit widget in place of label
+ self._edit_widget = QLineEdit(self.goal.text)
+ self._edit_widget.setStyleSheet("""
+ QLineEdit {
+ background-color: #2d2d2d;
+ border: 1px solid #4a9eff;
+ border-radius: 2px;
+ padding: 2px 4px;
+ color: #e0e0e0;
+ }
+ """)
+
+ # Connect signals - Enter to save, event filter handles Escape and focus out
+ self._edit_widget.returnPressed.connect(self._finish_editing)
+ self._edit_widget.installEventFilter(self)
+
+ # Replace label with edit widget
+ layout = self.layout()
+ label_index = layout.indexOf(self.text_label)
+ self.text_label.hide()
+ layout.insertWidget(label_index, self._edit_widget)
+
+ # Focus and select all
+ self._edit_widget.setFocus()
+ self._edit_widget.selectAll()
+
+ def _finish_editing(self):
+ """Finish editing and save changes."""
+ if not self._editing or not self._edit_widget:
+ return
+
+ new_text = self._edit_widget.text().strip()
+ self._editing = False
+
+ # Remove edit widget
+ self._edit_widget.hide()
+ self._edit_widget.deleteLater()
+ self._edit_widget = None
+
+ # Show label again
+ self.text_label.show()
+
+ # Only emit if text changed and not empty
+ if new_text and new_text != self._original_text:
+ self.text_label.setText(new_text)
+ self.edited.emit(self.goal, self._original_text, new_text)
+
+ def _cancel_editing(self):
+ """Cancel editing and restore original text."""
+ if not self._editing or not self._edit_widget:
+ return
+
+ self._editing = False
+
+ # Remove edit widget
+ self._edit_widget.hide()
+ self._edit_widget.deleteLater()
+ self._edit_widget = None
+
+ # Show label again (with original text)
+ self.text_label.show()
+
+ def eventFilter(self, obj, event):
+ """Handle Escape key to cancel editing, focus out to save."""
+ from PyQt6.QtCore import QEvent
+
+ if obj == self._edit_widget:
+ if event.type() == QEvent.Type.KeyPress:
+ if event.key() == Qt.Key.Key_Escape:
+ self._cancel_editing()
+ return True
+ elif event.type() == QEvent.Type.FocusOut:
+ # Save on focus loss
+ self._finish_editing()
+ return False
+ return super().eventFilter(obj, event)
+
class DeliverableItemWidget(QWidget):
"""Widget for displaying a single deliverable item with checkbox."""
@@ -551,6 +760,7 @@ class MilestoneWidget(QFrame):
todo_deleted = pyqtSignal(object) # (todo) - for linked todos mode
todo_added = pyqtSignal(str, str, str) # (text, priority, milestone_id) - for adding new todos
todo_start_discussion = pyqtSignal(object) # (todo) - for starting discussion from todo
+ todo_edited = pyqtSignal(object, str, str) # (todo, old_text, new_text) - for inline editing
def __init__(
self,
@@ -607,6 +817,10 @@ class MilestoneWidget(QFrame):
self.title_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 13px;")
header_layout.addWidget(self.title_label)
+ # Set tooltip with description if available
+ if self.milestone.description:
+ self.header.setToolTip(self.milestone.description)
+
# Progress bar right after label
self.progress_bar = QProgressBar()
self.progress_bar.setMinimum(0)
@@ -734,12 +948,20 @@ class MilestoneWidget(QFrame):
self.arrow_label.setStyleSheet("color: #888888; font-size: 10px;")
def _update_status_icon(self):
- """Update status icon based on milestone status."""
+ """Update status icon based on milestone status or linked todos progress."""
from development_hub.models.goal import MilestoneStatus
- if self.milestone.status == MilestoneStatus.COMPLETE:
+
+ # Check if complete - either from milestone status or all linked todos done
+ is_complete = self.milestone.status == MilestoneStatus.COMPLETE
+ if self._todos:
+ total = len(self._todos)
+ done = sum(1 for t in self._todos if t.completed)
+ is_complete = is_complete or (total > 0 and done == total)
+
+ if is_complete:
self.status_icon.setText("✓")
self.status_icon.setStyleSheet("color: #4caf50; font-size: 14px;")
- elif self.milestone.status == MilestoneStatus.IN_PROGRESS:
+ elif self.milestone.status == MilestoneStatus.IN_PROGRESS or (self._todos and any(t.completed for t in self._todos)):
self.status_icon.setText("●")
self.status_icon.setStyleSheet("color: #4a9eff; font-size: 14px;")
else:
@@ -798,6 +1020,7 @@ class MilestoneWidget(QFrame):
widget.toggled.connect(self._on_todo_toggled_internal)
widget.deleted.connect(self._on_todo_deleted_internal)
widget.start_discussion.connect(self.todo_start_discussion.emit)
+ widget.edited.connect(self.todo_edited.emit)
self.deliverables_layout.addWidget(widget)
else:
# Legacy: Add deliverables
diff --git a/src/development_hub/widgets/health_card.py b/src/development_hub/widgets/health_card.py
index 4cc0905..606578a 100644
--- a/src/development_hub/widgets/health_card.py
+++ b/src/development_hub/widgets/health_card.py
@@ -1,7 +1,7 @@
"""Project health card widget."""
from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel
+from PyQt6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar
class HealthCard(QFrame):
@@ -158,7 +158,35 @@ class HealthCardCompact(QFrame):
# Name
self.name_label = QLabel()
self.name_label.setStyleSheet("color: #e0e0e0;")
- layout.addWidget(self.name_label, stretch=1)
+ self.name_label.setMinimumWidth(120)
+ layout.addWidget(self.name_label)
+
+ # Milestone progress bar
+ self.milestone_progress = QProgressBar()
+ self.milestone_progress.setMaximumHeight(8)
+ self.milestone_progress.setMinimumWidth(80)
+ self.milestone_progress.setMaximumWidth(120)
+ self.milestone_progress.setTextVisible(False)
+ self.milestone_progress.setStyleSheet("""
+ QProgressBar {
+ background-color: #2d2d2d;
+ border: none;
+ border-radius: 4px;
+ }
+ QProgressBar::chunk {
+ background-color: #4a90d9;
+ border-radius: 4px;
+ }
+ """)
+ layout.addWidget(self.milestone_progress)
+
+ # Progress percentage label
+ self.progress_label = QLabel()
+ self.progress_label.setStyleSheet("color: #888888; font-size: 10px;")
+ self.progress_label.setMinimumWidth(30)
+ layout.addWidget(self.progress_label)
+
+ layout.addStretch()
# Time
self.time_label = QLabel()
@@ -177,6 +205,7 @@ class HealthCardCompact(QFrame):
status_icon: str,
time_since_commit: str,
active_todos: int,
+ milestone_progress: int = 0,
):
"""Set project data."""
self._project_key = project_key
@@ -185,6 +214,31 @@ class HealthCardCompact(QFrame):
self.time_label.setText(time_since_commit)
self.todos_label.setText(f"📊 {active_todos}")
+ # Set milestone progress
+ self.milestone_progress.setValue(milestone_progress)
+ if milestone_progress > 0:
+ self.progress_label.setText(f"{milestone_progress}%")
+ # Color based on progress
+ if milestone_progress >= 80:
+ color = "#4caf50" # Green
+ elif milestone_progress >= 50:
+ color = "#4a90d9" # Blue
+ else:
+ color = "#ff9800" # Orange
+ self.milestone_progress.setStyleSheet(f"""
+ QProgressBar {{
+ background-color: #2d2d2d;
+ border: none;
+ border-radius: 4px;
+ }}
+ QProgressBar::chunk {{
+ background-color: {color};
+ border-radius: 4px;
+ }}
+ """)
+ else:
+ self.progress_label.setText("")
+
def mousePressEvent(self, event):
"""Handle mouse press."""
if event.button() == Qt.MouseButton.LeftButton:
diff --git a/src/development_hub/workspace.py b/src/development_hub/workspace.py
index 3533388..9ee5550 100644
--- a/src/development_hub/workspace.py
+++ b/src/development_hub/workspace.py
@@ -307,7 +307,17 @@ class PaneWidget(QFrame):
"title": title,
"cwd": str(widget.cwd),
})
- # Skip welcome tab and other non-terminal widgets
+ elif isinstance(widget, ProjectDashboard):
+ tabs.append({
+ "type": "dashboard",
+ "project_key": widget.project.key,
+ "state": widget.get_state(),
+ })
+ elif isinstance(widget, GlobalDashboard):
+ tabs.append({
+ "type": "global_dashboard",
+ })
+ # Skip welcome tab and other non-saveable widgets
return {
"tabs": tabs,
@@ -316,15 +326,34 @@ class PaneWidget(QFrame):
def restore_tabs(self, state: dict):
"""Restore tabs from session state."""
+ from development_hub.project_discovery import discover_projects
+
tabs = state.get("tabs", [])
current_tab = state.get("current_tab", 0)
+ # Cache discovered projects for dashboard restoration
+ projects_by_key = {p.key: p for p in discover_projects()}
+
for tab_info in tabs:
- if tab_info.get("type") == "terminal":
+ tab_type = tab_info.get("type")
+
+ if tab_type == "terminal":
cwd = Path(tab_info.get("cwd", str(Path.home())))
title = tab_info.get("title", "Terminal")
self.add_terminal(cwd, title)
+ elif tab_type == "dashboard":
+ project_key = tab_info.get("project_key")
+ if project_key and project_key in projects_by_key:
+ project = projects_by_key[project_key]
+ dashboard = self.add_dashboard(project)
+ # Restore dashboard state
+ if dashboard and "state" in tab_info:
+ dashboard.restore_state(tab_info["state"])
+
+ elif tab_type == "global_dashboard":
+ self.add_global_dashboard()
+
# Restore current tab selection
if 0 <= current_tab < self.tab_widget.count():
self.tab_widget.setCurrentIndex(current_tab)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_parser_roundtrip.py b/tests/test_parser_roundtrip.py
new file mode 100644
index 0000000..371a613
--- /dev/null
+++ b/tests/test_parser_roundtrip.py
@@ -0,0 +1,395 @@
+"""Round-trip tests for parsers.
+
+These tests verify that parse -> save -> parse produces identical data.
+This ensures no data loss during save operations.
+"""
+
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from development_hub.parsers.todos_parser import TodosParser
+from development_hub.parsers.goals_parser import GoalsParser, MilestonesParser, GoalsSaver
+from development_hub.models.todo import Todo, TodoList
+from development_hub.models.goal import Goal, GoalList, Milestone, Deliverable, MilestoneStatus, DeliverableStatus
+
+
+class TestTodosRoundtrip:
+ """Test TodosParser round-trip preservation."""
+
+ def test_simple_todos_roundtrip(self, tmp_path):
+ """Parse -> save -> parse should produce identical data."""
+ todos_file = tmp_path / "todos.md"
+ original_content = """---
+type: todos
+project: test-project
+updated: 2026-01-08
+---
+
+# TODOs
+
+## Active Tasks
+
+### High Priority
+
+- [ ] Important task #high
+- [ ] Another important task #high @M1
+
+### Medium Priority
+
+- [ ] Medium task #medium
+- [x] Completed medium task #medium (2026-01-07)
+
+### Low Priority
+
+- [ ] Low priority task #low
+
+## Completed
+
+- [x] Done task #high (2026-01-06)
+- [x] Another done #medium (2026-01-05)
+"""
+ todos_file.write_text(original_content)
+
+ # First parse
+ parser1 = TodosParser(todos_file)
+ todo_list1 = parser1.parse()
+
+ # Save
+ parser1.save(todo_list1)
+
+ # Second parse
+ parser2 = TodosParser(todos_file)
+ todo_list2 = parser2.parse()
+
+ # Compare
+ assert len(todo_list1.all_todos) == len(todo_list2.all_todos)
+ assert len(todo_list1.completed) == len(todo_list2.completed)
+
+ # Compare individual todos
+ for t1, t2 in zip(todo_list1.all_todos, todo_list2.all_todos):
+ assert t1.text == t2.text
+ assert t1.priority == t2.priority
+ assert t1.completed == t2.completed
+ assert t1.milestone == t2.milestone
+
+ def test_todos_with_milestones_roundtrip(self, tmp_path):
+ """Milestone tags should be preserved."""
+ todos_file = tmp_path / "todos.md"
+ original_content = """---
+type: todos
+project: test
+---
+
+# TODOs
+
+## Active Tasks
+
+### High Priority
+
+- [ ] Task with milestone @M1 #high
+- [ ] Task with M2 @M2 #high
+
+### Medium Priority
+
+### Low Priority
+
+## Completed
+"""
+ todos_file.write_text(original_content)
+
+ parser1 = TodosParser(todos_file)
+ todo_list1 = parser1.parse()
+
+ # Verify milestones parsed
+ high_todos = [t for t in todo_list1.all_todos if t.priority == "high"]
+ assert any(t.milestone == "M1" for t in high_todos)
+ assert any(t.milestone == "M2" for t in high_todos)
+
+ # Save and reparse
+ parser1.save(todo_list1)
+ parser2 = TodosParser(todos_file)
+ todo_list2 = parser2.parse()
+
+ # Verify milestones preserved
+ high_todos2 = [t for t in todo_list2.all_todos if t.priority == "high"]
+ assert any(t.milestone == "M1" for t in high_todos2)
+ assert any(t.milestone == "M2" for t in high_todos2)
+
+
+class TestGoalsRoundtrip:
+ """Test GoalsParser round-trip preservation."""
+
+ def test_goals_roundtrip(self, tmp_path):
+ """Goals should be preserved across parse -> save -> parse."""
+ goals_file = tmp_path / "goals.md"
+ original_content = """---
+type: goals
+project: test-project
+updated: 2026-01-08
+---
+
+# Goals
+
+## Active
+
+- [ ] Incomplete goal #high
+- [~] Partial goal #medium
+- [x] Completed goal #low
+
+## Future
+
+- [ ] Future goal 1 #medium
+- [ ] Future goal 2 #low
+
+## Non-Goals
+
+- [ ] Not doing this #medium
+- [x] Confirmed non-goal #high
+"""
+ goals_file.write_text(original_content)
+
+ # First parse
+ parser1 = GoalsParser(goals_file)
+ goal_list1 = parser1.parse()
+
+ # Save using GoalsSaver
+ saver = GoalsSaver(goals_file, parser1.frontmatter)
+ saver.save(goal_list1)
+
+ # Second parse
+ parser2 = GoalsParser(goals_file)
+ goal_list2 = parser2.parse()
+
+ # Compare counts
+ assert len(goal_list1.active) == len(goal_list2.active)
+ assert len(goal_list1.future) == len(goal_list2.future)
+ assert len(goal_list1.non_goals) == len(goal_list2.non_goals)
+
+ # Compare active goals
+ for g1, g2 in zip(goal_list1.active, goal_list2.active):
+ assert g1.text == g2.text
+ assert g1.completed == g2.completed
+ assert g1.partial == g2.partial
+ assert g1.priority == g2.priority
+
+ def test_three_state_goals_roundtrip(self, tmp_path):
+ """Three-state goals ([ ], [~], [x]) should be preserved."""
+ goals_file = tmp_path / "goals.md"
+ original_content = """---
+type: goals
+project: test
+---
+
+# Goals
+
+## Active
+
+- [ ] Not started #medium
+- [~] In progress #high
+- [x] Done #low
+"""
+ goals_file.write_text(original_content)
+
+ parser1 = GoalsParser(goals_file)
+ goal_list1 = parser1.parse()
+
+ # Verify states parsed correctly
+ assert goal_list1.active[0].completed == False
+ assert goal_list1.active[0].partial == False
+ assert goal_list1.active[1].completed == False
+ assert goal_list1.active[1].partial == True
+ assert goal_list1.active[2].completed == True
+ assert goal_list1.active[2].partial == False
+
+ # Save and reparse
+ saver = GoalsSaver(goals_file, parser1.frontmatter)
+ saver.save(goal_list1)
+
+ parser2 = GoalsParser(goals_file)
+ goal_list2 = parser2.parse()
+
+ # Verify states preserved
+ assert goal_list2.active[0].completed == False
+ assert goal_list2.active[0].partial == False
+ assert goal_list2.active[1].completed == False
+ assert goal_list2.active[1].partial == True
+ assert goal_list2.active[2].completed == True
+ assert goal_list2.active[2].partial == False
+
+
+class TestMilestonesRoundtrip:
+ """Test MilestonesParser round-trip preservation."""
+
+ def test_milestones_roundtrip(self, tmp_path):
+ """Milestones should be preserved across parse -> save -> parse."""
+ milestones_file = tmp_path / "milestones.md"
+ original_content = """---
+type: milestones
+project: test-project
+updated: 2026-01-08
+---
+
+# Milestones
+
+#### M1: First Milestone
+**Target**: January 2026
+**Status**: In Progress (50%)
+
+First milestone description.
+
+| Deliverable | Status |
+|-------------|--------|
+| Task A | Done |
+| Task B | In Progress |
+| Task C | Not Started |
+
+---
+
+#### M2: Second Milestone
+**Target**: February 2026
+**Status**: Not Started
+
+Second milestone description.
+
+| Deliverable | Status |
+|-------------|--------|
+| Feature X | Not Started |
+| Feature Y | Not Started |
+
+---
+"""
+ milestones_file.write_text(original_content)
+
+ # First parse
+ parser1 = MilestonesParser(milestones_file)
+ milestones1 = parser1.parse()
+
+ # Save
+ parser1.save(milestones1)
+
+ # Second parse
+ parser2 = MilestonesParser(milestones_file)
+ milestones2 = parser2.parse()
+
+ # Compare counts
+ assert len(milestones1) == len(milestones2)
+
+ # Compare individual milestones
+ for m1, m2 in zip(milestones1, milestones2):
+ assert m1.id == m2.id
+ assert m1.name == m2.name
+ assert m1.target == m2.target
+ assert m1.status == m2.status
+ assert len(m1.deliverables) == len(m2.deliverables)
+
+ # Compare deliverables
+ for d1, d2 in zip(m1.deliverables, m2.deliverables):
+ assert d1.name == d2.name
+ assert d1.status == d2.status
+
+ def test_milestone_status_roundtrip(self, tmp_path):
+ """Milestone statuses should be preserved."""
+ milestones_file = tmp_path / "milestones.md"
+ original_content = """---
+type: milestones
+project: test
+---
+
+# Milestones
+
+#### M1: Complete
+**Target**: Dec 2025
+**Status**: Completed (100%)
+
+---
+
+#### M2: In Progress
+**Target**: Jan 2026
+**Status**: In Progress (75%)
+
+---
+
+#### M3: Planning
+**Target**: Feb 2026
+**Status**: Planning (10%)
+
+---
+
+#### M4: Not Started
+**Target**: Q2 2026
+**Status**: Not Started
+
+---
+"""
+ milestones_file.write_text(original_content)
+
+ parser1 = MilestonesParser(milestones_file)
+ milestones1 = parser1.parse()
+
+ # Create lookup by ID
+ by_id1 = {m.id: m for m in milestones1}
+
+ # Verify statuses parsed
+ assert by_id1["M1"].status == MilestoneStatus.COMPLETE
+ assert by_id1["M2"].status == MilestoneStatus.IN_PROGRESS
+ assert by_id1["M3"].status == MilestoneStatus.PLANNING
+ assert by_id1["M4"].status == MilestoneStatus.NOT_STARTED
+
+ # Save and reparse
+ parser1.save(milestones1)
+ parser2 = MilestonesParser(milestones_file)
+ milestones2 = parser2.parse()
+
+ # Create lookup by ID
+ by_id2 = {m.id: m for m in milestones2}
+
+ # Verify statuses preserved (order may change due to Active/Completed sections)
+ assert by_id2["M1"].status == MilestoneStatus.COMPLETE
+ assert by_id2["M2"].status == MilestoneStatus.IN_PROGRESS
+ assert by_id2["M3"].status == MilestoneStatus.PLANNING
+ assert by_id2["M4"].status == MilestoneStatus.NOT_STARTED
+
+
+class TestAtomicWrite:
+ """Test atomic write functionality."""
+
+ def test_atomic_write_creates_file(self, tmp_path):
+ """atomic_write should create file if it doesn't exist."""
+ from development_hub.parsers.base import atomic_write
+
+ test_file = tmp_path / "new_file.md"
+ content = "# Test Content\n\nSome text here."
+
+ atomic_write(test_file, content)
+
+ assert test_file.exists()
+ assert test_file.read_text() == content
+
+ def test_atomic_write_overwrites_file(self, tmp_path):
+ """atomic_write should safely overwrite existing file."""
+ from development_hub.parsers.base import atomic_write
+
+ test_file = tmp_path / "existing.md"
+ test_file.write_text("Original content")
+
+ new_content = "New content here"
+ atomic_write(test_file, new_content)
+
+ assert test_file.read_text() == new_content
+
+ def test_atomic_write_no_temp_files_left(self, tmp_path):
+ """atomic_write should not leave temp files after success."""
+ from development_hub.parsers.base import atomic_write
+
+ test_file = tmp_path / "test.md"
+ atomic_write(test_file, "content")
+
+ # Check no temp files remain
+ temp_files = list(tmp_path.glob(".test.md.*"))
+ assert len(temp_files) == 0
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])