Step 116: project save/load

This commit is contained in:
Bill
2026-02-09 12:52:20 -07:00
parent b007fee31e
commit 9c74603b10
6 changed files with 244 additions and 2 deletions

View File

@@ -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. |

View File

@@ -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)

View File

@@ -0,0 +1,64 @@
#pragma once
// Step 116: Project save/load helpers
#include <string>
#include <vector>
#include <memory>
#include <nlohmann/json.hpp>
#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<ProjectBufferInfo> buffers;
std::unique_ptr<Module> 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<Module*>(node));
} else {
deleteTree(node);
}
}
return project;
}

View File

@@ -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();

View File

@@ -0,0 +1,41 @@
// Step 116 TDD Test: Project save/load serialization
#include "ProjectManager.h"
#include <iostream>
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<Module>("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;
}

View File

@@ -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.