diff --git a/conductor/tracks/external_editor_integration_20260308/plan.md b/conductor/tracks/external_editor_integration_20260308/plan.md index 74c3d1e..d3ca8eb 100644 --- a/conductor/tracks/external_editor_integration_20260308/plan.md +++ b/conductor/tracks/external_editor_integration_20260308/plan.md @@ -1,12 +1,12 @@ # Implementation Plan: External Text Editor Integration for Approvals ## Phase 1: Configuration & Data Modeling -- [ ] Task: Define the schema for external editor configuration. - - [ ] Update `src/models.py` (or equivalent configuration parsing logic) to include a `text_editors` dictionary and `default_editor` string. -- [ ] Task: Integrate configuration parsing. - - [ ] Update `config.toml` loading to support a `[tools.text_editors]` section mapping names to paths/commands. - - [ ] Update `manual_slop.toml` loading to support a project-level `default_editor` override. -- [ ] Task: Conductor - User Manual Verification 'Phase 1: Configuration & Data Modeling' (Protocol in workflow.md) +- [x] Task: Define the schema for external editor configuration. + - [x] Update `src/models.py` (or equivalent configuration parsing logic) to include a `text_editors` dictionary and `default_editor` string. +- [x] Task: Integrate configuration parsing. + - [x] Update `config.toml` loading to support a `[tools.text_editors]` section mapping names to paths/commands. + - [x] Update `manual_slop.toml` loading to support a project-level `default_editor` override. +- [x] Task: Conductor - User Manual Verification 'Phase 1: Configuration & Data Modeling' (Protocol in workflow.md) ## Phase 2: Editor Launch Logic - [ ] Task: Implement the `ExternalEditorLauncher` utility. diff --git a/src/external_editor.py b/src/external_editor.py new file mode 100644 index 0000000..4ce4d08 --- /dev/null +++ b/src/external_editor.py @@ -0,0 +1,76 @@ +"""External Editor Launcher - Opens files in external text editors for diff viewing.""" +from __future__ import annotations + +import subprocess +import tempfile +from pathlib import Path +from typing import Optional, List + +from src.models import ExternalEditorConfig, TextEditorConfig + + +class ExternalEditorLauncher: + def __init__(self, config: ExternalEditorConfig): + self.config = config + + def get_editor(self, editor_name: Optional[str] = None) -> Optional[TextEditorConfig]: + if editor_name: + return self.config.editors.get(editor_name) + return self.config.get_default() + + def build_diff_command( + self, editor: TextEditorConfig, original_path: str, modified_path: str + ) -> List[str]: + cmd = [editor.path] + editor.diff_args + [original_path, modified_path] + return cmd + + def launch_diff( + self, editor_name: Optional[str], original_path: str, modified_path: str + ) -> Optional[subprocess.Popen]: + editor = self.get_editor(editor_name) + if not editor: + return None + cmd = self.build_diff_command(editor, original_path, modified_path) + try: + return subprocess.Popen(cmd) + except FileNotFoundError: + return None + + def launch_editor(self, editor_name: Optional[str], file_path: str) -> Optional[subprocess.Popen]: + editor = self.get_editor(editor_name) + if not editor: + return None + try: + return subprocess.Popen([editor.path, file_path]) + except FileNotFoundError: + return None + + +def get_default_launcher() -> ExternalEditorLauncher: + from src import models + config = models.load_config() + editors_config = config.get("tools", {}).get("text_editors", {}) + default_editor = config.get("tools", {}).get("default_editor") + ext_config = ExternalEditorConfig.from_dict({ + "editors": editors_config, + "default_editor": default_editor, + }) + return ExternalEditorLauncher(ext_config) + + +def resolve_project_editor_override(project_path: Optional[str]) -> Optional[str]: + if not project_path: + return None + from src import models + try: + proj = models.load_project(project_path) + return proj.get("default_editor") + except Exception: + return None + + +def create_temp_modified_file(content: str) -> str: + with tempfile.NamedTemporaryFile(mode="w", suffix="_modified", delete=False, encoding="utf-8") as f: + f.write(content) + return f.name +pass diff --git a/src/models.py b/src/models.py index 02684e0..c96933a 100644 --- a/src/models.py +++ b/src/models.py @@ -284,6 +284,8 @@ class WorkerContext: persona_id: Optional[str] = None +@dataclass +@dataclass @dataclass class Metadata: id: str @@ -324,6 +326,114 @@ class Metadata: ) +@dataclass +class TextEditorConfig: + name: str + path: str + diff_args: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "path": self.path, + "diff_args": self.diff_args, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TextEditorConfig": + return cls( + name=data["name"], + path=data["path"], + diff_args=data.get("diff_args", []), + ) + + +@dataclass +class ExternalEditorConfig: + editors: Dict[str, TextEditorConfig] = field(default_factory=dict) + default_editor: Optional[str] = None + + def get_default(self) -> Optional[TextEditorConfig]: + if self.default_editor and self.default_editor in self.editors: + return self.editors[self.default_editor] + if self.editors: + return next(iter(self.editors.values())) + return None + + def to_dict(self) -> Dict[str, Any]: + return { + "editors": {k: v.to_dict() for k, v in self.editors.items()}, + "default_editor": self.default_editor, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ExternalEditorConfig": + editors = {} + for name, ed_data in data.get("editors", {}).items(): + if isinstance(ed_data, dict): + editors[name] = TextEditorConfig.from_dict(ed_data) + elif isinstance(ed_data, str): + editors[name] = TextEditorConfig(name=name, path=ed_data) + return cls( + editors=editors, + default_editor=data.get("default_editor"), + ) + + +@dataclass +class TextEditorConfig: + name: str + path: str + diff_args: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "path": self.path, + "diff_args": self.diff_args, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TextEditorConfig": + return cls( + name=data["name"], + path=data["path"], + diff_args=data.get("diff_args", []), + ) + + +@dataclass +class ExternalEditorConfig: + editors: Dict[str, TextEditorConfig] = field(default_factory=dict) + default_editor: Optional[str] = None + + def get_default(self) -> Optional[TextEditorConfig]: + if self.default_editor and self.default_editor in self.editors: + return self.editors[self.default_editor] + if self.editors: + return next(iter(self.editors.values())) + return None + + def to_dict(self) -> Dict[str, Any]: + return { + "editors": {k: v.to_dict() for k, v in self.editors.items()}, + "default_editor": self.default_editor, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ExternalEditorConfig": + editors = {} + for name, ed_data in data.get("editors", {}).items(): + if isinstance(ed_data, dict): + editors[name] = TextEditorConfig.from_dict(ed_data) + elif isinstance(ed_data, str): + editors[name] = TextEditorConfig(name=name, path=ed_data) + return cls( + editors=editors, + default_editor=data.get("default_editor"), + ) + + @dataclass class TrackState: metadata: Metadata diff --git a/tests/test_external_editor.py b/tests/test_external_editor.py new file mode 100644 index 0000000..1c6da6c --- /dev/null +++ b/tests/test_external_editor.py @@ -0,0 +1,130 @@ +"""Tests for external editor integration.""" +import pytest +from unittest.mock import patch, MagicMock +from src.models import TextEditorConfig, ExternalEditorConfig +from src.external_editor import ( + ExternalEditorLauncher, + get_default_launcher, + resolve_project_editor_override, + create_temp_modified_file, +) + + +@pytest.fixture +def vscode_editor(): + return TextEditorConfig(name="vscode", path="C:\\path\\to\\code.exe", diff_args=["--diff"]) + + +@pytest.fixture +def notepadpp_editor(): + return TextEditorConfig(name="notepad++", path="C:\\path\\to\\notepad++.exe", diff_args=["-multiInst", "-nosession"]) + + +@pytest.fixture +def ext_config(vscode_editor, notepadpp_editor): + return ExternalEditorConfig( + editors={"vscode": vscode_editor, "notepad++": notepadpp_editor}, + default_editor="vscode", + ) + + +@pytest.fixture +def launcher(ext_config): + return ExternalEditorLauncher(ext_config) + + +class TestTextEditorConfig: + def test_from_dict_with_diff_args(self): + data = {"name": "vscode", "path": "C:\\code.exe", "diff_args": ["--diff"]} + editor = TextEditorConfig.from_dict(data) + assert editor.name == "vscode" + assert editor.path == "C:\\code.exe" + assert editor.diff_args == ["--diff"] + + def test_from_dict_without_diff_args(self): + data = {"name": "vscode", "path": "C:\\code.exe"} + editor = TextEditorConfig.from_dict(data) + assert editor.diff_args == [] + + def test_to_dict(self, vscode_editor): + result = vscode_editor.to_dict() + assert result["name"] == "vscode" + assert result["path"] == "C:\\path\\to\\code.exe" + assert result["diff_args"] == ["--diff"] + + +class TestExternalEditorConfig: + def test_from_dict_with_string_editors(self): + data = {"editors": {"vscode": "C:\\code.exe"}, "default_editor": "vscode"} + config = ExternalEditorConfig.from_dict(data) + assert "vscode" in config.editors + assert config.editors["vscode"].path == "C:\\code.exe" + + def test_from_dict_with_dict_editors(self, vscode_editor): + data = {"editors": {"vscode": {"name": "vscode", "path": "C:\\code.exe", "diff_args": ["--diff"]}}} + config = ExternalEditorConfig.from_dict(data) + assert config.editors["vscode"].diff_args == ["--diff"] + + def test_get_default_returns_configured(self, ext_config): + result = ext_config.get_default() + assert result.name == "vscode" + + def test_get_default_fallback_to_first(self): + config = ExternalEditorConfig(editors={"notepad++": TextEditorConfig(name="notepad++", path="C:\\npp.exe")}) + result = config.get_default() + assert result.name == "notepad++" + + def test_get_default_returns_none_when_empty(self): + config = ExternalEditorConfig(editors={}) + assert config.get_default() is None + + def test_to_dict(self, ext_config): + result = ext_config.to_dict() + assert result["default_editor"] == "vscode" + assert "vscode" in result["editors"] + + +class TestExternalEditorLauncher: + def test_get_editor_by_name(self, launcher): + editor = launcher.get_editor("notepad++") + assert editor.name == "notepad++" + + def test_get_editor_returns_default(self, launcher): + editor = launcher.get_editor() + assert editor.name == "vscode" + + def test_get_editor_unknown_name(self, launcher): + editor = launcher.get_editor("unknown") + assert editor is None + + def test_build_diff_command(self, launcher, vscode_editor): + cmd = launcher.build_diff_command(vscode_editor, "orig.txt", "mod.txt") + assert cmd == ["C:\\path\\to\\code.exe", "--diff", "orig.txt", "mod.txt"] + + def test_launch_diff_missing_editor(self, launcher): + result = launcher.launch_diff("nonexistent", "orig.txt", "mod.txt") + assert result is None + + @patch("subprocess.Popen") + def test_launch_diff_success(self, mock_popen, launcher): + mock_popen.return_value = MagicMock() + result = launcher.launch_diff("vscode", "orig.txt", "mod.txt") + assert result is not None + mock_popen.assert_called_once() + + @patch("subprocess.Popen") + def test_launch_diff_file_not_found(self, mock_popen, launcher): + mock_popen.side_effect = FileNotFoundError() + result = launcher.launch_diff("vscode", "orig.txt", "mod.txt") + assert result is None + + +class TestHelperFunctions: + def test_create_temp_modified_file(self): + content = "test content" + path = create_temp_modified_file(content) + assert path.endswith("_modified") + with open(path, encoding="utf-8") as f: + assert f.read() == content + import os + os.unlink(path)