Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/front-ends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/migration/from-jupyter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/Verso.Blazor/Services/NotebookServiceOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ public sealed record NotebookServiceOptions
/// built-in extensions are loaded.
/// </summary>
public string? ExtensionsDirectory { get; init; }

/// <summary>
/// When true, saving a notebook that was loaded from a non-.verso file (e.g.
/// <c>.ipynb</c>) writes back to the original format instead of converting
/// to <c>.verso</c>. Defaults to false.
/// </summary>
public bool PreserveFormat { get; init; }
}
13 changes: 12 additions & 1 deletion src/Verso.Blazor/Services/ServerNotebookService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,21 @@ private async Task<string> 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<CellModel> AddCellAsync(string type = "code", string? language = null)
Expand Down
13 changes: 10 additions & 3 deletions src/Verso.Cli/Commands/ReplCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public static Command Create()
var listThemesOption = new Option<bool>("--list-themes", () => false,
"Print registered themes and exit.");

var preserveFormatOption = new Option<bool>("--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,
Expand All @@ -82,7 +85,8 @@ public static Command Create()
plainOption,
historyOption,
listKernelsOption,
listThemesOption
listThemesOption,
preserveFormatOption
};

command.SetHandler(async (context) =>
Expand All @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -230,7 +236,8 @@ internal static async Task<int> 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.
Expand Down
10 changes: 8 additions & 2 deletions src/Verso.Cli/Commands/ServeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ public static Command Create()
var verboseOption = new Option<bool>("--verbose", () => false,
"Print startup details to stderr.");

var preserveFormatOption = new Option<bool>("--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,
portOption,
noBrowserOption,
noHttpsOption,
extensionsOption,
verboseOption
verboseOption,
preserveFormatOption
};

command.SetHandler(async (context) =>
Expand All @@ -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;
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/Verso.Cli/Hosting/BlazorHostBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

/// <summary>
Expand Down Expand Up @@ -74,6 +75,7 @@ public static WebApplication Build(ServeOptions options)
builder.Services.AddSingleton(new NotebookServiceOptions
{
ExtensionsDirectory = options.ExtensionsDirectory,
PreserveFormat = options.PreserveFormat,
});
builder.Services.AddScoped<INotebookService, ServerNotebookService>();

Expand Down
2 changes: 2 additions & 0 deletions src/Verso.Cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ verso serve --no-browser
| `--no-https` | false | Serve over HTTP only |
| `--extensions <dir>` | 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`

Expand Down Expand Up @@ -130,6 +131,7 @@ verso repl --list-themes
| `--history <path>\|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.

Expand Down
19 changes: 17 additions & 2 deletions src/Verso.Cli/Repl/Meta/Commands/SaveMeta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,34 @@ public sealed class SaveMeta : IMetaCommand
public string DetailedHelp =>
".save [<path>]\n" +
" Serializes the session notebook. When <path> 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<bool> 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))
{
context.Console.MarkupLine("[red].save requires a path when the session has no loaded notebook.[/] Usage: .save <path>");
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
{
Expand Down
1 change: 1 addition & 0 deletions src/Verso.Cli/Repl/ReplOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
7 changes: 7 additions & 0 deletions src/Verso.Cli/Repl/ReplSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ public sealed class ReplSession : IAsyncDisposable
/// <summary>Runtime-mutable settings (row/line caps, elapsed threshold). Mutated by <c>.set</c>.</summary>
public ReplSettings Settings { get; set; } = new();

/// <summary>
/// When true, <c>.save</c> with no explicit path writes back to the loaded notebook's
/// original format (e.g. <c>.ipynb</c>) instead of converting to <c>.verso</c>.
/// Set by <c>--preserve-format</c> at startup.
/// </summary>
public bool PreserveFormat { get; set; }

public ReplSession(NotebookModel notebook, Scaffold scaffold, ExtensionHost extensionHost, string? notebookPath)
{
Notebook = notebook ?? throw new ArgumentNullException(nameof(notebook));
Expand Down
25 changes: 21 additions & 4 deletions src/Verso.Host/Handlers/NotebookHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,20 +208,37 @@ public static object HandleSetDefaultKernel(NotebookSession ns, JsonElement? @pa
return new { success = true };
}

public static async Task<NotebookSaveResult> HandleSaveAsync(NotebookSession ns)
public static async Task<NotebookSaveResult> 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 };
}
Expand Down
2 changes: 1 addition & 1 deletion src/Verso.Host/HostSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ public async Task<string> 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),
Expand Down
Loading
Loading