Add dev setup script and restructure dependencies for editable installs
- Add scripts/dev-setup.sh for setting up development environment with editable installs of interdependent projects (cmdforge, ramble, artifact-editor, orchestrated-discussions) - Restructure pyproject.toml dependencies: top-level app specifies git URLs, libraries use name-only deps for compatibility with editable installs - Add artifact-editor as explicit dependency (transitive through discussions) - Various model, parser, and widget enhancements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
20818956b3
commit
de024965a0
|
|
@ -13,9 +13,12 @@ dependencies = [
|
|||
"PySide6>=6.4.0",
|
||||
"pyte>=0.8.0",
|
||||
"pyyaml>=6.0",
|
||||
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
|
||||
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
|
||||
# Git dependencies - top-level app specifies where to get internal packages
|
||||
# Libraries use name-only deps so editable installs work during development
|
||||
"cmdforge @ git+https://gitea.brrd.tech/rob/CmdForge.git",
|
||||
"ramble @ git+https://gitea.brrd.tech/rob/ramble.git",
|
||||
"artifact-editor @ git+https://gitea.brrd.tech/rob/artifact-editor.git",
|
||||
"orchestrated-discussions[gui] @ git+https://gitea.brrd.tech/rob/orchestrated-discussions.git",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
#!/bin/bash
|
||||
# Development setup script for development-hub
|
||||
#
|
||||
# This script installs all interdependent projects as editable packages,
|
||||
# allowing you to develop them in parallel without reinstalling.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/dev-setup.sh # Full setup with venv creation
|
||||
# ./scripts/dev-setup.sh --deps # Just reinstall editable deps (faster)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PROJECTS_ROOT="$(dirname "$PROJECT_DIR")"
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
echo -e "${BLUE}==>${NC} $1"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}==>${NC} $1"
|
||||
}
|
||||
|
||||
# Check if we're just updating deps
|
||||
DEPS_ONLY=false
|
||||
if [ "$1" = "--deps" ]; then
|
||||
DEPS_ONLY=true
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ "$DEPS_ONLY" = false ]; then
|
||||
# Create venv if it doesn't exist
|
||||
if [ ! -d ".venv" ]; then
|
||||
log "Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
fi
|
||||
|
||||
# Activate venv
|
||||
log "Activating virtual environment..."
|
||||
source .venv/bin/activate
|
||||
|
||||
if [ "$DEPS_ONLY" = false ]; then
|
||||
# Upgrade pip
|
||||
log "Upgrading pip..."
|
||||
pip install --upgrade pip
|
||||
fi
|
||||
|
||||
# Install editable packages in dependency order (base packages first)
|
||||
# Using --no-deps to avoid git URL conflicts, then installing other deps
|
||||
log "Installing cmdforge (base layer)..."
|
||||
pip install -e "$PROJECTS_ROOT/CmdForge" --no-deps
|
||||
pip install PyYAML requests PySide6 NodeGraphQt setuptools
|
||||
|
||||
log "Installing ramble..."
|
||||
pip install -e "$PROJECTS_ROOT/ramble" --no-deps
|
||||
|
||||
log "Installing artifact-editor..."
|
||||
pip install -e "$PROJECTS_ROOT/artifact-editor" --no-deps
|
||||
pip install QScintilla
|
||||
|
||||
log "Installing orchestrated-discussions[gui]..."
|
||||
pip install -e "$PROJECTS_ROOT/orchestrated-discussions[gui]" --no-deps
|
||||
pip install dearpygui sounddevice numpy urwid
|
||||
|
||||
log "Installing development-hub..."
|
||||
pip install -e "$PROJECT_DIR" --no-deps
|
||||
pip install pyte
|
||||
|
||||
if [ "$DEPS_ONLY" = false ]; then
|
||||
# Install dev dependencies
|
||||
log "Installing dev dependencies..."
|
||||
pip install pytest pytest-qt pytest-cov
|
||||
fi
|
||||
|
||||
success "Development environment ready!"
|
||||
echo ""
|
||||
echo "Installed packages (editable):"
|
||||
pip list | grep -E "(cmdforge|ramble|artifact-editor|orchestrated-discussions|development-hub)" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "To activate: source .venv/bin/activate"
|
||||
echo "To run: development-hub"
|
||||
|
|
@ -45,6 +45,23 @@ class Deliverable:
|
|||
return cls(name=name, status=status)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkedDocument:
|
||||
"""A document linked to a milestone (plan, spec, notes, etc.)."""
|
||||
path: str # Relative path to document
|
||||
title: str = "" # Display title (derived from path if empty)
|
||||
doc_type: str = "plan" # "plan", "spec", "notes", "discussion"
|
||||
|
||||
@property
|
||||
def display_title(self) -> str:
|
||||
"""Get display title, falling back to filename."""
|
||||
if self.title:
|
||||
return self.title
|
||||
# Extract filename without extension
|
||||
from pathlib import Path
|
||||
return Path(self.path).stem.replace("-", " ").replace("_", " ").title()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Milestone:
|
||||
"""A milestone with deliverables and progress tracking."""
|
||||
|
|
@ -56,6 +73,8 @@ class Milestone:
|
|||
deliverables: list[Deliverable] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
description: str = "" # Free-form description text
|
||||
plan_path: str | None = None # Path to linked plan document
|
||||
documents: list[LinkedDocument] = field(default_factory=list) # Additional linked docs
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class Todo:
|
|||
tags: list[str] = field(default_factory=list) # from #tag in text
|
||||
completed_date: str | None = None
|
||||
blocker_reason: str | None = None # For blocked items
|
||||
phase: str | None = None # from [Phase 1] prefix or #phase-1 tag
|
||||
notes: str | None = None # Additional context, shown as tooltip
|
||||
|
||||
@property
|
||||
def priority_order(self) -> int:
|
||||
|
|
@ -33,9 +35,21 @@ class Todo:
|
|||
self.blocker_reason = None
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Convert to markdown checkbox format."""
|
||||
"""Convert to markdown checkbox format.
|
||||
|
||||
Returns a string that may be multiple lines if notes are present:
|
||||
- [ ] Task text @M4
|
||||
> Notes about this task
|
||||
"""
|
||||
checkbox = "[x]" if self.completed else "[ ]"
|
||||
parts = [f"- {checkbox} {self.text}"]
|
||||
|
||||
# Include phase prefix if present
|
||||
if self.phase:
|
||||
text_part = f"[{self.phase}] {self.text}"
|
||||
else:
|
||||
text_part = self.text
|
||||
|
||||
parts = [f"- {checkbox} {text_part}"]
|
||||
|
||||
if self.milestone:
|
||||
parts.append(f"@{self.milestone}")
|
||||
|
|
@ -51,7 +65,13 @@ class Todo:
|
|||
if self.blocker_reason:
|
||||
parts.append(f"- {self.blocker_reason}")
|
||||
|
||||
return " ".join(parts)
|
||||
result = " ".join(parts)
|
||||
|
||||
# Add notes as indented blockquote line
|
||||
if self.notes:
|
||||
result += f"\n > {self.notes}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -251,6 +251,24 @@ class BaseParser:
|
|||
return date, text.strip()
|
||||
return None, text
|
||||
|
||||
@staticmethod
|
||||
def extract_phase(text: str) -> tuple[str | None, str]:
|
||||
"""Extract [Phase N] or [Phase Name] prefix from text.
|
||||
|
||||
Args:
|
||||
text: Text potentially starting with [Phase 1] or similar
|
||||
|
||||
Returns:
|
||||
Tuple of (phase string or None, text without phase prefix)
|
||||
"""
|
||||
# Match [Phase X] at the start of the text
|
||||
match = re.match(r"^\[([^\]]+)\]\s*", text)
|
||||
if match:
|
||||
phase = match.group(1)
|
||||
text = text[match.end():].strip()
|
||||
return phase, text
|
||||
return None, text
|
||||
|
||||
@staticmethod
|
||||
def parse_table(lines: list[str]) -> list[tuple[str, ...]]:
|
||||
"""Parse a markdown table.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from development_hub.models.goal import (
|
|||
GoalList,
|
||||
Milestone,
|
||||
Deliverable,
|
||||
LinkedDocument,
|
||||
MilestoneStatus,
|
||||
DeliverableStatus,
|
||||
)
|
||||
|
|
@ -211,6 +212,8 @@ class MilestonesParser(BaseParser):
|
|||
deliverables = []
|
||||
notes = ""
|
||||
description_lines = []
|
||||
plan_path = None
|
||||
documents = []
|
||||
|
||||
lines = content.split("\n")
|
||||
table_lines = []
|
||||
|
|
@ -242,6 +245,21 @@ class MilestonesParser(BaseParser):
|
|||
notes = notes_match.group(1).strip()
|
||||
continue
|
||||
|
||||
# Parse **Plan**: path/to/file.md
|
||||
plan_match = re.match(r"\*\*Plan\*\*:\s*(.+)", line_stripped)
|
||||
if plan_match:
|
||||
plan_path = plan_match.group(1).strip()
|
||||
continue
|
||||
|
||||
# Parse **Documents**: path1, path2, ... (optional multi-doc field)
|
||||
docs_match = re.match(r"\*\*Documents\*\*:\s*(.+)", line_stripped)
|
||||
if docs_match:
|
||||
doc_paths = [p.strip() for p in docs_match.group(1).split(",")]
|
||||
for path in doc_paths:
|
||||
if path:
|
||||
documents.append(LinkedDocument(path=path))
|
||||
continue
|
||||
|
||||
# Parse deliverables table
|
||||
if line_stripped.startswith("|"):
|
||||
in_table = True
|
||||
|
|
@ -270,6 +288,8 @@ class MilestonesParser(BaseParser):
|
|||
deliverables=deliverables,
|
||||
notes=notes,
|
||||
description=" ".join(description_lines),
|
||||
plan_path=plan_path,
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
def _parse_status(self, status_text: str) -> tuple[MilestoneStatus, int]:
|
||||
|
|
@ -407,6 +427,15 @@ class MilestonesParser(BaseParser):
|
|||
if milestone.notes:
|
||||
lines.append(f"**Notes**: {milestone.notes}")
|
||||
|
||||
# Plan path
|
||||
if milestone.plan_path:
|
||||
lines.append(f"**Plan**: {milestone.plan_path}")
|
||||
|
||||
# Additional documents
|
||||
if milestone.documents:
|
||||
doc_paths = ", ".join(d.path for d in milestone.documents)
|
||||
lines.append(f"**Documents**: {doc_paths}")
|
||||
|
||||
# Description (after fields, before table)
|
||||
if milestone.description:
|
||||
lines.append("")
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class TodosParser(BaseParser):
|
|||
Expected format:
|
||||
## Active Tasks / High Priority / Medium Priority / Low Priority
|
||||
- [ ] Task description @project #tag
|
||||
> Optional notes on indented line
|
||||
|
||||
## Completed
|
||||
- [x] Done task (2026-01-06)
|
||||
|
|
@ -38,10 +39,25 @@ class TodosParser(BaseParser):
|
|||
current_priority = "medium"
|
||||
table_lines = []
|
||||
in_table = False
|
||||
pending_todo = None # Track todo waiting for possible notes
|
||||
|
||||
for line in self.body.split("\n"):
|
||||
lines = self.body.split("\n")
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Check for indented note line (follows a todo)
|
||||
if pending_todo and line.startswith(" ") and line_stripped.startswith(">"):
|
||||
# Extract note text (remove leading > and whitespace)
|
||||
note_text = line_stripped[1:].strip()
|
||||
pending_todo.notes = note_text
|
||||
todo_list.add_todo(pending_todo)
|
||||
pending_todo = None
|
||||
continue
|
||||
elif pending_todo:
|
||||
# Previous line was a todo but this isn't a note - add the todo
|
||||
todo_list.add_todo(pending_todo)
|
||||
pending_todo = None
|
||||
|
||||
# Detect section headers
|
||||
if line_stripped.startswith("## ") or line_stripped.startswith("### "):
|
||||
# Save any pending table
|
||||
|
|
@ -93,7 +109,12 @@ class TodosParser(BaseParser):
|
|||
if line_stripped.startswith("- ["):
|
||||
todo = self._parse_todo_line(line_stripped, current_priority, current_section)
|
||||
if todo:
|
||||
todo_list.add_todo(todo)
|
||||
# Don't add yet - wait to see if next line has notes
|
||||
pending_todo = todo
|
||||
|
||||
# Handle any remaining pending todo
|
||||
if pending_todo:
|
||||
todo_list.add_todo(pending_todo)
|
||||
|
||||
# Handle any remaining table
|
||||
if in_table and table_lines:
|
||||
|
|
@ -105,7 +126,7 @@ class TodosParser(BaseParser):
|
|||
"""Parse a single todo line.
|
||||
|
||||
Args:
|
||||
line: Line like "- [ ] Task @M1 @project #tag"
|
||||
line: Line like "- [ ] [Phase 1] Task @M1 @project #tag"
|
||||
priority: Current priority level
|
||||
section: Current section name
|
||||
|
||||
|
|
@ -117,6 +138,9 @@ class TodosParser(BaseParser):
|
|||
if not text:
|
||||
return None
|
||||
|
||||
# Extract phase prefix first (e.g., [Phase 1])
|
||||
phase, text = self.extract_phase(text)
|
||||
|
||||
# Extract metadata (milestone first, then project)
|
||||
milestone, text = self.extract_milestone_tag(text)
|
||||
project, text = self.extract_project_tag(text)
|
||||
|
|
@ -156,6 +180,7 @@ class TodosParser(BaseParser):
|
|||
tags=tags,
|
||||
completed_date=date,
|
||||
blocker_reason=blocker_reason if section == "blocked" else None,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
# Handle blocked items
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Background worker for running goals audit."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -14,10 +15,18 @@ class AuditWorker(QObject):
|
|||
finished = Signal(str, bool) # output, success
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, project_key: str, project_path: Path | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
project_name: str,
|
||||
goals_path: Path,
|
||||
milestones_path: Path | None = None,
|
||||
project_dir: Path | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.project_key = project_key # e.g. "development-hub" or "global"
|
||||
self.project_path = project_path
|
||||
self.project_name = project_name
|
||||
self.goals_path = goals_path
|
||||
self.milestones_path = milestones_path
|
||||
self.project_dir = project_dir
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._cancelled = False
|
||||
|
||||
|
|
@ -27,6 +36,16 @@ class AuditWorker(QObject):
|
|||
if not cmdforge_path:
|
||||
cmdforge_path = Path("cmdforge")
|
||||
|
||||
# Build JSON input for the tool
|
||||
input_data = {
|
||||
"project_name": self.project_name,
|
||||
"goals_path": str(self.goals_path),
|
||||
}
|
||||
if self.milestones_path:
|
||||
input_data["milestones_path"] = str(self.milestones_path)
|
||||
if self.project_dir:
|
||||
input_data["project_dir"] = str(self.project_dir)
|
||||
|
||||
try:
|
||||
self._process = subprocess.Popen(
|
||||
[str(cmdforge_path), "run", "audit-goals"],
|
||||
|
|
@ -34,11 +53,11 @@ class AuditWorker(QObject):
|
|||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=str(self.project_path) if self.project_path and self.project_path.exists() else None,
|
||||
cwd=str(self.project_dir) if self.project_dir and self.project_dir.exists() else None,
|
||||
)
|
||||
|
||||
# Pass project key to stdin (tool expects project name, not file content)
|
||||
stdout, stderr = self._process.communicate(input=self.project_key)
|
||||
# Pass JSON input to tool
|
||||
stdout, stderr = self._process.communicate(input=json.dumps(input_data))
|
||||
|
||||
if self._cancelled:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -761,6 +761,9 @@ class MilestoneWidget(QFrame):
|
|||
todo_added = Signal(str, str, str) # (text, priority, milestone_id) - for adding new todos
|
||||
todo_start_discussion = Signal(object) # (todo) - for starting discussion from todo
|
||||
todo_edited = Signal(object, str, str) # (todo, old_text, new_text) - for inline editing
|
||||
milestone_start_discussion = Signal(object) # (milestone) - for starting discussion from milestone
|
||||
milestone_import_plan = Signal(object) # (milestone) - for importing a plan to this milestone
|
||||
milestone_view_plan = Signal(object, str) # (milestone, plan_path) - for viewing linked plan
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -848,6 +851,14 @@ class MilestoneWidget(QFrame):
|
|||
self.target_label.setStyleSheet("color: #888888; font-size: 11px;")
|
||||
content_layout.addWidget(self.target_label)
|
||||
|
||||
# Plan link (if set)
|
||||
if self.milestone.plan_path:
|
||||
self.plan_link = QLabel(f'📄 <a href="#" style="color: #4a9eff;">View Implementation Plan</a>')
|
||||
self.plan_link.setStyleSheet("font-size: 11px;")
|
||||
self.plan_link.setOpenExternalLinks(False)
|
||||
self.plan_link.linkActivated.connect(self._on_view_plan_clicked)
|
||||
content_layout.addWidget(self.plan_link)
|
||||
|
||||
# Deliverables list
|
||||
self.deliverables_container = QWidget()
|
||||
self.deliverables_layout = QVBoxLayout(self.deliverables_container)
|
||||
|
|
@ -1005,7 +1016,9 @@ class MilestoneWidget(QFrame):
|
|||
""")
|
||||
|
||||
def _load_deliverables(self):
|
||||
"""Load deliverable/todo widgets."""
|
||||
"""Load deliverable/todo widgets, grouped by phase if applicable."""
|
||||
import re
|
||||
|
||||
# Clear existing
|
||||
while self.deliverables_layout.count():
|
||||
item = self.deliverables_layout.takeAt(0)
|
||||
|
|
@ -1014,14 +1027,49 @@ class MilestoneWidget(QFrame):
|
|||
|
||||
# Show todos if available (preferred mode), otherwise show deliverables
|
||||
if self._todos:
|
||||
# Group todos by phase
|
||||
phases = {}
|
||||
for todo in self._todos:
|
||||
# Show priority badge instead of milestone (since milestone is obvious)
|
||||
widget = TodoItemWidget(todo, show_priority=True)
|
||||
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)
|
||||
phase_key = todo.phase or "_ungrouped"
|
||||
phases.setdefault(phase_key, []).append(todo)
|
||||
|
||||
# Sort phase names: "Phase 1" before "Phase 2", then alphabetic
|
||||
def phase_sort_key(name):
|
||||
if name == "_ungrouped":
|
||||
return (999, "") # Ungrouped at end
|
||||
match = re.search(r'\d+', name)
|
||||
return (int(match.group()) if match else 500, name)
|
||||
|
||||
sorted_phases = sorted(phases.keys(), key=phase_sort_key)
|
||||
|
||||
# If there's only one phase (or no phases), don't show phase headers
|
||||
show_phase_headers = len(sorted_phases) > 1 or (
|
||||
len(sorted_phases) == 1 and sorted_phases[0] != "_ungrouped"
|
||||
)
|
||||
|
||||
for phase_name in sorted_phases:
|
||||
phase_todos = phases[phase_name]
|
||||
|
||||
# Add phase header if needed
|
||||
if show_phase_headers and phase_name != "_ungrouped":
|
||||
phase_header = QLabel(f"▸ {phase_name}")
|
||||
phase_header.setStyleSheet(
|
||||
"color: #888888; font-size: 11px; font-weight: bold; "
|
||||
"padding: 4px 0px 2px 0px;"
|
||||
)
|
||||
self.deliverables_layout.addWidget(phase_header)
|
||||
|
||||
# Add todos for this phase
|
||||
for todo in phase_todos:
|
||||
widget = TodoItemWidget(todo, show_priority=True)
|
||||
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)
|
||||
# Set tooltip with notes if available
|
||||
if todo.notes:
|
||||
widget.setToolTip(todo.notes)
|
||||
self.deliverables_layout.addWidget(widget)
|
||||
else:
|
||||
# Legacy: Add deliverables
|
||||
for deliverable in self.milestone.deliverables:
|
||||
|
|
@ -1078,3 +1126,48 @@ class MilestoneWidget(QFrame):
|
|||
self._update_progress()
|
||||
self._update_status_icon()
|
||||
self._load_deliverables()
|
||||
|
||||
def _on_view_plan_clicked(self, link):
|
||||
"""Handle view plan link click."""
|
||||
if self.milestone.plan_path:
|
||||
self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Show context menu on right-click."""
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet("""
|
||||
QMenu {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #3d3d3d;
|
||||
padding: 4px;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 8px 24px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: #3d6a99;
|
||||
}
|
||||
""")
|
||||
|
||||
# Start Discussion action
|
||||
discuss_action = menu.addAction("Start Discussion...")
|
||||
discuss_action.triggered.connect(
|
||||
lambda: self.milestone_start_discussion.emit(self.milestone)
|
||||
)
|
||||
|
||||
# Import Plan action
|
||||
import_action = menu.addAction("Import Plan...")
|
||||
import_action.triggered.connect(
|
||||
lambda: self.milestone_import_plan.emit(self.milestone)
|
||||
)
|
||||
|
||||
# View Plan action (if plan exists)
|
||||
if self.milestone.plan_path:
|
||||
menu.addSeparator()
|
||||
view_action = menu.addAction("View Plan")
|
||||
view_action.triggered.connect(
|
||||
lambda: self.milestone_view_plan.emit(self.milestone, self.milestone.plan_path)
|
||||
)
|
||||
|
||||
menu.exec(event.globalPos())
|
||||
|
|
|
|||
Loading…
Reference in New Issue