From 17da26ee05284594948cd7d769e3ffa99c01b7c3 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Fri, 24 Apr 2026 17:29:04 -0500 Subject: [PATCH] feat: tree browser and VS Code-style tabs for clips Replace the flat clip list with a TreeView that mirrors the repository's folder hierarchy, and the single-clip editor pane with a tab strip that supports preview/permanent tabs and dirty indicators. - IClipRepository gains an optional FolderPath on ClipData; filesystem ClipRepository recurses subdirectories and round-trips folder paths on save, rejecting traversal segments. - ClipViewModel gains IsDirty (computed) and caches its Avalonia editor control so tab switches reparent the same instance. - ScriptTextEditor no longer disposes TextMate on visual-tree detach; lifetime is explicit via IDisposable. RegistryOptions and the FM script grammar are parsed once and shared across instances. - Open tabs are realised once into an ItemsControl and toggled via IsVisible, avoiding AvaloniaEdit layout/tokenise work on every switch. - Single-tap in the tree opens a preview tab (italic), double-tap or first edit graduates it to permanent. Dirty dot clears on save. --- src/SharpFM.Model/ClipData.cs | 13 +- src/SharpFM/Editors/ClipEditorViewFactory.cs | 46 +++++ src/SharpFM/Editors/ScriptTextEditor.cs | 28 ++- src/SharpFM/MainWindow.axaml | 180 ++++++++++++------ src/SharpFM/MainWindow.axaml.cs | 49 +++++ src/SharpFM/Models/ClipRepository.cs | 80 ++++++-- .../Editor/FmScriptRegistryOptions.cs | 10 +- .../ViewModels/ClipTreeNodeViewModel.cs | 159 ++++++++++++++++ src/SharpFM/ViewModels/ClipViewModel.cs | 70 ++++++- src/SharpFM/ViewModels/MainWindowViewModel.cs | 136 ++++++++++--- src/SharpFM/ViewModels/OpenTabViewModel.cs | 92 +++++++++ src/SharpFM/ViewModels/OpenTabsViewModel.cs | 176 +++++++++++++++++ .../Models/ClipRepositoryTests.cs | 93 +++++++++ .../ViewModels/ClipTreeNodeViewModelTests.cs | 97 ++++++++++ .../ViewModels/ClipViewModelTests.cs | 41 ++++ .../ViewModels/MainWindowViewModelTests.cs | 14 +- .../ViewModels/OpenTabsViewModelTests.cs | 138 ++++++++++++++ 17 files changed, 1305 insertions(+), 117 deletions(-) create mode 100644 src/SharpFM/Editors/ClipEditorViewFactory.cs create mode 100644 src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs create mode 100644 src/SharpFM/ViewModels/OpenTabViewModel.cs create mode 100644 src/SharpFM/ViewModels/OpenTabsViewModel.cs create mode 100644 tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs create mode 100644 tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs diff --git a/src/SharpFM.Model/ClipData.cs b/src/SharpFM.Model/ClipData.cs index 89e00f0..4b2d307 100644 --- a/src/SharpFM.Model/ClipData.cs +++ b/src/SharpFM.Model/ClipData.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace SharpFM.Model; /// @@ -7,4 +9,13 @@ namespace SharpFM.Model; /// Clip display name (filename without extension for file-based storage). /// Clipboard format identifier (e.g. "Mac-XMSS"). /// Raw XML content of the clip. -public record ClipData(string Name, string ClipType, string Xml); +public record ClipData(string Name, string ClipType, string Xml) +{ + /// + /// Hierarchy the clip lives under, as an ordered list of folder segment + /// names. Empty = root. Segments are plain strings (no slashes); each + /// repository implementation decides how to map them to its storage + /// (subdirectories, record columns, URL path, etc.). + /// + public IReadOnlyList FolderPath { get; init; } = []; +} diff --git a/src/SharpFM/Editors/ClipEditorViewFactory.cs b/src/SharpFM/Editors/ClipEditorViewFactory.cs new file mode 100644 index 0000000..2352efa --- /dev/null +++ b/src/SharpFM/Editors/ClipEditorViewFactory.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Media; +using AvaloniaEdit; +using AvaloniaEdit.Highlighting; +using SharpFM.Schema.Editor; + +namespace SharpFM.Editors; + +/// +/// Builds the Avalonia control used to edit a given clip. Kept centralised so +/// the clip view-model can cache a single control instance per clip — tab +/// switches reparent that control rather than reconstructing it, which +/// matters because installs TextMate on +/// construction and that is expensive. +/// +[ExcludeFromCodeCoverage] +public static class ClipEditorViewFactory +{ + private static readonly FontFamily MonoFont = + new("Cascadia Code,Consolas,Menlo,Monospace"); + + public static Control Create(IClipEditor editor) => editor switch + { + ScriptClipEditor s => new ScriptTextEditor + { + FontFamily = MonoFont, + ShowLineNumbers = true, + WordWrap = false, + DataContext = s, + }, + TableClipEditor t => new TableEditorControl + { + DataContext = t.ViewModel, + }, + FallbackXmlEditor f => new TextEditor + { + FontFamily = MonoFont, + ShowLineNumbers = true, + WordWrap = false, + Document = f.Document, + SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("Xml"), + }, + _ => new TextBlock { Text = "No editor available for this clip type." }, + }; +} diff --git a/src/SharpFM/Editors/ScriptTextEditor.cs b/src/SharpFM/Editors/ScriptTextEditor.cs index dbfe71a..4ee6c71 100644 --- a/src/SharpFM/Editors/ScriptTextEditor.cs +++ b/src/SharpFM/Editors/ScriptTextEditor.cs @@ -19,21 +19,28 @@ namespace SharpFM.Editors; /// DataContext is expected to be a . /// [ExcludeFromCodeCoverage] -public class ScriptTextEditor : TextEditor +public class ScriptTextEditor : TextEditor, IDisposable { // Avalonia 12 matches styles by exact type. Without this, a subclass of // TextEditor gets no control template and renders blank — the editor's // TextArea/TextView visual tree is never built. protected override Type StyleKeyOverride => typeof(TextEditor); + // Shared across every script editor in the process. Building RegistryOptions + // pulls bundled themes into memory; doing that once (and reusing the result) + // measurably improves the hitch on first tab realisation for new clips. + private static readonly RegistryOptions SharedRegistry = + new((ThemeName)(int)ThemeName.DarkPlus); + + private static readonly FmScriptRegistryOptions SharedFmRegistry = + new(SharedRegistry); + private readonly TextMate.Installation _textMate; private readonly ScriptEditorController _controller; public ScriptTextEditor() { - var registry = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); - var fmScriptRegistry = new FmScriptRegistryOptions(registry); - _textMate = this.InstallTextMate(fmScriptRegistry); + _textMate = this.InstallTextMate(SharedFmRegistry); _textMate.SetGrammar(FmScriptRegistryOptions.ScopeName); _controller = new ScriptEditorController(this); @@ -50,10 +57,17 @@ protected override void OnDataContextChanged(EventArgs e) _controller.AttachClipEditor(clipEditor); } - protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) - { - base.OnDetachedFromVisualTree(e); + // Intentionally no dispose-on-detach. Tab switches reparent this control + // many times; tearing down TextMate on each detach would force a full + // grammar/theme reinstall on every reattach. Lifetime is owned by the + // ClipViewModel, which calls when the clip is + // removed or its editor is replaced. + private bool _disposed; + public void Dispose() + { + if (_disposed) return; + _disposed = true; _controller.StatusMessageRaised -= OnStatusMessageRaised; _controller.Dispose(); _textMate.Dispose(); diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 36d6d91..82d6f26 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -138,16 +138,20 @@ Text="{Binding SearchText}" /> - + - - - + + - - - - - - + + + + + + + + Opacity="0.6" + Text="{Binding Clip.ClipTypeDisplay}" /> - - - - + + + + @@ -207,40 +212,103 @@ - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + +