diff --git a/Directory.Build.props b/Directory.Build.props
index 29b02b1..ba0c657 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -13,7 +13,7 @@
Copyright (c) gui-cs and contributors
- 2.1.0-rc.*
+ 2.1.1-develop.*
2.1.1-develop.*
diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs
index dcd7b38..e473f93 100644
--- a/src/Clet/Clets/Viewer/EditorClet.cs
+++ b/src/Clet/Clets/Viewer/EditorClet.cs
@@ -1,12 +1,16 @@
using System.Collections.ObjectModel;
using Terminal.Gui.App;
-using Terminal.Gui.Drawing;
-using Terminal.Gui.Input;
+using Terminal.Gui.Configuration;
using Terminal.Gui.Document;
+using Terminal.Gui.Document.Folding;
+using Terminal.Gui.Drawing;
using Terminal.Gui.Editor;
using Terminal.Gui.Highlighting;
+using Terminal.Gui.Input;
+using Terminal.Gui.Text.Indentation;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
+using TextMateSharp.Grammars;
using Command = Terminal.Gui.Input.Command;
namespace Clet;
@@ -62,12 +66,8 @@ public async Task RunAsync (
};
}
- // Preserve explicit (non-glob) paths for files that don't exist yet.
- // ExpandFiles skips missing files with a warning; for edit we want to
- // open a new empty buffer bound to the given path.
foreach (string arg in args)
{
- // Skip glob patterns (only * and ? are wildcards — matching ExpandFiles / Directory.GetFiles semantics).
if (arg.Contains ('*') || arg.Contains ('?'))
{
continue;
@@ -100,26 +100,271 @@ public async Task RunAsync (
BorderStyle = LineStyle.None,
};
+ // --- Settings are loaded by ConfigurationManager via [ConfigurationProperty] ---
+
Editor editor = new ()
{
X = 0,
- Y = 1, // below MenuBar
+ Y = 1,
Width = Dim.Fill (),
- Height = Dim.Fill (1), // above StatusBar
+ Height = Dim.Fill (1),
ReadOnly = readOnly,
- GutterOptions = GutterOptions.LineNumbers,
- ConvertTabsToSpaces = true,
+ ConvertTabsToSpaces = EditorSettings.ConvertTabsToSpaces,
+ IndentationSize = EditorSettings.IndentSize,
+ WordWrap = EditorSettings.WordWrap,
+ ShowTabs = EditorSettings.ShowTabs,
+ UseThemeBackground = EditorSettings.UseThemeBackground,
+ ViewportSettings = ViewportSettingsFlags.HasScrollBars,
};
+ // Apply gutter options from settings
+ GutterOptions initGutter = GutterOptions.None;
+
+ if (EditorSettings.LineNumbers)
+ {
+ initGutter |= GutterOptions.LineNumbers;
+ }
+
+ if (EditorSettings.FoldIndicators)
+ {
+ initGutter |= GutterOptions.Folding;
+ }
+
+ editor.GutterOptions = initGutter;
+
+ if (EditorSettings.AutoIndent)
+ {
+ editor.IndentationStrategy = new DefaultIndentationStrategy ();
+ }
+
editor.HighlightingDefinition = filePath is not null
? HighlightingManager.Instance.GetDefinitionByExtension (Path.GetExtension (filePath))
: null;
+ // --- Folding support ---
+
+ BraceFoldingStrategy braceFoldingStrategy = new ();
+
+ void InstallFolding ()
+ {
+ if (editor.Document is null)
+ {
+ return;
+ }
+
+ FoldingManager fm = new (editor.Document);
+ braceFoldingStrategy.UpdateFoldings (fm, editor.Document);
+ editor.FoldingManager = fm;
+
+ editor.Document.Changed += (_, _) =>
+ {
+ if (editor.FoldingManager is not null && editor.Document is not null)
+ {
+ braceFoldingStrategy.UpdateFoldings (editor.FoldingManager, editor.Document);
+ }
+ };
+ }
+
+ // --- Markdown preview ---
+
+ Markdown? markdownPreview = null;
+ bool syncingScroll = false;
+ bool isMarkdownFile = filePath is not null
+ && Path.GetExtension (filePath).Equals (".md", StringComparison.OrdinalIgnoreCase);
+
+ // View-menu toggle items — declared early so preview toggle can reference them.
+ MenuItem previewMarkdownItem = new () { Title = " _Preview Markdown", Enabled = isMarkdownFile };
+
+ bool optUseThemeBg = EditorSettings.UseThemeBackground;
+
+ void OnEditorViewportChanged (object? sender, DrawEventArgs e)
+ {
+ if (markdownPreview is null || syncingScroll)
+ {
+ return;
+ }
+
+ syncingScroll = true;
+
+ try
+ {
+ int editorContentHeight = editor.GetContentSize ().Height;
+ int editorViewportHeight = editor.Viewport.Height;
+ int maxEditorY = Math.Max (0, editorContentHeight - editorViewportHeight);
+ int editorY = editor.Viewport.Y;
+
+ int previewContentHeight = markdownPreview.GetContentSize ().Height;
+ int previewViewportHeight = markdownPreview.Viewport.Height;
+ int maxPreviewY = Math.Max (0, previewContentHeight - previewViewportHeight);
+
+ int newY = maxEditorY > 0
+ ? (int)((long)editorY * maxPreviewY / maxEditorY)
+ : 0;
+
+ markdownPreview.Viewport = markdownPreview.Viewport with { Y = Math.Clamp (newY, 0, maxPreviewY) };
+ }
+ finally
+ {
+ syncingScroll = false;
+ }
+ }
+
+ void OnPreviewViewportChanged (object? sender, DrawEventArgs e)
+ {
+ if (markdownPreview is null || syncingScroll)
+ {
+ return;
+ }
+
+ syncingScroll = true;
+
+ try
+ {
+ int previewContentHeight = markdownPreview.GetContentSize ().Height;
+ int previewViewportHeight = markdownPreview.Viewport.Height;
+ int maxPreviewY = Math.Max (0, previewContentHeight - previewViewportHeight);
+ int previewY = markdownPreview.Viewport.Y;
+
+ int editorContentHeight = editor.GetContentSize ().Height;
+ int editorViewportHeight = editor.Viewport.Height;
+ int maxEditorY = Math.Max (0, editorContentHeight - editorViewportHeight);
+
+ int newY = maxPreviewY > 0
+ ? (int)((long)previewY * maxEditorY / maxPreviewY)
+ : 0;
+
+ editor.Viewport = editor.Viewport with { Y = Math.Clamp (newY, 0, maxEditorY) };
+ }
+ finally
+ {
+ syncingScroll = false;
+ }
+ }
+
+ void OnDocumentChangedForPreview (object? sender, EventArgs e)
+ {
+ if (markdownPreview is null)
+ {
+ return;
+ }
+
+ markdownPreview.Text = editor.Document?.Text ?? string.Empty;
+ }
+
+ void ShowMarkdownPreview ()
+ {
+ if (markdownPreview is not null)
+ {
+ return;
+ }
+
+ markdownPreview = new Markdown ()
+ {
+ X = Pos.Right (editor),
+ Y = editor.Y,
+ Width = Dim.Fill (),
+ Height = editor.Height,
+ Text = editor.Document?.Text ?? string.Empty,
+ ViewportSettings = ViewportSettingsFlags.HasScrollBars,
+ SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus),
+ UseThemeBackground = optUseThemeBg,
+ };
+
+ editor.Width = Dim.Percent (50);
+ window.Add (markdownPreview);
+
+ // Sync scrolling bidirectionally.
+ editor.ViewportChanged += OnEditorViewportChanged;
+ markdownPreview.ViewportChanged += OnPreviewViewportChanged;
+
+ // Update preview when document content changes.
+ if (editor.Document is not null)
+ {
+ editor.Document.Changed += OnDocumentChangedForPreview;
+ }
+ }
+
+ void HideMarkdownPreview ()
+ {
+ if (markdownPreview is null)
+ {
+ return;
+ }
+
+ editor.ViewportChanged -= OnEditorViewportChanged;
+ markdownPreview.ViewportChanged -= OnPreviewViewportChanged;
+
+ if (editor.Document is not null)
+ {
+ editor.Document.Changed -= OnDocumentChangedForPreview;
+ }
+
+ window.Remove (markdownPreview);
+ markdownPreview.Dispose ();
+ markdownPreview = null;
+
+ editor.Width = Dim.Fill ();
+ }
+
+ void RefreshPreviewDocument ()
+ {
+ if (markdownPreview is null)
+ {
+ return;
+ }
+
+ if (editor.Document is not null)
+ {
+ editor.Document.Changed -= OnDocumentChangedForPreview;
+ editor.Document.Changed += OnDocumentChangedForPreview;
+ }
+
+ markdownPreview.Text = editor.Document?.Text ?? string.Empty;
+ }
+
+ void ToggleMarkdownPreview ()
+ {
+ if (previewMarkdownItem.Title?.StartsWith ("✓") == true)
+ {
+ HideMarkdownPreview ();
+ previewMarkdownItem.Title = " _Preview Markdown";
+ }
+ else
+ {
+ ShowMarkdownPreview ();
+ previewMarkdownItem.Title = "✓ _Preview Markdown";
+ }
+ }
+
+ void UpdatePreviewEnabled ()
+ {
+ previewMarkdownItem.Enabled = isMarkdownFile;
+
+ if (!isMarkdownFile && markdownPreview is not null)
+ {
+ HideMarkdownPreview ();
+ previewMarkdownItem.Title = " _Preview Markdown";
+ }
+ else if (isMarkdownFile && markdownPreview is not null)
+ {
+ RefreshPreviewDocument ();
+ }
+ }
+
// --- StatusBar shortcuts (declared early for capture) ---
Shortcut cursorPositionShortcut = new ()
{ Title = "Ln 1, Col 1", MouseHighlightStates = MouseState.None, Enabled = false };
- Shortcut modifiedShortcut = new () { Title = "", MouseHighlightStates = MouseState.None, Enabled = false };
+ Shortcut languageShortcut = new ()
+ { Title = "Plain Text", MouseHighlightStates = MouseState.None, Enabled = false };
+
+ // Filename shortcut for MenuBar — full path, dialog scheme
+ Shortcut filenameShortcut = new ()
+ {
+ Title = filePath ?? "",
+ MouseHighlightStates = MouseState.None,
+ SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog),
+ };
// --- Local state helpers ---
@@ -128,13 +373,18 @@ public async Task RunAsync (
void UpdateModifiedIndicator ()
{
bool dirty = UnsavedChanges ();
- modifiedShortcut.Title = dirty ? "Modified" : "";
window.Title = dirty ? $"{fileName ?? "Untitled"}*" : fileName ?? "Untitled";
}
+ void UpdateLanguageShortcut ()
+ {
+ languageShortcut.Title = editor.HighlightingDefinition?.Name ?? "Plain Text";
+ }
+
void UpdateSyntaxLanguage (string path)
{
editor.HighlightingDefinition = HighlightingManager.Instance.GetDefinitionByExtension (Path.GetExtension (path));
+ UpdateLanguageShortcut ();
}
void UpdateLocShortcut ()
@@ -144,12 +394,19 @@ void UpdateLocShortcut ()
if (document is null)
{
cursorPositionShortcut.Title = "Ln 1, Col 1";
+
+ return;
}
- else
+
+ DocumentLine line = document.GetLineByOffset (editor.CaretOffset);
+ string loc = $"Ln {line.LineNumber}, Col {editor.CaretOffset - line.Offset + 1}";
+
+ if (editor.HasMultipleCarets)
{
- DocumentLine line = document.GetLineByOffset (editor.CaretOffset);
- cursorPositionShortcut.Title = $"Ln {line.LineNumber}, Col {editor.CaretOffset - line.Offset + 1}";
+ loc += $" ({editor.AdditionalCaretOffsets.Count + 1} carets)";
}
+
+ cursorPositionShortcut.Title = loc;
}
// --- File operations ---
@@ -166,7 +423,11 @@ void LoadFile (string path)
savedText = string.Empty;
editor.Document = new TextDocument ();
UpdateSyntaxLanguage (fullPath);
+ InstallFolding ();
UpdateModifiedIndicator ();
+ filenameShortcut.Title = fullPath;
+ isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase);
+ UpdatePreviewEnabled ();
return;
}
@@ -180,7 +441,11 @@ void LoadFile (string path)
editor.Document = new TextDocument (text);
editor.CaretOffset = 0;
UpdateSyntaxLanguage (fullPath);
+ InstallFolding ();
UpdateModifiedIndicator ();
+ filenameShortcut.Title = fullPath;
+ isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase);
+ UpdatePreviewEnabled ();
}
bool SaveFile ()
@@ -254,15 +519,15 @@ bool PromptSaveIfDirty ()
if (result is null or 0)
{
- return false; // Cancel
+ return false;
}
if (result == 2)
{
- return SaveFile (); // Yes
+ return SaveFile ();
}
- return true; // No — discard
+ return true;
}
void NewFile ()
@@ -278,7 +543,13 @@ void NewFile ()
editor.ClearSelection ();
editor.Document = new TextDocument ();
editor.CaretOffset = 0;
+ editor.HighlightingDefinition = null;
+ InstallFolding ();
UpdateModifiedIndicator ();
+ UpdateLanguageShortcut ();
+ filenameShortcut.Title = "";
+ isMarkdownFile = false;
+ UpdatePreviewEnabled ();
}
void OpenFile ()
@@ -324,7 +595,7 @@ void QuitEditor ()
window.RequestStop ();
}
- // --- Clipboard helpers (Editor doesn't have built-in clipboard commands) ---
+ // --- Clipboard helpers ---
void Paste ()
{
@@ -371,9 +642,111 @@ void Cut ()
editor.ReplaceSelection (string.Empty);
}
+ // --- Find/Replace ---
+
+ void ShowFindReplace (bool showReplace = false)
+ {
+ FindReplaceDialog dlg = new (editor, showReplace);
+ app.Run (dlg);
+ dlg.Dispose ();
+ }
+
+ // --- Edit menu items (reusable for context menu) ---
+
+ MenuItem[] CreateEditMenuItems () =>
+ [
+ new () { Title = "_Undo", Key = Key.Z.WithCtrl, Action = () => editor.Document?.UndoStack.Undo () },
+ new () { Title = "_Redo", Key = Key.Y.WithCtrl, Action = () => editor.Document?.UndoStack.Redo () },
+ null!,
+ new () { Title = "Cu_t", Key = Key.X.WithCtrl, Action = Cut },
+ new () { Title = "_Copy", Key = Key.C.WithCtrl, Action = Copy },
+ new () { Title = "_Paste", Key = Key.V.WithCtrl, Action = Paste },
+ null!,
+ new () { Title = "Select _All", Key = Key.A.WithCtrl, Action = () => editor.SelectAll () },
+ ];
+
+ // --- About dialog ---
+
+ void ShowAbout ()
+ {
+ string editorVersion = VersionInfo.GetAssemblyVersion (
+ typeof (Editor).Assembly, "unknown");
+
+ Dialog about = new ()
+ {
+ Title = "About clet edit",
+ Width = Dim.Percent (50),
+ Height = 12,
+ };
+
+ Label info = new ()
+ {
+ X = 1,
+ Y = 0,
+ Width = Dim.Fill (1),
+ Text = $"""
+ clet {VersionInfo.GetCletVersion ()}
+ Terminal.Gui {VersionInfo.GetTerminalGuiVersion ()}
+ Terminal.Gui.Editor {editorVersion}
+
+ https://github.com/gui-cs/clet
+ """,
+ };
+
+ Button ok = new () { Text = "OK", X = Pos.Center (), Y = Pos.Bottom (info) + 1, IsDefault = true };
+ ok.Accepting += (_, _) => about.RequestStop ();
+ about.Add (info, ok);
+ app.Run (about);
+ about.Dispose ();
+ }
+
+ // --- Settings dialog ---
+
+ void ShowSettings ()
+ {
+ EditorSettingsDialog dlg = new (editor);
+ app.Run (dlg);
+
+ if (dlg.WasAccepted)
+ {
+ dlg.ApplyTo (editor);
+ SaveViewSettings ();
+ }
+
+ dlg.Dispose ();
+ }
+
+ // --- View menu toggle state ---
+
+ bool optLineNumbers = EditorSettings.LineNumbers;
+ bool optFoldIndicators = EditorSettings.FoldIndicators;
+ bool optWordWrap = EditorSettings.WordWrap;
+ bool optShowTabs = EditorSettings.ShowTabs;
+
+ void UpdateGutterOptions ()
+ {
+ GutterOptions g = GutterOptions.None;
+
+ if (optLineNumbers)
+ {
+ g |= GutterOptions.LineNumbers;
+ }
+
+ if (optFoldIndicators)
+ {
+ g |= GutterOptions.Folding;
+ }
+
+ editor.GutterOptions = g;
+ }
+
+ string ToggleTitle (bool on, string label) => on ? $"✓ {label}" : $" {label}";
+
// --- MenuBar ---
- MenuBar menu = new ();
+ MenuBar menu = new () { AlignmentModes = AlignmentModes.IgnoreFirstOrLast };
+
+ filenameShortcut.Accepting += (_, _) => OpenFile ();
menu.Add (new MenuBarItem ("_File",
[
@@ -381,22 +754,141 @@ void Cut ()
new MenuItem { Title = "_Open", Key = Key.O.WithCtrl, Action = OpenFile },
new MenuItem { Title = "_Save", Key = Key.S.WithCtrl, Action = () => SaveFile () },
new MenuItem { Title = "Save _As", Action = () => SaveAs () },
- null!, // separator
+ null!,
new MenuItem { Title = "_Quit", Key = Key.Q.WithCtrl, Action = QuitEditor },
]));
menu.Add (new MenuBarItem ("_Edit",
[
- new MenuItem { Title = "_Undo", Key = Key.Z.WithCtrl, Action = () => editor.Document?.UndoStack.Undo () },
- new MenuItem { Title = "_Redo", Key = Key.Y.WithCtrl, Action = () => editor.Document?.UndoStack.Redo () },
- null!, // separator
- new MenuItem { Title = "Cu_t", Key = Key.X.WithCtrl, Action = Cut },
- new MenuItem { Title = "_Copy", Key = Key.C.WithCtrl, Action = Copy },
- new MenuItem { Title = "_Paste", Key = Key.V.WithCtrl, Action = Paste },
- null!, // separator
- new MenuItem { Title = "Select _All", Key = Key.A.WithCtrl, Action = () => editor.SelectAll () },
+ new MenuItem { Title = "_Find...", Key = Key.F.WithCtrl, Action = () => ShowFindReplace () },
+ new MenuItem { Title = "_Replace...", Key = Key.H.WithCtrl, Action = () => ShowFindReplace (true) },
+ null!,
+ .. CreateEditMenuItems (),
+ ]));
+
+ // --- View menu ---
+
+ MenuItem viewLineNumbersItem = new () { Title = ToggleTitle (optLineNumbers, "_Line Numbers") };
+ MenuItem viewFoldIndicatorsItem = new () { Title = ToggleTitle (optFoldIndicators, "_Fold Indicators") };
+ MenuItem viewWordWrapItem = new () { Title = ToggleTitle (optWordWrap, "_Word Wrap") };
+ MenuItem viewShowTabsItem = new () { Title = ToggleTitle (optShowTabs, "Show _Tabs") };
+ MenuItem viewUseThemeBgItem = new () { Title = ToggleTitle (optUseThemeBg, "Use _Theme Background") };
+
+ void SaveViewSettings ()
+ {
+ EditorSettings.LineNumbers = optLineNumbers;
+ EditorSettings.FoldIndicators = optFoldIndicators;
+ EditorSettings.WordWrap = optWordWrap;
+ EditorSettings.ShowTabs = optShowTabs;
+ EditorSettings.UseThemeBackground = optUseThemeBg;
+ EditorSettings.IndentSize = editor.IndentationSize;
+ EditorSettings.ConvertTabsToSpaces = editor.ConvertTabsToSpaces;
+ EditorSettings.AutoIndent = editor.IndentationStrategy is not null;
+ EditorSettings.Save ();
+ }
+
+ viewLineNumbersItem.Action = () =>
+ {
+ optLineNumbers = !optLineNumbers;
+ viewLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers");
+ UpdateGutterOptions ();
+ SaveViewSettings ();
+ };
+
+ viewFoldIndicatorsItem.Action = () =>
+ {
+ optFoldIndicators = !optFoldIndicators;
+ viewFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators");
+ UpdateGutterOptions ();
+ SaveViewSettings ();
+ };
+
+ viewWordWrapItem.Action = () =>
+ {
+ optWordWrap = !optWordWrap;
+ viewWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap");
+ editor.WordWrap = optWordWrap;
+ SaveViewSettings ();
+ };
+
+ viewShowTabsItem.Action = () =>
+ {
+ optShowTabs = !optShowTabs;
+ viewShowTabsItem.Title = ToggleTitle (optShowTabs, "Show _Tabs");
+ editor.ShowTabs = optShowTabs;
+ SaveViewSettings ();
+ };
+
+ viewUseThemeBgItem.Action = () =>
+ {
+ optUseThemeBg = !optUseThemeBg;
+ viewUseThemeBgItem.Title = ToggleTitle (optUseThemeBg, "Use _Theme Background");
+ editor.UseThemeBackground = optUseThemeBg;
+
+ if (markdownPreview is not null)
+ {
+ markdownPreview.UseThemeBackground = optUseThemeBg;
+ }
+
+ SaveViewSettings ();
+ };
+
+ previewMarkdownItem.Action = () =>
+ {
+ if (isMarkdownFile)
+ {
+ ToggleMarkdownPreview ();
+ }
+ };
+
+ menu.Add (new MenuBarItem ("_View",
+ [
+ viewLineNumbersItem,
+ viewFoldIndicatorsItem,
+ viewWordWrapItem,
+ viewShowTabsItem,
+ null!,
+ previewMarkdownItem,
+ null!,
+ viewUseThemeBgItem,
]));
+ // --- Options menu ---
+
+ menu.Add (new MenuBarItem ("_Options",
+ [
+ new MenuItem { Title = "_Settings...", Action = ShowSettings },
+ ]));
+
+ menu.Add (new MenuBarItem ("_Help",
+ [
+ new MenuItem { Title = "_About", Action = ShowAbout },
+ ]),
+ filenameShortcut);
+
+ // --- Right-click context menu ---
+
+ PopoverMenu contextMenu = new (CreateEditMenuItems ())
+ {
+ Target = new WeakReference (editor),
+ };
+
+ editor.MouseEvent += (_, e) =>
+ {
+ if (!e.Flags.HasFlag (MouseFlags.RightButtonClicked))
+ {
+ return;
+ }
+
+ contextMenu.MakeVisible (e.ScreenPosition);
+ e.Handled = true;
+ };
+
+ // --- Wire find/replace events ---
+
+ editor.FindRequested += (_, _) => ShowFindReplace ();
+ editor.ReplaceRequested += (_, _) => ShowFindReplace (true);
+
// --- Wire events ---
editor.CaretChanged += (_, _) =>
@@ -412,8 +904,8 @@ void Cut ()
new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", QuitEditor),
new Shortcut (Key.F2, "Open", OpenFile),
new Shortcut (Key.F3, "Save", () => SaveFile ()),
- modifiedShortcut,
cursorPositionShortcut,
+ languageShortcut,
];
// File selector: dropdown when multiple files, plain label otherwise
@@ -421,8 +913,6 @@ void Cut ()
if (files.Count > 1)
{
- // Use basenames when they are all distinct; fall back to relative paths
- // so that files like a/readme.md and b/readme.md get unique labels.
List basenames = [.. files.Select (f => Path.GetFileName (f) ?? f)];
bool hasCollisions = basenames.Count != basenames.Distinct (StringComparer.OrdinalIgnoreCase).Count ();
string cwd = Directory.GetCurrentDirectory ();
@@ -449,8 +939,6 @@ void Cut ()
return;
}
- // Use the unique display name list — since labels are guaranteed distinct,
- // IndexOf is unambiguous even when files share the same basename.
int index = displayNames.IndexOf (fileSelector.Text);
if (index < 0 || index >= files.Count)
@@ -460,7 +948,6 @@ void Cut ()
if (!PromptSaveIfDirty ())
{
- // Revert dropdown to current file
switchingFile = true;
int currentIndex = filePath is not null ? files.IndexOf (filePath) : -1;
@@ -479,14 +966,6 @@ void Cut ()
statusItems.Add (new Shortcut () { CommandView = fileSelector, HelpText = "File" });
}
- else
- {
- Shortcut fileInfoShortcut = new ()
- { Title = fileName ?? "Untitled", MouseHighlightStates = MouseState.None, Enabled = false };
-
- // UpdateTitle needs to update this shortcut
- statusItems.Add (fileInfoShortcut);
- }
StatusBar statusBar = new (statusItems)
{ AlignmentModes = AlignmentModes.StartToEnd | AlignmentModes.IgnoreFirstOrLast };
@@ -505,19 +984,20 @@ void Cut ()
}
else if (filePath is not null)
{
- // File doesn't exist yet — treat as new file at that path
savedText = string.Empty;
editor.Document = new TextDocument ();
+ InstallFolding ();
UpdateModifiedIndicator ();
}
else if (content is not null)
{
- // Piped content via --initial
editor.Document = new TextDocument (content);
- savedText = string.Empty; // Mark as unsaved
+ savedText = string.Empty;
+ InstallFolding ();
UpdateModifiedIndicator ();
}
+ UpdateLanguageShortcut ();
editor.SetFocus ();
};
diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs
new file mode 100644
index 0000000..4fd8824
--- /dev/null
+++ b/src/Clet/Clets/Viewer/EditorSettings.cs
@@ -0,0 +1,197 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Terminal.Gui.App;
+using Terminal.Gui.Configuration;
+
+namespace Clet;
+
+///
+/// Persisted settings for the editor clet. Each property is discovered by
+/// via
+/// and is loaded automatically from ~/.tui/clet.config.json.
+///
+internal static class EditorSettings
+{
+ // --- View toggles ---
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool LineNumbers { get; set; } = true;
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool FoldIndicators { get; set; } = true;
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool WordWrap { get; set; }
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool ShowTabs { get; set; }
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool UseThemeBackground { get; set; }
+
+ // --- Tab settings ---
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static int IndentSize { get; set; } = 4;
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool ConvertTabsToSpaces { get; set; } = true;
+
+ [ConfigurationProperty (Scope = typeof (SettingsScope))]
+ public static bool AutoIndent { get; set; }
+
+ ///
+ /// All keys managed by this class. Used for selective persistence.
+ ///
+ private static readonly string[] _keys =
+ [
+ "EditorSettings.LineNumbers",
+ "EditorSettings.FoldIndicators",
+ "EditorSettings.WordWrap",
+ "EditorSettings.ShowTabs",
+ "EditorSettings.UseThemeBackground",
+ "EditorSettings.IndentSize",
+ "EditorSettings.ConvertTabsToSpaces",
+ "EditorSettings.AutoIndent",
+ ];
+
+ ///
+ /// Saves current property values to ~/.tui/clet.config.json,
+ /// preserving all JSONC content (comments, formatting, non-editor keys).
+ /// After writing, reloads so that in-memory
+ /// state matches the persisted file.
+ ///
+ internal static void Save () => Save (ConfigClet.GetConfigPath ());
+
+ ///
+ /// Saves current property values to the specified config file path,
+ /// preserving all JSONC content (comments, formatting, non-editor keys).
+ ///
+ internal static void Save (string path)
+ {
+ ConfigClet.EnsureConfigFile (path);
+
+ try
+ {
+ string text = File.ReadAllText (path);
+
+ // Build key → JSON-value pairs for each managed setting.
+ Dictionary entries = new ()
+ {
+ ["EditorSettings.LineNumbers"] = ToJson (LineNumbers),
+ ["EditorSettings.FoldIndicators"] = ToJson (FoldIndicators),
+ ["EditorSettings.WordWrap"] = ToJson (WordWrap),
+ ["EditorSettings.ShowTabs"] = ToJson (ShowTabs),
+ ["EditorSettings.UseThemeBackground"] = ToJson (UseThemeBackground),
+ ["EditorSettings.IndentSize"] = IndentSize.ToString (),
+ ["EditorSettings.ConvertTabsToSpaces"] = ToJson (ConvertTabsToSpaces),
+ ["EditorSettings.AutoIndent"] = ToJson (AutoIndent),
+ };
+
+ List toInsert = [];
+
+ foreach (KeyValuePair kvp in entries)
+ {
+ // Replace an existing key in-place (preserves surrounding JSONC).
+ // The negative lookbehind skips keys inside JSONC line comments.
+ // Only matches bool and int values (all current EditorSettings types).
+ string pattern = $@"(? 0)
+ {
+ int lastBrace = text.LastIndexOf ('}');
+
+ if (lastBrace >= 0)
+ {
+ // Find the position of the last non-whitespace, non-comment character
+ // before the closing brace so we can insert a comma after it.
+ int insertCommaAfter = FindLastJsonTokenPosition (text, lastBrace);
+
+ if (insertCommaAfter >= 0 && text[insertCommaAfter] != ',' && text[insertCommaAfter] != '{')
+ {
+ // Insert comma after the last JSON value
+ text = text.Insert (insertCommaAfter + 1, ",");
+
+ // Adjust lastBrace since we inserted a character
+ lastBrace = text.LastIndexOf ('}');
+ }
+
+ string insertion = $"\n\n{string.Join (",\n", toInsert)}\n";
+ text = text.Insert (lastBrace, insertion);
+ }
+ }
+
+ File.WriteAllText (path, text);
+
+ // Sync ConfigurationManager so in-memory state matches the file.
+ if (ConfigurationManager.IsEnabled)
+ {
+ ConfigurationManager.Load (ConfigLocations.All);
+ ConfigurationManager.Apply ();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logging.Error ($"EditorSettings.Save: {ex.GetType ().Name}: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Returns the keys managed by this class. Useful for testing.
+ ///
+ internal static IReadOnlyList ManagedKeys => _keys;
+
+ /// Converts a boolean to its JSON literal.
+ private static string ToJson (bool value) => value ? "true" : "false";
+
+ ///
+ /// Finds the position of the last non-whitespace, non-comment character
+ /// before . This is where a trailing comma
+ /// should be inserted when appending new properties.
+ /// Returns -1 if only whitespace/comments precede the brace.
+ ///
+ private static int FindLastJsonTokenPosition (string text, int braceIndex)
+ {
+ int i = braceIndex - 1;
+
+ while (i >= 0)
+ {
+ char c = text[i];
+
+ if (char.IsWhiteSpace (c))
+ {
+ i--;
+
+ continue;
+ }
+
+ // Check if we're at the end of a line comment.
+ // Walk back to find if this line starts with "//".
+ int lineStart = text.LastIndexOf ('\n', i) + 1;
+ string line = text[lineStart..(i + 1)].TrimStart ();
+
+ if (line.StartsWith ("//", StringComparison.Ordinal))
+ {
+ // This entire line is a comment — skip to before it.
+ i = lineStart - 1;
+
+ continue;
+ }
+
+ return i;
+ }
+
+ return -1;
+ }
+}
diff --git a/src/Clet/Clets/Viewer/EditorSettingsDialog.cs b/src/Clet/Clets/Viewer/EditorSettingsDialog.cs
new file mode 100644
index 0000000..0342916
--- /dev/null
+++ b/src/Clet/Clets/Viewer/EditorSettingsDialog.cs
@@ -0,0 +1,124 @@
+using Terminal.Gui.App;
+using Terminal.Gui.Editor;
+using Terminal.Gui.Input;
+using Terminal.Gui.Text.Indentation;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace Clet;
+
+///
+/// Settings dialog for the editor clet. Provides tabs for Config and Tab Settings.
+///
+internal sealed class EditorSettingsDialog : Dialog
+{
+ private readonly NumericUpDown _indentSize;
+ private readonly CheckBox _convertTabsCheck;
+ private readonly CheckBox _autoIndentCheck;
+
+ internal bool WasAccepted { get; private set; }
+
+ internal EditorSettingsDialog (Editor editor)
+ {
+ Title = "Settings";
+ Width = Dim.Percent (60);
+ Height = 16;
+
+ // --- Tab Settings tab ---
+ View tabSettingsTab = new ()
+ {
+ Title = "_Tab Settings",
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ };
+
+ _indentSize = new ()
+ {
+ X = 20,
+ Y = 1,
+ Value = editor.IndentationSize,
+ Width = 8,
+ };
+
+ _convertTabsCheck = new ()
+ {
+ X = 1,
+ Y = 3,
+ Title = "Con_vert Tabs to Spaces",
+ Value = editor.ConvertTabsToSpaces ? CheckState.Checked : CheckState.UnChecked,
+ };
+
+ _autoIndentCheck = new ()
+ {
+ X = 1,
+ Y = 5,
+ Title = "_Auto Indent",
+ Value = editor.IndentationStrategy is not null ? CheckState.Checked : CheckState.UnChecked,
+ };
+
+ tabSettingsTab.Add (
+ new Label () { X = 1, Y = 1, Text = "_Indent size:" },
+ _indentSize,
+ _convertTabsCheck,
+ _autoIndentCheck);
+
+ // --- Config tab (empty for now) ---
+ View configTab = new ()
+ {
+ Title = "_Config",
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ };
+
+ configTab.Add (new Label () { X = 1, Y = 1, Text = "No settings yet." });
+
+ // --- Tabs ---
+ Tabs tabs = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (2),
+ };
+
+ tabs.InsertTab (0, configTab);
+ tabs.InsertTab (1, tabSettingsTab);
+
+ Button okBtn = new ()
+ {
+ Text = "OK",
+ X = Pos.Center () - 6,
+ Y = Pos.Bottom (tabs),
+ IsDefault = true,
+ };
+
+ Button cancelBtn = new ()
+ {
+ Text = "Cancel",
+ X = Pos.Right (okBtn) + 2,
+ Y = Pos.Bottom (tabs),
+ };
+
+ okBtn.Accepting += (_, _) =>
+ {
+ WasAccepted = true;
+ RequestStop ();
+ };
+
+ cancelBtn.Accepting += (_, _) => RequestStop ();
+
+ Add (tabs, okBtn, cancelBtn);
+ }
+
+ ///
+ /// Applies the accepted settings to the editor. Call only when is true.
+ ///
+ internal void ApplyTo (Editor editor)
+ {
+ editor.IndentationSize = _indentSize.Value;
+ editor.ConvertTabsToSpaces = _convertTabsCheck.Value == CheckState.Checked;
+ editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked
+ ? new DefaultIndentationStrategy ()
+ : null;
+ }
+}
diff --git a/src/Clet/Clets/Viewer/FindReplaceDialog.cs b/src/Clet/Clets/Viewer/FindReplaceDialog.cs
new file mode 100644
index 0000000..5e5c446
--- /dev/null
+++ b/src/Clet/Clets/Viewer/FindReplaceDialog.cs
@@ -0,0 +1,200 @@
+using Terminal.Gui.App;
+using Terminal.Gui.Document.Search;
+using Terminal.Gui.Editor;
+using Terminal.Gui.Input;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
+
+namespace Clet;
+
+internal sealed class FindReplaceDialog : Dialog
+{
+ private readonly Editor _editor;
+ private readonly TextField _findField;
+ private readonly TextField _replaceField;
+ private readonly CheckBox _matchCase;
+ private readonly CheckBox _wholeWord;
+ private readonly CheckBox _regex;
+ private readonly Label _statusLabel;
+
+ internal FindReplaceDialog (Editor editor, bool showReplace = false)
+ {
+ _editor = editor;
+ Title = "Find and Replace";
+ Width = Dim.Percent (60);
+ Height = 14;
+
+ _findField = new () { X = 12, Y = 0, Width = Dim.Fill (1) };
+ _replaceField = new () { X = 12, Y = 0, Width = Dim.Fill (1) };
+ _matchCase = new () { Title = "Match _case" };
+ _wholeWord = new () { Title = "_Whole word" };
+ _regex = new () { Title = "Re_gex" };
+ _statusLabel = new () { X = 0, Width = Dim.Fill (), Text = "" };
+
+ // Pre-populate from editor selection
+ if (_editor.HasSelection)
+ {
+ int start = Math.Min (_editor.SelectionStart, _editor.SelectionEnd);
+ int end = Math.Max (_editor.SelectionStart, _editor.SelectionEnd);
+ string selected = _editor.Document?.Text?.Substring (start, end - start) ?? "";
+
+ if (!selected.Contains ('\n'))
+ {
+ _findField.Text = selected;
+ }
+ }
+
+ // Find tab content
+ Label findLabel = new () { Text = "_Find what:", X = 0, Y = 0 };
+
+ View findTab = new ()
+ {
+ Title = "Find",
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ };
+ findTab.Add (findLabel, _findField);
+
+ // Replace tab content
+ Label replaceLabel = new () { Text = "_Replace:", X = 0, Y = 0 };
+
+ View replaceTab = new ()
+ {
+ Title = "Replace",
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ };
+ replaceTab.Add (replaceLabel, _replaceField);
+
+ // Tabs
+ Tabs tabs = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = 3,
+ };
+ tabs.InsertTab (0, findTab);
+ tabs.InsertTab (1, replaceTab);
+
+ if (showReplace)
+ {
+ tabs.Value = replaceTab;
+ }
+
+ // Buttons
+ Button findNextBtn = new () { Text = "Find _Next", IsDefault = true };
+ findNextBtn.Accepting += (_, _) => DoFind (forward: true);
+
+ Button findPrevBtn = new () { Text = "Find _Previous" };
+ findPrevBtn.Accepting += (_, _) => DoFind (forward: false);
+
+ Button replaceBtn = new () { Text = "_Replace" };
+ replaceBtn.Accepting += (_, _) => DoReplace ();
+
+ Button replaceAllBtn = new () { Text = "Replace _All" };
+ replaceAllBtn.Accepting += (_, _) => DoReplaceAll ();
+
+ // Layout below tabs
+ View controlsArea = new ()
+ {
+ X = 0,
+ Y = Pos.Bottom (tabs),
+ Width = Dim.Fill (),
+ Height = 5,
+ };
+
+ _matchCase.X = 0;
+ _matchCase.Y = 0;
+ _wholeWord.X = Pos.Right (_matchCase) + 2;
+ _wholeWord.Y = 0;
+ _regex.X = Pos.Right (_wholeWord) + 2;
+ _regex.Y = 0;
+
+ findNextBtn.X = 0;
+ findNextBtn.Y = 1;
+ findPrevBtn.X = Pos.Right (findNextBtn) + 1;
+ findPrevBtn.Y = 1;
+ replaceBtn.X = Pos.Right (findPrevBtn) + 1;
+ replaceBtn.Y = 1;
+ replaceAllBtn.X = Pos.Right (replaceBtn) + 1;
+ replaceAllBtn.Y = 1;
+ _statusLabel.Y = 2;
+
+ controlsArea.Add (_matchCase, _wholeWord, _regex);
+ controlsArea.Add (findNextBtn, findPrevBtn, replaceBtn, replaceAllBtn);
+ controlsArea.Add (_statusLabel);
+
+ Button closeBtn = new () { Text = "Close", X = Pos.Center (), Y = Pos.Bottom (controlsArea) };
+ closeBtn.Accepting += (_, _) => RequestStop ();
+
+ Add (tabs, controlsArea, closeBtn);
+ }
+
+ private void ApplySearchStrategy ()
+ {
+ string searchText = _findField.Text ?? "";
+
+ if (string.IsNullOrEmpty (searchText))
+ {
+ _editor.SearchStrategy = null;
+
+ return;
+ }
+
+ bool ignoreCase = _matchCase.Value != CheckState.Checked;
+ bool wholeWord = _wholeWord.Value == CheckState.Checked;
+ SearchMode mode = _regex.Value == CheckState.Checked ? SearchMode.RegEx : SearchMode.Normal;
+
+ try
+ {
+ _editor.SearchStrategy = SearchStrategyFactory.Create (searchText, ignoreCase, wholeWord, mode);
+ _statusLabel.Text = "";
+ }
+ catch (SearchPatternException ex)
+ {
+ _statusLabel.Text = $"Pattern error: {ex.Message}";
+ _editor.SearchStrategy = null;
+ }
+ }
+
+ private void DoFind (bool forward)
+ {
+ ApplySearchStrategy ();
+
+ if (_editor.SearchStrategy is null)
+ {
+ return;
+ }
+
+ bool found = forward ? _editor.FindNext (true) : _editor.FindPrevious (true);
+ _statusLabel.Text = found ? "" : "No match";
+ }
+
+ private void DoReplace ()
+ {
+ ApplySearchStrategy ();
+
+ if (_editor.SearchStrategy is null)
+ {
+ return;
+ }
+
+ string replacement = _replaceField.Text ?? "";
+ _editor.ReplaceNext (replacement, true);
+ }
+
+ private void DoReplaceAll ()
+ {
+ ApplySearchStrategy ();
+
+ if (_editor.SearchStrategy is null)
+ {
+ return;
+ }
+
+ string replacement = _replaceField.Text ?? "";
+ int count = _editor.ReplaceAll (replacement);
+ _statusLabel.Text = count > 0 ? $"Replaced {count} occurrences" : "No match";
+ }
+}
diff --git a/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs
new file mode 100644
index 0000000..d3cbf58
--- /dev/null
+++ b/tests/Clet.UnitTests/EditorSettingsTests.cs
@@ -0,0 +1,408 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Terminal.Gui.Configuration;
+using Xunit;
+
+namespace Clet.UnitTests;
+
+///
+/// Defines a non-parallel collection for tests that interact with
+/// , which uses global static state.
+///
+[CollectionDefinition (nameof (ConfigurationManagerCollection), DisableParallelization = true)]
+public class ConfigurationManagerCollection;
+
+///
+/// Tests for round-tripping through
+/// .
+///
+[Collection (nameof (ConfigurationManagerCollection))]
+public class EditorSettingsTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _configPath;
+ private readonly string? _originalHome;
+
+ public EditorSettingsTests ()
+ {
+ _tempDir = Path.Combine (Path.GetTempPath (), $"clet-test-{Guid.NewGuid ():N}");
+ string tuiDir = Path.Combine (_tempDir, ".tui");
+ Directory.CreateDirectory (tuiDir);
+ _configPath = Path.Combine (tuiDir, ConfigClet.ConfigFileName);
+
+ // Save original HOME so we can restore it on cleanup.
+ _originalHome = Environment.GetEnvironmentVariable ("HOME");
+
+ // Point HOME at our temp directory (used by Save's CM reload on Linux).
+ Environment.SetEnvironmentVariable ("HOME", _tempDir);
+
+ // Ensure CM uses the "clet" app name (matches the clet binary; in tests
+ // the assembly name is different).
+ ConfigurationManager.AppName = "clet";
+ }
+
+ public void Dispose ()
+ {
+ // Reset CM so other tests start clean.
+ try
+ {
+ ConfigurationManager.Disable (resetToHardCodedDefaults: true);
+ }
+ catch
+ {
+ // Best-effort cleanup.
+ }
+
+ // Restore original HOME.
+ Environment.SetEnvironmentVariable ("HOME", _originalHome);
+
+ if (Directory.Exists (_tempDir))
+ {
+ Directory.Delete (_tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void ManagedKeys_ContainsAllProperties ()
+ {
+ Assert.Contains ("EditorSettings.LineNumbers", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.FoldIndicators", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.WordWrap", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.ShowTabs", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.UseThemeBackground", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.IndentSize", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.ConvertTabsToSpaces", EditorSettings.ManagedKeys);
+ Assert.Contains ("EditorSettings.AutoIndent", EditorSettings.ManagedKeys);
+ Assert.Equal (8, EditorSettings.ManagedKeys.Count);
+ }
+
+ [Fact]
+ public void ConfigurationManager_Discovers_EditorSettings ()
+ {
+ ConfigurationManager.Enable (ConfigLocations.None);
+
+ foreach (string key in EditorSettings.ManagedKeys)
+ {
+ Assert.True (
+ ConfigurationManager.Settings!.Keys.Contains (key),
+ $"ConfigurationManager.Settings should contain '{key}'");
+ }
+ }
+
+ [Fact]
+ public void Save_WritesAllKeys_ToConfigFile ()
+ {
+ // Arrange — set known values
+ EditorSettings.LineNumbers = false;
+ EditorSettings.FoldIndicators = false;
+ EditorSettings.WordWrap = true;
+ EditorSettings.ShowTabs = true;
+ EditorSettings.UseThemeBackground = true;
+ EditorSettings.IndentSize = 2;
+ EditorSettings.ConvertTabsToSpaces = false;
+ EditorSettings.AutoIndent = true;
+
+ // Write a minimal config file so Save can insert into it
+ File.WriteAllText (_configPath, "{}");
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — parse the written file and check values
+ string json = File.ReadAllText (_configPath);
+
+ JsonNode? root = JsonNode.Parse (
+ json,
+ documentOptions: new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+
+ Assert.NotNull (root);
+ JsonObject obj = Assert.IsType (root);
+
+ Assert.False ((bool)obj["EditorSettings.LineNumbers"]!);
+ Assert.False ((bool)obj["EditorSettings.FoldIndicators"]!);
+ Assert.True ((bool)obj["EditorSettings.WordWrap"]!);
+ Assert.True ((bool)obj["EditorSettings.ShowTabs"]!);
+ Assert.True ((bool)obj["EditorSettings.UseThemeBackground"]!);
+ Assert.Equal (2, (int)obj["EditorSettings.IndentSize"]!);
+ Assert.False ((bool)obj["EditorSettings.ConvertTabsToSpaces"]!);
+ Assert.True ((bool)obj["EditorSettings.AutoIndent"]!);
+ }
+
+ [Fact]
+ public void Save_PreservesExistingKeys ()
+ {
+ // Arrange
+ File.WriteAllText (
+ _configPath,
+ """
+ {
+ "$schema": "https://example.com/schema.json",
+ "Theme": "Dark"
+ }
+ """);
+
+ EditorSettings.LineNumbers = true;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert
+ string json = File.ReadAllText (_configPath);
+
+ JsonNode? root = JsonNode.Parse (
+ json,
+ documentOptions: new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+
+ Assert.NotNull (root);
+ JsonObject obj = Assert.IsType (root);
+
+ Assert.Equal ("https://example.com/schema.json", (string)obj["$schema"]!);
+ Assert.Equal ("Dark", (string)obj["Theme"]!);
+ Assert.True ((bool)obj["EditorSettings.LineNumbers"]!);
+ }
+
+ [Fact]
+ public void Save_PreservesJsoncComments ()
+ {
+ // Arrange — write a JSONC file with comments
+ string jsonc =
+ """
+ {
+ // This is a comment
+ "$schema": "https://example.com/schema.json",
+
+ // Theme configuration
+ // "Theme": "Anders",
+
+ "Key.Separator": "+"
+ }
+ """;
+ File.WriteAllText (_configPath, jsonc);
+
+ EditorSettings.LineNumbers = false;
+ EditorSettings.IndentSize = 2;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — JSONC comments and existing keys are preserved
+ string result = File.ReadAllText (_configPath);
+
+ Assert.Contains ("// This is a comment", result);
+ Assert.Contains ("// Theme configuration", result);
+ Assert.Contains ("// \"Theme\": \"Anders\",", result);
+ Assert.Contains ("\"Key.Separator\": \"+\"", result);
+ Assert.Contains ("\"$schema\": \"https://example.com/schema.json\"", result);
+ Assert.Contains ("\"EditorSettings.LineNumbers\": false", result);
+ Assert.Contains ("\"EditorSettings.IndentSize\": 2", result);
+ }
+
+ [Fact]
+ public void Save_PreservesDefaultConfigContent ()
+ {
+ // Arrange — use the full ConfigClet default JSONC template
+ File.WriteAllText (_configPath, ConfigClet.DefaultConfigContent);
+
+ EditorSettings.LineNumbers = false;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — the original JSONC structure is intact
+ string result = File.ReadAllText (_configPath);
+
+ Assert.Contains ("clet configuration", result);
+ Assert.Contains ("Terminal.Gui's ConfigurationManager", result);
+ Assert.Contains ("$schema", result);
+ Assert.Contains ("\"EditorSettings.LineNumbers\": false", result);
+ }
+
+ [Fact]
+ public void Save_UpdatesExistingEditorSettingsKeys ()
+ {
+ // Arrange — write a file that already has EditorSettings keys
+ File.WriteAllText (
+ _configPath,
+ """
+ {
+ // comments
+ "EditorSettings.LineNumbers": true,
+ "EditorSettings.IndentSize": 4
+ }
+ """);
+
+ EditorSettings.LineNumbers = false;
+ EditorSettings.IndentSize = 8;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — existing keys are updated in place
+ string result = File.ReadAllText (_configPath);
+
+ Assert.Contains ("// comments", result);
+ Assert.Contains ("\"EditorSettings.LineNumbers\": false", result);
+ Assert.Contains ("\"EditorSettings.IndentSize\": 8", result);
+
+ // Verify no duplicate keys
+ Assert.Equal (1, CountOccurrences (result, "EditorSettings.LineNumbers"));
+ Assert.Equal (1, CountOccurrences (result, "EditorSettings.IndentSize"));
+ }
+
+ [Fact]
+ public void Save_DoesNotModifyCommentedOutKeys ()
+ {
+ // Arrange — file has a commented-out EditorSettings key
+ File.WriteAllText (
+ _configPath,
+ """
+ {
+ // "EditorSettings.LineNumbers": true,
+ "EditorSettings.IndentSize": 4
+ }
+ """);
+
+ EditorSettings.LineNumbers = false;
+ EditorSettings.IndentSize = 2;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — commented-out key is untouched, active key is updated
+ string result = File.ReadAllText (_configPath);
+
+ Assert.Contains ("// \"EditorSettings.LineNumbers\": true,", result);
+ Assert.Contains ("\"EditorSettings.IndentSize\": 2", result);
+ Assert.Contains ("\"EditorSettings.LineNumbers\": false", result);
+ }
+
+ [Fact]
+ public void RoundTrip_LoadApply_RestoresPersistedValues ()
+ {
+ // Arrange — JSON with non-default values
+ string json = """
+ {
+ "EditorSettings.LineNumbers": false,
+ "EditorSettings.IndentSize": 8,
+ "EditorSettings.WordWrap": true,
+ "EditorSettings.AutoIndent": true
+ }
+ """;
+
+ // Reset to defaults first
+ EditorSettings.LineNumbers = true;
+ EditorSettings.IndentSize = 4;
+ EditorSettings.WordWrap = false;
+ EditorSettings.AutoIndent = false;
+
+ // Act — load via RuntimeConfig (cross-platform; avoids ~ resolution
+ // issues on Windows where GetFolderPath ignores env var changes).
+ ConfigurationManager.RuntimeConfig = json;
+ ConfigurationManager.Enable (ConfigLocations.Runtime);
+
+ // Assert
+ Assert.False (EditorSettings.LineNumbers);
+ Assert.Equal (8, EditorSettings.IndentSize);
+ Assert.True (EditorSettings.WordWrap);
+ Assert.True (EditorSettings.AutoIndent);
+ }
+
+ [Fact]
+ public void RoundTrip_SaveThenLoad_RestoresValues ()
+ {
+ // Arrange — write initial config, set non-default values, save
+ File.WriteAllText (_configPath, "{}");
+
+ EditorSettings.LineNumbers = false;
+ EditorSettings.FoldIndicators = false;
+ EditorSettings.IndentSize = 3;
+ EditorSettings.ConvertTabsToSpaces = false;
+ EditorSettings.Save (_configPath);
+
+ // Reset in-memory to defaults
+ EditorSettings.LineNumbers = true;
+ EditorSettings.FoldIndicators = true;
+ EditorSettings.IndentSize = 4;
+ EditorSettings.ConvertTabsToSpaces = true;
+
+ // Act — load saved file via RuntimeConfig (cross-platform).
+ string savedJson = File.ReadAllText (_configPath);
+ ConfigurationManager.RuntimeConfig = savedJson;
+ ConfigurationManager.Enable (ConfigLocations.Runtime);
+
+ // Assert — values should match what we saved
+ Assert.False (EditorSettings.LineNumbers);
+ Assert.False (EditorSettings.FoldIndicators);
+ Assert.Equal (3, EditorSettings.IndentSize);
+ Assert.False (EditorSettings.ConvertTabsToSpaces);
+ }
+
+ [Fact]
+ public void Save_CreatesConfigFile_WhenMissing ()
+ {
+ // Arrange — no config file exists yet
+ Assert.False (File.Exists (_configPath));
+
+ EditorSettings.IndentSize = 6;
+
+ // Act
+ EditorSettings.Save (_configPath);
+
+ // Assert — file was created and contains the setting
+ Assert.True (File.Exists (_configPath));
+
+ string json = File.ReadAllText (_configPath);
+
+ JsonNode? root = JsonNode.Parse (
+ json,
+ documentOptions: new JsonDocumentOptions
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ });
+
+ Assert.NotNull (root);
+ Assert.Equal (6, (int)root!["EditorSettings.IndentSize"]!);
+ }
+
+ [Fact]
+ public void Defaults_AreCorrect ()
+ {
+ // Reset CM so properties are at hard-coded defaults
+ ConfigurationManager.Enable (ConfigLocations.None);
+ ConfigurationManager.Load (ConfigLocations.HardCoded);
+ ConfigurationManager.Apply ();
+
+ Assert.True (EditorSettings.LineNumbers);
+ Assert.True (EditorSettings.FoldIndicators);
+ Assert.False (EditorSettings.WordWrap);
+ Assert.False (EditorSettings.ShowTabs);
+ Assert.False (EditorSettings.UseThemeBackground);
+ Assert.Equal (4, EditorSettings.IndentSize);
+ Assert.True (EditorSettings.ConvertTabsToSpaces);
+ Assert.False (EditorSettings.AutoIndent);
+ }
+
+ /// Counts the number of occurrences of in .
+ private static int CountOccurrences (string text, string substring)
+ {
+ int count = 0;
+ int index = 0;
+
+ while ((index = text.IndexOf (substring, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += substring.Length;
+ }
+
+ return count;
+ }
+}