From f8643592902c5755e7256584da1b814b06c78050 Mon Sep 17 00:00:00 2001
From: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com>
Date: Tue, 3 Nov 2026 14:43:26 +0100
Subject: [PATCH] Fixed mixed line breaks in saved code files and tests failing
on Windows (#1149)
---
.../Chat/IContentExtensions.cs | 10 +++++--
.../wwwroot/changelog/v26.11.1.md | 1 +
.../SwotCategoryAnalysisResponseTests.cs | 3 +-
app/Tests/Chat/IContentExtensionsTests.cs | 29 +++++++++++++++++++
4 files changed, 39 insertions(+), 4 deletions(-)
diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs
index 0f0a7f489..d0082d3c7 100644
--- a/app/MindWork AI Studio/Chat/IContentExtensions.cs
+++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs
@@ -118,14 +118,18 @@ public static class IContentExtensions
///
/// Markdig hands us the lines of a block without a line break after the last one. Compilers
/// and shells read such a file, but Git marks it, and some tools drop the last line altogether.
- /// The lines end with LF as the model wrote them, except where the language needs CR LF.
+ /// Between the lines, Markdig keeps the line breaks the answer came with: LF, CR LF, or a lone
+ /// CR, the three that Markdown knows. Every line ends with LF, except where the language needs
+ /// CR LF, so that the file never mixes two kinds. Only these three count as line breaks, unlike
+ /// in ReplaceLineEndings: a form feed or a U+2028 may stand inside a string of the code.
///
/// The code block.
/// The content of the file to write.
private static string ToSourceCodeFile(MessageFile file)
{
- var code = file.Content.EndsWith('\n') ? file.Content : $"{file.Content}\n";
- return file.Language.NeedsWindowsLineEndings ? code.ReplaceLineEndings("\r\n") : code;
+ var text = file.Content.Replace("\r\n", "\n").Replace('\r', '\n');
+ var code = text.EndsWith('\n') ? text : $"{text}\n";
+ return file.Language.NeedsWindowsLineEndings ? code.Replace("\n", "\r\n") : code;
}
///
diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md
index 3fa5c5964..0354b3431 100644
--- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md
+++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.1.md
@@ -16,6 +16,7 @@
- Fixed configuration plugins being unable to lock the preselected provider, the preselected profile, or the transcription provider to none. An empty value now does this, as the example configuration plugin describes. Older versions of AI Studio ignore the empty value and leave the choice to your users.
- Fixed AI Studio loading none of your organization's configurations when one place, such as the Windows registry, named a configuration with an invalid ID. AI Studio now skips that entry and uses the configurations from the other places.
- Fixed chats being stored although you chose "Store chats manually" or "Disable workspaces". AI Studio now stores a chat only when you click "Save chat", or not at all while workspaces are disabled. In both modes, leaving a chat whose answer is still being written asks you first and stops that answer. Chats outside of workspaces that earlier versions stored this way appear among your disappearing chats once you use workspaces again.
+- Fixed saving a code block as a file, where the last line could end differently from all other lines, for example, on Windows. All lines of the file now end the same way.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
- Upgraded MudBlazor to v9.12.0.
diff --git a/app/Tests/Assistants/SWOTAnalysis/SwotCategoryAnalysisResponseTests.cs b/app/Tests/Assistants/SWOTAnalysis/SwotCategoryAnalysisResponseTests.cs
index c80249db5..2535aa262 100644
--- a/app/Tests/Assistants/SWOTAnalysis/SwotCategoryAnalysisResponseTests.cs
+++ b/app/Tests/Assistants/SWOTAnalysis/SwotCategoryAnalysisResponseTests.cs
@@ -117,7 +117,8 @@ public sealed class SwotCategoryAnalysisResponseTests
public void TheSystemPromptAssignsExactlyOneCategory(string categoryName, string expectedDescription)
{
var category = Enum.Parse(categoryName);
- var prompt = SwotCategoryAnalysis.BuildSystemPrompt(category, "Answer in German.");
+ // The prompt takes the line breaks of its source file, which depend on the checkout:
+ var prompt = SwotCategoryAnalysis.BuildSystemPrompt(category, "Answer in German.").ReplaceLineEndings("\n");
using (Assert.EnterMultipleScope())
{
diff --git a/app/Tests/Chat/IContentExtensionsTests.cs b/app/Tests/Chat/IContentExtensionsTests.cs
index 681e0394a..cd12b0611 100644
--- a/app/Tests/Chat/IContentExtensionsTests.cs
+++ b/app/Tests/Chat/IContentExtensionsTests.cs
@@ -251,6 +251,35 @@ public sealed class IContentExtensionsTests
Assert.That(TextWith(string.Empty).ToExportContent(file), Is.EqualTo("print(42)\n"));
}
+ [TestCase("import sys\r\nprint(sys.argv)")]
+ [TestCase("import sys\r\nprint(sys.argv)\r\n")]
+ [TestCase("import sys\rprint(sys.argv)")]
+ public void CodeNeverMixesLineEndings(string code)
+ {
+ var file = new MessageFile(1, Position: 0, string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage("python"), code);
+
+ Assert.That(TextWith(string.Empty).ToExportContent(file), Is.EqualTo("import sys\nprint(sys.argv)\n"), "Every line ends with LF, whatever line breaks the code came with.");
+ }
+
+ [TestCase("@echo off\necho done")]
+ [TestCase("@echo off\r\necho done\r\n")]
+ [TestCase("@echo off\recho done")]
+ public void ABatchFileNeverMixesLineEndings(string code)
+ {
+ var file = new MessageFile(1, Position: 0, string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage("bat"), code);
+
+ Assert.That(TextWith(string.Empty).ToExportContent(file), Is.EqualTo("@echo off\r\necho done\r\n"), "Every line ends with CR LF, whatever line breaks the code came with.");
+ }
+
+ [TestCase("python", "print('a\fb\u2028c')", "print('a\fb\u2028c')\n")]
+ [TestCase("bat", "echo a\fb\u2028c", "echo a\fb\u2028c\r\n")]
+ public void AFormFeedOrALineSeparatorStaysInItsLine(string language, string code, string expected)
+ {
+ var file = new MessageFile(1, Position: 0, string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage(language), code);
+
+ Assert.That(TextWith(string.Empty).ToExportContent(file), Is.EqualTo(expected), "Markdown breaks lines only at LF and CR, and so does the file.");
+ }
+
[Test]
public void ABatchFileEndsItsLinesTheWindowsWay()
{