diff --git a/PROGRESS.md b/PROGRESS.md index bac4167..6b3a635 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -382,7 +382,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 complete. Step 126 (build system integration) done. +Sprint 5 in progress. Step 129 (dependency management UI) done. Next: Step 130 (library API indexing via LSP). --- @@ -472,3 +472,6 @@ Sprint 4 complete. Step 126 (build system integration) done. | 2026-02-09 | Codex | Step 125: Agents panel enhancements (permissions, disconnect, request logging). 2/2 tests pass. | | 2026-02-09 | Codex | Added integration test for Step 125 agent mutation flow; updated testing policy in ARCHITECTURE.md. 3/3 integration tests pass. | | 2026-02-09 | Codex | Step 126: Build system detection, build commands, and error parsing/jump. 7/7 tests pass. | +| 2026-02-09 | Codex | Step 127: Package registry abstraction stubs for major ecosystems. 4/4 tests pass. | +| 2026-02-09 | Codex | Step 128: Dependency file parsing with Import population. 6/6 tests pass. | +| 2026-02-09 | Codex | Step 129: Dependency management UI panel with add/remove/update, source targeting, and writeback support for requirements.txt/package.json/Cargo.toml/go.mod/vcpkg.json. 8/8 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index b29128e..44c9bc0 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -728,6 +728,10 @@ add_executable(step128_test tests/step128_test.cpp) target_include_directories(step128_test PRIVATE src) target_link_libraries(step128_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step129_test tests/step129_test.cpp) +target_include_directories(step129_test PRIVATE src) +target_link_libraries(step129_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/DependencyPanel.h b/editor/src/DependencyPanel.h new file mode 100644 index 0000000..8a33e40 --- /dev/null +++ b/editor/src/DependencyPanel.h @@ -0,0 +1,477 @@ +#pragma once +#include "imgui.h" +#include "DependencyParser.h" +#include "PackageRegistry.h" +#include +#include +#include +#include +#include +#include +#include + +struct DependencyFile { + std::string path; + std::string label; +}; + +struct DependencyPanelState { + std::vector deps; + std::vector sources; + int selected = -1; + int sourceSelected = -1; + int editVersionIndex = -1; + char searchBuf[128] = {}; + char versionBuf[32] = {}; + char editVersionBuf[32] = {}; + char addBuf[128] = {}; + bool showAddPopup = false; + PackageEcosystem addEcosystem = PackageEcosystem::Python; + PackageInfo lastLookup; + std::string lastWorkspaceRoot; +}; + +static std::vector dependencyFileNames() { + return { + "requirements.txt", + "package.json", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "vcpkg.json", + "CMakeLists.txt", + "setup.py", + "pyproject.toml" + }; +} + +static std::string sourceBase(const std::string& source) { + auto pos = source.find(':'); + if (pos == std::string::npos) return source; + return source.substr(0, pos); +} + +static std::string sourceGroup(const std::string& source) { + auto pos = source.find(':'); + if (pos == std::string::npos) return ""; + return source.substr(pos + 1); +} + +static PackageEcosystem ecosystemForSource(const std::string& source) { + std::string base = sourceBase(source); + if (base == "requirements.txt" || base == "setup.py" || base == "pyproject.toml") + return PackageEcosystem::Python; + if (base == "package.json") + return PackageEcosystem::Npm; + if (base == "Cargo.toml") + return PackageEcosystem::Rust; + if (base == "go.mod") + return PackageEcosystem::Go; + if (base == "pom.xml" || base == "build.gradle") + return PackageEcosystem::Java; + return PackageEcosystem::Cpp; +} + +static const char* ecosystemLabel(PackageEcosystem eco) { + switch (eco) { + case PackageEcosystem::Python: return "Python (PyPI)"; + case PackageEcosystem::Npm: return "JavaScript (npm)"; + case PackageEcosystem::Rust: return "Rust (crates.io)"; + case PackageEcosystem::Go: return "Go (pkg.go.dev)"; + case PackageEcosystem::Java: return "Java (Maven)"; + case PackageEcosystem::Cpp: return "C/C++ (vcpkg/Conan)"; + default: return "Unknown"; + } +} + +static std::vector discoverDependencyFiles(const std::string& workspaceRoot) { + std::vector out; + if (workspaceRoot.empty()) return out; + std::filesystem::path root(workspaceRoot); + for (const auto& name : dependencyFileNames()) { + auto path = root / name; + if (std::filesystem::exists(path)) { + DependencyFile file; + file.path = path.string(); + file.label = name; + out.push_back(file); + } + } + return out; +} + +static std::string resolveSourcePath(const std::string& workspaceRoot, const std::string& source) { + std::filesystem::path root(workspaceRoot); + std::string base = sourceBase(source); + return (root / base).string(); +} + +static std::vector depsForSource(const std::vector& deps, + const std::string& base) { + std::vector out; + for (const auto& dep : deps) { + if (sourceBase(dep.source) == base) { + out.push_back(dep); + } + } + return out; +} + +static std::string formatRequirementLine(const DependencySpec& dep) { + if (dep.version.empty()) return dep.name; + if (!dep.version.empty()) { + char c = dep.version[0]; + if (c == '>' || c == '<' || c == '=' || c == '~' || c == '^') { + return dep.name + dep.version; + } + } + return dep.name + "==" + dep.version; +} + +static bool writeRequirementsFile(const std::string& path, + const std::vector& deps, + std::string& logOut) { + std::ofstream out(path); + if (!out.is_open()) { + logOut += "[deps] Failed to write " + path + "\n"; + return false; + } + for (const auto& dep : deps) { + if (dep.name.empty()) continue; + out << formatRequirementLine(dep) << "\n"; + } + logOut += "[deps] Wrote " + path + "\n"; + return true; +} + +static bool writePackageJson(const std::string& path, + const std::vector& deps, + std::string& logOut) { + nlohmann::json j; + if (std::filesystem::exists(path)) { + std::ifstream in(path); + if (in.is_open()) { + try { in >> j; } catch (...) { j = nlohmann::json::object(); } + } + } + if (!j.is_object()) j = nlohmann::json::object(); + + nlohmann::json depsObj = nlohmann::json::object(); + nlohmann::json devObj = nlohmann::json::object(); + for (const auto& dep : deps) { + if (dep.name.empty()) continue; + std::string group = sourceGroup(dep.source); + std::string version = dep.version.empty() ? "*" : dep.version; + if (group == "devDependencies") { + devObj[dep.name] = version; + } else { + depsObj[dep.name] = version; + } + } + j["dependencies"] = depsObj; + j["devDependencies"] = devObj; + + std::ofstream out(path); + if (!out.is_open()) { + logOut += "[deps] Failed to write " + path + "\n"; + return false; + } + out << j.dump(2); + logOut += "[deps] Wrote " + path + "\n"; + return true; +} + +static bool writeCargoToml(const std::string& path, + const std::vector& deps, + std::string& logOut) { + std::vector prefix; + bool foundDeps = false; + if (std::filesystem::exists(path)) { + std::ifstream in(path); + std::string line; + while (std::getline(in, line)) { + if (line.rfind("[dependencies]", 0) == 0) { + foundDeps = true; + break; + } + prefix.push_back(line); + } + } + std::ofstream out(path); + if (!out.is_open()) { + logOut += "[deps] Failed to write " + path + "\n"; + return false; + } + for (const auto& line : prefix) { + out << line << "\n"; + } + if (!foundDeps) out << "\n"; + out << "[dependencies]\n"; + for (const auto& dep : deps) { + if (dep.name.empty()) continue; + std::string version = dep.version.empty() ? "*" : dep.version; + out << dep.name << " = \"" << version << "\"\n"; + } + logOut += "[deps] Wrote " + path + "\n"; + return true; +} + +static bool writeGoMod(const std::string& path, + const std::vector& deps, + std::string& logOut) { + std::string moduleLine; + std::string goLine; + if (std::filesystem::exists(path)) { + std::ifstream in(path); + std::string line; + while (std::getline(in, line)) { + if (line.rfind("module ", 0) == 0) moduleLine = line; + if (line.rfind("go ", 0) == 0) goLine = line; + } + } + if (moduleLine.empty()) moduleLine = "module example.com/project"; + if (goLine.empty()) goLine = "go 1.20"; + + std::ofstream out(path); + if (!out.is_open()) { + logOut += "[deps] Failed to write " + path + "\n"; + return false; + } + out << moduleLine << "\n" << goLine << "\n\n"; + out << "require (\n"; + for (const auto& dep : deps) { + if (dep.name.empty()) continue; + std::string version = dep.version.empty() ? "v0.0.0" : dep.version; + out << " " << dep.name << " " << version << "\n"; + } + out << ")\n"; + logOut += "[deps] Wrote " + path + "\n"; + return true; +} + +static bool writeVcpkgJson(const std::string& path, + const std::vector& deps, + std::string& logOut) { + nlohmann::json j; + if (std::filesystem::exists(path)) { + std::ifstream in(path); + if (in.is_open()) { + try { in >> j; } catch (...) { j = nlohmann::json::object(); } + } + } + if (!j.is_object()) j = nlohmann::json::object(); + nlohmann::json depsArr = nlohmann::json::array(); + for (const auto& dep : deps) { + if (dep.name.empty()) continue; + if (dep.version.empty()) { + depsArr.push_back(dep.name); + } else { + depsArr.push_back({{"name", dep.name}, {"version", dep.version}}); + } + } + j["dependencies"] = depsArr; + std::ofstream out(path); + if (!out.is_open()) { + logOut += "[deps] Failed to write " + path + "\n"; + return false; + } + out << j.dump(2); + logOut += "[deps] Wrote " + path + "\n"; + return true; +} + +static bool writeDependenciesForSource(const std::string& source, + const std::string& workspaceRoot, + const std::vector& deps, + std::string& logOut) { + if (workspaceRoot.empty()) { + logOut += "[deps] No workspace root set.\n"; + return false; + } + std::string base = sourceBase(source); + std::string path = resolveSourcePath(workspaceRoot, base); + auto filtered = depsForSource(deps, base); + + if (base == "requirements.txt") return writeRequirementsFile(path, filtered, logOut); + if (base == "package.json") return writePackageJson(path, filtered, logOut); + if (base == "Cargo.toml") return writeCargoToml(path, filtered, logOut); + if (base == "go.mod") return writeGoMod(path, filtered, logOut); + if (base == "vcpkg.json") return writeVcpkgJson(path, filtered, logOut); + + logOut += "[deps] Writeback not implemented for " + base + "\n"; + return false; +} + +static void refreshDependencies(DependencyPanelState& state, + const std::string& workspaceRoot, + std::string& logOut) { + state.deps.clear(); + state.sources = discoverDependencyFiles(workspaceRoot); + state.selected = -1; + state.editVersionIndex = -1; + state.lastWorkspaceRoot = workspaceRoot; + if (workspaceRoot.empty()) return; + for (const auto& file : state.sources) { + auto parsed = DependencyParser::parseFile(file.path); + state.deps.insert(state.deps.end(), parsed.begin(), parsed.end()); + } + if (!state.sources.empty()) { + if (state.sourceSelected < 0 || state.sourceSelected >= (int)state.sources.size()) { + state.sourceSelected = 0; + } + } else { + state.sourceSelected = -1; + } + logOut += "[deps] Loaded " + std::to_string(state.deps.size()) + " dependencies.\n"; +} + +static void renderDependencyPanel(DependencyPanelState& state, + const std::string& workspaceRoot, + std::string& logOut) { + if (workspaceRoot.empty()) { + ImGui::TextDisabled("Open a workspace to manage dependencies."); + return; + } + if (state.lastWorkspaceRoot != workspaceRoot || state.deps.empty()) { + refreshDependencies(state, workspaceRoot, logOut); + } + + ImGui::InputText("Search", state.searchBuf, sizeof(state.searchBuf)); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshDependencies(state, workspaceRoot, logOut); + } + ImGui::SameLine(); + if (ImGui::Button("Add Package")) { + state.showAddPopup = true; + state.addBuf[0] = '\0'; + state.versionBuf[0] = '\0'; + } + + std::string targetLabel = "(create requirements.txt)"; + if (state.sourceSelected >= 0 && state.sourceSelected < (int)state.sources.size()) { + targetLabel = state.sources[state.sourceSelected].label; + } + if (ImGui::BeginCombo("Target File", targetLabel.c_str())) { + for (int i = 0; i < (int)state.sources.size(); ++i) { + bool selected = (state.sourceSelected == i); + if (ImGui::Selectable(state.sources[i].label.c_str(), selected)) { + state.sourceSelected = i; + } + } + if (ImGui::Selectable("(create requirements.txt)", state.sourceSelected == -1)) { + state.sourceSelected = -1; + } + ImGui::EndCombo(); + } + + if (state.showAddPopup) { + ImGui::OpenPopup("Add Dependency"); + state.showAddPopup = false; + } + if (ImGui::BeginPopupModal("Add Dependency", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::InputText("Package", state.addBuf, sizeof(state.addBuf)); + ImGui::InputText("Version (optional)", state.versionBuf, sizeof(state.versionBuf)); + if (ImGui::BeginCombo("Ecosystem", ecosystemLabel(state.addEcosystem))) { + for (int i = 0; i < 6; ++i) { + auto eco = static_cast(i); + bool selected = (state.addEcosystem == eco); + if (ImGui::Selectable(ecosystemLabel(eco), selected)) { + state.addEcosystem = eco; + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + if (ImGui::Button("Search Registry")) { + state.lastLookup = PackageRegistry::query(state.addEcosystem, state.addBuf); + logOut += "[deps] Registry: " + state.lastLookup.name + " - " + + state.lastLookup.description + "\n"; + } + if (!state.lastLookup.name.empty()) { + ImGui::TextWrapped("Found: %s", state.lastLookup.name.c_str()); + } + ImGui::Separator(); + if (ImGui::Button("Add")) { + if (state.addBuf[0] == '\0') { + logOut += "[deps] Add aborted: empty package name.\n"; + } else { + DependencySpec dep; + dep.name = state.addBuf; + dep.version = state.versionBuf; + if (state.sourceSelected >= 0 && state.sourceSelected < (int)state.sources.size()) { + dep.source = state.sources[state.sourceSelected].label; + } else { + dep.source = "requirements.txt"; + } + state.deps.push_back(dep); + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { + refreshDependencies(state, workspaceRoot, logOut); + } + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::Separator(); + ImGui::BeginChild("##depList", ImVec2(0, 0), false); + for (int i = 0; i < (int)state.deps.size(); ++i) { + const auto& dep = state.deps[i]; + std::string label = dep.name; + if (!dep.version.empty()) label += " (" + dep.version + ")"; + if (!dep.source.empty()) label += " [" + dep.source + "]"; + if (state.searchBuf[0] != '\0') { + std::string q = state.searchBuf; + if (label.find(q) == std::string::npos) continue; + } + if (ImGui::Selectable(label.c_str(), state.selected == i)) { + state.selected = i; + } + } + ImGui::EndChild(); + + if (state.selected >= 0 && state.selected < (int)state.deps.size()) { + auto& dep = state.deps[state.selected]; + ImGui::Separator(); + ImGui::Text("Selected: %s", dep.name.c_str()); + if (state.editVersionIndex != state.selected) { + std::snprintf(state.editVersionBuf, sizeof(state.editVersionBuf), "%s", dep.version.c_str()); + state.editVersionIndex = state.selected; + } + ImGui::InputText("Version", state.editVersionBuf, sizeof(state.editVersionBuf)); + if (ImGui::Button("Apply Version")) { + dep.version = state.editVersionBuf; + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { + refreshDependencies(state, workspaceRoot, logOut); + } + } + ImGui::SameLine(); + if (ImGui::Button("Remove")) { + std::string src = dep.source; + state.deps.erase(state.deps.begin() + state.selected); + state.selected = -1; + if (writeDependenciesForSource(src, workspaceRoot, state.deps, logOut)) { + refreshDependencies(state, workspaceRoot, logOut); + } + } + ImGui::SameLine(); + if (ImGui::Button("Update to Latest")) { + PackageEcosystem eco = ecosystemForSource(dep.source); + auto info = PackageRegistry::query(eco, dep.name); + if (!info.versions.empty()) { + dep.version = info.versions.back(); + if (writeDependenciesForSource(dep.source, workspaceRoot, state.deps, logOut)) { + refreshDependencies(state, workspaceRoot, logOut); + } + } else { + logOut += "[deps] No version info for " + dep.name + "\n"; + } + } + } +} diff --git a/editor/src/DependencyParser.h b/editor/src/DependencyParser.h index 0f55de8..8398be6 100644 --- a/editor/src/DependencyParser.h +++ b/editor/src/DependencyParser.h @@ -84,18 +84,18 @@ private: if (!in.is_open()) return out; nlohmann::json j; try { in >> j; } catch (...) { return out; } - auto collect = [&](const std::string& key) { + auto collect = [&](const std::string& key, const std::string& sourceTag) { if (!j.contains(key)) return; for (auto it = j[key].begin(); it != j[key].end(); ++it) { DependencySpec dep; dep.name = it.key(); dep.version = it.value().get(); - dep.source = "package.json"; + dep.source = sourceTag; out.push_back(dep); } }; - collect("dependencies"); - collect("devDependencies"); + collect("dependencies", "package.json:dependencies"); + collect("devDependencies", "package.json:devDependencies"); return out; } diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index ee0448f..cb9fd9a 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -43,6 +43,7 @@ #include "TerminalPanel.h" #include "WebSocketServer.h" #include "BuildSystem.h" +#include "DependencyPanel.h" #include "IncrementalOptimizer.h" #include "ast/Serialization.h" #include "ast/Generator.h" @@ -163,6 +164,8 @@ struct EditorState { std::vector buildErrors; std::string lastBuildOutput; std::string lastBuildCommand; + bool showDependencyPanel = true; + DependencyPanelState dependencyPanel; // Custom editor widget state bool showWhitespace = false; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index b0d1ed1..44149e5 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -302,6 +302,7 @@ int main(int, char**) { ImGui::MenuItem("Show Outline", nullptr, &state.showOutline); ImGui::MenuItem("Terminal", state.keys.getBinding("view.toggleTerminal").toString().c_str(), &state.showTerminalPanel); + ImGui::MenuItem("Dependencies", nullptr, &state.showDependencyPanel); ImGui::MenuItem("Settings", nullptr, &state.showSettingsPanel); ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings); if (state.active()) { @@ -550,6 +551,17 @@ int main(int, char**) { ImGui::End(); } + // --------------------------------------------------------------- + // Dependencies Panel + // --------------------------------------------------------------- + if (state.showDependencyPanel) { + ImGui::Begin("Dependencies", &state.showDependencyPanel); + ImGui::PushFont(uiFont); + renderDependencyPanel(state.dependencyPanel, state.workspaceRoot, state.outputLog); + ImGui::PopFont(); + ImGui::End(); + } + // --------------------------------------------------------------- // Find / Replace bar (floating at top of editor) // --------------------------------------------------------------- diff --git a/editor/tests/step129_test.cpp b/editor/tests/step129_test.cpp new file mode 100644 index 0000000..8f4a77b --- /dev/null +++ b/editor/tests/step129_test.cpp @@ -0,0 +1,104 @@ +// Step 129 TDD Test: Dependency management UI writeback +#include "DependencyPanel.h" +#include +#include +#include +#include +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +static std::string readFile(const std::filesystem::path& path) { + std::ifstream in(path); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +int main() { + int passed = 0; + int failed = 0; + + std::filesystem::path dir = std::filesystem::current_path() / "dep_panel_tmp"; + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + + // requirements.txt writeback + { + std::ofstream out(dir / "requirements.txt"); + out << "numpy==1.26.0\n"; + out.close(); + + DependencyPanelState panel; + std::string log; + refreshDependencies(panel, dir.string(), log); + expect(panel.deps.size() == 1, "requirements parsed", passed, failed); + + DependencySpec dep; + dep.name = "requests"; + dep.version = "2.0.0"; + dep.source = "requirements.txt"; + panel.deps.push_back(dep); + bool ok = writeDependenciesForSource("requirements.txt", dir.string(), panel.deps, log); + expect(ok, "requirements writeback ok", passed, failed); + std::string content = readFile(dir / "requirements.txt"); + expect(content.find("numpy==1.26.0") != std::string::npos, "requirements keep numpy", passed, failed); + expect(content.find("requests==2.0.0") != std::string::npos, "requirements add requests", passed, failed); + } + + // package.json writeback with devDependencies + { + std::ofstream out(dir / "package.json"); + out << "{ \"dependencies\": { \"react\": \"^18.2.0\" }, " + "\"devDependencies\": { \"vite\": \"^5.0.0\" } }"; + out.close(); + + DependencyPanelState panel; + std::string log; + refreshDependencies(panel, dir.string(), log); + + for (auto it = panel.deps.begin(); it != panel.deps.end(); ) { + if (it->name == "react") { + it->version = "^19.0.0"; + ++it; + } else if (it->name == "vite") { + it = panel.deps.erase(it); + } else { + ++it; + } + } + bool ok = writeDependenciesForSource("package.json", dir.string(), panel.deps, log); + expect(ok, "package.json writeback ok", passed, failed); + + nlohmann::json j; + { + std::ifstream in(dir / "package.json"); + in >> j; + } + expect(j["dependencies"]["react"] == "^19.0.0", "package.json updates dependency", passed, failed); + bool hasVite = j.contains("devDependencies") && j["devDependencies"].contains("vite"); + expect(!hasVite, "package.json removes devDependency", passed, failed); + } + + // edge case: unsupported writeback + { + std::string log; + std::vector deps; + deps.push_back({"Boost", "", "CMakeLists.txt"}); + bool ok = writeDependenciesForSource("CMakeLists.txt", dir.string(), deps, log); + expect(!ok, "unsupported writeback returns false", passed, failed); + } + + std::filesystem::remove_all(dir); + + std::cout << "\n=== Step 129 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index 37f035e..b70da1a 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -47,7 +47,7 @@ The foundation: import libraries, parse their APIs, index their symbols. Populate `Import` AST nodes from parsed dependencies. *New:* `DependencyParser.h`, modifies `ast/ASTNode.h` (Import concept) -- [ ] **Step 129: Dependency management UI** +- [x] **Step 129: Dependency management UI** New dockable panel: "Dependencies". Lists all imported packages with version. "Add Package" button → search dialog (queries PackageRegistry). Version selector dropdown. "Remove" button. "Update" shows available newer versions.