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", 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, diff --git a/tests/integration/document_lifecycle_commands.lua b/tests/integration/document_lifecycle_commands.lua index d7caee4..8483de6 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(no_writer_host.fs:read_test_file(path), 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()