From f373ddfaa91b1af7ff0b51d3502bc591f558d143 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 19:04:39 +0000 Subject: [PATCH 01/13] Initial plan From 6bdd3422692ba8854e6e4db6497b44e5b4672887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 19:25:37 +0000 Subject: [PATCH 02/13] feat(edit): port ted editor features into EditorClet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Find/Replace dialog, Options menu, code folding, language display, indentation spinner, show-tabs toggle, markdown preview, right-click context menu, filename shortcut on menubar, multi-caret count, Help/About dialog, and Find/Replace event wiring. New file: FindReplaceDialog.cs — modeless find/replace with regex support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 382 ++++++++++++++++++--- src/Clet/Clets/Viewer/FindReplaceDialog.cs | 200 +++++++++++ 2 files changed, 539 insertions(+), 43 deletions(-) create mode 100644 src/Clet/Clets/Viewer/FindReplaceDialog.cs diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index dcd7b38..6748f37 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -1,12 +1,15 @@ using System.Collections.ObjectModel; using Terminal.Gui.App; -using Terminal.Gui.Drawing; -using Terminal.Gui.Input; 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 +65,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; @@ -103,11 +102,11 @@ public async Task RunAsync ( 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, + GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding, ConvertTabsToSpaces = true, }; @@ -115,11 +114,96 @@ public async Task RunAsync ( ? 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; + CheckBox? previewCheckBox = null; + bool isMarkdownFile = filePath is not null + && Path.GetExtension (filePath).Equals (".md", StringComparison.OrdinalIgnoreCase); + + void UpdatePreviewVisibility () + { + bool show = previewCheckBox?.Value == CheckState.Checked && isMarkdownFile; + + if (show) + { + if (markdownPreview is null) + { + markdownPreview = new Markdown () + { + X = Pos.Percent (50), + Y = 1, + Width = Dim.Fill (), + Height = Dim.Fill (1), + SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus), + }; + + markdownPreview.Text = editor.Document?.Text ?? ""; + + editor.Document!.Changed += (_, _) => + { + if (markdownPreview.Visible) + { + markdownPreview.Text = editor.Document?.Text ?? ""; + } + }; + + window.Add (markdownPreview); + } + + editor.Width = Dim.Percent (50); + markdownPreview.Visible = true; + markdownPreview.Text = editor.Document?.Text ?? ""; + } + else + { + editor.Width = Dim.Fill (); + + if (markdownPreview is not null) + { + markdownPreview.Visible = false; + } + } + } + // --- 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 + Shortcut filenameShortcut = new () + { + Title = fileName ?? "", + MouseHighlightStates = MouseState.None, + }; // --- Local state helpers --- @@ -132,9 +216,15 @@ void UpdateModifiedIndicator () 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 +234,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 +263,17 @@ void LoadFile (string path) savedText = string.Empty; editor.Document = new TextDocument (); UpdateSyntaxLanguage (fullPath); + InstallFolding (); UpdateModifiedIndicator (); + filenameShortcut.Title = fileName; + isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); + + if (previewCheckBox is not null) + { + previewCheckBox.Visible = isMarkdownFile; + } + + UpdatePreviewVisibility (); return; } @@ -180,7 +287,17 @@ void LoadFile (string path) editor.Document = new TextDocument (text); editor.CaretOffset = 0; UpdateSyntaxLanguage (fullPath); + InstallFolding (); UpdateModifiedIndicator (); + filenameShortcut.Title = fileName; + isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); + + if (previewCheckBox is not null) + { + previewCheckBox.Visible = isMarkdownFile; + } + + UpdatePreviewVisibility (); } bool SaveFile () @@ -254,15 +371,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 +395,19 @@ void NewFile () editor.ClearSelection (); editor.Document = new TextDocument (); editor.CaretOffset = 0; + editor.HighlightingDefinition = null; + InstallFolding (); UpdateModifiedIndicator (); + UpdateLanguageShortcut (); + filenameShortcut.Title = ""; + isMarkdownFile = false; + + if (previewCheckBox is not null) + { + previewCheckBox.Visible = false; + } + + UpdatePreviewVisibility (); } void OpenFile () @@ -324,7 +453,7 @@ void QuitEditor () window.RequestStop (); } - // --- Clipboard helpers (Editor doesn't have built-in clipboard commands) --- + // --- Clipboard helpers --- void Paste () { @@ -371,9 +500,95 @@ 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 (); + } + + // --- Options menu state --- + + bool optLineNumbers = true; + bool optFoldIndicators = true; + bool optConvertTabs = true; + bool optAutoIndent = false; + bool optUseThemeBg = false; + bool optWordWrap = false; + + void UpdateGutterOptions () + { + GutterOptions g = GutterOptions.None; + + if (optLineNumbers) + { + g |= GutterOptions.LineNumbers; + } + + if (optFoldIndicators) + { + g |= GutterOptions.Folding; + } + + editor.GutterOptions = g; + } + // --- MenuBar --- - MenuBar menu = new (); + MenuBar menu = new () { AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; + + filenameShortcut.Accepting += (_, _) => OpenFile (); menu.Add (new MenuBarItem ("_File", [ @@ -381,22 +596,100 @@ 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 = "_Find...", Key = Key.F.WithCtrl, Action = () => ShowFindReplace () }, + new MenuItem { Title = "_Replace...", Key = Key.H.WithCtrl, Action = () => ShowFindReplace (true) }, + null!, new MenuItem { Title = "_Quit", Key = Key.Q.WithCtrl, Action = QuitEditor }, ])); - menu.Add (new MenuBarItem ("_Edit", + menu.Add (new MenuBarItem ("_Edit", CreateEditMenuItems ())); + + // Options menu items with toggle titles + MenuItem optLineNumbersItem = new () { Title = "✓ _Line Numbers" }; + MenuItem optFoldIndicatorsItem = new () { Title = "✓ _Fold Indicators" }; + MenuItem optConvertTabsItem = new () { Title = "✓ _Convert Tabs To Spaces" }; + MenuItem optAutoIndentItem = new () { Title = " _Auto Indent" }; + MenuItem optUseThemeBgItem = new () { Title = " Use _Theme Background" }; + MenuItem optWordWrapItem = new () { Title = " _Word Wrap" }; + + string ToggleTitle (bool on, string label) => on ? $"✓ {label}" : $" {label}"; + + optLineNumbersItem.Action = () => + { + optLineNumbers = !optLineNumbers; + optLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers"); + UpdateGutterOptions (); + }; + + optFoldIndicatorsItem.Action = () => + { + optFoldIndicators = !optFoldIndicators; + optFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators"); + UpdateGutterOptions (); + }; + + optConvertTabsItem.Action = () => + { + optConvertTabs = !optConvertTabs; + optConvertTabsItem.Title = ToggleTitle (optConvertTabs, "_Convert Tabs To Spaces"); + editor.ConvertTabsToSpaces = optConvertTabs; + }; + + optAutoIndentItem.Action = () => + { + optAutoIndent = !optAutoIndent; + optAutoIndentItem.Title = ToggleTitle (optAutoIndent, "_Auto Indent"); + editor.IndentationStrategy = optAutoIndent ? new DefaultIndentationStrategy () : null; + }; + + optUseThemeBgItem.Action = () => + { + optUseThemeBg = !optUseThemeBg; + optUseThemeBgItem.Title = ToggleTitle (optUseThemeBg, "Use _Theme Background"); + editor.UseThemeBackground = optUseThemeBg; + }; + + optWordWrapItem.Action = () => + { + optWordWrap = !optWordWrap; + optWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap"); + editor.WordWrap = optWordWrap; + }; + + menu.Add (new MenuBarItem ("_Options", + [ + optLineNumbersItem, + optFoldIndicatorsItem, + optConvertTabsItem, + optAutoIndentItem, + optUseThemeBgItem, + optWordWrapItem, + ])); + + menu.Add (new MenuBarItem ("_Help", [ - 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 = "_About", Action = ShowAbout }, ])); + // --- Right-click context menu --- + + editor.MouseEvent += (_, e) => + { + if (!e.Flags.HasFlag (MouseFlags.RightButtonClicked)) + { + return; + } + + PopoverMenu contextMenu = new (CreateEditMenuItems ()); + contextMenu.Visible = true; + }; + + // --- Wire find/replace events --- + + editor.FindRequested += (_, _) => ShowFindReplace (); + editor.ReplaceRequested += (_, _) => ShowFindReplace (true); + // --- Wire events --- editor.CaretChanged += (_, _) => @@ -407,6 +700,16 @@ void Cut () // --- StatusBar --- + NumericUpDown indentSpinner = new () { Value = editor.IndentationSize, Width = 5 }; + indentSpinner.ValueChanged += (_, e) => editor.IndentationSize = e.NewValue; + + CheckBox showTabsCheck = new () { Title = "↹", Value = CheckState.UnChecked }; + showTabsCheck.ValueChanged += (_, e) => + editor.ShowTabs = e.NewValue == CheckState.Checked; + + previewCheckBox = new () { Title = "Preview", Value = CheckState.UnChecked, Visible = isMarkdownFile }; + previewCheckBox.ValueChanged += (_, _) => UpdatePreviewVisibility (); + List statusItems = [ new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", QuitEditor), @@ -414,6 +717,11 @@ void Cut () new Shortcut (Key.F3, "Save", () => SaveFile ()), modifiedShortcut, cursorPositionShortcut, + languageShortcut, + new () { CommandView = indentSpinner, HelpText = "Indent" }, + new () { CommandView = showTabsCheck, HelpText = "" }, + new () { CommandView = previewCheckBox, HelpText = "" }, + filenameShortcut, ]; // File selector: dropdown when multiple files, plain label otherwise @@ -421,8 +729,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 +755,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 +764,6 @@ void Cut () if (!PromptSaveIfDirty ()) { - // Revert dropdown to current file switchingFile = true; int currentIndex = filePath is not null ? files.IndexOf (filePath) : -1; @@ -479,14 +782,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 +800,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/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"; + } +} From 817ac795cebed5384f7eb291d275a010d1264a0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 19:30:08 +0000 Subject: [PATCH 03/13] fix: move Find/Replace to Edit menu, fix context menu, move filename to menubar Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/acc89057-5344-4a0e-b01d-378b30a3cadb Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index 6748f37..f38066d 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -597,14 +597,17 @@ void UpdateGutterOptions () new MenuItem { Title = "_Save", Key = Key.S.WithCtrl, Action = () => SaveFile () }, new MenuItem { Title = "Save _As", Action = () => SaveAs () }, null!, + new MenuItem { Title = "_Quit", Key = Key.Q.WithCtrl, Action = QuitEditor }, + ])); + + menu.Add (new MenuBarItem ("_Edit", + [ new MenuItem { Title = "_Find...", Key = Key.F.WithCtrl, Action = () => ShowFindReplace () }, new MenuItem { Title = "_Replace...", Key = Key.H.WithCtrl, Action = () => ShowFindReplace (true) }, null!, - new MenuItem { Title = "_Quit", Key = Key.Q.WithCtrl, Action = QuitEditor }, + .. CreateEditMenuItems (), ])); - menu.Add (new MenuBarItem ("_Edit", CreateEditMenuItems ())); - // Options menu items with toggle titles MenuItem optLineNumbersItem = new () { Title = "✓ _Line Numbers" }; MenuItem optFoldIndicatorsItem = new () { Title = "✓ _Fold Indicators" }; @@ -670,10 +673,16 @@ void UpdateGutterOptions () 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)) @@ -681,8 +690,8 @@ void UpdateGutterOptions () return; } - PopoverMenu contextMenu = new (CreateEditMenuItems ()); - contextMenu.Visible = true; + contextMenu.MakeVisible (e.ScreenPosition); + e.Handled = true; }; // --- Wire find/replace events --- @@ -721,7 +730,6 @@ void UpdateGutterOptions () new () { CommandView = indentSpinner, HelpText = "Indent" }, new () { CommandView = showTabsCheck, HelpText = "" }, new () { CommandView = previewCheckBox, HelpText = "" }, - filenameShortcut, ]; // File selector: dropdown when multiple files, plain label otherwise From 1d7a69b72e9655aa6c23c5e6bb4294d0b75765aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:16:34 +0000 Subject: [PATCH 04/13] refactor: reorganize menus/settings per review feedback - Add _View menu with Line Numbers, Fold Indicators, Word Wrap, Show Tabs, Preview Markdown (always visible, enabled only for .md), Use Theme Background - Options menu now contains only _Settings... which opens a tabbed dialog (_Config tab empty, _Tab Settings with indent size, convert tabs, auto indent) - Status bar simplified to: quit, open, save, loc, file type, file chooser - MenuBar filename shortcut shows full path with Dialog scheme - Add bidirectional proportional scroll sync between editor and md preview - Use Theme Background applies to both editor and markdown viewer - Extract EditorSettingsDialog to its own file Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/b910bf36-e8a1-47dd-8488-53095037abc0 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 350 ++++++++++++------ src/Clet/Clets/Viewer/EditorSettingsDialog.cs | 124 +++++++ 2 files changed, 364 insertions(+), 110 deletions(-) create mode 100644 src/Clet/Clets/Viewer/EditorSettingsDialog.cs diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index f38066d..52a0552 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -1,12 +1,12 @@ using System.Collections.ObjectModel; using Terminal.Gui.App; +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; @@ -108,6 +108,7 @@ public async Task RunAsync ( ReadOnly = readOnly, GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding, ConvertTabsToSpaces = true, + ViewportSettings = ViewportSettingsFlags.HasScrollBars, }; editor.HighlightingDefinition = filePath is not null @@ -141,52 +142,186 @@ void InstallFolding () // --- Markdown preview --- Markdown? markdownPreview = null; - CheckBox? previewCheckBox = null; + bool syncingScroll = false; bool isMarkdownFile = filePath is not null && Path.GetExtension (filePath).Equals (".md", StringComparison.OrdinalIgnoreCase); - void UpdatePreviewVisibility () + // View-menu toggle items — declared early so preview toggle can reference them. + MenuItem previewMarkdownItem = new () { Title = " _Preview Markdown", Enabled = isMarkdownFile }; + + bool optUseThemeBg = false; + + void OnEditorViewportChanged (object? sender, DrawEventArgs e) { - bool show = previewCheckBox?.Value == CheckState.Checked && isMarkdownFile; + if (markdownPreview is null || syncingScroll) + { + return; + } + + syncingScroll = true; - if (show) + try { - if (markdownPreview is null) - { - markdownPreview = new Markdown () - { - X = Pos.Percent (50), - Y = 1, - Width = Dim.Fill (), - Height = Dim.Fill (1), - SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus), - }; + int editorContentHeight = editor.GetContentSize ().Height; + int editorViewportHeight = editor.Viewport.Height; + int maxEditorY = Math.Max (0, editorContentHeight - editorViewportHeight); + int editorY = editor.Viewport.Y; - markdownPreview.Text = editor.Document?.Text ?? ""; + int previewContentHeight = markdownPreview.GetContentSize ().Height; + int previewViewportHeight = markdownPreview.Viewport.Height; + int maxPreviewY = Math.Max (0, previewContentHeight - previewViewportHeight); - editor.Document!.Changed += (_, _) => - { - if (markdownPreview.Visible) - { - markdownPreview.Text = editor.Document?.Text ?? ""; - } - }; + int newY = maxEditorY > 0 + ? (int)((long)editorY * maxPreviewY / maxEditorY) + : 0; - window.Add (markdownPreview); - } + 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 (); + } - editor.Width = Dim.Percent (50); - markdownPreview.Visible = true; - markdownPreview.Text = editor.Document?.Text ?? ""; + 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 { - editor.Width = Dim.Fill (); + ShowMarkdownPreview (); + previewMarkdownItem.Title = "✓ _Preview Markdown"; + } + } - if (markdownPreview is not null) - { - markdownPreview.Visible = false; - } + void UpdatePreviewEnabled () + { + previewMarkdownItem.Enabled = isMarkdownFile; + + if (!isMarkdownFile && markdownPreview is not null) + { + HideMarkdownPreview (); + previewMarkdownItem.Title = " _Preview Markdown"; + } + else if (isMarkdownFile && markdownPreview is not null) + { + RefreshPreviewDocument (); } } @@ -194,15 +329,15 @@ void UpdatePreviewVisibility () 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 + // Filename shortcut for MenuBar — full path, dialog scheme Shortcut filenameShortcut = new () { - Title = fileName ?? "", + Title = filePath ?? "", MouseHighlightStates = MouseState.None, + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog), }; // --- Local state helpers --- @@ -212,7 +347,6 @@ void UpdatePreviewVisibility () void UpdateModifiedIndicator () { bool dirty = UnsavedChanges (); - modifiedShortcut.Title = dirty ? "Modified" : ""; window.Title = dirty ? $"{fileName ?? "Untitled"}*" : fileName ?? "Untitled"; } @@ -265,15 +399,9 @@ void LoadFile (string path) UpdateSyntaxLanguage (fullPath); InstallFolding (); UpdateModifiedIndicator (); - filenameShortcut.Title = fileName; + filenameShortcut.Title = fullPath; isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); - - if (previewCheckBox is not null) - { - previewCheckBox.Visible = isMarkdownFile; - } - - UpdatePreviewVisibility (); + UpdatePreviewEnabled (); return; } @@ -289,15 +417,9 @@ void LoadFile (string path) UpdateSyntaxLanguage (fullPath); InstallFolding (); UpdateModifiedIndicator (); - filenameShortcut.Title = fileName; + filenameShortcut.Title = fullPath; isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); - - if (previewCheckBox is not null) - { - previewCheckBox.Visible = isMarkdownFile; - } - - UpdatePreviewVisibility (); + UpdatePreviewEnabled (); } bool SaveFile () @@ -401,13 +523,7 @@ void NewFile () UpdateLanguageShortcut (); filenameShortcut.Title = ""; isMarkdownFile = false; - - if (previewCheckBox is not null) - { - previewCheckBox.Visible = false; - } - - UpdatePreviewVisibility (); + UpdatePreviewEnabled (); } void OpenFile () @@ -558,14 +674,27 @@ void ShowAbout () about.Dispose (); } - // --- Options menu state --- + // --- Settings dialog --- + + void ShowSettings () + { + EditorSettingsDialog dlg = new (editor); + app.Run (dlg); + + if (dlg.WasAccepted) + { + dlg.ApplyTo (editor); + } + + dlg.Dispose (); + } + + // --- View menu toggle state --- bool optLineNumbers = true; bool optFoldIndicators = true; - bool optConvertTabs = true; - bool optAutoIndent = false; - bool optUseThemeBg = false; bool optWordWrap = false; + bool optShowTabs = false; void UpdateGutterOptions () { @@ -584,6 +713,8 @@ void UpdateGutterOptions () editor.GutterOptions = g; } + string ToggleTitle (bool on, string label) => on ? $"✓ {label}" : $" {label}"; + // --- MenuBar --- MenuBar menu = new () { AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; @@ -608,66 +739,79 @@ void UpdateGutterOptions () .. CreateEditMenuItems (), ])); - // Options menu items with toggle titles - MenuItem optLineNumbersItem = new () { Title = "✓ _Line Numbers" }; - MenuItem optFoldIndicatorsItem = new () { Title = "✓ _Fold Indicators" }; - MenuItem optConvertTabsItem = new () { Title = "✓ _Convert Tabs To Spaces" }; - MenuItem optAutoIndentItem = new () { Title = " _Auto Indent" }; - MenuItem optUseThemeBgItem = new () { Title = " Use _Theme Background" }; - MenuItem optWordWrapItem = new () { Title = " _Word Wrap" }; + // --- View menu --- - string ToggleTitle (bool on, string label) => on ? $"✓ {label}" : $" {label}"; + 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") }; - optLineNumbersItem.Action = () => + viewLineNumbersItem.Action = () => { optLineNumbers = !optLineNumbers; - optLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers"); + viewLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers"); UpdateGutterOptions (); }; - optFoldIndicatorsItem.Action = () => + viewFoldIndicatorsItem.Action = () => { optFoldIndicators = !optFoldIndicators; - optFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators"); + viewFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators"); UpdateGutterOptions (); }; - optConvertTabsItem.Action = () => + viewWordWrapItem.Action = () => { - optConvertTabs = !optConvertTabs; - optConvertTabsItem.Title = ToggleTitle (optConvertTabs, "_Convert Tabs To Spaces"); - editor.ConvertTabsToSpaces = optConvertTabs; + optWordWrap = !optWordWrap; + viewWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap"); + editor.WordWrap = optWordWrap; }; - optAutoIndentItem.Action = () => + viewShowTabsItem.Action = () => { - optAutoIndent = !optAutoIndent; - optAutoIndentItem.Title = ToggleTitle (optAutoIndent, "_Auto Indent"); - editor.IndentationStrategy = optAutoIndent ? new DefaultIndentationStrategy () : null; + optShowTabs = !optShowTabs; + viewShowTabsItem.Title = ToggleTitle (optShowTabs, "Show _Tabs"); + editor.ShowTabs = optShowTabs; }; - optUseThemeBgItem.Action = () => + viewUseThemeBgItem.Action = () => { optUseThemeBg = !optUseThemeBg; - optUseThemeBgItem.Title = ToggleTitle (optUseThemeBg, "Use _Theme Background"); + viewUseThemeBgItem.Title = ToggleTitle (optUseThemeBg, "Use _Theme Background"); editor.UseThemeBackground = optUseThemeBg; + + if (markdownPreview is not null) + { + markdownPreview.UseThemeBackground = optUseThemeBg; + } }; - optWordWrapItem.Action = () => + previewMarkdownItem.Action = () => { - optWordWrap = !optWordWrap; - optWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap"); - editor.WordWrap = optWordWrap; + if (isMarkdownFile) + { + ToggleMarkdownPreview (); + } }; + menu.Add (new MenuBarItem ("_View", + [ + viewLineNumbersItem, + viewFoldIndicatorsItem, + viewWordWrapItem, + viewShowTabsItem, + null!, + previewMarkdownItem, + null!, + viewUseThemeBgItem, + ])); + + // --- Options menu --- + menu.Add (new MenuBarItem ("_Options", [ - optLineNumbersItem, - optFoldIndicatorsItem, - optConvertTabsItem, - optAutoIndentItem, - optUseThemeBgItem, - optWordWrapItem, + new MenuItem { Title = "_Settings...", Action = ShowSettings }, ])); menu.Add (new MenuBarItem ("_Help", @@ -709,27 +853,13 @@ .. CreateEditMenuItems (), // --- StatusBar --- - NumericUpDown indentSpinner = new () { Value = editor.IndentationSize, Width = 5 }; - indentSpinner.ValueChanged += (_, e) => editor.IndentationSize = e.NewValue; - - CheckBox showTabsCheck = new () { Title = "↹", Value = CheckState.UnChecked }; - showTabsCheck.ValueChanged += (_, e) => - editor.ShowTabs = e.NewValue == CheckState.Checked; - - previewCheckBox = new () { Title = "Preview", Value = CheckState.UnChecked, Visible = isMarkdownFile }; - previewCheckBox.ValueChanged += (_, _) => UpdatePreviewVisibility (); - List statusItems = [ new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", QuitEditor), new Shortcut (Key.F2, "Open", OpenFile), new Shortcut (Key.F3, "Save", () => SaveFile ()), - modifiedShortcut, cursorPositionShortcut, languageShortcut, - new () { CommandView = indentSpinner, HelpText = "Indent" }, - new () { CommandView = showTabsCheck, HelpText = "" }, - new () { CommandView = previewCheckBox, HelpText = "" }, ]; // File selector: dropdown when multiple files, plain label otherwise 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; + } +} From 564716f254798ba1094b6073794d91fa35045bb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:32:29 +0000 Subject: [PATCH 05/13] feat: persist editor settings to ~/.tui/clet.config.json Settings are stored under a "clet.edit" key in the config file, preserving all existing Terminal.Gui configuration. View menu toggles save immediately; Settings dialog changes save on Accept. Settings loaded on editor startup. Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/b0517f76-2811-4e95-8c3f-b533f0b5c913 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 61 +++++++++++-- src/Clet/Clets/Viewer/EditorSettings.cs | 90 +++++++++++++++++++ .../Clets/Viewer/EditorSettingsJsonContext.cs | 9 ++ 3 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/Clet/Clets/Viewer/EditorSettings.cs create mode 100644 src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index 52a0552..6467be2 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -99,6 +99,10 @@ public async Task RunAsync ( BorderStyle = LineStyle.None, }; + // --- Load persisted settings --- + + EditorSettings settings = EditorSettings.Load (); + Editor editor = new () { X = 0, @@ -106,11 +110,34 @@ public async Task RunAsync ( Width = Dim.Fill (), Height = Dim.Fill (1), ReadOnly = readOnly, - GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding, - ConvertTabsToSpaces = true, + ConvertTabsToSpaces = settings.ConvertTabsToSpaces, + IndentationSize = settings.IndentSize, + WordWrap = settings.WordWrap, + ShowTabs = settings.ShowTabs, + UseThemeBackground = settings.UseThemeBackground, ViewportSettings = ViewportSettingsFlags.HasScrollBars, }; + // Apply gutter options from settings + GutterOptions initGutter = GutterOptions.None; + + if (settings.LineNumbers) + { + initGutter |= GutterOptions.LineNumbers; + } + + if (settings.FoldIndicators) + { + initGutter |= GutterOptions.Folding; + } + + editor.GutterOptions = initGutter; + + if (settings.AutoIndent) + { + editor.IndentationStrategy = new Terminal.Gui.Text.Indentation.DefaultIndentationStrategy (); + } + editor.HighlightingDefinition = filePath is not null ? HighlightingManager.Instance.GetDefinitionByExtension (Path.GetExtension (filePath)) : null; @@ -149,7 +176,7 @@ void InstallFolding () // View-menu toggle items — declared early so preview toggle can reference them. MenuItem previewMarkdownItem = new () { Title = " _Preview Markdown", Enabled = isMarkdownFile }; - bool optUseThemeBg = false; + bool optUseThemeBg = settings.UseThemeBackground; void OnEditorViewportChanged (object? sender, DrawEventArgs e) { @@ -684,6 +711,7 @@ void ShowSettings () if (dlg.WasAccepted) { dlg.ApplyTo (editor); + SaveViewSettings (); } dlg.Dispose (); @@ -691,10 +719,10 @@ void ShowSettings () // --- View menu toggle state --- - bool optLineNumbers = true; - bool optFoldIndicators = true; - bool optWordWrap = false; - bool optShowTabs = false; + bool optLineNumbers = settings.LineNumbers; + bool optFoldIndicators = settings.FoldIndicators; + bool optWordWrap = settings.WordWrap; + bool optShowTabs = settings.ShowTabs; void UpdateGutterOptions () { @@ -747,11 +775,25 @@ .. CreateEditMenuItems (), MenuItem viewShowTabsItem = new () { Title = ToggleTitle (optShowTabs, "Show _Tabs") }; MenuItem viewUseThemeBgItem = new () { Title = ToggleTitle (optUseThemeBg, "Use _Theme Background") }; + void SaveViewSettings () + { + settings.LineNumbers = optLineNumbers; + settings.FoldIndicators = optFoldIndicators; + settings.WordWrap = optWordWrap; + settings.ShowTabs = optShowTabs; + settings.UseThemeBackground = optUseThemeBg; + settings.IndentSize = editor.IndentationSize; + settings.ConvertTabsToSpaces = editor.ConvertTabsToSpaces; + settings.AutoIndent = editor.IndentationStrategy is not null; + settings.Save (); + } + viewLineNumbersItem.Action = () => { optLineNumbers = !optLineNumbers; viewLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers"); UpdateGutterOptions (); + SaveViewSettings (); }; viewFoldIndicatorsItem.Action = () => @@ -759,6 +801,7 @@ .. CreateEditMenuItems (), optFoldIndicators = !optFoldIndicators; viewFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators"); UpdateGutterOptions (); + SaveViewSettings (); }; viewWordWrapItem.Action = () => @@ -766,6 +809,7 @@ .. CreateEditMenuItems (), optWordWrap = !optWordWrap; viewWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap"); editor.WordWrap = optWordWrap; + SaveViewSettings (); }; viewShowTabsItem.Action = () => @@ -773,6 +817,7 @@ .. CreateEditMenuItems (), optShowTabs = !optShowTabs; viewShowTabsItem.Title = ToggleTitle (optShowTabs, "Show _Tabs"); editor.ShowTabs = optShowTabs; + SaveViewSettings (); }; viewUseThemeBgItem.Action = () => @@ -785,6 +830,8 @@ .. CreateEditMenuItems (), { markdownPreview.UseThemeBackground = optUseThemeBg; } + + SaveViewSettings (); }; previewMarkdownItem.Action = () => diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs new file mode 100644 index 0000000..021b05b --- /dev/null +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Clet; + +/// +/// Persisted settings for the editor clet. Stored under the "clet.edit" key +/// in ~/.tui/clet.config.json. +/// +internal sealed class EditorSettings +{ + /// The key used in the config JSON file. + private const string ConfigKey = "clet.edit"; + + // --- View toggles --- + + public bool LineNumbers { get; set; } = true; + public bool FoldIndicators { get; set; } = true; + public bool WordWrap { get; set; } + public bool ShowTabs { get; set; } + public bool UseThemeBackground { get; set; } + + // --- Tab settings --- + + public int IndentSize { get; set; } = 4; + public bool ConvertTabsToSpaces { get; set; } = true; + public bool AutoIndent { get; set; } + + /// + /// Loads editor settings from ~/.tui/clet.config.json. + /// Returns defaults if the file or section doesn't exist. + /// + internal static EditorSettings Load () + { + string path = ConfigClet.GetConfigPath (); + + if (!File.Exists (path)) + { + return new (); + } + + try + { + string json = File.ReadAllText (path); + JsonNode? root = JsonNode.Parse (json, documentOptions: new () { CommentHandling = JsonCommentHandling.Skip }); + + if (root is JsonObject obj && obj.TryGetPropertyValue (ConfigKey, out JsonNode? section) && section is not null) + { + EditorSettings? settings = section.Deserialize (EditorSettingsJsonContext.Default.EditorSettings); + + return settings ?? new (); + } + } + catch + { + // If the file is malformed, fall back to defaults. + } + + return new (); + } + + /// + /// Saves the current settings to ~/.tui/clet.config.json, + /// preserving all other keys in the file. + /// + internal void Save () + { + string path = ConfigClet.GetConfigPath (); + ConfigClet.EnsureConfigFile (path); + + try + { + string existing = File.ReadAllText (path); + JsonNode? root = JsonNode.Parse (existing, documentOptions: new () { CommentHandling = JsonCommentHandling.Skip }); + JsonObject obj = root as JsonObject ?? new (); + + // Serialize our settings to a JsonNode and merge into the root object. + JsonNode? settingsNode = JsonSerializer.SerializeToNode (this, EditorSettingsJsonContext.Default.EditorSettings); + obj[ConfigKey] = settingsNode; + + JsonSerializerOptions writeOptions = new () { WriteIndented = true }; + string output = obj.ToJsonString (writeOptions); + File.WriteAllText (path, output); + } + catch + { + // Best-effort persistence — don't crash the editor if the config file is locked, etc. + } + } +} diff --git a/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs b/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs new file mode 100644 index 0000000..3dd0f9f --- /dev/null +++ b/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Clet; + +[JsonSerializable (typeof (EditorSettings))] +[JsonSourceGenerationOptions ( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] +internal sealed partial class EditorSettingsJsonContext : JsonSerializerContext; From 4eb9505d62a171aed76aaa28643fdb1ae12a035c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:35:56 +0000 Subject: [PATCH 06/13] fix: add logging to EditorSettings catch blocks, use DefaultIndentationStrategy import Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/b0517f76-2811-4e95-8c3f-b533f0b5c913 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 3 ++- src/Clet/Clets/Viewer/EditorSettings.cs | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index 6467be2..27926df 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -7,6 +7,7 @@ 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; @@ -135,7 +136,7 @@ public async Task RunAsync ( if (settings.AutoIndent) { - editor.IndentationStrategy = new Terminal.Gui.Text.Indentation.DefaultIndentationStrategy (); + editor.IndentationStrategy = new DefaultIndentationStrategy (); } editor.HighlightingDefinition = filePath is not null diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs index 021b05b..5a5019f 100644 --- a/src/Clet/Clets/Viewer/EditorSettings.cs +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Terminal.Gui.App; namespace Clet; @@ -51,9 +52,9 @@ internal static EditorSettings Load () return settings ?? new (); } } - catch + catch (Exception ex) { - // If the file is malformed, fall back to defaults. + Logging.Error ($"EditorSettings.Load: {ex.GetType ().Name}: {ex.Message}"); } return new (); @@ -82,9 +83,9 @@ internal void Save () string output = obj.ToJsonString (writeOptions); File.WriteAllText (path, output); } - catch + catch (Exception ex) { - // Best-effort persistence — don't crash the editor if the config file is locked, etc. + Logging.Error ($"EditorSettings.Save: {ex.GetType ().Name}: {ex.Message}"); } } } From fd6417b7498c8faf1a7f0f84e8becd9b194a391f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:55:50 +0000 Subject: [PATCH 07/13] refactor: use ConfigurationManager for EditorSettings, add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the custom JSON serialization with [ConfigurationProperty] attributes discovered by Terminal.Gui's ConfigurationManager. Settings are now part of the standard CM pipeline — loaded automatically on Enable(All), saved to ~/.tui/clet.config.json as top-level keys (e.g. "EditorSettings.LineNumbers"). Adds 8 unit tests for EditorSettings covering: - CM discovery of all 8 properties - Save writes all keys to config file - Save preserves existing keys - Round-trip: load+apply restores persisted values - Round-trip: save then load restores values - Save creates config file when missing - Defaults are correct - ManagedKeys list is complete Tests use [Collection] with DisableParallelization since CM uses global state. Removes EditorSettingsJsonContext.cs (no longer needed). Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/d6419ce6-10b7-49a0-be44-eea8e25080b3 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorClet.cs | 48 ++-- src/Clet/Clets/Viewer/EditorSettings.cs | 120 +++++---- .../Clets/Viewer/EditorSettingsJsonContext.cs | 9 - tests/Clet.UnitTests/EditorSettingsTests.cs | 255 ++++++++++++++++++ 4 files changed, 347 insertions(+), 85 deletions(-) delete mode 100644 src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs create mode 100644 tests/Clet.UnitTests/EditorSettingsTests.cs diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index 27926df..e473f93 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -100,9 +100,7 @@ public async Task RunAsync ( BorderStyle = LineStyle.None, }; - // --- Load persisted settings --- - - EditorSettings settings = EditorSettings.Load (); + // --- Settings are loaded by ConfigurationManager via [ConfigurationProperty] --- Editor editor = new () { @@ -111,30 +109,30 @@ public async Task RunAsync ( Width = Dim.Fill (), Height = Dim.Fill (1), ReadOnly = readOnly, - ConvertTabsToSpaces = settings.ConvertTabsToSpaces, - IndentationSize = settings.IndentSize, - WordWrap = settings.WordWrap, - ShowTabs = settings.ShowTabs, - UseThemeBackground = settings.UseThemeBackground, + 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 (settings.LineNumbers) + if (EditorSettings.LineNumbers) { initGutter |= GutterOptions.LineNumbers; } - if (settings.FoldIndicators) + if (EditorSettings.FoldIndicators) { initGutter |= GutterOptions.Folding; } editor.GutterOptions = initGutter; - if (settings.AutoIndent) + if (EditorSettings.AutoIndent) { editor.IndentationStrategy = new DefaultIndentationStrategy (); } @@ -177,7 +175,7 @@ void InstallFolding () // View-menu toggle items — declared early so preview toggle can reference them. MenuItem previewMarkdownItem = new () { Title = " _Preview Markdown", Enabled = isMarkdownFile }; - bool optUseThemeBg = settings.UseThemeBackground; + bool optUseThemeBg = EditorSettings.UseThemeBackground; void OnEditorViewportChanged (object? sender, DrawEventArgs e) { @@ -720,10 +718,10 @@ void ShowSettings () // --- View menu toggle state --- - bool optLineNumbers = settings.LineNumbers; - bool optFoldIndicators = settings.FoldIndicators; - bool optWordWrap = settings.WordWrap; - bool optShowTabs = settings.ShowTabs; + bool optLineNumbers = EditorSettings.LineNumbers; + bool optFoldIndicators = EditorSettings.FoldIndicators; + bool optWordWrap = EditorSettings.WordWrap; + bool optShowTabs = EditorSettings.ShowTabs; void UpdateGutterOptions () { @@ -778,15 +776,15 @@ .. CreateEditMenuItems (), void SaveViewSettings () { - settings.LineNumbers = optLineNumbers; - settings.FoldIndicators = optFoldIndicators; - settings.WordWrap = optWordWrap; - settings.ShowTabs = optShowTabs; - settings.UseThemeBackground = optUseThemeBg; - settings.IndentSize = editor.IndentationSize; - settings.ConvertTabsToSpaces = editor.ConvertTabsToSpaces; - settings.AutoIndent = editor.IndentationStrategy is not null; - settings.Save (); + 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 = () => diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs index 5a5019f..301ff25 100644 --- a/src/Clet/Clets/Viewer/EditorSettings.cs +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -1,91 +1,109 @@ using System.Text.Json; using System.Text.Json.Nodes; using Terminal.Gui.App; +using Terminal.Gui.Configuration; namespace Clet; /// -/// Persisted settings for the editor clet. Stored under the "clet.edit" key -/// in ~/.tui/clet.config.json. +/// Persisted settings for the editor clet. Each property is discovered by +/// via +/// and is loaded automatically from ~/.tui/clet.config.json. /// -internal sealed class EditorSettings +internal static class EditorSettings { - /// The key used in the config JSON file. - private const string ConfigKey = "clet.edit"; - // --- View toggles --- - public bool LineNumbers { get; set; } = true; - public bool FoldIndicators { get; set; } = true; - public bool WordWrap { get; set; } - public bool ShowTabs { get; set; } - public bool UseThemeBackground { get; set; } + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool LineNumbers { get; set; } = true; - // --- Tab settings --- + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool FoldIndicators { get; set; } = true; - public int IndentSize { get; set; } = 4; - public bool ConvertTabsToSpaces { get; set; } = true; - public bool AutoIndent { get; set; } + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool WordWrap { get; set; } - /// - /// Loads editor settings from ~/.tui/clet.config.json. - /// Returns defaults if the file or section doesn't exist. - /// - internal static EditorSettings Load () - { - string path = ConfigClet.GetConfigPath (); + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool ShowTabs { get; set; } - if (!File.Exists (path)) - { - return new (); - } + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool UseThemeBackground { get; set; } - try - { - string json = File.ReadAllText (path); - JsonNode? root = JsonNode.Parse (json, documentOptions: new () { CommentHandling = JsonCommentHandling.Skip }); + // --- Tab settings --- - if (root is JsonObject obj && obj.TryGetPropertyValue (ConfigKey, out JsonNode? section) && section is not null) - { - EditorSettings? settings = section.Deserialize (EditorSettingsJsonContext.Default.EditorSettings); + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static int IndentSize { get; set; } = 4; - return settings ?? new (); - } - } - catch (Exception ex) - { - Logging.Error ($"EditorSettings.Load: {ex.GetType ().Name}: {ex.Message}"); - } + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool ConvertTabsToSpaces { get; set; } = true; - return new (); - } + [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 the current settings to ~/.tui/clet.config.json, + /// Saves current property values to ~/.tui/clet.config.json, /// preserving all other keys in the file. /// - internal void Save () + internal static void Save () => Save (ConfigClet.GetConfigPath ()); + + /// + /// Saves current property values to the specified config file path, + /// preserving all other keys in the file. + /// + internal static void Save (string path) { - string path = ConfigClet.GetConfigPath (); ConfigClet.EnsureConfigFile (path); try { string existing = File.ReadAllText (path); - JsonNode? root = JsonNode.Parse (existing, documentOptions: new () { CommentHandling = JsonCommentHandling.Skip }); + + JsonNode? root = JsonNode.Parse ( + existing, + documentOptions: new () + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + JsonObject obj = root as JsonObject ?? new (); - // Serialize our settings to a JsonNode and merge into the root object. - JsonNode? settingsNode = JsonSerializer.SerializeToNode (this, EditorSettingsJsonContext.Default.EditorSettings); - obj[ConfigKey] = settingsNode; + // Write each managed key into the config JSON + obj["EditorSettings.LineNumbers"] = LineNumbers; + obj["EditorSettings.FoldIndicators"] = FoldIndicators; + obj["EditorSettings.WordWrap"] = WordWrap; + obj["EditorSettings.ShowTabs"] = ShowTabs; + obj["EditorSettings.UseThemeBackground"] = UseThemeBackground; + obj["EditorSettings.IndentSize"] = IndentSize; + obj["EditorSettings.ConvertTabsToSpaces"] = ConvertTabsToSpaces; + obj["EditorSettings.AutoIndent"] = AutoIndent; JsonSerializerOptions writeOptions = new () { WriteIndented = true }; - string output = obj.ToJsonString (writeOptions); - File.WriteAllText (path, output); + File.WriteAllText (path, obj.ToJsonString (writeOptions)); } 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; } diff --git a/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs b/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs deleted file mode 100644 index 3dd0f9f..0000000 --- a/src/Clet/Clets/Viewer/EditorSettingsJsonContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Clet; - -[JsonSerializable (typeof (EditorSettings))] -[JsonSourceGenerationOptions ( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.Never)] -internal sealed partial class EditorSettingsJsonContext : JsonSerializerContext; diff --git a/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs new file mode 100644 index 0000000..dbeb420 --- /dev/null +++ b/tests/Clet.UnitTests/EditorSettingsTests.cs @@ -0,0 +1,255 @@ +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; + + 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); + + // Point HOME at our temp directory so CM picks up our test config file. + 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 HOME. + Environment.SetEnvironmentVariable ("HOME", null); + + 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 merge into it + File.WriteAllText (_configPath, "{}"); + + // Act + EditorSettings.Save (_configPath); + + // Assert + string json = File.ReadAllText (_configPath); + JsonNode? root = JsonNode.Parse (json); + 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); + 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 RoundTrip_LoadApply_RestoresPersistedValues () + { + // Arrange — write a config file with non-default values + File.WriteAllText ( + _configPath, + """ + { + "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 — enable CM and load + apply + ConfigurationManager.Enable (ConfigLocations.All); + ConfigurationManager.Load (ConfigLocations.All); + ConfigurationManager.Apply (); + + // 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 + apply via CM + ConfigurationManager.Enable (ConfigLocations.All); + ConfigurationManager.Load (ConfigLocations.All); + ConfigurationManager.Apply (); + + // 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 (by EnsureConfigFile + overwritten by Save) + Assert.True (File.Exists (_configPath)); + + string json = File.ReadAllText (_configPath); + + // Save writes pure JSON (JSONC comments/trailing commas are stripped during round-trip) + JsonNode? root = JsonNode.Parse (json); + + 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); + } +} From 81978050bbd6beb64241c6d83ce97e73f67d90dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 21:13:21 +0000 Subject: [PATCH 08/13] fix: preserve JSONC content in EditorSettings.Save, reload CM after save EditorSettings.Save() now uses text-based key insertion/replacement instead of JsonNode.Parse + ToJsonString, which was stripping JSONC comments from ~/.tui/clet.config.json. The new approach: - Updates existing EditorSettings keys in-place via regex replacement - Inserts new keys before the closing } with proper comma handling - Preserves all JSONC comments, formatting, and non-EditorSettings keys - After writing, reloads ConfigurationManager (Load + Apply) to sync state Added 3 new tests: - Save_PreservesJsoncComments: verifies comments survive a save - Save_PreservesDefaultConfigContent: verifies ConfigClet.DefaultConfigContent survives - Save_UpdatesExistingEditorSettingsKeys: verifies in-place updates without duplicates Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/450a79e0-24ac-4d67-937f-0befd24e11c4 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorSettings.cs | 139 ++++++++++++++++---- tests/Clet.UnitTests/EditorSettingsTests.cs | 138 ++++++++++++++++++- 2 files changed, 244 insertions(+), 33 deletions(-) diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs index 301ff25..ba99000 100644 --- a/src/Clet/Clets/Viewer/EditorSettings.cs +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Terminal.Gui.App; using Terminal.Gui.Configuration; @@ -10,7 +10,7 @@ namespace Clet; /// via /// and is loaded automatically from ~/.tui/clet.config.json. /// -internal static class EditorSettings +internal static partial class EditorSettings { // --- View toggles --- @@ -57,13 +57,15 @@ internal static class EditorSettings /// /// Saves current property values to ~/.tui/clet.config.json, - /// preserving all other keys in the file. + /// 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 other keys in the file. + /// preserving all JSONC content (comments, formatting, non-editor keys). /// internal static void Save (string path) { @@ -71,30 +73,72 @@ internal static void Save (string path) try { - string existing = File.ReadAllText (path); + 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) + { + // Try to replace an existing key in-place (preserves surrounding JSONC). + // The regex matches: "key" : on non-comment lines. + string pattern = $@"(""{Regex.Escape (kvp.Key)}""\s*:\s*)(?:true|false|\d+)"; + + if (Regex.IsMatch (text, pattern)) + { + text = Regex.Replace (text, pattern, $"${{1}}{kvp.Value}"); + } + else + { + toInsert.Add ($" \"{kvp.Key}\": {kvp.Value}"); + } + } + + // Insert new keys (not previously in the file) before the last closing '}'. + if (toInsert.Count > 0) + { + int lastBrace = text.LastIndexOf ('}'); - JsonNode? root = JsonNode.Parse ( - existing, - documentOptions: new () + if (lastBrace >= 0) { - CommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }); - - JsonObject obj = root as JsonObject ?? new (); - - // Write each managed key into the config JSON - obj["EditorSettings.LineNumbers"] = LineNumbers; - obj["EditorSettings.FoldIndicators"] = FoldIndicators; - obj["EditorSettings.WordWrap"] = WordWrap; - obj["EditorSettings.ShowTabs"] = ShowTabs; - obj["EditorSettings.UseThemeBackground"] = UseThemeBackground; - obj["EditorSettings.IndentSize"] = IndentSize; - obj["EditorSettings.ConvertTabsToSpaces"] = ConvertTabsToSpaces; - obj["EditorSettings.AutoIndent"] = AutoIndent; - - JsonSerializerOptions writeOptions = new () { WriteIndented = true }; - File.WriteAllText (path, obj.ToJsonString (writeOptions)); + // 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) { @@ -106,4 +150,47 @@ internal static void Save (string path) /// 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/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs index dbeb420..65a57f7 100644 --- a/tests/Clet.UnitTests/EditorSettingsTests.cs +++ b/tests/Clet.UnitTests/EditorSettingsTests.cs @@ -98,15 +98,23 @@ public void Save_WritesAllKeys_ToConfigFile () EditorSettings.ConvertTabsToSpaces = false; EditorSettings.AutoIndent = true; - // Write a minimal config file so Save can merge into it + // Write a minimal config file so Save can insert into it File.WriteAllText (_configPath, "{}"); // Act EditorSettings.Save (_configPath); - // Assert + // Assert — parse the written file and check values string json = File.ReadAllText (_configPath); - JsonNode? root = JsonNode.Parse (json); + + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + Assert.NotNull (root); JsonObject obj = Assert.IsType (root); @@ -140,7 +148,15 @@ public void Save_PreservesExistingKeys () // Assert string json = File.ReadAllText (_configPath); - JsonNode? root = JsonNode.Parse (json); + + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + Assert.NotNull (root); JsonObject obj = Assert.IsType (root); @@ -149,6 +165,94 @@ public void Save_PreservesExistingKeys () 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 RoundTrip_LoadApply_RestoresPersistedValues () { @@ -223,13 +327,18 @@ public void Save_CreatesConfigFile_WhenMissing () // Act EditorSettings.Save (_configPath); - // Assert — file was created (by EnsureConfigFile + overwritten by Save) + // Assert — file was created and contains the setting Assert.True (File.Exists (_configPath)); string json = File.ReadAllText (_configPath); - // Save writes pure JSON (JSONC comments/trailing commas are stripped during round-trip) - JsonNode? root = JsonNode.Parse (json); + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); Assert.NotNull (root); Assert.Equal (6, (int)root!["EditorSettings.IndentSize"]!); @@ -252,4 +361,19 @@ public void Defaults_AreCorrect () 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; + } } From 185a8c2e8cca4486256e8a3b832b7005945c5d44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 21:15:53 +0000 Subject: [PATCH 09/13] fix: skip commented-out keys in regex, remove unnecessary partial modifier Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/450a79e0-24ac-4d67-937f-0befd24e11c4 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorSettings.cs | 9 ++++--- tests/Clet.UnitTests/EditorSettingsTests.cs | 27 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs index ba99000..990f9fb 100644 --- a/src/Clet/Clets/Viewer/EditorSettings.cs +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -10,7 +10,7 @@ namespace Clet; /// via /// and is loaded automatically from ~/.tui/clet.config.json. /// -internal static partial class EditorSettings +internal static class EditorSettings { // --- View toggles --- @@ -92,9 +92,10 @@ internal static void Save (string path) foreach (KeyValuePair kvp in entries) { - // Try to replace an existing key in-place (preserves surrounding JSONC). - // The regex matches: "key" : on non-comment lines. - string pattern = $@"(""{Regex.Escape (kvp.Key)}""\s*:\s*)(?:true|false|\d+)"; + // 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 = $@"(? Date: Fri, 15 May 2026 12:07:38 +0000 Subject: [PATCH 10/13] fix: handle negative values in config regex, restore original HOME in tests - EditorSettings.Save regex now matches -?\d+ instead of \d+ to handle negative numeric values that may appear from manual config edits. - EditorSettingsTests now saves/restores the original HOME env var instead of unconditionally clearing it in Dispose(). Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/8d0ce91c-4ff0-461b-951d-86a2ca0bef25 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Clet/Clets/Viewer/EditorSettings.cs | 2 +- tests/Clet.UnitTests/EditorSettingsTests.cs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs index 990f9fb..4fd8824 100644 --- a/src/Clet/Clets/Viewer/EditorSettings.cs +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -95,7 +95,7 @@ internal static void Save (string path) // 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 = $@"(? Date: Fri, 15 May 2026 12:43:18 +0000 Subject: [PATCH 11/13] fix: update Terminal.Gui version to 2.1.1-develop.* to match Editor dependency Terminal.Gui.Editor 2.1.1-develop.87 requires Terminal.Gui >= 2.1.1-develop.59. The previous pin of 2.1.0-rc.* caused NU1605 package downgrade errors. Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/270b8e6c-afbe-4b6c-ad9c-305fb207cc85 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.* From 8a9f646f64bb8089052a7a9fcca3bc24bae907e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 13:05:16 +0000 Subject: [PATCH 12/13] fix: use TUI_CONFIG env var in round-trip tests for Windows compatibility On Windows, Environment.GetFolderPath(SpecialFolder.UserProfile) uses native APIs that don't respect env var changes, so CM's ~ resolution doesn't find our test config. Use TUI_CONFIG env var with ConfigLocations.Env to load directly from the test config path instead. Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/a3d70dd7-9c5e-4c54-a1fa-83d283f93c69 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- tests/Clet.UnitTests/EditorSettingsTests.cs | 68 ++++++++++++++------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs index 1953256..bae4f3d 100644 --- a/tests/Clet.UnitTests/EditorSettingsTests.cs +++ b/tests/Clet.UnitTests/EditorSettingsTests.cs @@ -22,6 +22,7 @@ public class EditorSettingsTests : IDisposable private readonly string _tempDir; private readonly string _configPath; private readonly string? _originalHome; + private readonly string? _originalUserProfile; public EditorSettingsTests () { @@ -30,11 +31,14 @@ public EditorSettingsTests () Directory.CreateDirectory (tuiDir); _configPath = Path.Combine (tuiDir, ConfigClet.ConfigFileName); - // Save original HOME so we can restore it on cleanup. + // Save original HOME/USERPROFILE so we can restore them on cleanup. _originalHome = Environment.GetEnvironmentVariable ("HOME"); + _originalUserProfile = Environment.GetEnvironmentVariable ("USERPROFILE"); - // Point HOME at our temp directory so CM picks up our test config file. + // Point HOME (Linux/macOS) and USERPROFILE (Windows) at our temp directory + // so CM's ~ resolution picks up our test config file on all platforms. Environment.SetEnvironmentVariable ("HOME", _tempDir); + Environment.SetEnvironmentVariable ("USERPROFILE", _tempDir); // Ensure CM uses the "clet" app name (matches the clet binary; in tests // the assembly name is different). @@ -53,8 +57,9 @@ public void Dispose () // Best-effort cleanup. } - // Restore original HOME. + // Restore original HOME/USERPROFILE. Environment.SetEnvironmentVariable ("HOME", _originalHome); + Environment.SetEnvironmentVariable ("USERPROFILE", _originalUserProfile); if (Directory.Exists (_tempDir)) { @@ -305,16 +310,27 @@ public void RoundTrip_LoadApply_RestoresPersistedValues () EditorSettings.WordWrap = false; EditorSettings.AutoIndent = false; - // Act — enable CM and load + apply - ConfigurationManager.Enable (ConfigLocations.All); - ConfigurationManager.Load (ConfigLocations.All); - ConfigurationManager.Apply (); + // Act — enable CM and load + apply using the TUI_CONFIG env var + // (avoids ~ resolution issues on Windows where GetFolderPath + // doesn't respect USERPROFILE env var changes). + Environment.SetEnvironmentVariable ("TUI_CONFIG", _configPath); - // Assert - Assert.False (EditorSettings.LineNumbers); - Assert.Equal (8, EditorSettings.IndentSize); - Assert.True (EditorSettings.WordWrap); - Assert.True (EditorSettings.AutoIndent); + try + { + ConfigurationManager.Enable (ConfigLocations.All); + ConfigurationManager.Load (ConfigLocations.Env); + ConfigurationManager.Apply (); + + // Assert + Assert.False (EditorSettings.LineNumbers); + Assert.Equal (8, EditorSettings.IndentSize); + Assert.True (EditorSettings.WordWrap); + Assert.True (EditorSettings.AutoIndent); + } + finally + { + Environment.SetEnvironmentVariable ("TUI_CONFIG", null); + } } [Fact] @@ -335,16 +351,26 @@ public void RoundTrip_SaveThenLoad_RestoresValues () EditorSettings.IndentSize = 4; EditorSettings.ConvertTabsToSpaces = true; - // Act — load + apply via CM - ConfigurationManager.Enable (ConfigLocations.All); - ConfigurationManager.Load (ConfigLocations.All); - ConfigurationManager.Apply (); + // Act — load + apply via CM using the TUI_CONFIG env var + // (avoids ~ resolution issues on Windows). + Environment.SetEnvironmentVariable ("TUI_CONFIG", _configPath); - // Assert — values should match what we saved - Assert.False (EditorSettings.LineNumbers); - Assert.False (EditorSettings.FoldIndicators); - Assert.Equal (3, EditorSettings.IndentSize); - Assert.False (EditorSettings.ConvertTabsToSpaces); + try + { + ConfigurationManager.Enable (ConfigLocations.All); + ConfigurationManager.Load (ConfigLocations.Env); + ConfigurationManager.Apply (); + + // Assert — values should match what we saved + Assert.False (EditorSettings.LineNumbers); + Assert.False (EditorSettings.FoldIndicators); + Assert.Equal (3, EditorSettings.IndentSize); + Assert.False (EditorSettings.ConvertTabsToSpaces); + } + finally + { + Environment.SetEnvironmentVariable ("TUI_CONFIG", null); + } } [Fact] From df389acd5f97cf456020bf507df418c5f2d39c92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 13:12:08 +0000 Subject: [PATCH 13/13] fix: use RuntimeConfig for cross-platform CM round-trip tests Enable(All) calls Load(All) which resolves ~ via GetFolderPath(UserProfile). On Windows this uses native APIs that ignore env var changes, so test configs written to a temp dir aren't found. Use ConfigurationManager.RuntimeConfig to feed JSON directly, avoiding filesystem path resolution entirely. Agent-Logs-Url: https://github.com/gui-cs/clet/sessions/a3d70dd7-9c5e-4c54-a1fa-83d283f93c69 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- tests/Clet.UnitTests/EditorSettingsTests.cs | 76 +++++++-------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs index bae4f3d..d3cbf58 100644 --- a/tests/Clet.UnitTests/EditorSettingsTests.cs +++ b/tests/Clet.UnitTests/EditorSettingsTests.cs @@ -22,7 +22,6 @@ public class EditorSettingsTests : IDisposable private readonly string _tempDir; private readonly string _configPath; private readonly string? _originalHome; - private readonly string? _originalUserProfile; public EditorSettingsTests () { @@ -31,14 +30,11 @@ public EditorSettingsTests () Directory.CreateDirectory (tuiDir); _configPath = Path.Combine (tuiDir, ConfigClet.ConfigFileName); - // Save original HOME/USERPROFILE so we can restore them on cleanup. + // Save original HOME so we can restore it on cleanup. _originalHome = Environment.GetEnvironmentVariable ("HOME"); - _originalUserProfile = Environment.GetEnvironmentVariable ("USERPROFILE"); - // Point HOME (Linux/macOS) and USERPROFILE (Windows) at our temp directory - // so CM's ~ resolution picks up our test config file on all platforms. + // Point HOME at our temp directory (used by Save's CM reload on Linux). Environment.SetEnvironmentVariable ("HOME", _tempDir); - Environment.SetEnvironmentVariable ("USERPROFILE", _tempDir); // Ensure CM uses the "clet" app name (matches the clet binary; in tests // the assembly name is different). @@ -57,9 +53,8 @@ public void Dispose () // Best-effort cleanup. } - // Restore original HOME/USERPROFILE. + // Restore original HOME. Environment.SetEnvironmentVariable ("HOME", _originalHome); - Environment.SetEnvironmentVariable ("USERPROFILE", _originalUserProfile); if (Directory.Exists (_tempDir)) { @@ -292,17 +287,15 @@ public void Save_DoesNotModifyCommentedOutKeys () [Fact] public void RoundTrip_LoadApply_RestoresPersistedValues () { - // Arrange — write a config file with non-default values - File.WriteAllText ( - _configPath, - """ + // 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; @@ -310,27 +303,16 @@ public void RoundTrip_LoadApply_RestoresPersistedValues () EditorSettings.WordWrap = false; EditorSettings.AutoIndent = false; - // Act — enable CM and load + apply using the TUI_CONFIG env var - // (avoids ~ resolution issues on Windows where GetFolderPath - // doesn't respect USERPROFILE env var changes). - Environment.SetEnvironmentVariable ("TUI_CONFIG", _configPath); + // Act — load via RuntimeConfig (cross-platform; avoids ~ resolution + // issues on Windows where GetFolderPath ignores env var changes). + ConfigurationManager.RuntimeConfig = json; + ConfigurationManager.Enable (ConfigLocations.Runtime); - try - { - ConfigurationManager.Enable (ConfigLocations.All); - ConfigurationManager.Load (ConfigLocations.Env); - ConfigurationManager.Apply (); - - // Assert - Assert.False (EditorSettings.LineNumbers); - Assert.Equal (8, EditorSettings.IndentSize); - Assert.True (EditorSettings.WordWrap); - Assert.True (EditorSettings.AutoIndent); - } - finally - { - Environment.SetEnvironmentVariable ("TUI_CONFIG", null); - } + // Assert + Assert.False (EditorSettings.LineNumbers); + Assert.Equal (8, EditorSettings.IndentSize); + Assert.True (EditorSettings.WordWrap); + Assert.True (EditorSettings.AutoIndent); } [Fact] @@ -351,26 +333,16 @@ public void RoundTrip_SaveThenLoad_RestoresValues () EditorSettings.IndentSize = 4; EditorSettings.ConvertTabsToSpaces = true; - // Act — load + apply via CM using the TUI_CONFIG env var - // (avoids ~ resolution issues on Windows). - Environment.SetEnvironmentVariable ("TUI_CONFIG", _configPath); + // Act — load saved file via RuntimeConfig (cross-platform). + string savedJson = File.ReadAllText (_configPath); + ConfigurationManager.RuntimeConfig = savedJson; + ConfigurationManager.Enable (ConfigLocations.Runtime); - try - { - ConfigurationManager.Enable (ConfigLocations.All); - ConfigurationManager.Load (ConfigLocations.Env); - ConfigurationManager.Apply (); - - // Assert — values should match what we saved - Assert.False (EditorSettings.LineNumbers); - Assert.False (EditorSettings.FoldIndicators); - Assert.Equal (3, EditorSettings.IndentSize); - Assert.False (EditorSettings.ConvertTabsToSpaces); - } - finally - { - Environment.SetEnvironmentVariable ("TUI_CONFIG", null); - } + // 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]