diff --git a/PROGRESS.md b/PROGRESS.md index a881737..4c2b125 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -219,6 +219,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 113: **IMPLEMENTED** — Project-wide search panel with regex and glob filters (3/3 tests pass) - [x] Step 114: **IMPLEMENTED** — Go-to-line popup with :line:col parsing (4/4 tests pass) - [x] Step 115: **IMPLEMENTED** — Orchestrator wired for structured mutations; Emacs config path setting (3/3 tests pass) +- [x] Step 116: **IMPLEMENTED** — Project save/load (.whetstone) with AST serialization (4/4 tests pass) --- @@ -317,6 +318,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 113:** Compile and pass (3/3) **Step 114:** Compile and pass (4/4) **Step 115:** Compile and pass (3/3) +**Step 116:** Compile and pass (4/4) --- @@ -368,7 +370,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 in progress. Step 115 (wire orchestrator) done. Next: Step 116 (project save/load). +Sprint 4 in progress. Step 116 (project save/load) done. Next: Step 117 (session persistence). --- @@ -445,3 +447,4 @@ Sprint 4 in progress. Step 115 (wire orchestrator) done. Next: Step 116 (project | 2026-02-09 | Codex | Step 113: Project-wide search panel with regex and glob filters. 3/3 tests pass. | | 2026-02-09 | Codex | Step 114: Go-to-line popup with :line:col parsing. 4/4 tests pass. | | 2026-02-09 | Codex | Step 115: Orchestrator wired for structured mutations; Emacs config path setting. 3/3 tests pass. | +| 2026-02-09 | Codex | Step 116: Project save/load (.whetstone) with AST serialization. 4/4 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 2f08dbe..b17700d 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -622,6 +622,10 @@ add_executable(step115_test tests/step115_test.cpp) target_include_directories(step115_test PRIVATE src) target_link_libraries(step115_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step116_test tests/step116_test.cpp) +target_include_directories(step116_test PRIVATE src) +target_link_libraries(step116_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/ProjectManager.h b/editor/src/ProjectManager.h new file mode 100644 index 0000000..5eb9fbd --- /dev/null +++ b/editor/src/ProjectManager.h @@ -0,0 +1,64 @@ +#pragma once +// Step 116: Project save/load helpers + +#include +#include +#include +#include +#include "ast/Serialization.h" +#include "ast/Module.h" + +struct ProjectBufferInfo { + std::string path; + std::string language; + std::string mode; +}; + +struct ProjectFile { + std::string workspaceRoot; + std::string activePath; + std::vector buffers; + std::unique_ptr ast; +}; + +inline nlohmann::json projectToJson(const ProjectFile& project) { + nlohmann::json j; + j["workspaceRoot"] = project.workspaceRoot; + j["activePath"] = project.activePath; + j["buffers"] = nlohmann::json::array(); + for (const auto& b : project.buffers) { + j["buffers"].push_back({ + {"path", b.path}, + {"language", b.language}, + {"mode", b.mode} + }); + } + if (project.ast) { + j["ast"] = toJson(project.ast.get()); + } + return j; +} + +inline ProjectFile projectFromJson(const nlohmann::json& j) { + ProjectFile project; + project.workspaceRoot = j.value("workspaceRoot", ""); + project.activePath = j.value("activePath", ""); + if (j.contains("buffers") && j["buffers"].is_array()) { + for (const auto& item : j["buffers"]) { + ProjectBufferInfo info; + info.path = item.value("path", ""); + info.language = item.value("language", ""); + info.mode = item.value("mode", "structured"); + if (!info.path.empty()) project.buffers.push_back(std::move(info)); + } + } + if (j.contains("ast")) { + ASTNode* node = fromJson(j["ast"]); + if (node && node->conceptType == "Module") { + project.ast.reset(static_cast(node)); + } else { + deleteTree(node); + } + } + return project; +} diff --git a/editor/src/main.cpp b/editor/src/main.cpp index f94b32f..3a33d6e 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -46,6 +46,7 @@ #include "ProjectSearch.h" #include "GoToLine.h" #include "Orchestrator.h" +#include "ProjectManager.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -469,6 +470,93 @@ struct EditorState { out << j.dump(2); } + void closeAllBuffers() { + auto open = buffers.getOpenBuffers(); + for (const auto& path : open) { + buffers.closeBuffer(path); + bufferStates.erase(path); + if (path.rfind("(untitled", 0) != 0) watcher.unwatch(path); + } + activeBuffer = nullptr; + } + + bool saveProject(const std::string& path) { + ProjectFile project; + project.workspaceRoot = workspaceRoot; + project.activePath = active() ? active()->path : ""; + for (const auto& bufPath : buffers.getOpenBuffers()) { + auto it = bufferStates.find(bufPath); + if (it == bufferStates.end()) continue; + ProjectBufferInfo info; + info.path = bufPath; + info.language = it->second->language; + info.mode = bufferModeToString(it->second->bufferMode); + project.buffers.push_back(std::move(info)); + } + if (isStructured()) { + syncOrchestratorFromActive(); + if (orchestrator.getAST()) { + project.ast = cloneModule(orchestrator.getAST()); + } + } + + try { + nlohmann::json j = projectToJson(project); + std::ofstream out(path, std::ios::binary); + if (!out.is_open()) return false; + out << j.dump(2); + return true; + } catch (...) { + return false; + } + } + + bool loadProject(const std::string& path) { + try { + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) return false; + nlohmann::json j; + in >> j; + ProjectFile project = projectFromJson(j); + closeAllBuffers(); + + if (!project.workspaceRoot.empty()) { + workspaceRoot = project.workspaceRoot; + fileTreeDirty = true; + projectSearch.setRoot(workspaceRoot); + } + + std::string activePath = project.activePath; + for (const auto& buf : project.buffers) { + if (buf.path == activePath && project.ast) { + std::string lang = buf.language.empty() ? project.ast->targetLanguage : buf.language; + std::string generated = generateForLanguage(project.ast.get(), lang); + createBuffer(buf.path, generated, lang, bufferModeFromString(buf.mode)); + if (active()) { + active()->sync.setAST(std::move(project.ast)); + active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + active()->editBuf = active()->sync.getText(); + active()->editor.setContent(active()->editBuf, lang); + active()->mode.setLanguage(lang); + active()->highlightsDirty = true; + active()->generatedHighlightsDirty = true; + active()->modified = false; + } + } else { + doOpen(buf.path, bufferModeFromString(buf.mode)); + } + } + + if (!activePath.empty() && buffers.hasBuffer(activePath)) { + switchToBuffer(activePath); + } + orchestratorDirty = true; + return true; + } catch (...) { + return false; + } + } + void init() { outputLog = "Whetstone Editor ready.\n"; workspaceRoot = std::filesystem::current_path().string(); @@ -536,6 +624,28 @@ struct EditorState { registerCommand("file.save", "File: Save", keys.getBinding("file.save").toString(), [this]() { doSave(); }); + registerCommand("project.open", "Project: Open...", + "", + [this]() { + std::string path = FileDialog::openFile( + {"Open Project", {"*.whetstone"}, std::string()}); + if (!path.empty()) { + if (!loadProject(path)) { + outputLog += "Failed to open project: " + path + "\n"; + } + } + }); + registerCommand("project.save", "Project: Save...", + "", + [this]() { + std::string path = FileDialog::saveFile( + {"Save Project", {"*.whetstone"}, std::string()}); + if (!path.empty()) { + if (!saveProject(path)) { + outputLog += "Failed to save project: " + path + "\n"; + } + } + }); registerCommand("edit.undo", "Edit: Undo", keys.getBinding("edit.undo").toString(), [this]() { doUndo(); }); @@ -1751,6 +1861,16 @@ int main(int, char**) { state.doOpen(path); } } + if (ImGui::MenuItem("Open Project...")) { + auto path = FileDialog::openFile({"Open Project", {"*.whetstone"}, lastDialogPath}); + if (!path.empty()) { + if (!state.loadProject(path)) { + state.outputLog += "Failed to open project: " + path + "\n"; + } else { + lastDialogPath = path; + } + } + } if (ImGui::MenuItem("Open Folder...")) { auto path = FileDialog::openFolder({"Open Folder", state.workspaceRoot}); if (!path.empty()) { @@ -1775,6 +1895,16 @@ int main(int, char**) { state.doSave(); } } + if (ImGui::MenuItem("Save Project...")) { + auto path = FileDialog::saveFile({"Save Project", {"*.whetstone"}, lastDialogPath}); + if (!path.empty()) { + if (!state.saveProject(path)) { + state.outputLog += "Failed to save project: " + path + "\n"; + } else { + lastDialogPath = path; + } + } + } ImGui::Separator(); if (ImGui::MenuItem("Exit")) done = true; ImGui::EndMenu(); diff --git a/editor/tests/step116_test.cpp b/editor/tests/step116_test.cpp new file mode 100644 index 0000000..5b8ff76 --- /dev/null +++ b/editor/tests/step116_test.cpp @@ -0,0 +1,41 @@ +// Step 116 TDD Test: Project save/load serialization +#include "ProjectManager.h" +#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; + } +} + +int main() { + int passed = 0; + int failed = 0; + + ProjectFile project; + project.workspaceRoot = "C:/repo"; + project.activePath = "C:/repo/main.py"; + project.buffers.push_back({"C:/repo/main.py", "python", "structured"}); + project.buffers.push_back({"C:/repo/util.py", "python", "text"}); + project.ast = std::make_unique("m1", "Main", "python"); + + auto j = projectToJson(project); + ProjectFile loaded = projectFromJson(j); + + bool okRoot = loaded.workspaceRoot == "C:/repo"; + bool okActive = loaded.activePath == "C:/repo/main.py"; + bool okBuffers = loaded.buffers.size() == 2 && loaded.buffers[1].mode == "text"; + bool okAst = loaded.ast && loaded.ast->name == "Main" && loaded.ast->targetLanguage == "python"; + + expect(okRoot, "workspace root", passed, failed); + expect(okActive, "active path", passed, failed); + expect(okBuffers, "buffer list", passed, failed); + expect(okAst, "ast roundtrip", passed, failed); + + std::cout << "\n=== Step 116 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 60d2613..1079997 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -361,7 +361,7 @@ AST extensions for Sprint 5. their own `~/.emacs.d/`). *Wires:* `Orchestrator.h` -- [ ] **Step 116: Project save/load (AST serialization)** +- [x] **Step 116: Project save/load (AST serialization)** Save/load `.whetstone` project files using `ast/Serialization.h`. JSON format preserving the full AST with annotations. File > Save Project / Open Project. Includes workspace root path and open buffer list.