diff --git a/PROGRESS.md b/PROGRESS.md index 4c2b125..7f411d1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -220,6 +220,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [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) +- [x] Step 117: **IMPLEMENTED** — Session persistence (layout, buffers, cursors, folds) (5/5 tests pass) --- @@ -319,6 +320,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 114:** Compile and pass (4/4) **Step 115:** Compile and pass (3/3) **Step 116:** Compile and pass (4/4) +**Step 117:** Compile and pass (5/5) --- @@ -370,7 +372,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi ## What's Next -Sprint 4 in progress. Step 116 (project save/load) done. Next: Step 117 (session persistence). +Sprint 4 in progress. Step 117 (session persistence) done. Next: Step 118 (settings panel). --- @@ -448,3 +450,4 @@ Sprint 4 in progress. Step 116 (project save/load) done. Next: Step 117 (session | 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. | +| 2026-02-09 | Codex | Step 117: Session persistence (layout, buffers, cursors, folds). 5/5 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index b17700d..651949f 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -626,6 +626,10 @@ 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) +add_executable(step117_test tests/step117_test.cpp) +target_include_directories(step117_test PRIVATE src) +target_link_libraries(step117_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/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index ec8363b..47e0aa9 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -585,6 +585,18 @@ public: void setCursor(int pos) { cursor_ = pos; } int getCursor() const { return cursor_; } const std::vector& getFoldRegions() const { return folds_; } + std::vector getFoldedLines() const { + std::vector lines; + for (const auto& f : folds_) { + if (f.folded) lines.push_back(f.startLine); + } + return lines; + } + + void setDesiredFoldedLines(const std::vector& lines) { + desiredFoldedLines_ = lines; + applyDesiredFoldState_ = true; + } void toggleFoldAtLine(int line) { for (auto& f : folds_) { @@ -604,6 +616,8 @@ private: std::string lastFoldText_; std::string lastFoldLang_; std::string annotationPopupMessage_; + std::vector desiredFoldedLines_; + bool applyDesiredFoldState_ = false; bool hasSelection() const { return selStart_ >= 0 && selEnd_ >= 0 && selStart_ != selEnd_; @@ -826,6 +840,14 @@ private: } folds_ = std::move(newFolds); + if (applyDesiredFoldState_) { + for (auto& f : folds_) { + f.folded = std::find(desiredFoldedLines_.begin(), + desiredFoldedLines_.end(), + f.startLine) != desiredFoldedLines_.end(); + } + applyDesiredFoldState_ = false; + } ts_tree_delete(tree); ts_parser_delete(parser); diff --git a/editor/src/SessionManager.h b/editor/src/SessionManager.h new file mode 100644 index 0000000..d88eb38 --- /dev/null +++ b/editor/src/SessionManager.h @@ -0,0 +1,68 @@ +#pragma once +// Step 117: Session persistence + +#include +#include +#include + +struct SessionBufferState { + std::string path; + std::string language; + std::string mode; + int cursorLine = 1; + int cursorCol = 1; + std::vector foldedLines; +}; + +struct SessionData { + std::string workspaceRoot; + std::string activePath; + std::string layoutPreset; + std::string imguiIni; + std::vector buffers; +}; + +inline nlohmann::json sessionToJson(const SessionData& session) { + nlohmann::json j; + j["workspaceRoot"] = session.workspaceRoot; + j["activePath"] = session.activePath; + j["layoutPreset"] = session.layoutPreset; + j["imguiIni"] = session.imguiIni; + j["buffers"] = nlohmann::json::array(); + for (const auto& b : session.buffers) { + nlohmann::json item; + item["path"] = b.path; + item["language"] = b.language; + item["mode"] = b.mode; + item["cursorLine"] = b.cursorLine; + item["cursorCol"] = b.cursorCol; + item["foldedLines"] = b.foldedLines; + j["buffers"].push_back(std::move(item)); + } + return j; +} + +inline SessionData sessionFromJson(const nlohmann::json& j) { + SessionData session; + session.workspaceRoot = j.value("workspaceRoot", ""); + session.activePath = j.value("activePath", ""); + session.layoutPreset = j.value("layoutPreset", "VSCode"); + session.imguiIni = j.value("imguiIni", ""); + if (j.contains("buffers") && j["buffers"].is_array()) { + for (const auto& item : j["buffers"]) { + SessionBufferState b; + b.path = item.value("path", ""); + b.language = item.value("language", ""); + b.mode = item.value("mode", "structured"); + b.cursorLine = item.value("cursorLine", 1); + b.cursorCol = item.value("cursorCol", 1); + if (item.contains("foldedLines") && item["foldedLines"].is_array()) { + for (const auto& ln : item["foldedLines"]) { + if (ln.is_number_integer()) b.foldedLines.push_back(ln.get()); + } + } + if (!b.path.empty()) session.buffers.push_back(std::move(b)); + } + } + return session; +} diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 3a33d6e..1fbcd71 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -47,6 +47,7 @@ #include "GoToLine.h" #include "Orchestrator.h" #include "ProjectManager.h" +#include "SessionManager.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -557,6 +558,76 @@ struct EditorState { } } + std::filesystem::path sessionFilePath() const { + return configDir() / "session.json"; + } + + bool saveSession(const std::string& imguiIni) { + SessionData session; + session.workspaceRoot = workspaceRoot; + session.activePath = active() ? active()->path : ""; + session.layoutPreset = LayoutManager::presetName(layoutPreset); + session.imguiIni = imguiIni; + for (const auto& bufPath : buffers.getOpenBuffers()) { + auto it = bufferStates.find(bufPath); + if (it == bufferStates.end()) continue; + const auto* buf = it->second.get(); + SessionBufferState entry; + entry.path = bufPath; + entry.language = buf->language; + entry.mode = bufferModeToString(buf->bufferMode); + entry.cursorLine = buf->cursorLine; + entry.cursorCol = buf->cursorCol; + entry.foldedLines = buf->widget.getFoldedLines(); + session.buffers.push_back(std::move(entry)); + } + try { + std::ofstream out(sessionFilePath().string(), std::ios::binary); + if (!out.is_open()) return false; + out << sessionToJson(session).dump(2); + return true; + } catch (...) { + return false; + } + } + + bool loadSession(SessionData& out) { + try { + std::ifstream in(sessionFilePath().string(), std::ios::binary); + if (!in.is_open()) return false; + nlohmann::json j; + in >> j; + out = sessionFromJson(j); + return true; + } catch (...) { + return false; + } + } + + void applySession(const SessionData& session) { + closeAllBuffers(); + if (!session.workspaceRoot.empty()) { + workspaceRoot = session.workspaceRoot; + fileTreeDirty = true; + projectSearch.setRoot(workspaceRoot); + } + layoutPreset = LayoutManager::presetFromName(session.layoutPreset); + for (const auto& buf : session.buffers) { + doOpen(buf.path, bufferModeFromString(buf.mode)); + auto it = bufferStates.find(buf.path); + if (it != bufferStates.end()) { + auto* bs = it->second.get(); + bs->cursorLine = buf.cursorLine; + bs->cursorCol = buf.cursorCol; + bs->widget.setDesiredFoldedLines(buf.foldedLines); + jumpTo(bs, std::max(0, buf.cursorLine - 1), std::max(0, buf.cursorCol - 1)); + } + } + if (!session.activePath.empty() && buffers.hasBuffer(session.activePath)) { + switchToBuffer(session.activePath); + } + } + void init() { outputLog = "Whetstone Editor ready.\n"; workspaceRoot = std::filesystem::current_path().string(); @@ -1740,6 +1811,14 @@ int main(int, char**) { // Editor state EditorState state; state.init(); + SessionData session; + if (state.loadSession(session)) { + if (!session.imguiIni.empty()) { + ImGui::LoadIniSettingsFromMemory(session.imguiIni.c_str(), + session.imguiIni.size()); + } + state.applySession(session); + } state.lspTransport = std::make_shared(); state.lsp = std::make_shared(state.lspTransport); @@ -3745,6 +3824,11 @@ int main(int, char**) { } // Cleanup + size_t iniSize = 0; + const char* iniData = ImGui::SaveIniSettingsToMemory(&iniSize); + if (iniData && iniSize > 0) { + state.saveSession(std::string(iniData, iniSize)); + } ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplSDL2_Shutdown(); ImGui::DestroyContext(); diff --git a/editor/tests/step117_test.cpp b/editor/tests/step117_test.cpp new file mode 100644 index 0000000..a984ad6 --- /dev/null +++ b/editor/tests/step117_test.cpp @@ -0,0 +1,44 @@ +// Step 117 TDD Test: Session persistence +#include "SessionManager.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; + + SessionData session; + session.workspaceRoot = "C:/repo"; + session.activePath = "C:/repo/main.py"; + session.layoutPreset = "JetBrains"; + session.imguiIni = "[Window][Editor]"; + SessionBufferState buf; + buf.path = "C:/repo/main.py"; + buf.language = "python"; + buf.mode = "structured"; + buf.cursorLine = 12; + buf.cursorCol = 5; + buf.foldedLines = {1, 10}; + session.buffers.push_back(buf); + + auto j = sessionToJson(session); + SessionData loaded = sessionFromJson(j); + + expect(loaded.workspaceRoot == "C:/repo", "workspace root", passed, failed); + expect(loaded.activePath == "C:/repo/main.py", "active path", passed, failed); + expect(loaded.layoutPreset == "JetBrains", "layout preset", passed, failed); + expect(loaded.buffers.size() == 1 && loaded.buffers[0].cursorLine == 12, "cursor", passed, failed); + expect(loaded.buffers[0].foldedLines.size() == 2, "folded lines", passed, failed); + + std::cout << "\n=== Step 117 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 1079997..dd637d2 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -367,7 +367,7 @@ AST extensions for Sprint 5. Open Project. Includes workspace root path and open buffer list. *Wires:* `ast/Serialization.h` -- [ ] **Step 117: Session persistence** +- [x] **Step 117: Session persistence** Remember window layout (docking configuration), open files, active tab, cursor positions, fold state, and layout preset across sessions. Saved to `~/.whetstone/session.json`. Restore on next launch.