From 92f9d8e54a97f1cd0e7500b5fb72821774ddc922 Mon Sep 17 00:00:00 2001 From: Genix Date: Thu, 5 Nov 2026 21:35:37 +0330 Subject: [PATCH 1/4] fix(runtime): guard dirty close and preserve save capability errors --- lua/cdin/runtime/command/defaults.lua | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lua/cdin/runtime/command/defaults.lua b/lua/cdin/runtime/command/defaults.lua index 550c2b9..bed8d28 100644 --- a/lua/cdin/runtime/command/defaults.lua +++ b/lua/cdin/runtime/command/defaults.lua @@ -70,11 +70,11 @@ function M.register(runtime) description = "Save a document snapshot atomically through Backend filesystem services", available = function(context, args) local document = active_document(runtime, context) - local has_path = document ~= nil + -- Keep path-backed save commands executable even when the optional Host + -- writer is absent. Saver:save owns the typed UNSUPPORTED error; hiding + -- the command here would incorrectly turn Ctrl+S into an unhandled key. + return document ~= nil and (document.path ~= nil or (args and args.path ~= nil)) - local has_atomic_writer = type(runtime.host.fs) == "table" - and type(runtime.host.fs.begin_atomic_write) == "function" - return has_path and has_atomic_writer end, }) @@ -159,11 +159,20 @@ function M.register(runtime) description = "Open exact file bytes through the optional Backend Host filesystem service", }) - commands:register("document.close", function(context) + commands:register("document.close", function(context, args) local document = active_document(runtime, context) if not document then return false, "no active document" end + args = args or {} + -- Never discard the only canonical copy of an edited document implicitly. + -- The UI can offer Save / Discard / Cancel; trusted callers must opt into + -- destructive close explicitly with { discard = true }. + if document:is_clean() ~= true and args.discard ~= true then + errors.raise("UNSAVED_CHANGES", + "document has unsaved changes; save it or explicitly request discard", + { document_id = document.id }) + end return runtime:_close_document(document.id, false) end, { description = "Close the focused document and remove its views", From 43a70f7b792930ff8ffaf6584d2149627aaa6689 Mon Sep 17 00:00:00 2001 From: Genix Date: Thu, 5 Nov 2026 21:35:39 +0330 Subject: [PATCH 2/4] test(runtime): cover dirty close and missing atomic writer --- .../document_lifecycle_commands.lua | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/integration/document_lifecycle_commands.lua b/tests/integration/document_lifecycle_commands.lua index d7caee4..7989a47 100644 --- a/tests/integration/document_lifecycle_commands.lua +++ b/tests/integration/document_lifecycle_commands.lua @@ -108,11 +108,23 @@ A.equal(new_document.id, created.result.document_id) A.truthy(new_document ~= nil) A.equal(new_document.length, 0) local new_id = new_document.id -local close_key = runtime:handle_frontend_event({ - type = "input.key", key = "w", action = "press", modifiers = { "ctrl" }, +A.equal(runtime:handle_frontend_event({ type = "input.text", text = "unsaved" }).status, "inserted") +A.falsy(new_document:is_clean()) +A.error_code(function() + runtime:handle_frontend_event({ + type = "input.key", key = "w", action = "press", modifiers = { "ctrl" }, + }) +end, "UNSAVED_CHANGES") +-- A rejected close must leave both the canonical document and its view alive. +A.equal(runtime.documents.documents[new_id], new_document) +A.truthy(runtime.ui.views[created.result.view_id] ~= nil) +A.falsy(new_document:is_clean()) +local discarded = runtime:handle_frontend_event({ + type = "request.command", name = "document.close", arguments = { discard = true }, }) -A.equal(close_key.status, "executed") +A.equal(discarded.status, "executed") A.equal(runtime.documents.documents[new_id], nil) +A.equal(runtime.ui.views[created.result.view_id], nil) runtime:close_document(reopened_document.id) A.truthy(runtime:shutdown()) @@ -131,6 +143,29 @@ A.equal(#no_reader_runtime.documents:list(), 0) A.equal(next(no_reader_runtime.ui.views), nil) no_reader_runtime:shutdown() +-- A missing atomic writer must surface the typed UNSUPPORTED failure. +-- Ctrl+S must not silently become an unhandled key when a document has a path. +local no_writer_host = FakeHost.new() +no_writer_host.fs.begin_atomic_write = nil +local no_writer_runtime = Runtime.new({ host = no_writer_host }) +no_writer_runtime:start() +local no_writer_document = no_writer_runtime:open_document("original", { path = path }) +no_writer_runtime:create_view(no_writer_document.id) +no_writer_document:replace(0, 0, "edit", { + expected_version = no_writer_document.version, +}) +A.falsy(no_writer_document:is_clean()) +A.error_code(function() + no_writer_runtime:handle_frontend_event({ + type = "input.key", key = "s", action = "press", modifiers = { "ctrl" }, + }) +end, "UNSUPPORTED") +A.falsy(no_writer_document:is_clean()) +A.equal(#no_writer_runtime.documents:list(), 1) +A.equal(next(no_writer_host.snapshots), nil) +no_writer_runtime:close_document(no_writer_document.id) +A.truthy(no_writer_runtime:shutdown()) + local missing_file_host = FakeHost.new() local missing_file_runtime = Runtime.new({ host = missing_file_host }) missing_file_runtime:start() From de6043d974230c53e763120daf827516846be1fb Mon Sep 17 00:00:00 2001 From: Genix Date: Thu, 5 Nov 2026 21:41:17 +0330 Subject: [PATCH 3/4] fix(runtime): register unsaved changes error code --- lua/cdin/runtime/core/error.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/cdin/runtime/core/error.lua b/lua/cdin/runtime/core/error.lua index dcad8b7..3c592bf 100644 --- a/lua/cdin/runtime/core/error.lua +++ b/lua/cdin/runtime/core/error.lua @@ -14,6 +14,7 @@ M.codes = { CANCELLED = true, SHUTDOWN = true, UNSUPPORTED = true, + UNSAVED_CHANGES = true, BUFFER_TOO_SMALL = true, VERSION_MISMATCH = true, INTERNAL = true, From ecf45e9f5103d9d9cff350cc5be310fb8209e979 Mon Sep 17 00:00:00 2001 From: Genix Date: Thu, 5 Nov 2026 21:44:50 +0330 Subject: [PATCH 4/4] test(runtime): assert missing-writer save leaves file untouched --- tests/integration/document_lifecycle_commands.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/document_lifecycle_commands.lua b/tests/integration/document_lifecycle_commands.lua index 7989a47..8483de6 100644 --- a/tests/integration/document_lifecycle_commands.lua +++ b/tests/integration/document_lifecycle_commands.lua @@ -162,7 +162,7 @@ A.error_code(function() end, "UNSUPPORTED") A.falsy(no_writer_document:is_clean()) A.equal(#no_writer_runtime.documents:list(), 1) -A.equal(next(no_writer_host.snapshots), nil) +A.equal(no_writer_host.fs:read_test_file(path), nil) no_writer_runtime:close_document(no_writer_document.id) A.truthy(no_writer_runtime:shutdown())