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 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs
index 4e3002f..025e31c 100644
--- a/src/SharpFM/MainWindow.axaml.cs
+++ b/src/SharpFM/MainWindow.axaml.cs
@@ -194,4 +194,53 @@ private void ShowPluginManager()
window.Configure(_pluginService, _pluginHost, vm, _pluginConfigService);
window.ShowDialog(this);
}
+
+ // --- Tree / tab interaction ---
+
+ // Walk the visual tree up from the tapped item to find the clip node that
+ // was hit. TreeView raises Tapped with e.Source pointing at the innermost
+ // text/border; we need the DataContext of the enclosing TreeViewItem.
+ private static ClipTreeNodeViewModel? FindClipNode(object? source)
+ {
+ var current = source as Control;
+ while (current is not null)
+ {
+ if (current is TreeViewItem tvi && tvi.DataContext is ClipTreeNodeViewModel node)
+ return node;
+ if (current.DataContext is ClipTreeNodeViewModel d)
+ return d;
+ current = current.Parent as Control;
+ }
+ return null;
+ }
+
+ private void ClipsTree_Tapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ var node = FindClipNode(e.Source);
+ if (node?.Clip is null) return;
+ vm.OpenClipAsPreview(node.Clip);
+ }
+
+ private void ClipsTree_DoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ var node = FindClipNode(e.Source);
+ if (node?.Clip is null) return;
+ vm.OpenClipAsPermanent(node.Clip);
+ }
+
+ private void TabHeader_DoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ if ((sender as Control)?.DataContext is OpenTabViewModel tab)
+ vm.OpenTabs.Graduate(tab);
+ }
+
+ private void CloseTab_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+ if ((sender as Button)?.Tag is OpenTabViewModel tab)
+ vm.OpenTabs.Close(tab);
+ }
}
diff --git a/src/SharpFM/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs
index f181065..601e362 100644
--- a/src/SharpFM/Models/ClipRepository.cs
+++ b/src/SharpFM/Models/ClipRepository.cs
@@ -9,7 +9,9 @@
namespace SharpFM.Models;
///
-/// File-system-based clip repository. Stores each clip as a file in a directory.
+/// File-system-based clip repository. Stores each clip as a file in a directory
+/// tree. Subdirectories map one-to-one to
+/// segments.
///
public class ClipRepository : IClipRepository
{
@@ -39,17 +41,24 @@ public ClipRepository(string path)
public Task> LoadClipsAsync()
{
var clips = new List();
+ var root = new DirectoryInfo(ClipPath);
- foreach (var clipFile in Directory.EnumerateFiles(ClipPath))
+ foreach (var clipFile in Directory.EnumerateFiles(ClipPath, "*", SearchOption.AllDirectories))
{
try
{
var fi = new FileInfo(clipFile);
+ if (string.IsNullOrEmpty(fi.Extension)) continue;
+
+ var folderPath = GetRelativeFolderSegments(root.FullName, fi.Directory!.FullName);
clips.Add(new ClipData(
- Name: fi.Name.Replace(fi.Extension, string.Empty),
- ClipType: fi.Extension.Replace(".", string.Empty),
- Xml: File.ReadAllText(clipFile)));
+ Name: Path.GetFileNameWithoutExtension(fi.Name),
+ ClipType: fi.Extension.TrimStart('.'),
+ Xml: File.ReadAllText(clipFile))
+ {
+ FolderPath = folderPath,
+ });
}
catch (Exception ex)
{
@@ -62,25 +71,46 @@ public Task> LoadClipsAsync()
public Task SaveClipsAsync(IReadOnlyList clips)
{
+ var activeRelativePaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+
foreach (var clip in clips)
{
- var clipPath = Path.Combine(ClipPath, $"{clip.Name}.{clip.ClipType}");
+ var safeSegments = SanitizeFolderPath(clip.FolderPath);
+ var targetDir = safeSegments.Count == 0
+ ? ClipPath
+ : Path.Combine(new[] { ClipPath }.Concat(safeSegments).ToArray());
+
+ Directory.CreateDirectory(targetDir);
+
+ var fileName = $"{clip.Name}.{clip.ClipType}";
+ var clipPath = Path.Combine(targetDir, fileName);
File.WriteAllText(clipPath, clip.Xml);
- }
- // Remove files for clips that no longer exist
- var activeNames = new HashSet(
- clips.Select(c => $"{c.Name}.{c.ClipType}"),
- StringComparer.OrdinalIgnoreCase);
+ var relative = Path.GetRelativePath(ClipPath, clipPath);
+ activeRelativePaths.Add(relative);
+ }
- foreach (var file in Directory.EnumerateFiles(ClipPath))
+ // Remove files for clips that no longer exist (anywhere in the tree).
+ foreach (var file in Directory.EnumerateFiles(ClipPath, "*", SearchOption.AllDirectories))
{
- if (!activeNames.Contains(Path.GetFileName(file)))
+ var relative = Path.GetRelativePath(ClipPath, file);
+ if (!activeRelativePaths.Contains(relative))
{
File.Delete(file);
}
}
+ // Clean up any now-empty subdirectories (bottom-up).
+ foreach (var dir in Directory.EnumerateDirectories(ClipPath, "*", SearchOption.AllDirectories)
+ .OrderByDescending(d => d.Length))
+ {
+ if (!Directory.EnumerateFileSystemEntries(dir).Any())
+ {
+ try { Directory.Delete(dir); }
+ catch (IOException) { /* best-effort */ }
+ }
+ }
+
return Task.CompletedTask;
}
@@ -90,4 +120,28 @@ public Task SaveClipsAsync(IReadOnlyList clips)
// The host manages folder selection and creates a new ClipRepository.
return Task.FromResult(null);
}
+
+ private static IReadOnlyList GetRelativeFolderSegments(string root, string directory)
+ {
+ var rel = Path.GetRelativePath(root, directory);
+ if (string.IsNullOrEmpty(rel) || rel == ".") return [];
+ return rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ // Reject traversal/rooted segments; repositories are logical stores and
+ // must not escape their root no matter what a misbehaving provider sends.
+ private static IReadOnlyList SanitizeFolderPath(IReadOnlyList segments)
+ {
+ if (segments is null || segments.Count == 0) return [];
+ var safe = new List(segments.Count);
+ foreach (var raw in segments)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) continue;
+ if (raw == "." || raw == "..") continue;
+ if (raw.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) continue;
+ safe.Add(raw);
+ }
+ return safe;
+ }
}
diff --git a/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs b/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs
index f143949..9e057ec 100644
--- a/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs
+++ b/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs
@@ -1,7 +1,9 @@
+using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Reflection;
+using System.Threading;
using TextMateSharp.Grammars;
using TextMateSharp.Internal.Grammars.Reader;
using TextMateSharp.Internal.Types;
@@ -17,6 +19,12 @@ public class FmScriptRegistryOptions : IRegistryOptions
private readonly RegistryOptions _inner;
+ // The embedded .tmLanguage.json is immutable; parsing it is not free. Cache
+ // the parsed grammar once per process so repeated TextMate installs (e.g.
+ // fresh script-editor instances) don't reparse ~KB of JSON each time.
+ private static readonly Lazy CachedGrammar =
+ new(LoadFmScriptGrammar, LazyThreadSafetyMode.ExecutionAndPublication);
+
public FmScriptRegistryOptions(RegistryOptions inner)
{
_inner = inner;
@@ -31,7 +39,7 @@ public FmScriptRegistryOptions(RegistryOptions inner)
public IRawGrammar GetGrammar(string scopeName)
{
if (scopeName == ScopeName)
- return LoadFmScriptGrammar();
+ return CachedGrammar.Value;
return _inner.GetGrammar(scopeName);
}
diff --git a/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs b/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs
new file mode 100644
index 0000000..3697763
--- /dev/null
+++ b/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace SharpFM.ViewModels;
+
+///
+/// Node in the left-panel clip tree. A node is either a folder (with children)
+/// or a clip leaf (wrapping a ). The tree mirrors
+/// the FolderPath hierarchy exposed by the active repository.
+///
+public class ClipTreeNodeViewModel : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ /// Display name for this node (folder segment or clip name).
+ public string Name { get; }
+
+ /// Null when this is a folder node; non-null when a clip leaf.
+ public ClipViewModel? Clip { get; }
+
+ public bool IsFolder => Clip is null;
+ public bool IsClip => Clip is not null;
+
+ public ObservableCollection Children { get; } = [];
+
+ private bool _isExpanded = true;
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set { if (_isExpanded == value) return; _isExpanded = value; NotifyPropertyChanged(); }
+ }
+
+ private ClipTreeNodeViewModel(string name, ClipViewModel? clip)
+ {
+ Name = name;
+ Clip = clip;
+ }
+
+ public static ClipTreeNodeViewModel Folder(string name) => new(name, null);
+ public static ClipTreeNodeViewModel ClipLeaf(ClipViewModel clip) => new(clip.Clip.Name, clip);
+
+ ///
+ /// Build a set of root-level nodes from a flat clip collection. Clips are
+ /// grouped by . Folders are sorted
+ /// alphabetically and listed before clip leaves at each level; clip leaves
+ /// are sorted by name for stable display.
+ ///
+ /// Flat clip collection.
+ /// Optional filter. When non-empty, only clips
+ /// whose names contain the text survive (case-insensitive); folders survive
+ /// when they have any surviving descendant, and matching folders are
+ /// auto-expanded.
+ public static IReadOnlyList Build(
+ IEnumerable clips,
+ string searchText = "")
+ {
+ var rootFolders = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var rootLeaves = new List();
+ var filter = string.IsNullOrEmpty(searchText) ? null : searchText;
+
+ foreach (var clip in clips)
+ {
+ var matches = filter is null ||
+ clip.Clip.Name.Contains(filter, StringComparison.OrdinalIgnoreCase);
+
+ if (clip.FolderPath is { Count: > 0 })
+ {
+ var head = clip.FolderPath[0];
+ if (!rootFolders.TryGetValue(head, out var folder))
+ {
+ folder = Folder(head);
+ rootFolders.Add(head, folder);
+ }
+
+ InsertClipIntoFolder(folder, clip, clip.FolderPath, 1, matches, filter is not null);
+ }
+ else if (matches)
+ {
+ rootLeaves.Add(ClipLeaf(clip));
+ }
+ }
+
+ // Drop folders that end up empty after filtering.
+ var folderList = rootFolders.Values
+ .Where(f => HasAnyDescendant(f))
+ .OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ foreach (var f in folderList) SortRecursive(f);
+
+ rootLeaves.Sort((a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Name, b.Name));
+
+ var result = new List(folderList.Count + rootLeaves.Count);
+ result.AddRange(folderList);
+ result.AddRange(rootLeaves);
+ return result;
+ }
+
+ private static void InsertClipIntoFolder(
+ ClipTreeNodeViewModel folder,
+ ClipViewModel clip,
+ IReadOnlyList path,
+ int depth,
+ bool matches,
+ bool filtering)
+ {
+ if (depth >= path.Count)
+ {
+ if (matches) folder.Children.Add(ClipLeaf(clip));
+ if (filtering && matches) folder.IsExpanded = true;
+ return;
+ }
+
+ var segment = path[depth];
+ var child = folder.Children.FirstOrDefault(c => c.IsFolder &&
+ string.Equals(c.Name, segment, StringComparison.OrdinalIgnoreCase));
+ if (child is null)
+ {
+ child = Folder(segment);
+ folder.Children.Add(child);
+ }
+
+ InsertClipIntoFolder(child, clip, path, depth + 1, matches, filtering);
+
+ if (filtering && HasAnyDescendant(child))
+ folder.IsExpanded = true;
+ }
+
+ private static bool HasAnyDescendant(ClipTreeNodeViewModel node)
+ {
+ foreach (var c in node.Children)
+ {
+ if (c.IsClip) return true;
+ if (HasAnyDescendant(c)) return true;
+ }
+ return false;
+ }
+
+ private static void SortRecursive(ClipTreeNodeViewModel node)
+ {
+ if (!node.IsFolder || node.Children.Count == 0) return;
+
+ var folders = node.Children.Where(c => c.IsFolder)
+ .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
+ var leaves = node.Children.Where(c => c.IsClip)
+ .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
+
+ node.Children.Clear();
+ foreach (var f in folders) { node.Children.Add(f); SortRecursive(f); }
+ foreach (var l in leaves) node.Children.Add(l);
+ }
+}
diff --git a/src/SharpFM/ViewModels/ClipViewModel.cs b/src/SharpFM/ViewModels/ClipViewModel.cs
index 221d8d4..d788f99 100644
--- a/src/SharpFM/ViewModels/ClipViewModel.cs
+++ b/src/SharpFM/ViewModels/ClipViewModel.cs
@@ -1,13 +1,16 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
+using Avalonia.Controls;
using AvaloniaEdit.Document;
using SharpFM.Editors;
+using SharpFM.Model;
using SharpFM.Schema.Editor;
namespace SharpFM.ViewModels;
-public partial class ClipViewModel : INotifyPropertyChanged
+public partial class ClipViewModel : INotifyPropertyChanged, IDisposable
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -24,16 +27,43 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
///
public IClipEditor Editor { get; private set; }
+ ///
+ /// Hierarchy the clip lives under in its repository. Matches
+ /// — empty list means "root".
+ ///
+ public IReadOnlyList FolderPath { get; set; } = [];
+
///
/// Fires when the editor content changes due to user edits (debounced).
///
public event EventHandler? EditorContentChanged;
+ ///
+ /// Snapshot of the editor's XML the last time the clip was known to be
+ /// persisted. Used to compute .
+ ///
+ private string _savedXml;
+
public ClipViewModel(FileMakerClip clip)
{
Clip = clip;
Editor = CreateEditor(clip.XmlData);
Editor.ContentChanged += OnEditorContentChanged;
+ _savedXml = Editor.ToXml();
+ }
+
+ // The Avalonia control that renders this clip. Cached so tab switches
+ // reparent the same control instead of reconstructing a ScriptTextEditor
+ // (and re-running its TextMate install) on every switch. Lazily built on
+ // first access.
+ private Control? _editorView;
+ public Control EditorView => _editorView ??= ClipEditorViewFactory.Create(Editor);
+
+ public void Dispose()
+ {
+ Editor.ContentChanged -= OnEditorContentChanged;
+ if (_editorView is IDisposable d) d.Dispose();
+ _editorView = null;
}
///
@@ -48,12 +78,41 @@ public void ReplaceEditor(string xml)
Editor = CreateEditor(xml);
Editor.ContentChanged += OnEditorContentChanged;
+ // The cached editor view was built around the old editor instance.
+ // Tear it down so the next EditorView access rebuilds cleanly.
+ if (_editorView is IDisposable d) d.Dispose();
+ _editorView = null;
+
+ // Reverse sync from plugins / external tools must not flag the clip
+ // dirty — the source of truth is what the editor now holds.
+ _savedXml = Editor.ToXml();
+ NotifyPropertyChanged(nameof(IsDirty));
+
NotifyPropertyChanged(nameof(Editor));
+ NotifyPropertyChanged(nameof(EditorView));
NotifyPropertyChanged(nameof(ScriptDocument));
NotifyPropertyChanged(nameof(TableEditor));
NotifyPropertyChanged(nameof(XmlDocument));
}
+ ///
+ /// Called by the host after a successful save; captures the current XML as
+ /// the "clean" baseline so edits that follow light the dirty indicator.
+ ///
+ public void MarkSaved()
+ {
+ _savedXml = Editor.ToXml();
+ NotifyPropertyChanged(nameof(IsDirty));
+ }
+
+ ///
+ /// True when the editor's current XML differs from the last saved snapshot.
+ /// Drives the dirty dot on open tabs. Computed on demand — cheap enough
+ /// for a UI binding evaluated only when ContentChanged fires.
+ ///
+ public bool IsDirty =>
+ !string.Equals(Editor.ToXml(), _savedXml, StringComparison.Ordinal);
+
private IClipEditor CreateEditor(string? xml) => Clip.ClipboardFormat switch
{
"Mac-XMSS" or "Mac-XMSC" => new ScriptClipEditor(xml),
@@ -61,9 +120,16 @@ public void ReplaceEditor(string xml)
_ => new FallbackXmlEditor(xml),
};
- private void OnEditorContentChanged(object? sender, EventArgs e)
+ private void OnEditorContentChanged(object? sender, EventArgs e) =>
+ HandleEditorContentChanged();
+
+ // Exposed to the test assembly (InternalsVisibleTo SharpFM.Tests) so tests
+ // can drive the post-debounce path without standing up an Avalonia
+ // Dispatcher. In production this is invoked from Editor.ContentChanged.
+ internal void HandleEditorContentChanged()
{
Clip.XmlData = Editor.ToXml();
+ NotifyPropertyChanged(nameof(IsDirty));
EditorContentChanged?.Invoke(this, EventArgs.Empty);
}
diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs
index 19993f8..c4ad938 100644
--- a/src/SharpFM/ViewModels/MainWindowViewModel.cs
+++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs
@@ -26,6 +26,13 @@ public partial class MainWindowViewModel : INotifyPropertyChanged
private readonly IFolderService _folderService;
private readonly DispatcherTimer _statusTimer;
private IClipRepository _repository;
+ private OpenTabViewModel? _trackedActiveTab;
+
+ private void OnActiveTabPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(OpenTabViewModel.Clip))
+ NotifyPropertyChanged(nameof(SelectedClip));
+ }
public event PropertyChangedEventHandler? PropertyChanged;
@@ -56,15 +63,45 @@ public MainWindowViewModel(
path2: Path.Join("SharpFM", "Clips")
);
+ OpenTabs = new OpenTabsViewModel();
+ OpenTabs.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName != nameof(OpenTabsViewModel.ActiveTab)) return;
+
+ // Re-subscribe to the new active tab so preview-slot clip swaps
+ // also surface as SelectedClip changes (the tab instance stays
+ // the same, but Clip changes underneath).
+ if (_trackedActiveTab is not null)
+ _trackedActiveTab.PropertyChanged -= OnActiveTabPropertyChanged;
+ _trackedActiveTab = OpenTabs.ActiveTab;
+ if (_trackedActiveTab is not null)
+ _trackedActiveTab.PropertyChanged += OnActiveTabPropertyChanged;
+
+ NotifyPropertyChanged(nameof(SelectedClip));
+ };
+
+ RootNodes = [];
+
FileMakerClips = [];
FileMakerClips.CollectionChanged += (sender, e) =>
{
- // reset search text, which will trigger a property notify changed
- // that will re-run the search with empty value (which shows all)
- SearchText = string.Empty;
- };
+ // A flat-list change invalidates the tree; rebuilding respects the
+ // current search filter.
+ RebuildTree();
- FilteredClips = [];
+ // When a clip is removed from the catalog (e.g., DeleteSelectedClip),
+ // close any tabs that still point at it and dispose the cached
+ // editor view so resources (e.g. TextMate installation) don't leak.
+ if (e.OldItems is not null)
+ {
+ foreach (var item in e.OldItems)
+ {
+ if (item is not ClipViewModel vm) continue;
+ OpenTabs.CloseClip(vm);
+ vm.Dispose();
+ }
+ }
+ };
_repository = new ClipRepository(_currentPath);
LoadClipsFromRepository();
@@ -75,26 +112,33 @@ public MainWindowViewModel(
private void LoadClipsFromRepository()
{
var clips = _repository.LoadClipsAsync().GetAwaiter().GetResult();
-
- FileMakerClips.Clear();
-
- foreach (var clip in clips)
- {
- FileMakerClips.Add(new ClipViewModel(
- new FileMakerClip(clip.Name, clip.ClipType, clip.Xml)));
- }
+ PopulateClips(clips);
}
private async Task LoadClipsFromRepositoryAsync()
{
var clips = await _repository.LoadClipsAsync();
+ PopulateClips(clips);
+ }
+ private void PopulateClips(IReadOnlyList clips)
+ {
+ // Closing the tab strip first keeps the editor area from holding refs
+ // to clips that are about to be replaced.
+ OpenTabs.Tabs.Clear();
+ OpenTabs.ActiveTab = null;
+
+ // Clear fires CollectionChanged with Reset (no OldItems), so dispose
+ // the outgoing clips explicitly.
+ foreach (var c in FileMakerClips) c.Dispose();
FileMakerClips.Clear();
-
foreach (var clip in clips)
{
FileMakerClips.Add(new ClipViewModel(
- new FileMakerClip(clip.Name, clip.ClipType, clip.Xml)));
+ new FileMakerClip(clip.Name, clip.ClipType, clip.Xml))
+ {
+ FolderPath = clip.FolderPath,
+ });
}
}
@@ -131,11 +175,18 @@ public async Task SaveClipsStorageAsync()
clip.Clip.XmlData = clip.Editor.ToXml();
var clipData = FileMakerClips
- .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.XmlData))
+ .Select(c => new ClipData(c.Clip.Name, c.ClipType, c.Clip.XmlData)
+ {
+ FolderPath = c.FolderPath,
+ })
.ToList();
await _repository.SaveClipsAsync(clipData);
+ // Clip is now known-persisted — rebase the dirty snapshot so the
+ // tab dirty dots clear.
+ foreach (var c in FileMakerClips) c.MarkSaved();
+
ShowStatus($"Saved {clipData.Count} clip(s) to {_repository.CurrentLocation}");
}
catch (Exception e)
@@ -173,9 +224,9 @@ public void DeleteSelectedClip()
return;
}
- var name = SelectedClip.Clip.Name;
var clip = SelectedClip;
- SelectedClip = null;
+ var name = clip.Clip.Name;
+ OpenTabs.CloseClip(clip);
FileMakerClips.Remove(clip);
ShowStatus($"Deleted clip '{name}'");
}
@@ -393,20 +444,44 @@ public static string Version
public ObservableCollection FileMakerClips { get; set; }
- public ObservableCollection FilteredClips { get; set; }
+ ///
+ /// VS Code-style open tabs — the right-side editor area binds to this.
+ ///
+ public OpenTabsViewModel OpenTabs { get; }
- private ClipViewModel? _selectedClip;
+ ///
+ /// Tree nodes shown in the left-side clip browser. Rebuilt whenever
+ /// or changes.
+ ///
+ public ObservableCollection RootNodes { get; set; }
+
+ ///
+ /// The clip backing the active tab, if any. Kept as a property so existing
+ /// commands (copy/paste/delete) and tests that reasoned about a single
+ /// "current" clip continue to work with the tabbed UI.
+ ///
public ClipViewModel? SelectedClip
{
- get => _selectedClip;
+ get => OpenTabs.ActiveTab?.Clip;
set
{
- _selectedClip = value;
StatusMessage = "";
+ if (value is null) { OpenTabs.ActiveTab = null; NotifyPropertyChanged(); return; }
+ OpenTabs.OpenAsPermanent(value);
NotifyPropertyChanged();
}
}
+ ///
+ /// Open a clip as a preview tab (single-click in the tree).
+ ///
+ public void OpenClipAsPreview(ClipViewModel clip) => OpenTabs.OpenAsPreview(clip);
+
+ ///
+ /// Open a clip as a permanent tab (double-click in the tree).
+ ///
+ public void OpenClipAsPermanent(ClipViewModel clip) => OpenTabs.OpenAsPermanent(clip);
+
private string _searchText = string.Empty;
public string SearchText
{
@@ -414,19 +489,18 @@ public string SearchText
set
{
_searchText = value;
- var previousSelection = _selectedClip;
- FilteredClips.Clear();
- foreach (var c in FileMakerClips.Where(c => c.Clip.Name.Contains(_searchText, StringComparison.OrdinalIgnoreCase)))
- {
- FilteredClips.Add(c);
- }
- // Preserve selection if still visible in filtered results
- if (previousSelection != null && FilteredClips.Contains(previousSelection))
- SelectedClip = previousSelection;
+ RebuildTree();
NotifyPropertyChanged();
}
}
+ private void RebuildTree()
+ {
+ var nodes = ClipTreeNodeViewModel.Build(FileMakerClips, _searchText);
+ RootNodes.Clear();
+ foreach (var n in nodes) RootNodes.Add(n);
+ }
+
private string _currentPath;
public string CurrentPath
{
diff --git a/src/SharpFM/ViewModels/OpenTabViewModel.cs b/src/SharpFM/ViewModels/OpenTabViewModel.cs
new file mode 100644
index 0000000..53eb23b
--- /dev/null
+++ b/src/SharpFM/ViewModels/OpenTabViewModel.cs
@@ -0,0 +1,92 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Avalonia.Media;
+
+namespace SharpFM.ViewModels;
+
+///
+/// A single open clip tab. Wraps a and carries
+/// tab-specific state (preview vs permanent). Dirty state is read through to
+/// .
+///
+public class OpenTabViewModel : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private ClipViewModel _clip;
+ public ClipViewModel Clip
+ {
+ get => _clip;
+ set
+ {
+ if (ReferenceEquals(_clip, value)) return;
+
+ if (_clip is not null)
+ _clip.PropertyChanged -= OnClipPropertyChanged;
+
+ _clip = value;
+ _clip.PropertyChanged += OnClipPropertyChanged;
+
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(Title));
+ NotifyPropertyChanged(nameof(IsDirty));
+ }
+ }
+
+ private bool _isPreview;
+ ///
+ /// True when this tab was opened via single-click (VS Code "preview"). A
+ /// preview tab graduates to permanent on double-click of its header or on
+ /// the first edit to its clip.
+ ///
+ public bool IsPreview
+ {
+ get => _isPreview;
+ set
+ {
+ if (_isPreview == value) return;
+ _isPreview = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(TitleFontStyle));
+ }
+ }
+
+ public string Title => _clip.Clip.Name;
+
+ ///
+ /// Italic while previewing, normal once graduated. Avalonia doesn't
+ /// evaluate data triggers, so the VM computes the style directly.
+ ///
+ public FontStyle TitleFontStyle => _isPreview ? FontStyle.Italic : FontStyle.Normal;
+
+ public bool IsDirty => _clip.IsDirty;
+
+ private bool _isActive;
+ ///
+ /// Whether this tab is the currently active (visible) one. Driven by
+ /// ; bound to the tab content's
+ /// IsVisible so every realised editor stays in the visual tree
+ /// and switching is a visibility toggle rather than a reparent.
+ ///
+ public bool IsActive
+ {
+ get => _isActive;
+ internal set { if (_isActive == value) return; _isActive = value; NotifyPropertyChanged(); }
+ }
+
+ public OpenTabViewModel(ClipViewModel clip, bool isPreview)
+ {
+ _clip = clip;
+ _isPreview = isPreview;
+ _clip.PropertyChanged += OnClipPropertyChanged;
+ }
+
+ private void OnClipPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ClipViewModel.IsDirty))
+ NotifyPropertyChanged(nameof(IsDirty));
+ }
+}
diff --git a/src/SharpFM/ViewModels/OpenTabsViewModel.cs b/src/SharpFM/ViewModels/OpenTabsViewModel.cs
new file mode 100644
index 0000000..d8d344e
--- /dev/null
+++ b/src/SharpFM/ViewModels/OpenTabsViewModel.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace SharpFM.ViewModels;
+
+///
+/// Collection of VS Code-style document tabs shown in the main editor area.
+/// Implements preview/permanent semantics:
+///
+/// - Single-click in the tree calls : reuses a
+/// single "preview" tab slot if one exists, otherwise creates a new italic
+/// tab.
+/// - Double-click in the tree calls : opens
+/// a non-italic, sticky tab.
+/// - Double-tapping a preview tab header, or editing its clip, graduates
+/// the preview to a permanent tab ().
+///
+///
+public class OpenTabsViewModel : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ public ObservableCollection Tabs { get; } = [];
+
+ private OpenTabViewModel? _activeTab;
+ public OpenTabViewModel? ActiveTab
+ {
+ get => _activeTab;
+ set
+ {
+ if (ReferenceEquals(_activeTab, value)) return;
+ if (_activeTab is not null) _activeTab.IsActive = false;
+ _activeTab = value;
+ if (_activeTab is not null) _activeTab.IsActive = true;
+ NotifyPropertyChanged();
+ }
+ }
+
+ ///
+ /// The lone preview tab, if any. A tab leaves this slot when it graduates
+ /// to permanent or is closed.
+ ///
+ public OpenTabViewModel? PreviewTab { get; private set; }
+
+ ///
+ /// Open a clip in the preview slot. If the clip is already open (preview
+ /// or permanent) its tab is activated; otherwise the preview slot is
+ /// reused — or created if absent.
+ ///
+ public OpenTabViewModel OpenAsPreview(ClipViewModel clip)
+ {
+ var existing = Tabs.FirstOrDefault(t => ReferenceEquals(t.Clip, clip));
+ if (existing is not null)
+ {
+ ActiveTab = existing;
+ return existing;
+ }
+
+ if (PreviewTab is not null)
+ {
+ DetachGraduationWatch(PreviewTab);
+ PreviewTab.Clip = clip;
+ AttachGraduationWatch(PreviewTab);
+ ActiveTab = PreviewTab;
+ return PreviewTab;
+ }
+
+ var tab = new OpenTabViewModel(clip, isPreview: true);
+ Tabs.Add(tab);
+ PreviewTab = tab;
+ AttachGraduationWatch(tab);
+ ActiveTab = tab;
+ return tab;
+ }
+
+ ///
+ /// Open a clip as a permanent tab. If the clip is already in the preview
+ /// slot the preview simply graduates; if it's already open as permanent,
+ /// its tab is activated.
+ ///
+ public OpenTabViewModel OpenAsPermanent(ClipViewModel clip)
+ {
+ var existing = Tabs.FirstOrDefault(t => ReferenceEquals(t.Clip, clip));
+ if (existing is not null)
+ {
+ if (existing.IsPreview) Graduate(existing);
+ ActiveTab = existing;
+ return existing;
+ }
+
+ var tab = new OpenTabViewModel(clip, isPreview: false);
+ Tabs.Add(tab);
+ ActiveTab = tab;
+ return tab;
+ }
+
+ ///
+ /// Graduate a preview tab to permanent. No-op if the tab is not a preview.
+ ///
+ public void Graduate(OpenTabViewModel tab)
+ {
+ if (!tab.IsPreview) return;
+ DetachGraduationWatch(tab);
+ tab.IsPreview = false;
+ if (ReferenceEquals(PreviewTab, tab)) PreviewTab = null;
+ }
+
+ ///
+ /// Convenience: graduate the currently active tab if it's a preview.
+ /// Called when the user double-taps the active tab header.
+ ///
+ public void GraduateActive()
+ {
+ if (_activeTab is not null) Graduate(_activeTab);
+ }
+
+ ///
+ /// Close a tab. If it was the active tab, picks the neighbour (next, else
+ /// previous) as the new active; falls back to null when the last tab is
+ /// closed. Clears the preview slot if the closed tab held it.
+ ///
+ public void Close(OpenTabViewModel tab)
+ {
+ var idx = Tabs.IndexOf(tab);
+ if (idx < 0) return;
+
+ DetachGraduationWatch(tab);
+ Tabs.RemoveAt(idx);
+ if (ReferenceEquals(PreviewTab, tab)) PreviewTab = null;
+
+ if (ReferenceEquals(_activeTab, tab))
+ {
+ if (Tabs.Count == 0) ActiveTab = null;
+ else if (idx < Tabs.Count) ActiveTab = Tabs[idx];
+ else ActiveTab = Tabs[Tabs.Count - 1];
+ }
+ }
+
+ ///
+ /// Remove every open tab that points at . Used when
+ /// a clip is deleted from the repository so it doesn't linger in the
+ /// editor area.
+ ///
+ public void CloseClip(ClipViewModel clip)
+ {
+ foreach (var tab in Tabs.Where(t => ReferenceEquals(t.Clip, clip)).ToList())
+ Close(tab);
+ }
+
+ private void AttachGraduationWatch(OpenTabViewModel tab)
+ {
+ tab.Clip.PropertyChanged += OnWatchedClipChanged;
+ }
+
+ private void DetachGraduationWatch(OpenTabViewModel tab)
+ {
+ tab.Clip.PropertyChanged -= OnWatchedClipChanged;
+ }
+
+ private void OnWatchedClipChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ // The first user edit graduates a preview tab — VS Code parity.
+ if (e.PropertyName != nameof(ClipViewModel.IsDirty)) return;
+ if (sender is not ClipViewModel clip) return;
+ if (!clip.IsDirty) return;
+
+ var tab = Tabs.FirstOrDefault(t => ReferenceEquals(t.Clip, clip));
+ if (tab is not null && tab.IsPreview) Graduate(tab);
+ }
+}
diff --git a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
index d3dc515..05cd0ed 100644
--- a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
+++ b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
@@ -168,7 +168,100 @@ public async Task Roundtrip_LoadSaveLoad()
Assert.Equal("Script", loaded[0].Name);
Assert.Equal("Mac-XMSS", loaded[0].ClipType);
Assert.Equal("data", loaded[0].Xml);
+ Assert.Empty(loaded[0].FolderPath);
}
finally { Directory.Delete(dir, true); }
}
+
+ [Fact]
+ public async Task LoadClipsAsync_Recurses_IntoSubdirectories()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var sub = Path.Combine(dir, "Scripts", "Utilities");
+ Directory.CreateDirectory(sub);
+ File.WriteAllText(Path.Combine(sub, "Log.Mac-XMSS"), "");
+ File.WriteAllText(Path.Combine(dir, "RootClip.Mac-XMTB"), "");
+
+ var repo = new ClipRepository(dir);
+ var clips = await repo.LoadClipsAsync();
+
+ var nested = Assert.Single(clips, c => c.Name == "Log");
+ Assert.Equal(new[] { "Scripts", "Utilities" }, nested.FolderPath);
+
+ var rooted = Assert.Single(clips, c => c.Name == "RootClip");
+ Assert.Empty(rooted.FolderPath);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveClipsAsync_RoundTripsFolderPath()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ var clips = new List
+ {
+ new("A", "Mac-XMSS", "") { FolderPath = new[] { "Group1", "Sub" } },
+ new("B", "Mac-XMTB", "") { FolderPath = Array.Empty() }
+ };
+
+ await repo.SaveClipsAsync(clips);
+ Assert.True(File.Exists(Path.Combine(dir, "Group1", "Sub", "A.Mac-XMSS")));
+ Assert.True(File.Exists(Path.Combine(dir, "B.Mac-XMTB")));
+
+ var loaded = await repo.LoadClipsAsync();
+ var a = Assert.Single(loaded, c => c.Name == "A");
+ Assert.Equal(new[] { "Group1", "Sub" }, a.FolderPath);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveClipsAsync_DeletesOrphans_AcrossSubdirectories()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var sub = Path.Combine(dir, "Old");
+ Directory.CreateDirectory(sub);
+ File.WriteAllText(Path.Combine(sub, "Gone.Mac-XMSS"), "");
+
+ var repo = new ClipRepository(dir);
+ await repo.SaveClipsAsync([new("Keep", "Mac-XMSS", "")]);
+
+ Assert.False(File.Exists(Path.Combine(sub, "Gone.Mac-XMSS")));
+ Assert.False(Directory.Exists(sub));
+ Assert.True(File.Exists(Path.Combine(dir, "Keep.Mac-XMSS")));
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveClipsAsync_RejectsTraversalSegments()
+ {
+ var dir = CreateTempDir();
+ var sibling = Path.Combine(Path.GetTempPath(), $"sharpfm-sibling-{Guid.NewGuid()}");
+ try
+ {
+ Directory.CreateDirectory(sibling);
+ var repo = new ClipRepository(dir);
+
+ await repo.SaveClipsAsync([new("Evil", "Mac-XMSS", "")
+ { FolderPath = new[] { "..", Path.GetFileName(sibling) } }]);
+
+ Assert.False(File.Exists(Path.Combine(sibling, "Evil.Mac-XMSS")));
+ // ".." is stripped; any remaining safe segments stay under dir.
+ var written = Directory.EnumerateFiles(dir, "Evil.Mac-XMSS", SearchOption.AllDirectories).Single();
+ Assert.StartsWith(dir, written);
+ }
+ finally
+ {
+ if (Directory.Exists(dir)) Directory.Delete(dir, true);
+ if (Directory.Exists(sibling)) Directory.Delete(sibling, true);
+ }
+ }
}
diff --git a/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
new file mode 100644
index 0000000..723a5da
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
@@ -0,0 +1,97 @@
+using System.Linq;
+using SharpFM.Model;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class ClipTreeNodeViewModelTests
+{
+ private static ClipViewModel Clip(string name, params string[] folderPath)
+ {
+ var vm = new ClipViewModel(new FileMakerClip(name, "Mac-XMSS",
+ ""));
+ vm.FolderPath = folderPath;
+ return vm;
+ }
+
+ [Fact]
+ public void Build_FlatClips_ProducesRootLeaves()
+ {
+ var clips = new[] { Clip("A"), Clip("B") };
+ var nodes = ClipTreeNodeViewModel.Build(clips);
+
+ Assert.Equal(2, nodes.Count);
+ Assert.All(nodes, n => Assert.True(n.IsClip));
+ Assert.Equal(new[] { "A", "B" }, nodes.Select(n => n.Name));
+ }
+
+ [Fact]
+ public void Build_NestedClips_GroupsUnderFolders()
+ {
+ var clips = new[]
+ {
+ Clip("Top"),
+ Clip("Inner", "Scripts"),
+ Clip("Deep", "Scripts", "Utils")
+ };
+
+ var nodes = ClipTreeNodeViewModel.Build(clips);
+
+ // Folder "Scripts" first (folders before leaves at root).
+ Assert.Equal(2, nodes.Count);
+ var scripts = nodes[0];
+ Assert.True(scripts.IsFolder);
+ Assert.Equal("Scripts", scripts.Name);
+
+ var innerLeaf = scripts.Children.Single(c => c.IsClip);
+ Assert.Equal("Inner", innerLeaf.Name);
+
+ var utils = scripts.Children.Single(c => c.IsFolder);
+ Assert.Equal("Utils", utils.Name);
+ Assert.Equal("Deep", utils.Children.Single().Name);
+
+ Assert.True(nodes[1].IsClip);
+ Assert.Equal("Top", nodes[1].Name);
+ }
+
+ [Fact]
+ public void Build_Search_FiltersClipsAndExpandsMatchingFolders()
+ {
+ var clips = new[]
+ {
+ Clip("Hidden", "Scripts"),
+ Clip("NeedleClip", "Scripts", "Utils"),
+ Clip("RootMatch")
+ };
+
+ var nodes = ClipTreeNodeViewModel.Build(clips, "needle");
+
+ // Hidden clip filtered out; Scripts folder survives because a
+ // descendant matches; its "Utils" subfolder is auto-expanded.
+ var scripts = nodes.Single(n => n.IsFolder);
+ Assert.True(scripts.IsExpanded);
+ var utils = scripts.Children.Single();
+ Assert.True(utils.IsFolder);
+ Assert.True(utils.IsExpanded);
+ Assert.Equal("NeedleClip", utils.Children.Single().Name);
+
+ // Unrelated root clip drops because it doesn't match.
+ Assert.DoesNotContain(nodes, n => n.IsClip && n.Name == "RootMatch");
+ }
+
+ [Fact]
+ public void Build_IsStable_WhenFolderPathCasingDiffers()
+ {
+ var clips = new[]
+ {
+ Clip("A", "Scripts"),
+ Clip("B", "scripts")
+ };
+
+ var nodes = ClipTreeNodeViewModel.Build(clips);
+
+ var folder = Assert.Single(nodes, n => n.IsFolder);
+ Assert.Equal(2, folder.Children.Count);
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
index 68adee7..a6027ea 100644
--- a/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/ClipViewModelTests.cs
@@ -109,4 +109,45 @@ public void Clip_Name_FiresPropertyChanged()
Assert.Equal("Renamed", vm.Clip.Name);
Assert.Equal("Name", changed);
}
+
+ [Fact]
+ public void IsDirty_FalseImmediatelyAfterConstruction()
+ {
+ var vm = CreateScriptClip(WrapXml(""));
+ Assert.False(vm.IsDirty);
+ }
+
+ [Fact]
+ public void IsDirty_TrueAfterEditorEdit()
+ {
+ var vm = CreateScriptClip(WrapXml("a"));
+ vm.ScriptDocument!.Text += "\n# new line";
+
+ // IsDirty is computed live — no need to pump the ContentChanged
+ // debouncer; a UI binding watching IsDirty gets notified when
+ // ContentChanged fires, but the value itself is always fresh.
+ Assert.True(vm.IsDirty);
+ }
+
+ [Fact]
+ public void MarkSaved_ClearsIsDirty()
+ {
+ var vm = CreateScriptClip(WrapXml("a"));
+ vm.ScriptDocument!.Text += "\n# edited";
+ Assert.True(vm.IsDirty);
+
+ vm.MarkSaved();
+ Assert.False(vm.IsDirty);
+ }
+
+ [Fact]
+ public void ReplaceEditor_ResetsIsDirty()
+ {
+ var vm = CreateScriptClip(WrapXml("a"));
+ vm.ScriptDocument!.Text += "\n# edited";
+ Assert.True(vm.IsDirty);
+
+ vm.ReplaceEditor(vm.Editor.ToXml());
+ Assert.False(vm.IsDirty);
+ }
}
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
index 5f454d0..313ee6e 100644
--- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -175,27 +175,29 @@ public void DeleteSelectedClip_NoSelection_ShowsStatus()
}
[Fact]
- public void DeleteSelectedClip_RemovesFromFilteredClips()
+ public void DeleteSelectedClip_RemovesFromCatalogAndTree()
{
var vm = CreateVm();
vm.NewScriptCommand();
var clip = vm.SelectedClip!;
- Assert.Contains(clip, vm.FilteredClips);
+ Assert.Contains(clip, vm.FileMakerClips);
+ Assert.Contains(vm.RootNodes, n => n.IsClip && ReferenceEquals(n.Clip, clip));
vm.DeleteSelectedClip();
- Assert.DoesNotContain(clip, vm.FilteredClips);
+ Assert.DoesNotContain(clip, vm.FileMakerClips);
+ Assert.DoesNotContain(vm.RootNodes, n => n.IsClip && ReferenceEquals(n.Clip, clip));
}
[Fact]
- public void SearchText_FiltersClips()
+ public void SearchText_FiltersTree()
{
var vm = CreateVm();
vm.NewScriptCommand(); // adds a clip named "New Script"
vm.SearchText = "zzz_nonexistent";
- Assert.Empty(vm.FilteredClips);
+ Assert.Empty(vm.RootNodes);
vm.SearchText = "";
- Assert.NotEmpty(vm.FilteredClips);
+ Assert.NotEmpty(vm.RootNodes);
}
[Fact]
diff --git a/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs
new file mode 100644
index 0000000..8f9c945
--- /dev/null
+++ b/tests/SharpFM.Tests/ViewModels/OpenTabsViewModelTests.cs
@@ -0,0 +1,138 @@
+using SharpFM.Model;
+using SharpFM.ViewModels;
+using Xunit;
+
+namespace SharpFM.Tests.ViewModels;
+
+public class OpenTabsViewModelTests
+{
+ private static ClipViewModel Clip(string name) =>
+ new(new FileMakerClip(name, "Mac-XMSS",
+ "a"));
+
+ [Fact]
+ public void OpenAsPreview_CreatesPreviewTab_FirstTime()
+ {
+ var tabs = new OpenTabsViewModel();
+ var tab = tabs.OpenAsPreview(Clip("A"));
+
+ Assert.True(tab.IsPreview);
+ Assert.Same(tab, tabs.PreviewTab);
+ Assert.Same(tab, tabs.ActiveTab);
+ Assert.Single(tabs.Tabs);
+ }
+
+ [Fact]
+ public void OpenAsPreview_ReusesSlot_WhenAnotherPreviewExists()
+ {
+ var tabs = new OpenTabsViewModel();
+ var first = tabs.OpenAsPreview(Clip("A"));
+ var secondClip = Clip("B");
+
+ var second = tabs.OpenAsPreview(secondClip);
+
+ Assert.Same(first, second); // reused the same tab instance
+ Assert.Same(secondClip, second.Clip);
+ Assert.Single(tabs.Tabs);
+ Assert.True(second.IsPreview);
+ }
+
+ [Fact]
+ public void OpenAsPreview_ActivatesExistingTab_WithoutChangingIt()
+ {
+ var tabs = new OpenTabsViewModel();
+ var clip = Clip("A");
+ var tab = tabs.OpenAsPermanent(clip);
+ tabs.OpenAsPreview(Clip("B")); // another preview, shouldn't affect A
+
+ tabs.OpenAsPreview(clip);
+
+ Assert.Same(tab, tabs.ActiveTab);
+ Assert.False(tab.IsPreview); // still permanent
+ }
+
+ [Fact]
+ public void OpenAsPermanent_GraduatesExistingPreview()
+ {
+ var tabs = new OpenTabsViewModel();
+ var clip = Clip("A");
+ var preview = tabs.OpenAsPreview(clip);
+
+ var promoted = tabs.OpenAsPermanent(clip);
+
+ Assert.Same(preview, promoted);
+ Assert.False(promoted.IsPreview);
+ Assert.Null(tabs.PreviewTab);
+ }
+
+ [Fact]
+ public void GraduateActive_ClearsPreviewFlag()
+ {
+ var tabs = new OpenTabsViewModel();
+ tabs.OpenAsPreview(Clip("A"));
+ tabs.GraduateActive();
+
+ Assert.False(tabs.ActiveTab!.IsPreview);
+ Assert.Null(tabs.PreviewTab);
+ }
+
+ [Fact]
+ public void EditingPreviewClip_GraduatesTab()
+ {
+ var tabs = new OpenTabsViewModel();
+ var clip = Clip("A");
+ var tab = tabs.OpenAsPreview(clip);
+
+ clip.ScriptDocument!.Text += "\n# edited";
+ // The editor's ContentChanged event is debounced via the UI dispatcher;
+ // drive the same handler synchronously for the test.
+ clip.HandleEditorContentChanged();
+
+ Assert.True(clip.IsDirty);
+ Assert.False(tab.IsPreview);
+ Assert.Null(tabs.PreviewTab);
+ }
+
+ [Fact]
+ public void Close_PicksNeighbour_WhenClosingActive()
+ {
+ var tabs = new OpenTabsViewModel();
+ var a = tabs.OpenAsPermanent(Clip("A"));
+ var b = tabs.OpenAsPermanent(Clip("B"));
+ var c = tabs.OpenAsPermanent(Clip("C"));
+
+ tabs.ActiveTab = b;
+ tabs.Close(b);
+
+ Assert.Same(c, tabs.ActiveTab); // next neighbour preferred
+ Assert.DoesNotContain(b, tabs.Tabs);
+
+ tabs.Close(c);
+ Assert.Same(a, tabs.ActiveTab); // falls back to previous when no next
+ }
+
+ [Fact]
+ public void Close_LastTab_NullsActive()
+ {
+ var tabs = new OpenTabsViewModel();
+ var a = tabs.OpenAsPreview(Clip("A"));
+
+ tabs.Close(a);
+
+ Assert.Null(tabs.ActiveTab);
+ Assert.Null(tabs.PreviewTab);
+ Assert.Empty(tabs.Tabs);
+ }
+
+ [Fact]
+ public void CloseClip_RemovesAllTabsForThatClip()
+ {
+ var tabs = new OpenTabsViewModel();
+ var clip = Clip("A");
+ tabs.OpenAsPermanent(clip);
+
+ tabs.CloseClip(clip);
+
+ Assert.Empty(tabs.Tabs);
+ }
+}