diff --git a/README.md b/README.md
index ad0f469..f5db913 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ Three built-in themes (Light, Dark, High Contrast) are hot-swappable at runtime.
### Import from Jupyter and Polyglot Notebooks
-Open any `.ipynb` or `.dib` file and Verso converts it automatically. Polyglot Notebook patterns like `#!fsharp`, `#!connect`, and `#!sql` are mapped to native Verso cells during import. Saving writes to a `.verso` file, preserving the original.
+Open any `.ipynb` or `.dib` file and Verso converts it automatically. Polyglot Notebook patterns like `#!fsharp`, `#!connect`, and `#!sql` are mapped to native Verso cells during import. By default, saving writes to a sibling `.verso` file and leaves the original untouched. To save `.ipynb` notebooks back to `.ipynb` (cell outputs preserved), enable the `verso.preserveOriginalFormat` setting in VS Code, or pass `--preserve-format` to `verso repl` / `verso serve`.
## Extension Model
@@ -147,7 +147,7 @@ Verso includes a `dotnet new` template, a testing library (`Verso.Testing`), and
| **Magic Commands** | `#!time`, `#!nuget`, `#!pip`, `#!npm`, `#!extension`, `#!restart`, `#!about`, `#!import`, `#!sql-connect`, `#!sql-disconnect`, `#!sql-schema`, `#!sql-scaffold`, `#!http-set-base`, `#!http-set-header`, `#!http-set-timeout` |
| **Toolbar Actions** | Run Cell, Run All, Clear Outputs, Restart, Switch Layout, Switch Theme, Export HTML, Export Markdown |
| **Data Formatters** | Primitives, Collections (HTML tables), HTML, Images, SVG, Exceptions, F# types, SQL result sets |
-| **Serializers** | `.verso` (native JSON), `.ipynb` import, `.dib` import |
+| **Serializers** | `.verso` (native JSON, read/write), `.ipynb` (read/write, write opt-in), `.dib` (read only) |
## The `.verso` File Format
diff --git a/docs/architecture/engine.md b/docs/architecture/engine.md
index fb2a188..3c53f77 100644
--- a/docs/architecture/engine.md
+++ b/docs/architecture/engine.md
@@ -198,10 +198,10 @@ Notebooks are serialized and deserialized through the `INotebookSerializer` inte
| Serializer | Format ID | Extensions | Read | Write |
|------------|-----------|------------|:----:|:-----:|
| `VersoSerializer` | `verso.serializer.verso` | `.verso` | Yes | Yes |
-| `JupyterSerializer` | `verso.serializer.jupyter` | `.ipynb` | Yes | No |
+| `JupyterSerializer` | `verso.serializer.jupyter` | `.ipynb` | Yes | Yes |
| `DibSerializer` | `verso.serializer.dib` | `.dib` | Yes | No |
-The `.verso` format is native JSON. Jupyter and Polyglot Notebook (`.dib`) files are import-only. Saving always writes `.verso` format.
+The `.verso` format is native JSON. Polyglot Notebook (`.dib`) files are import-only. Jupyter (`.ipynb`) files round-trip when the host receives `format: "jupyter"` on `notebook/save`; otherwise the host writes the native `.verso` format. Front-ends expose this opt-in through the `verso.preserveOriginalFormat` VS Code setting and the `--preserve-format` flag on `verso repl` and `verso serve`. When preservation is off, opening a non-`.verso` notebook and saving produces a sibling `.verso` file.
The `VersoSerializer` intentionally omits parameters cell outputs during serialization because those outputs are always re-rendered from `metadata.parameters` at display time.
diff --git a/docs/architecture/front-ends.md b/docs/architecture/front-ends.md
index f21c36e..1097222 100644
--- a/docs/architecture/front-ends.md
+++ b/docs/architecture/front-ends.md
@@ -123,7 +123,7 @@ When a `.verso` file is opened:
Each open notebook gets its own host process and bridge. When the editor panel is closed, the host process is terminated.
-Save operations flow through the host: `saveCustomDocument` sends `notebook/save` to the host, receives serialized content, and writes it via `vscode.workspace.fs.writeFile`.
+Save operations flow through the host: `saveCustomDocument` sends `notebook/save` to the host, receives serialized content, and writes it via `vscode.workspace.fs.writeFile`. The request carries an optional `format` parameter (default `"verso"`) that selects the serializer by `FormatId`. When the `verso.preserveOriginalFormat` setting is enabled and the document's extension matches a writable non-native serializer (currently `.ipynb`), the editor sends `format: "jupyter"` so the file round-trips in place; otherwise it falls back to the original convert-to-`.verso` behavior.
### HostProcess
diff --git a/docs/migration/from-jupyter.md b/docs/migration/from-jupyter.md
index 9c773db..911f15b 100644
--- a/docs/migration/from-jupyter.md
+++ b/docs/migration/from-jupyter.md
@@ -28,7 +28,7 @@ The original file is not modified. Use `--output` to specify a different output
verso convert notebook.ipynb --to verso --output cleaned.verso --strip-outputs
```
-Saving an imported notebook in Verso always writes to a new `.verso` file, preserving the original.
+By default, saving an imported notebook in Verso writes to a sibling `.verso` file and leaves the original untouched. To save back to `.ipynb` with cell outputs preserved, enable the `verso.preserveOriginalFormat` setting in VS Code, or pass `--preserve-format` to `verso repl` / `verso serve`. The flag is off by default to keep existing convert-on-save behavior for users who rely on it.
## What Gets Converted
@@ -149,7 +149,7 @@ Jupyter uses a kernel/server extension model. Verso's extension model is based o
## Limitations
-- **Export back to `.ipynb` is not supported.** Conversion is one-way. If you need to maintain a Jupyter-compatible version, keep the original `.ipynb` file.
+- **Round-trip to `.ipynb` is opt-in and lossy for non-standard cell types.** With `verso.preserveOriginalFormat` (or `--preserve-format`) on, `.ipynb` files save back as `.ipynb` with code, markdown, and cell outputs preserved. Verso-specific cell types (HTML, Mermaid, SQL, HTTP) are written as Jupyter `raw` cells with the original type stashed in `cell.metadata.verso_type`; notebook-level features like layouts, parameter definitions, and theme preferences are not represented in the Jupyter format.
- **Jupyter notebook format versions below 4 are not supported.** Notebooks created with very old versions of Jupyter (nbformat 1-3) need to be upgraded to nbformat 4 first (open and re-save in JupyterLab).
- **IPython magics are not converted.** Cell and line magics (`%`, `%%`) are Python kernel-specific and pass through as literal text.
- **Jupyter widgets are not supported.** Interactive widgets (`ipywidgets`) do not have a Verso equivalent. Static output from widgets (HTML snapshots) may be preserved in cell outputs.
diff --git a/src/Verso.Blazor/Services/NotebookServiceOptions.cs b/src/Verso.Blazor/Services/NotebookServiceOptions.cs
index aa922e7..27e09e9 100644
--- a/src/Verso.Blazor/Services/NotebookServiceOptions.cs
+++ b/src/Verso.Blazor/Services/NotebookServiceOptions.cs
@@ -13,4 +13,11 @@ public sealed record NotebookServiceOptions
/// built-in extensions are loaded.
///
public string? ExtensionsDirectory { get; init; }
+
+ ///
+ /// When true, saving a notebook that was loaded from a non-.verso file (e.g.
+ /// .ipynb) writes back to the original format instead of converting
+ /// to .verso. Defaults to false.
+ ///
+ public bool PreserveFormat { get; init; }
}
diff --git a/src/Verso.Blazor/Services/ServerNotebookService.cs b/src/Verso.Blazor/Services/ServerNotebookService.cs
index f17add6..5c651ba 100644
--- a/src/Verso.Blazor/Services/ServerNotebookService.cs
+++ b/src/Verso.Blazor/Services/ServerNotebookService.cs
@@ -353,10 +353,21 @@ private async Task PrepareSerializedContentAsync()
await sm.SaveSettingsAsync(_scaffold.Notebook);
_scaffold.Notebook.Modified = DateTimeOffset.UtcNow;
- var serializer = new VersoSerializer();
+ INotebookSerializer serializer = ResolveSerializer();
return await serializer.SerializeAsync(_scaffold.Notebook);
}
+ private INotebookSerializer ResolveSerializer()
+ {
+ if (_options.PreserveFormat
+ && !string.IsNullOrEmpty(_filePath)
+ && _filePath.EndsWith(".ipynb", StringComparison.OrdinalIgnoreCase))
+ {
+ return new JupyterSerializer();
+ }
+ return new VersoSerializer();
+ }
+
// ── Cell operations ────────────────────────────────────────────────
public Task AddCellAsync(string type = "code", string? language = null)
diff --git a/src/Verso.Cli/Commands/ReplCommand.cs b/src/Verso.Cli/Commands/ReplCommand.cs
index b6ac2b2..2e772f9 100644
--- a/src/Verso.Cli/Commands/ReplCommand.cs
+++ b/src/Verso.Cli/Commands/ReplCommand.cs
@@ -70,6 +70,9 @@ public static Command Create()
var listThemesOption = new Option("--list-themes", () => false,
"Print registered themes and exit.");
+ var preserveFormatOption = new Option("--preserve-format", () => false,
+ "When the loaded notebook is .ipynb, .save (no arg) writes back to .ipynb instead of converting to .verso. Cell outputs are preserved.");
+
var command = new Command("repl", "Start an interactive Verso REPL in the terminal.")
{
notebookArg,
@@ -82,7 +85,8 @@ public static Command Create()
plainOption,
historyOption,
listKernelsOption,
- listThemesOption
+ listThemesOption,
+ preserveFormatOption
};
command.SetHandler(async (context) =>
@@ -98,6 +102,7 @@ public static Command Create()
var historyArg = context.ParseResult.GetValueForOption(historyOption);
var listKernels = context.ParseResult.GetValueForOption(listKernelsOption);
var listThemes = context.ParseResult.GetValueForOption(listThemesOption);
+ var preserveFormat = context.ParseResult.GetValueForOption(preserveFormatOption);
var ct = context.GetCancellationToken();
@@ -112,7 +117,8 @@ public static Command Create()
NoColor = noColor,
Plain = plain,
HistoryPath = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase) ? null : historyArg,
- HistoryDisabled = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase)
+ HistoryDisabled = string.Equals(historyArg, "none", StringComparison.OrdinalIgnoreCase),
+ PreserveFormat = preserveFormat
};
context.ExitCode = await RunAsync(options, listKernels, listThemes, ct);
@@ -230,7 +236,8 @@ internal static async Task RunAsync(ReplOptions options, bool listKernels,
ActiveKernelId = notebook.DefaultKernelId,
ActiveTheme = activeTheme,
ActiveLayoutId = options.LayoutId,
- Settings = ReplSettingsLoader.Load()
+ Settings = ReplSettingsLoader.Load(),
+ PreserveFormat = options.PreserveFormat
};
// Execute pre-loaded cells when --execute is set.
diff --git a/src/Verso.Cli/Commands/ServeCommand.cs b/src/Verso.Cli/Commands/ServeCommand.cs
index 6f34e63..b3d4f98 100644
--- a/src/Verso.Cli/Commands/ServeCommand.cs
+++ b/src/Verso.Cli/Commands/ServeCommand.cs
@@ -32,6 +32,9 @@ public static Command Create()
var verboseOption = new Option("--verbose", () => false,
"Print startup details to stderr.");
+ var preserveFormatOption = new Option("--preserve-format", () => false,
+ "When a loaded .ipynb notebook is saved, write back to .ipynb instead of converting to .verso. Cell outputs are preserved.");
+
var command = new Command("serve", "Launch the Verso Blazor application as a local web server.")
{
notebookArg,
@@ -39,7 +42,8 @@ public static Command Create()
noBrowserOption,
noHttpsOption,
extensionsOption,
- verboseOption
+ verboseOption,
+ preserveFormatOption
};
command.SetHandler(async (context) =>
@@ -50,6 +54,7 @@ public static Command Create()
var noHttps = context.ParseResult.GetValueForOption(noHttpsOption);
var extensions = context.ParseResult.GetValueForOption(extensionsOption);
var verbose = context.ParseResult.GetValueForOption(verboseOption);
+ var preserveFormat = context.ParseResult.GetValueForOption(preserveFormatOption);
// Validate notebook path if provided
string? notebookPath = null;
@@ -71,7 +76,8 @@ public static Command Create()
Port = port,
NoHttps = noHttps,
Verbose = verbose,
- ExtensionsDirectory = extensions?.FullName
+ ExtensionsDirectory = extensions?.FullName,
+ PreserveFormat = preserveFormat
};
var app = BlazorHostBuilder.Build(options);
diff --git a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs
index 3241bed..710406e 100644
--- a/src/Verso.Cli/Hosting/BlazorHostBuilder.cs
+++ b/src/Verso.Cli/Hosting/BlazorHostBuilder.cs
@@ -18,6 +18,7 @@ public sealed record ServeOptions
public bool NoHttps { get; init; }
public bool Verbose { get; init; }
public string? ExtensionsDirectory { get; init; }
+ public bool PreserveFormat { get; init; }
}
///
@@ -74,6 +75,7 @@ public static WebApplication Build(ServeOptions options)
builder.Services.AddSingleton(new NotebookServiceOptions
{
ExtensionsDirectory = options.ExtensionsDirectory,
+ PreserveFormat = options.PreserveFormat,
});
builder.Services.AddScoped();
diff --git a/src/Verso.Cli/README.md b/src/Verso.Cli/README.md
index c2fabbf..0c5472c 100644
--- a/src/Verso.Cli/README.md
+++ b/src/Verso.Cli/README.md
@@ -45,6 +45,7 @@ verso serve --no-browser
| `--no-https` | false | Serve over HTTP only |
| `--extensions ` | none | Additional directory to scan for extension assemblies |
| `--verbose` | false | Enable detailed startup logging |
+| `--preserve-format` | false | When a `.ipynb` notebook is loaded, save back to `.ipynb` (cell outputs preserved) instead of converting to `.verso` |
### `verso run`
@@ -130,6 +131,7 @@ verso repl --list-themes
| `--history \|none` | platform state dir | Override or disable persistent history |
| `--list-kernels` | false | Print registered kernels and exit |
| `--list-themes` | false | Print registered themes and exit |
+| `--preserve-format` | false | When a `.ipynb` notebook is loaded, `.save` with no argument writes back to `.ipynb` instead of converting to `.verso` |
**Submitting cells.** In the full-featured (PrettyPrompt) driver, `Enter` inserts a newline unless the buffer already ends with two trailing newlines — a third `Enter` submits. Meta-commands (leading `.`) submit on the first `Enter`. In `--plain` mode, a blank line submits and `;;` on its own line force-submits.
diff --git a/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs b/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs
index 6458d3e..1907dec 100644
--- a/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs
+++ b/src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs
@@ -12,12 +12,15 @@ public sealed class SaveMeta : IMetaCommand
public string DetailedHelp =>
".save []\n" +
" Serializes the session notebook. When is omitted, saves to the original\n" +
- " loaded path (if any) or reports an error. Format is inferred from the extension.";
+ " loaded path (if any) or reports an error. Format is inferred from the extension.\n" +
+ " Without --preserve-format, a .save with no arg against an .ipynb-loaded notebook\n" +
+ " converts to a sibling .verso file; with --preserve-format the original format is kept.";
public async Task ExecuteAsync(string argumentText, MetaContext context, CancellationToken ct)
{
var arg = argumentText.Trim();
- var targetPath = string.IsNullOrEmpty(arg) ? context.Session.NotebookPath : Path.GetFullPath(arg);
+ var explicitTarget = !string.IsNullOrEmpty(arg);
+ var targetPath = explicitTarget ? Path.GetFullPath(arg) : context.Session.NotebookPath;
if (string.IsNullOrEmpty(targetPath))
{
@@ -25,6 +28,18 @@ public async Task ExecuteAsync(string argumentText, MetaContext context, C
return true;
}
+ // Implicit-target saves on a non-.verso path: when the user has not asked to preserve
+ // the original format, route to a sibling .verso file (matching the VS Code default).
+ // Explicit `.save foo.ipynb` always honors the path the user typed.
+ if (!explicitTarget
+ && !context.Session.PreserveFormat
+ && !targetPath.EndsWith(".verso", StringComparison.OrdinalIgnoreCase))
+ {
+ context.Console.MarkupLine(
+ $"[yellow]Converting to .verso; use --preserve-format to keep[/] {Markup.Escape(Path.GetExtension(targetPath))}.");
+ targetPath = Path.ChangeExtension(targetPath, ".verso");
+ }
+
INotebookSerializer serializer;
try
{
diff --git a/src/Verso.Cli/Repl/ReplOptions.cs b/src/Verso.Cli/Repl/ReplOptions.cs
index f55c689..87941e6 100644
--- a/src/Verso.Cli/Repl/ReplOptions.cs
+++ b/src/Verso.Cli/Repl/ReplOptions.cs
@@ -15,4 +15,5 @@ public sealed class ReplOptions
public bool Plain { get; init; }
public string? HistoryPath { get; init; }
public bool HistoryDisabled { get; init; }
+ public bool PreserveFormat { get; init; }
}
diff --git a/src/Verso.Cli/Repl/ReplSession.cs b/src/Verso.Cli/Repl/ReplSession.cs
index 5473071..fe517f2 100644
--- a/src/Verso.Cli/Repl/ReplSession.cs
+++ b/src/Verso.Cli/Repl/ReplSession.cs
@@ -63,6 +63,13 @@ public sealed class ReplSession : IAsyncDisposable
/// Runtime-mutable settings (row/line caps, elapsed threshold). Mutated by .set.
public ReplSettings Settings { get; set; } = new();
+ ///
+ /// When true, .save with no explicit path writes back to the loaded notebook's
+ /// original format (e.g. .ipynb) instead of converting to .verso.
+ /// Set by --preserve-format at startup.
+ ///
+ public bool PreserveFormat { get; set; }
+
public ReplSession(NotebookModel notebook, Scaffold scaffold, ExtensionHost extensionHost, string? notebookPath)
{
Notebook = notebook ?? throw new ArgumentNullException(nameof(notebook));
diff --git a/src/Verso.Host/Handlers/NotebookHandler.cs b/src/Verso.Host/Handlers/NotebookHandler.cs
index 9c82a09..f299e7d 100644
--- a/src/Verso.Host/Handlers/NotebookHandler.cs
+++ b/src/Verso.Host/Handlers/NotebookHandler.cs
@@ -208,20 +208,37 @@ public static object HandleSetDefaultKernel(NotebookSession ns, JsonElement? @pa
return new { success = true };
}
- public static async Task HandleSaveAsync(NotebookSession ns)
+ public static async Task HandleSaveAsync(NotebookSession ns, JsonElement? @params)
{
+ var format = "verso";
+ if (@params is { ValueKind: JsonValueKind.Object } p
+ && p.TryGetProperty("format", out var fmtEl)
+ && fmtEl.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(fmtEl.GetString()))
+ {
+ format = fmtEl.GetString()!;
+ }
+
// Flush layout metadata (grid positions, etc.) into the notebook model
if (ns.Scaffold.LayoutManager is { } lm)
await lm.SaveMetadataAsync(ns.Scaffold.Notebook);
- // Run post-processors before serialization
+
+ // Run post-processors before serialization. The format key the post-processor
+ // sees matches the requested serializer so format-specific processors can opt in.
+ var postProcessorFormat = string.Equals(format, "verso", StringComparison.OrdinalIgnoreCase)
+ ? "verso-native"
+ : format;
var notebook = ns.Scaffold.Notebook;
var postProcessors = ns.ExtensionHost.GetPostProcessors()
- .Where(pp => pp.CanProcess(null, "verso-native"))
+ .Where(pp => pp.CanProcess(null, postProcessorFormat))
.OrderBy(pp => pp.Priority);
foreach (var pp in postProcessors)
notebook = await pp.PreSerializeAsync(notebook, null);
- var serializer = new VersoSerializer();
+ var serializer = ns.ExtensionHost.GetSerializers()
+ .FirstOrDefault(s => string.Equals(s.FormatId, format, StringComparison.OrdinalIgnoreCase))
+ ?? (INotebookSerializer)new VersoSerializer();
+
var content = await serializer.SerializeAsync(notebook);
return new NotebookSaveResult { Content = content };
}
diff --git a/src/Verso.Host/HostSession.cs b/src/Verso.Host/HostSession.cs
index 616aaee..90b044f 100644
--- a/src/Verso.Host/HostSession.cs
+++ b/src/Verso.Host/HostSession.cs
@@ -278,7 +278,7 @@ public async Task DispatchAsync(object id, string method, JsonElement? @
{
MethodNames.NotebookSetFilePath => NotebookHandler.HandleSetFilePath(ns, @params),
MethodNames.NotebookSetDefaultKernel => NotebookHandler.HandleSetDefaultKernel(ns, @params),
- MethodNames.NotebookSave => await NotebookHandler.HandleSaveAsync(ns),
+ MethodNames.NotebookSave => await NotebookHandler.HandleSaveAsync(ns, @params),
MethodNames.NotebookGetLanguages => NotebookHandler.HandleGetLanguages(ns),
MethodNames.NotebookGetToolbarActions => NotebookHandler.HandleGetToolbarActions(ns),
MethodNames.NotebookGetTheme => ThemeHandler.HandleGetTheme(ns),
diff --git a/src/Verso/Serializers/JupyterSerializer.cs b/src/Verso/Serializers/JupyterSerializer.cs
index bc094b9..6471082 100644
--- a/src/Verso/Serializers/JupyterSerializer.cs
+++ b/src/Verso/Serializers/JupyterSerializer.cs
@@ -5,7 +5,7 @@
namespace Verso.Serializers;
///
-/// Import-only serializer for Jupyter .ipynb notebooks (nbformat v4).
+/// Serializer for Jupyter .ipynb notebooks (nbformat v4).
///
[VersoExtension]
public sealed class JupyterSerializer : INotebookSerializer
@@ -15,13 +15,19 @@ public sealed class JupyterSerializer : INotebookSerializer
PropertyNameCaseInsensitive = true
};
+ private static readonly JsonSerializerOptions WriteOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
// --- IExtension ---
public string ExtensionId => "verso.serializer.jupyter";
public string Name => "Jupyter Serializer";
public string Version => "1.0.0";
public string? Author => "Verso Contributors";
- public string? Description => "Import-only serializer for Jupyter .ipynb notebooks.";
+ public string? Description => "Serializer for Jupyter .ipynb notebooks (nbformat v4).";
public Task OnLoadedAsync(IExtensionHostContext context) => Task.CompletedTask;
public Task OnUnloadedAsync() => Task.CompletedTask;
@@ -39,7 +45,17 @@ public bool CanImport(string filePath)
public Task SerializeAsync(NotebookModel notebook)
{
- throw new NotSupportedException("Jupyter .ipynb export is not supported. Use the Verso native format.");
+ ArgumentNullException.ThrowIfNull(notebook);
+
+ var doc = new JupyterWriteNotebook
+ {
+ NbFormat = 4,
+ NbFormatMinor = 5,
+ Metadata = BuildMetadata(notebook.DefaultKernelId),
+ Cells = notebook.Cells.Select(BuildCell).ToList()
+ };
+
+ return Task.FromResult(JsonSerializer.Serialize(doc, WriteOptions));
}
public Task DeserializeAsync(string content)
@@ -96,6 +112,144 @@ public Task DeserializeAsync(string content)
return Task.FromResult(notebook);
}
+ // --- Write helpers ---
+
+ // Code/markdown round-trip cleanly. Other cell types collapse to "raw" so structure
+ // survives — the Verso type label is preserved in cell.metadata.verso_type.
+ private static JupyterWriteCell BuildCell(CellModel cell)
+ {
+ var jupyterType = cell.Type?.ToLowerInvariant() switch
+ {
+ "code" => "code",
+ "markdown" => "markdown",
+ _ => "raw"
+ };
+
+ var writeCell = new JupyterWriteCell
+ {
+ CellType = jupyterType,
+ Source = SplitSourceForJupyter(cell.Source ?? "")
+ };
+
+ if (!string.Equals(jupyterType, cell.Type, StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrEmpty(cell.Type))
+ {
+ writeCell.Metadata["verso_type"] = cell.Type!;
+ }
+
+ foreach (var (key, value) in cell.Metadata)
+ {
+ if (key == "execution_count") continue;
+ writeCell.Metadata[key] = value;
+ }
+
+ if (string.Equals(jupyterType, "code", StringComparison.OrdinalIgnoreCase))
+ {
+ writeCell.ExecutionCount = TryGetExecutionCount(cell.Metadata);
+ writeCell.Outputs = cell.Outputs.Select(BuildOutput).ToList();
+ }
+
+ return writeCell;
+ }
+
+ private static JupyterWriteOutput BuildOutput(CellOutput output)
+ {
+ if (output.IsError)
+ {
+ var lines = string.IsNullOrEmpty(output.ErrorStackTrace)
+ ? new List()
+ : output.ErrorStackTrace.Split('\n').ToList();
+ return new JupyterWriteOutput
+ {
+ OutputType = "error",
+ EName = output.ErrorName ?? "Error",
+ EValue = output.Content ?? "",
+ Traceback = lines
+ };
+ }
+
+ // Null/empty MimeType is treated as text/plain rather than producing a
+ // display_data with an invalid empty MIME key.
+ var mimeType = string.IsNullOrEmpty(output.MimeType) ? "text/plain" : output.MimeType;
+
+ if (string.Equals(mimeType, "text/plain", StringComparison.OrdinalIgnoreCase))
+ {
+ return new JupyterWriteOutput
+ {
+ OutputType = "stream",
+ Name = "stdout",
+ Text = SplitSourceForJupyter(output.Content ?? "")
+ };
+ }
+
+ return new JupyterWriteOutput
+ {
+ OutputType = "display_data",
+ Data = new Dictionary>
+ {
+ [mimeType] = SplitSourceForJupyter(output.Content ?? "")
+ },
+ Metadata = new Dictionary()
+ };
+ }
+
+ private static JupyterWriteMetadata BuildMetadata(string? defaultKernelId)
+ {
+ if (string.IsNullOrWhiteSpace(defaultKernelId))
+ return new JupyterWriteMetadata();
+
+ var (name, displayName, language) = ReverseNormalizeKernel(defaultKernelId);
+ return new JupyterWriteMetadata
+ {
+ Kernelspec = new JupyterKernelSpec
+ {
+ Name = name,
+ DisplayName = displayName,
+ Language = language
+ },
+ LanguageInfo = new JupyterLanguageInfo { Name = language }
+ };
+ }
+
+ private static (string Name, string DisplayName, string Language) ReverseNormalizeKernel(string kernelId)
+ {
+ return kernelId.ToLowerInvariant() switch
+ {
+ "csharp" => ("csharp", "C#", "csharp"),
+ "fsharp" => ("fsharp", "F#", "fsharp"),
+ "python" => ("python3", "Python 3", "python"),
+ _ => (kernelId, kernelId, kernelId)
+ };
+ }
+
+ private static List SplitSourceForJupyter(string source)
+ {
+ if (string.IsNullOrEmpty(source))
+ return new List();
+
+ var lines = source.Split('\n');
+ var result = new List(lines.Length);
+ for (int i = 0; i < lines.Length - 1; i++)
+ result.Add(lines[i] + "\n");
+ result.Add(lines[lines.Length - 1]);
+ return result;
+ }
+
+ private static int? TryGetExecutionCount(Dictionary metadata)
+ {
+ if (!metadata.TryGetValue("execution_count", out var value))
+ return null;
+
+ return value switch
+ {
+ int i => i,
+ long l => (int)l,
+ double d => (int)d,
+ JsonElement je when je.ValueKind == JsonValueKind.Number && je.TryGetInt32(out var v) => v,
+ _ => null
+ };
+ }
+
// --- Mapping helpers ---
private static string MapCellType(string? cellType)
@@ -253,7 +407,96 @@ private static string ExtractMimeContent(JsonElement element)
};
}
- // --- Internal DTOs ---
+ // --- Internal write DTOs ---
+
+ private sealed class JupyterWriteNotebook
+ {
+ [JsonPropertyName("cells")]
+ public List Cells { get; set; } = new();
+
+ [JsonPropertyName("metadata")]
+ public JupyterWriteMetadata Metadata { get; set; } = new();
+
+ [JsonPropertyName("nbformat")]
+ public int NbFormat { get; set; }
+
+ [JsonPropertyName("nbformat_minor")]
+ public int NbFormatMinor { get; set; }
+ }
+
+ private sealed class JupyterWriteMetadata
+ {
+ [JsonPropertyName("kernelspec")]
+ public JupyterKernelSpec? Kernelspec { get; set; }
+
+ [JsonPropertyName("language_info")]
+ public JupyterLanguageInfo? LanguageInfo { get; set; }
+ }
+
+ private sealed class JupyterKernelSpec
+ {
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("display_name")]
+ public string DisplayName { get; set; } = "";
+
+ [JsonPropertyName("language")]
+ public string Language { get; set; } = "";
+ }
+
+ private sealed class JupyterLanguageInfo
+ {
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+ }
+
+ private sealed class JupyterWriteCell
+ {
+ [JsonPropertyName("cell_type")]
+ public string CellType { get; set; } = "code";
+
+ [JsonPropertyName("metadata")]
+ public Dictionary Metadata { get; set; } = new();
+
+ [JsonPropertyName("source")]
+ public List Source { get; set; } = new();
+
+ [JsonPropertyName("execution_count")]
+ public int? ExecutionCount { get; set; }
+
+ [JsonPropertyName("outputs")]
+ public List? Outputs { get; set; }
+ }
+
+ private sealed class JupyterWriteOutput
+ {
+ [JsonPropertyName("output_type")]
+ public string OutputType { get; set; } = "";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("text")]
+ public List? Text { get; set; }
+
+ [JsonPropertyName("data")]
+ public Dictionary>? Data { get; set; }
+
+ [JsonPropertyName("metadata")]
+ public Dictionary? Metadata { get; set; }
+
+ [JsonPropertyName("ename")]
+ public string? EName { get; set; }
+
+ [JsonPropertyName("evalue")]
+ public string? EValue { get; set; }
+
+ [JsonPropertyName("traceback")]
+ public List? Traceback { get; set; }
+ }
+
+ // --- Internal read DTOs ---
private sealed class JupyterNotebook
{
diff --git a/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs b/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs
new file mode 100644
index 0000000..0f7dd8f
--- /dev/null
+++ b/tests/Verso.Cli.Tests/Repl/SaveMetaTests.cs
@@ -0,0 +1,121 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Spectre.Console.Testing;
+using Verso.Abstractions;
+using Verso.Cli.Repl;
+using Verso.Cli.Repl.Meta;
+using Verso.Cli.Repl.Meta.Commands;
+using Verso.Cli.Repl.Rendering;
+using Verso.Cli.Repl.Settings;
+using Verso.Execution;
+using Verso.Extensions;
+
+namespace Verso.Cli.Tests.Repl;
+
+[TestClass]
+public class SaveMetaTests
+{
+ [TestMethod]
+ public async Task Save_NoArg_PreserveFormatTrue_KeepsIpynbExtension()
+ {
+ using var tmp = new TempIpynb();
+ var session = await BuildSessionAsync(tmp.Path, preserveFormat: true);
+ var context = BuildContext(session);
+ var save = new SaveMeta();
+
+ await save.ExecuteAsync("", context, CancellationToken.None);
+
+ Assert.IsTrue(File.Exists(tmp.Path), "Original .ipynb should still exist.");
+ Assert.AreEqual(tmp.Path, session.NotebookPath);
+ var written = await File.ReadAllTextAsync(tmp.Path);
+ Assert.IsTrue(written.Contains("\"nbformat\": 4"), "File should be re-serialized as nbformat 4.");
+ }
+
+ [TestMethod]
+ public async Task Save_NoArg_PreserveFormatFalse_RewritesToVersoSibling()
+ {
+ using var tmp = new TempIpynb();
+ var session = await BuildSessionAsync(tmp.Path, preserveFormat: false);
+ var context = BuildContext(session);
+ var save = new SaveMeta();
+
+ await save.ExecuteAsync("", context, CancellationToken.None);
+
+ var expectedVerso = Path.ChangeExtension(tmp.Path, ".verso");
+ Assert.IsTrue(File.Exists(expectedVerso),
+ $"Sibling .verso file expected at {expectedVerso}.");
+ Assert.AreEqual(expectedVerso, session.NotebookPath);
+ var written = await File.ReadAllTextAsync(expectedVerso);
+ Assert.IsTrue(written.Contains("\"verso\""), "Sibling file should be Verso-native JSON.");
+
+ File.Delete(expectedVerso);
+ }
+
+ [TestMethod]
+ public async Task Save_ExplicitIpynbPath_AlwaysHonored_RegardlessOfFlag()
+ {
+ using var tmp = new TempIpynb();
+ var session = await BuildSessionAsync(tmp.Path, preserveFormat: false);
+ var context = BuildContext(session);
+ var save = new SaveMeta();
+
+ var explicitTarget = Path.Combine(Path.GetDirectoryName(tmp.Path)!,
+ $"explicit-{Guid.NewGuid():N}.ipynb");
+ try
+ {
+ await save.ExecuteAsync(explicitTarget, context, CancellationToken.None);
+
+ Assert.IsTrue(File.Exists(explicitTarget),
+ "Explicit .ipynb target must be honored even with preserve-format off.");
+ }
+ finally
+ {
+ if (File.Exists(explicitTarget)) File.Delete(explicitTarget);
+ }
+ }
+
+ // --- helpers ---
+
+ private static async Task BuildSessionAsync(string notebookPath, bool preserveFormat)
+ {
+ var host = new ExtensionHost();
+ host.ConsentHandler = (_, _) => Task.FromResult(true);
+ await host.LoadBuiltInExtensionsAsync();
+
+ var notebook = new NotebookModel
+ {
+ Cells = { new CellModel { Type = "code", Language = "csharp", Source = "var x = 1;" } }
+ };
+ var scaffold = new Scaffold(notebook, host, notebookPath);
+
+ return new ReplSession(notebook, scaffold, host, notebookPath)
+ {
+ PreserveFormat = preserveFormat
+ };
+ }
+
+ private static MetaContext BuildContext(ReplSession session)
+ {
+ var console = new TestConsole();
+ var renderer = new TerminalRenderer(console, useColor: false);
+ renderer.BindSettings(new ReplSettings());
+ var registry = new MetaCommandRegistry();
+ return new MetaContext(session, console, renderer, registry, useColor: false);
+ }
+
+ private sealed class TempIpynb : IDisposable
+ {
+ public string Path { get; }
+
+ public TempIpynb()
+ {
+ Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(),
+ $"verso-save-test-{Guid.NewGuid():N}.ipynb");
+ File.WriteAllText(Path, "{ \"nbformat\": 4, \"nbformat_minor\": 5, \"metadata\": {}, \"cells\": [] }");
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(Path)) File.Delete(Path);
+ }
+ }
+}
diff --git a/tests/Verso.Host.Tests/HandlerTests.cs b/tests/Verso.Host.Tests/HandlerTests.cs
index 426a070..03607d0 100644
--- a/tests/Verso.Host.Tests/HandlerTests.cs
+++ b/tests/Verso.Host.Tests/HandlerTests.cs
@@ -294,7 +294,7 @@ public async Task Dispatch_UnknownMethod_ReturnsMethodNotFoundError()
}
[TestMethod]
- public async Task NotebookSave_ReturnsVersoContent()
+ public async Task NotebookSave_NoParams_ReturnsVersoContent()
{
var (session, notebookId) = await CreateOpenSession();
var ns = GetNs(session, notebookId);
@@ -302,10 +302,64 @@ public async Task NotebookSave_ReturnsVersoContent()
new CellAddParams { Source = "Console.WriteLine(\"test\");" },
JsonRpcMessage.SerializerOptions));
- var result = await NotebookHandler.HandleSaveAsync(ns);
+ var result = await NotebookHandler.HandleSaveAsync(ns, null);
Assert.IsFalse(string.IsNullOrWhiteSpace(result.Content));
Assert.IsTrue(result.Content.Contains("Console.WriteLine"));
+ Assert.IsTrue(result.Content.TrimStart().StartsWith("{"), "Verso format is JSON.");
+ Assert.IsTrue(result.Content.Contains("\"verso\""), "Verso format includes a 'verso' format-version field.");
+ }
+
+ [TestMethod]
+ public async Task NotebookSave_JupyterFormat_ReturnsIpynbContent()
+ {
+ var (session, notebookId) = await CreateOpenSession();
+ var ns = GetNs(session, notebookId);
+ CellHandler.HandleAdd(ns, JsonSerializer.SerializeToElement(
+ new CellAddParams { Source = "print hello" },
+ JsonRpcMessage.SerializerOptions));
+
+ var paramsEl = JsonSerializer.SerializeToElement(new { format = "jupyter" });
+ var result = await NotebookHandler.HandleSaveAsync(ns, paramsEl);
+
+ using var doc = JsonDocument.Parse(result.Content);
+ Assert.AreEqual(4, doc.RootElement.GetProperty("nbformat").GetInt32());
+ var source = doc.RootElement.GetProperty("cells")[0].GetProperty("source");
+ Assert.AreEqual("print hello", source[0].GetString());
+ }
+
+ [TestMethod]
+ public async Task NotebookSave_UnknownFormat_FallsBackToVerso()
+ {
+ var (session, notebookId) = await CreateOpenSession();
+ var ns = GetNs(session, notebookId);
+
+ var paramsEl = JsonSerializer.SerializeToElement(new { format = "no-such-format" });
+ var result = await NotebookHandler.HandleSaveAsync(ns, paramsEl);
+
+ Assert.IsTrue(result.Content.Contains("\"verso\""),
+ "Unknown format should fall back to verso, not throw.");
+ }
+
+ [TestMethod]
+ public async Task NotebookSave_NonObjectParams_FallsBackToVerso()
+ {
+ var (session, notebookId) = await CreateOpenSession();
+ var ns = GetNs(session, notebookId);
+
+ // JSON null, an array, and a primitive are all legal JSON-RPC params shapes;
+ // none should cause TryGetProperty to throw.
+ var jsonNull = JsonDocument.Parse("null").RootElement;
+ var jsonArray = JsonDocument.Parse("[1,2,3]").RootElement;
+ var jsonNumber = JsonDocument.Parse("42").RootElement;
+
+ var nullResult = await NotebookHandler.HandleSaveAsync(ns, jsonNull);
+ var arrayResult = await NotebookHandler.HandleSaveAsync(ns, jsonArray);
+ var numberResult = await NotebookHandler.HandleSaveAsync(ns, jsonNumber);
+
+ Assert.IsTrue(nullResult.Content.Contains("\"verso\""));
+ Assert.IsTrue(arrayResult.Content.Contains("\"verso\""));
+ Assert.IsTrue(numberResult.Content.Contains("\"verso\""));
}
[TestMethod]
diff --git a/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs b/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs
index 3a8798f..a0a2fa3 100644
--- a/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs
+++ b/tests/Verso.Host.Tests/Handlers/PropertiesHandlerTests.cs
@@ -205,7 +205,7 @@ public async Task NotebookSaveOpen_RoundTripsCellDisplayMetadata()
await PropertiesHandler.HandleUpdatePropertyAsync(ns, updateParams);
}
- var saved = await NotebookHandler.HandleSaveAsync(ns);
+ var saved = await NotebookHandler.HandleSaveAsync(ns, null);
var reopenParams = JsonSerializer.SerializeToElement(
new NotebookOpenParams { Content = saved.Content, FilePath = "roundtrip.verso" },
diff --git a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs
index 9c75b8e..46cb48f 100644
--- a/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs
+++ b/tests/Verso.Tests/Serializers/JupyterSerializerTests.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using Verso.Serializers;
namespace Verso.Tests.Serializers;
@@ -34,13 +35,6 @@ public void CanImport_Verso_ReturnsFalse()
Assert.IsFalse(_serializer.CanImport("notebook.verso"));
}
- [TestMethod]
- public void SerializeAsync_ThrowsNotSupported()
- {
- Assert.ThrowsExceptionAsync(
- () => _serializer.SerializeAsync(new NotebookModel()));
- }
-
[TestMethod]
public async Task Deserialize_CodeCell()
{
@@ -369,4 +363,247 @@ public async Task Deserialize_StreamOutput_SourceAsArray()
Assert.AreEqual("line1\nline2\n", notebook.Cells[0].Outputs[0].Content);
}
+
+ // --- Serialize ---
+
+ [TestMethod]
+ public async Task Serialize_EmptyNotebook_ProducesNbformat4()
+ {
+ var notebook = new NotebookModel();
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ Assert.AreEqual(4, doc.RootElement.GetProperty("nbformat").GetInt32());
+ Assert.AreEqual(5, doc.RootElement.GetProperty("nbformat_minor").GetInt32());
+ Assert.AreEqual(0, doc.RootElement.GetProperty("cells").GetArrayLength());
+ }
+
+ [TestMethod]
+ public async Task Serialize_KernelSpec_FromDefaultKernel()
+ {
+ var notebook = new NotebookModel { DefaultKernelId = "csharp" };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var kernelspec = doc.RootElement.GetProperty("metadata").GetProperty("kernelspec");
+ Assert.AreEqual("csharp", kernelspec.GetProperty("name").GetString());
+ Assert.AreEqual("C#", kernelspec.GetProperty("display_name").GetString());
+ Assert.AreEqual("csharp", kernelspec.GetProperty("language").GetString());
+ Assert.AreEqual("csharp",
+ doc.RootElement.GetProperty("metadata").GetProperty("language_info").GetProperty("name").GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_MultiLineSource_AsStringList()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells = { new CellModel { Type = "code", Source = "line1\nline2\nline3" } }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var source = doc.RootElement.GetProperty("cells")[0].GetProperty("source");
+ Assert.AreEqual(JsonValueKind.Array, source.ValueKind);
+ Assert.AreEqual(3, source.GetArrayLength());
+ Assert.AreEqual("line1\n", source[0].GetString());
+ Assert.AreEqual("line2\n", source[1].GetString());
+ Assert.AreEqual("line3", source[2].GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_StreamOutput_FromTextPlain()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells =
+ {
+ new CellModel
+ {
+ Type = "code",
+ Source = "x",
+ Outputs = { new CellOutput("text/plain", "hello\n") }
+ }
+ }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0];
+ Assert.AreEqual("stream", output.GetProperty("output_type").GetString());
+ Assert.AreEqual("stdout", output.GetProperty("name").GetString());
+ Assert.AreEqual("hello\n", output.GetProperty("text")[0].GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_EmptyMimeType_TreatsAsTextPlain()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells =
+ {
+ new CellModel
+ {
+ Type = "code",
+ Source = "x",
+ Outputs = { new CellOutput("", "hello") }
+ }
+ }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0];
+ Assert.AreEqual("stream", output.GetProperty("output_type").GetString(),
+ "Empty MimeType should coerce to text/plain stream rather than emit an empty MIME key.");
+ Assert.AreEqual("stdout", output.GetProperty("name").GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_ErrorOutput_PreservesENameAndTraceback()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells =
+ {
+ new CellModel
+ {
+ Type = "code",
+ Source = "x",
+ Outputs =
+ {
+ new CellOutput(
+ "text/plain",
+ "bad value",
+ IsError: true,
+ ErrorName: "ValueError",
+ ErrorStackTrace: "frame 1\nframe 2")
+ }
+ }
+ }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0];
+ Assert.AreEqual("error", output.GetProperty("output_type").GetString());
+ Assert.AreEqual("ValueError", output.GetProperty("ename").GetString());
+ Assert.AreEqual("bad value", output.GetProperty("evalue").GetString());
+ var tb = output.GetProperty("traceback");
+ Assert.AreEqual(2, tb.GetArrayLength());
+ Assert.AreEqual("frame 1", tb[0].GetString());
+ Assert.AreEqual("frame 2", tb[1].GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_DisplayData_FromImagePng()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells =
+ {
+ new CellModel
+ {
+ Type = "code",
+ Source = "plot()",
+ Outputs = { new CellOutput("image/png", "iVBORw0KGgo=") }
+ }
+ }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var output = doc.RootElement.GetProperty("cells")[0].GetProperty("outputs")[0];
+ Assert.AreEqual("display_data", output.GetProperty("output_type").GetString());
+ var data = output.GetProperty("data");
+ Assert.AreEqual("iVBORw0KGgo=", data.GetProperty("image/png")[0].GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_MarkdownCell_HasNoOutputs()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells = { new CellModel { Type = "markdown", Source = "# Title" } }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var cell = doc.RootElement.GetProperty("cells")[0];
+ Assert.AreEqual("markdown", cell.GetProperty("cell_type").GetString());
+ Assert.IsFalse(cell.TryGetProperty("outputs", out _));
+ Assert.IsFalse(cell.TryGetProperty("execution_count", out _));
+ }
+
+ [TestMethod]
+ public async Task Serialize_NonStandardCellType_FallsBackToRaw()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells = { new CellModel { Type = "sql", Source = "SELECT 1" } }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var cell = doc.RootElement.GetProperty("cells")[0];
+ Assert.AreEqual("raw", cell.GetProperty("cell_type").GetString());
+ Assert.AreEqual("sql", cell.GetProperty("metadata").GetProperty("verso_type").GetString());
+ }
+
+ [TestMethod]
+ public async Task Serialize_ExecutionCount_FromMetadata()
+ {
+ var notebook = new NotebookModel
+ {
+ Cells =
+ {
+ new CellModel
+ {
+ Type = "code",
+ Source = "x = 1",
+ Metadata = { ["execution_count"] = 7 }
+ }
+ }
+ };
+ var json = await _serializer.SerializeAsync(notebook);
+
+ using var doc = JsonDocument.Parse(json);
+ var cell = doc.RootElement.GetProperty("cells")[0];
+ Assert.AreEqual(7, cell.GetProperty("execution_count").GetInt32());
+ Assert.IsFalse(cell.GetProperty("metadata").TryGetProperty("execution_count", out _),
+ "execution_count should not be duplicated in cell metadata.");
+ }
+
+ [TestMethod]
+ public async Task RoundTrip_StreamOutput_PreservesContent()
+ {
+ var original = @"{
+ ""nbformat"": 4, ""nbformat_minor"": 5,
+ ""metadata"": { ""kernelspec"": { ""language"": ""python"" } },
+ ""cells"": [{
+ ""cell_type"": ""code"",
+ ""source"": ""print('hi')"",
+ ""outputs"": [{
+ ""output_type"": ""stream"",
+ ""name"": ""stdout"",
+ ""text"": ""hi\n""
+ }],
+ ""metadata"": {},
+ ""execution_count"": 3
+ }]
+ }";
+
+ var notebook = await _serializer.DeserializeAsync(original);
+ var roundtripped = await _serializer.SerializeAsync(notebook);
+ var reread = await _serializer.DeserializeAsync(roundtripped);
+
+ Assert.AreEqual(1, reread.Cells.Count);
+ Assert.AreEqual("code", reread.Cells[0].Type);
+ Assert.AreEqual("python", reread.Cells[0].Language);
+ Assert.AreEqual("print('hi')", reread.Cells[0].Source);
+ Assert.AreEqual(1, reread.Cells[0].Outputs.Count);
+ Assert.AreEqual("text/plain", reread.Cells[0].Outputs[0].MimeType);
+ Assert.AreEqual("hi\n", reread.Cells[0].Outputs[0].Content);
+ Assert.AreEqual(3, Convert.ToInt32(reread.Cells[0].Metadata["execution_count"]));
+ }
}
diff --git a/vscode/README.md b/vscode/README.md
index 4091144..5121124 100644
--- a/vscode/README.md
+++ b/vscode/README.md
@@ -99,6 +99,12 @@ The Python kernel requires **Python 3.8-3.12** installed on your system. Python
To import an existing notebook, use **File > Open** on any `.ipynb` or `.dib` file.
+### Settings
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `verso.preserveOriginalFormat` | `false` | When opening an `.ipynb` file, save changes back to `.ipynb` (cell outputs preserved) instead of converting to a sibling `.verso` file. Leave off to keep the existing convert-on-save behavior. |
+
## Supported Languages
| Language | IntelliSense | Variable Sharing |
diff --git a/vscode/package.json b/vscode/package.json
index a82c19b..ee7b8fc 100644
--- a/vscode/package.json
+++ b/vscode/package.json
@@ -64,6 +64,11 @@
"type": "string",
"default": "",
"description": "Directory of third-party Verso extension assemblies to load on notebook open. Changes apply on next notebook open or kernel restart."
+ },
+ "verso.preserveOriginalFormat": {
+ "type": "boolean",
+ "default": false,
+ "description": "When opening an .ipynb file, save changes back to .ipynb instead of converting to .verso. Cell outputs are preserved. Default off keeps the existing convert-on-save behavior."
}
}
},
diff --git a/vscode/src/blazor/blazorEditorProvider.ts b/vscode/src/blazor/blazorEditorProvider.ts
index 87198c5..b33b56e 100644
--- a/vscode/src/blazor/blazorEditorProvider.ts
+++ b/vscode/src/blazor/blazorEditorProvider.ts
@@ -371,6 +371,13 @@ export class BlazorEditorProvider
// --- Save / Revert ---
+ // Extensions Verso can write back in their original format. Anything not listed
+ // here falls through to the convert-to-.verso path even when the
+ // verso.preserveOriginalFormat setting is on.
+ private static readonly preservableFormats: Record = {
+ ".ipynb": "jupyter",
+ };
+
async saveCustomDocument(
document: VersoDocument,
_cancellation: vscode.CancellationToken
@@ -380,10 +387,16 @@ export class BlazorEditorProvider
if (!session || !notebookId) return;
const host = session.host;
- // If the source file is not a .verso file (e.g. imported .dib or .ipynb),
- // redirect the save to a .verso file to avoid overwriting the original format.
const fsPath = document.uri.fsPath;
- if (!fsPath.endsWith(".verso")) {
+ const ext = path.extname(fsPath).toLowerCase();
+ const preserveSetting = vscode.workspace.getConfiguration("verso")
+ .get("preserveOriginalFormat", false);
+ const preservableFormat = BlazorEditorProvider.preservableFormats[ext];
+ const shouldPreserve = ext !== ".verso" && preserveSetting && preservableFormat !== undefined;
+
+ // Source isn't .verso and the user hasn't opted in to preservation (or the
+ // format isn't writable back): redirect to a sibling .verso file.
+ if (ext !== ".verso" && !shouldPreserve) {
const versoPath = fsPath.replace(/\.[^.]+$/, ".verso");
const versoUri = vscode.Uri.file(versoPath);
@@ -406,9 +419,10 @@ export class BlazorEditorProvider
return;
}
+ const format = ext === ".verso" ? "verso" : preservableFormat;
const result = await host.sendRequest(
"notebook/save",
- { notebookId }
+ { notebookId, format }
);
const data = new TextEncoder().encode(result.content);
await vscode.workspace.fs.writeFile(document.uri, data);
@@ -424,16 +438,27 @@ export class BlazorEditorProvider
if (!session || !notebookId) return;
const host = session.host;
- // If the destination is not a .verso file, adjust the extension
+ const destExt = path.extname(destination.fsPath).toLowerCase();
+ const preserveSetting = vscode.workspace.getConfiguration("verso")
+ .get("preserveOriginalFormat", false);
+ const preservableFormat = BlazorEditorProvider.preservableFormats[destExt];
+ const shouldPreserve = destExt !== ".verso" && preserveSetting && preservableFormat !== undefined;
+
let targetUri = destination;
- if (!destination.fsPath.endsWith(".verso")) {
+ let format = "verso";
+ if (destExt === ".verso") {
+ format = "verso";
+ } else if (shouldPreserve) {
+ format = preservableFormat;
+ } else {
+ // Destination isn't writable in its requested format: coerce to .verso.
const versoPath = destination.fsPath.replace(/\.[^.]+$/, ".verso");
targetUri = vscode.Uri.file(versoPath);
}
const result = await host.sendRequest(
"notebook/save",
- { notebookId }
+ { notebookId, format }
);
const data = new TextEncoder().encode(result.content);
await vscode.workspace.fs.writeFile(targetUri, data);