diff --git a/src/SharpFM.Model/FolderData.cs b/src/SharpFM.Model/FolderData.cs
new file mode 100644
index 0000000..7b22b0b
--- /dev/null
+++ b/src/SharpFM.Model/FolderData.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+
+namespace SharpFM.Model;
+
+///
+/// Data transfer object for folder-metadata persistence. Carries the FileMaker
+/// <Group> attributes that have no home on individual
+/// entries. An entry exists for every folder the user
+/// has materialized — including empty folders that contain no clips.
+///
+///
+/// Hierarchy this folder lives at, as an ordered list of segment names. The
+/// final segment is the folder's own name. Empty paths are not valid.
+///
+public sealed record FolderData(IReadOnlyList Path)
+{
+ ///
+ /// FileMaker Group/@id, preserved when the folder came from a paste.
+ /// Null for folders the user created locally; the host assigns an id when
+ /// the folder is copied back to FileMaker.
+ ///
+ public int? Id { get; init; }
+
+ ///
+ /// FileMaker Group/@includeInMenu. Defaults to true, matching
+ /// FileMaker's default for new groups.
+ ///
+ public bool IncludeInMenu { get; init; } = true;
+
+ ///
+ /// FileMaker Group/@groupCollapsed. Defaults to false; the UI
+ /// uses this to pre-collapse a pasted group on load.
+ ///
+ public bool GroupCollapsed { get; init; }
+}
diff --git a/src/SharpFM.Model/GroupPasteDecomposer.cs b/src/SharpFM.Model/GroupPasteDecomposer.cs
new file mode 100644
index 0000000..69baeeb
--- /dev/null
+++ b/src/SharpFM.Model/GroupPasteDecomposer.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace SharpFM.Model;
+
+///
+/// One script extracted from a FileMaker Group paste, paired with the folder
+/// path it should land in. is a full <fmxmlsnippet>
+/// envelope wrapping a single <Script>, ready to be re-pasted back
+/// to FileMaker as a single clip.
+///
+public sealed record GroupPasteEntry(string Name, string Xml, IReadOnlyList FolderPath);
+
+///
+/// Decomposed view of an fmxmlsnippet Group paste: the per-script
+/// entries plus folder metadata captured from every enclosing
+/// <Group>.
+///
+public sealed record GroupPasteResult(
+ IReadOnlyList Entries,
+ IReadOnlyList Folders);
+
+///
+/// Decomposes a FileMaker script-folder paste (an fmxmlsnippet whose
+/// root contains <Group> elements) into one entry per
+/// <Script>, preserving the Group hierarchy as
+/// and the Group attributes as
+/// .
+///
+public static class GroupPasteDecomposer
+{
+ ///
+ /// Returns the entries and folder metadata when the snippet contains any
+ /// <Group> at the top level, or null when the snippet
+ /// is a plain single-clip paste. Scripts that sit at the root alongside a
+ /// Group are emitted with an empty
+ /// so the caller can place them at the paste root.
+ ///
+ public static GroupPasteResult? TryDecompose(string xml)
+ {
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Parse(xml);
+ }
+ catch (XmlException)
+ {
+ return null;
+ }
+
+ var root = doc.Root;
+ if (root is null) return null;
+
+ var hasGroup = false;
+ foreach (var _ in root.Elements("Group")) { hasGroup = true; break; }
+ if (!hasGroup) return null;
+
+ var entries = new List();
+ var folders = new List();
+ Walk(root, new List(), entries, folders);
+ return new GroupPasteResult(entries, folders);
+ }
+
+ private static void Walk(
+ XElement parent,
+ List folderPath,
+ List entries,
+ List folders)
+ {
+ foreach (var child in parent.Elements())
+ {
+ switch (child.Name.LocalName)
+ {
+ case "Script":
+ entries.Add(BuildEntry(child, folderPath));
+ break;
+ case "Group":
+ var name = child.Attribute("name")?.Value ?? "Group";
+ folderPath.Add(name);
+ folders.Add(BuildFolder(child, folderPath));
+ Walk(child, folderPath, entries, folders);
+ folderPath.RemoveAt(folderPath.Count - 1);
+ break;
+ }
+ }
+ }
+
+ private static GroupPasteEntry BuildEntry(XElement script, List folderPath)
+ {
+ var name = script.Attribute("name")?.Value;
+ if (string.IsNullOrEmpty(name)) name = "new-clip";
+
+ var snippet = new XElement("fmxmlsnippet",
+ new XAttribute("type", "FMObjectList"),
+ new XElement(script));
+
+ var xml = new XDocument(snippet).ToString(SaveOptions.DisableFormatting);
+
+ return new GroupPasteEntry(name, xml, folderPath.ToArray());
+ }
+
+ private static FolderData BuildFolder(XElement group, List folderPath)
+ {
+ int? id = null;
+ if (int.TryParse(group.Attribute("id")?.Value, out var parsedId))
+ {
+ id = parsedId;
+ }
+
+ return new FolderData(folderPath.ToArray())
+ {
+ Id = id,
+ IncludeInMenu = ParseFmBool(group.Attribute("includeInMenu"), defaultValue: true),
+ GroupCollapsed = ParseFmBool(group.Attribute("groupCollapsed"), defaultValue: false),
+ };
+ }
+
+ private static bool ParseFmBool(XAttribute? attribute, bool defaultValue)
+ {
+ if (attribute is null) return defaultValue;
+ return string.Equals(attribute.Value, "True", System.StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/SharpFM.Model/IClipRepository.cs b/src/SharpFM.Model/IClipRepository.cs
index b17808c..06f1f76 100644
--- a/src/SharpFM.Model/IClipRepository.cs
+++ b/src/SharpFM.Model/IClipRepository.cs
@@ -36,6 +36,22 @@ public interface IClipRepository
///
Task SaveClipsAsync(IReadOnlyList clips);
+ ///
+ /// Load folder metadata records — one per materialized folder (including
+ /// empty ones). Default implementation returns an empty list for backends
+ /// that don't model folders independently of clip paths.
+ ///
+ Task> LoadFoldersAsync() =>
+ Task.FromResult>([]);
+
+ ///
+ /// Save folder metadata. The implementation should handle creates,
+ /// updates, and deletes (folders not in the list should be removed).
+ /// Default implementation is a no-op for backends that infer folders from
+ /// clip paths only.
+ ///
+ Task SaveFoldersAsync(IReadOnlyList folders) => Task.CompletedTask;
+
///
/// Open a location picker and switch to the selected location.
/// Only called if is true.
diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml
index 9683ebc..52fa61f 100644
--- a/src/SharpFM/MainWindow.axaml
+++ b/src/SharpFM/MainWindow.axaml
@@ -35,12 +35,14 @@
@@ -156,72 +159,100 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpFM/MainWindow.axaml.cs b/src/SharpFM/MainWindow.axaml.cs
index b808f4d..565655d 100644
--- a/src/SharpFM/MainWindow.axaml.cs
+++ b/src/SharpFM/MainWindow.axaml.cs
@@ -225,19 +225,34 @@ 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);
+ if (node is null) return;
+ if (node.Clip is not null)
+ {
+ vm.OpenClipAsPreview(node.Clip);
+ return;
+ }
+ vm.OpenFolderAsSelection(node.Path);
}
// Right-click bypasses the Tapped handler, so the context menu would
// otherwise operate on whatever was previously selected. Promote the
- // right-clicked node into the active selection before the menu opens.
+ // right-clicked node into the active selection before the menu opens —
+ // and treat a click in the empty area as "select the repository root".
private void ClipsTree_ContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
var node = FindClipNode(e.Source);
- if (node?.Clip is null) return;
- vm.OpenClipAsPreview(node.Clip);
+ if (node is null)
+ {
+ vm.OpenFolderAsSelection([]);
+ return;
+ }
+ if (node.Clip is not null)
+ {
+ vm.OpenClipAsPreview(node.Clip);
+ return;
+ }
+ vm.OpenFolderAsSelection(node.Path);
}
private void ClipsTree_DoubleTapped(object? sender, TappedEventArgs e)
diff --git a/src/SharpFM/Models/ClipRepository.cs b/src/SharpFM/Models/ClipRepository.cs
index 601e362..6014e07 100644
--- a/src/SharpFM/Models/ClipRepository.cs
+++ b/src/SharpFM/Models/ClipRepository.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Threading.Tasks;
using NLog;
using SharpFM.Model;
@@ -11,12 +12,32 @@ namespace SharpFM.Models;
///
/// File-system-based clip repository. Stores each clip as a file in a directory
/// tree. Subdirectories map one-to-one to
-/// segments.
+/// segments. Per-folder metadata (FileMaker Group id, includeInMenu,
+/// groupCollapsed) is persisted in a sidecar
+/// inside each folder; an "empty folder" is a directory that contains only the
+/// sidecar.
///
public class ClipRepository : IClipRepository
{
private static readonly Logger Log = LogManager.GetCurrentClassLogger();
+ /// Sidecar filename used to store folder metadata and to mark
+ /// empty folders so the orphan sweep doesn't reclaim them.
+ public const string FolderMarkerFileName = ".sharpfm-folder.json";
+
+ private static readonly JsonSerializerOptions FolderJsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ private sealed record FolderMarker
+ {
+ public int? Id { get; init; }
+ public bool IncludeInMenu { get; init; } = true;
+ public bool GroupCollapsed { get; init; }
+ }
+
public string ProviderName => "Local Files";
public string CurrentLocation => ClipPath;
@@ -49,11 +70,12 @@ public Task> LoadClipsAsync()
{
var fi = new FileInfo(clipFile);
if (string.IsNullOrEmpty(fi.Extension)) continue;
+ if (string.Equals(fi.Name, FolderMarkerFileName, StringComparison.OrdinalIgnoreCase)) continue;
var folderPath = GetRelativeFolderSegments(root.FullName, fi.Directory!.FullName);
clips.Add(new ClipData(
- Name: Path.GetFileNameWithoutExtension(fi.Name),
+ Name: DecodeName(Path.GetFileNameWithoutExtension(fi.Name)),
ClipType: fi.Extension.TrimStart('.'),
Xml: File.ReadAllText(clipFile))
{
@@ -82,7 +104,7 @@ public Task SaveClipsAsync(IReadOnlyList clips)
Directory.CreateDirectory(targetDir);
- var fileName = $"{clip.Name}.{clip.ClipType}";
+ var fileName = $"{EncodeName(clip.Name)}.{clip.ClipType}";
var clipPath = Path.Combine(targetDir, fileName);
File.WriteAllText(clipPath, clip.Xml);
@@ -91,8 +113,14 @@ public Task SaveClipsAsync(IReadOnlyList clips)
}
// Remove files for clips that no longer exist (anywhere in the tree).
+ // Folder marker files are preserved by SaveFoldersAsync — they belong
+ // to the folder, not to any individual clip.
foreach (var file in Directory.EnumerateFiles(ClipPath, "*", SearchOption.AllDirectories))
{
+ if (string.Equals(Path.GetFileName(file), FolderMarkerFileName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
var relative = Path.GetRelativePath(ClipPath, file);
if (!activeRelativePaths.Contains(relative))
{
@@ -100,7 +128,83 @@ public Task SaveClipsAsync(IReadOnlyList clips)
}
}
- // Clean up any now-empty subdirectories (bottom-up).
+ PruneEmptyDirectories();
+
+ return Task.CompletedTask;
+ }
+
+ public Task> LoadFoldersAsync()
+ {
+ var folders = new List();
+ var root = new DirectoryInfo(ClipPath);
+
+ foreach (var markerPath in Directory.EnumerateFiles(ClipPath, FolderMarkerFileName, SearchOption.AllDirectories))
+ {
+ try
+ {
+ var fi = new FileInfo(markerPath);
+ var path = GetRelativeFolderSegments(root.FullName, fi.Directory!.FullName);
+ if (path.Count == 0) continue;
+
+ var marker = JsonSerializer.Deserialize(File.ReadAllText(markerPath), FolderJsonOptions)
+ ?? new FolderMarker();
+
+ folders.Add(new FolderData(path)
+ {
+ Id = marker.Id,
+ IncludeInMenu = marker.IncludeInMenu,
+ GroupCollapsed = marker.GroupCollapsed,
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to load folder marker: {File}", markerPath);
+ }
+ }
+
+ return Task.FromResult>(folders);
+ }
+
+ public Task SaveFoldersAsync(IReadOnlyList folders)
+ {
+ var activeMarkerPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var folder in folders)
+ {
+ var safeSegments = SanitizeFolderPath(folder.Path);
+ if (safeSegments.Count == 0) continue;
+
+ var targetDir = Path.Combine(new[] { ClipPath }.Concat(safeSegments).ToArray());
+ Directory.CreateDirectory(targetDir);
+
+ var marker = new FolderMarker
+ {
+ Id = folder.Id,
+ IncludeInMenu = folder.IncludeInMenu,
+ GroupCollapsed = folder.GroupCollapsed,
+ };
+
+ var markerPath = Path.Combine(targetDir, FolderMarkerFileName);
+ File.WriteAllText(markerPath, JsonSerializer.Serialize(marker, FolderJsonOptions));
+ activeMarkerPaths.Add(markerPath);
+ }
+
+ // Remove orphan marker files. Empty directories are reclaimed below.
+ foreach (var marker in Directory.EnumerateFiles(ClipPath, FolderMarkerFileName, SearchOption.AllDirectories))
+ {
+ if (!activeMarkerPaths.Contains(marker))
+ {
+ File.Delete(marker);
+ }
+ }
+
+ PruneEmptyDirectories();
+
+ return Task.CompletedTask;
+ }
+
+ private void PruneEmptyDirectories()
+ {
foreach (var dir in Directory.EnumerateDirectories(ClipPath, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
@@ -110,8 +214,6 @@ public Task SaveClipsAsync(IReadOnlyList clips)
catch (IOException) { /* best-effort */ }
}
}
-
- return Task.CompletedTask;
}
public Task PickLocationAsync()
@@ -125,12 +227,16 @@ private static IReadOnlyList GetRelativeFolderSegments(string root, stri
{
var rel = Path.GetRelativePath(root, directory);
if (string.IsNullOrEmpty(rel) || rel == ".") return [];
- return rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ var raw = rel.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries);
+ var decoded = new string[raw.Length];
+ for (var i = 0; i < raw.Length; i++) decoded[i] = DecodeName(raw[i]);
+ return decoded;
}
- // Reject traversal/rooted segments; repositories are logical stores and
- // must not escape their root no matter what a misbehaving provider sends.
+ // Reject traversal segments (security) and percent-encode any character
+ // the filesystem rejects so FileMaker names containing '/' or ':' survive
+ // the round-trip instead of being silently dropped.
private static IReadOnlyList SanitizeFolderPath(IReadOnlyList segments)
{
if (segments is null || segments.Count == 0) return [];
@@ -139,9 +245,69 @@ private static IReadOnlyList SanitizeFolderPath(IReadOnlyList se
{
if (string.IsNullOrWhiteSpace(raw)) continue;
if (raw == "." || raw == "..") continue;
- if (raw.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) continue;
- safe.Add(raw);
+ safe.Add(EncodeName(raw));
}
return safe;
}
+
+ private static readonly char[] InvalidNameChars = Path.GetInvalidFileNameChars();
+
+ ///
+ /// Percent-encode characters the filesystem rejects (path separators,
+ /// reserved chars, control codes) plus '%' itself so encoding is reversible.
+ /// Output is plain ASCII and safe to embed in any cross-platform filename.
+ ///
+ internal static string EncodeName(string raw)
+ {
+ var needsEncoding = false;
+ for (var i = 0; i < raw.Length; i++)
+ {
+ if (raw[i] == '%' || Array.IndexOf(InvalidNameChars, raw[i]) >= 0)
+ {
+ needsEncoding = true;
+ break;
+ }
+ }
+ if (!needsEncoding) return raw;
+
+ var sb = new System.Text.StringBuilder(raw.Length + 8);
+ foreach (var ch in raw)
+ {
+ if (ch == '%' || Array.IndexOf(InvalidNameChars, ch) >= 0)
+ {
+ sb.Append('%');
+ sb.Append(((int)ch).ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+ }
+ return sb.ToString();
+ }
+
+ /// Reverse of . Unrecognised '%' triplets pass through unchanged.
+ internal static string DecodeName(string encoded)
+ {
+ if (encoded.IndexOf('%') < 0) return encoded;
+
+ var sb = new System.Text.StringBuilder(encoded.Length);
+ for (var i = 0; i < encoded.Length; i++)
+ {
+ if (encoded[i] == '%' && i + 2 < encoded.Length
+ && int.TryParse(encoded.AsSpan(i + 1, 2),
+ System.Globalization.NumberStyles.HexNumber,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var code))
+ {
+ sb.Append((char)code);
+ i += 2;
+ }
+ else
+ {
+ sb.Append(encoded[i]);
+ }
+ }
+ return sb.ToString();
+ }
}
diff --git a/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs b/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs
index 3697763..3370983 100644
--- a/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs
+++ b/src/SharpFM/ViewModels/ClipTreeNodeViewModel.cs
@@ -4,6 +4,7 @@
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
+using SharpFM.Model;
namespace SharpFM.ViewModels;
@@ -28,6 +29,13 @@ private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
public bool IsFolder => Clip is null;
public bool IsClip => Clip is not null;
+ ///
+ /// Folder segments leading from the repository root to this node. For
+ /// folder nodes this includes the folder's own name; for clip leaves it
+ /// matches the clip's .
+ ///
+ public IReadOnlyList Path { get; private set; } = [];
+
public ObservableCollection Children { get; } = [];
private bool _isExpanded = true;
@@ -43,8 +51,15 @@ private ClipTreeNodeViewModel(string name, ClipViewModel? clip)
Clip = clip;
}
- public static ClipTreeNodeViewModel Folder(string name) => new(name, null);
- public static ClipTreeNodeViewModel ClipLeaf(ClipViewModel clip) => new(clip.Clip.Name, clip);
+ public static ClipTreeNodeViewModel Folder(string name, IReadOnlyList path)
+ {
+ return new ClipTreeNodeViewModel(name, null) { Path = path };
+ }
+
+ public static ClipTreeNodeViewModel ClipLeaf(ClipViewModel clip)
+ {
+ return new ClipTreeNodeViewModel(clip.Clip.Name, clip) { Path = clip.FolderPath };
+ }
///
/// Build a set of root-level nodes from a flat clip collection. Clips are
@@ -53,18 +68,29 @@ private ClipTreeNodeViewModel(string name, ClipViewModel? clip)
/// are sorted by name for stable display.
///
/// Flat clip collection.
+ /// Materialized folders (including empty ones) the
+ /// tree should render even when they contain no clips.
/// 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.
+ /// auto-expanded. Empty folders are filtered out while a filter is active.
public static IReadOnlyList Build(
IEnumerable clips,
- string searchText = "")
+ string searchText = "",
+ IEnumerable? folders = null)
{
var rootFolders = new Dictionary(StringComparer.OrdinalIgnoreCase);
var rootLeaves = new List();
var filter = string.IsNullOrEmpty(searchText) ? null : searchText;
+ if (filter is null && folders is not null)
+ {
+ foreach (var folder in folders)
+ {
+ EnsureFolderPath(rootFolders, folder);
+ }
+ }
+
foreach (var clip in clips)
{
var matches = filter is null ||
@@ -75,7 +101,7 @@ public static IReadOnlyList Build(
var head = clip.FolderPath[0];
if (!rootFolders.TryGetValue(head, out var folder))
{
- folder = Folder(head);
+ folder = Folder(head, new[] { head });
rootFolders.Add(head, folder);
}
@@ -87,9 +113,10 @@ public static IReadOnlyList Build(
}
}
- // Drop folders that end up empty after filtering.
+ // Filtering hides empty folders; with no filter, materialized folders
+ // (created or pasted) stay visible even before they contain anything.
var folderList = rootFolders.Values
- .Where(f => HasAnyDescendant(f))
+ .Where(f => filter is null || HasAnyDescendant(f))
.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -103,6 +130,38 @@ public static IReadOnlyList Build(
return result;
}
+ private static void EnsureFolderPath(
+ Dictionary rootFolders,
+ FolderData folder)
+ {
+ if (folder.Path.Count == 0) return;
+
+ var head = folder.Path[0];
+ if (!rootFolders.TryGetValue(head, out var node))
+ {
+ node = Folder(head, new[] { head });
+ rootFolders.Add(head, node);
+ }
+ EnsureFolderChildren(node, folder.Path, depth: 1);
+ }
+
+ private static void EnsureFolderChildren(
+ ClipTreeNodeViewModel parent,
+ IReadOnlyList path,
+ int depth)
+ {
+ if (depth >= path.Count) return;
+ var segment = path[depth];
+ var child = parent.Children.FirstOrDefault(c => c.IsFolder &&
+ string.Equals(c.Name, segment, StringComparison.OrdinalIgnoreCase));
+ if (child is null)
+ {
+ child = Folder(segment, path.Take(depth + 1).ToArray());
+ parent.Children.Add(child);
+ }
+ EnsureFolderChildren(child, path, depth + 1);
+ }
+
private static void InsertClipIntoFolder(
ClipTreeNodeViewModel folder,
ClipViewModel clip,
@@ -123,7 +182,7 @@ private static void InsertClipIntoFolder(
string.Equals(c.Name, segment, StringComparison.OrdinalIgnoreCase));
if (child is null)
{
- child = Folder(segment);
+ child = Folder(segment, path.Take(depth + 1).ToArray());
folder.Children.Add(child);
}
diff --git a/src/SharpFM/ViewModels/MainWindowViewModel.cs b/src/SharpFM/ViewModels/MainWindowViewModel.cs
index bde6459..48c35ab 100644
--- a/src/SharpFM/ViewModels/MainWindowViewModel.cs
+++ b/src/SharpFM/ViewModels/MainWindowViewModel.cs
@@ -123,6 +123,9 @@ public MainWindowViewModel(
RootNodes = [];
+ Folders = [];
+ Folders.CollectionChanged += (_, _) => RebuildTree();
+
FileMakerClips = [];
FileMakerClips.CollectionChanged += (sender, e) =>
{
@@ -145,24 +148,26 @@ public MainWindowViewModel(
};
_repository = new ClipRepository(_currentPath);
- LoadClipsFromRepository();
+ LoadFromRepository();
}
public IClipRepository ActiveRepository => _repository;
- private void LoadClipsFromRepository()
+ private void LoadFromRepository()
{
var clips = _repository.LoadClipsAsync().GetAwaiter().GetResult();
- PopulateClips(clips);
+ var folders = _repository.LoadFoldersAsync().GetAwaiter().GetResult();
+ Populate(clips, folders);
}
- private async Task LoadClipsFromRepositoryAsync()
+ private async Task LoadFromRepositoryAsync()
{
var clips = await _repository.LoadClipsAsync();
- PopulateClips(clips);
+ var folders = await _repository.LoadFoldersAsync();
+ Populate(clips, folders);
}
- private void PopulateClips(IReadOnlyList clips)
+ private void Populate(IReadOnlyList clips, IReadOnlyList folders)
{
// Closing the tab strip first keeps the editor area from holding refs
// to clips that are about to be replaced.
@@ -173,6 +178,10 @@ private void PopulateClips(IReadOnlyList clips)
// the outgoing clips explicitly.
foreach (var c in FileMakerClips) c.Dispose();
FileMakerClips.Clear();
+ Folders.Clear();
+
+ foreach (var folder in folders) Folders.Add(folder);
+
foreach (var clip in clips)
{
FileMakerClips.Add(new ClipViewModel(
@@ -189,7 +198,7 @@ public async Task OpenFolderPicker()
{
CurrentPath = await _folderService.GetFolderAsync();
_repository = new ClipRepository(CurrentPath);
- await LoadClipsFromRepositoryAsync();
+ await LoadFromRepositoryAsync();
ShowStatus($"Opened {CurrentPath}");
}
catch (Exception e)
@@ -203,7 +212,7 @@ public async Task SwitchRepository(IClipRepository repository)
{
_repository = repository;
CurrentPath = repository.CurrentLocation;
- await LoadClipsFromRepositoryAsync();
+ await LoadFromRepositoryAsync();
ShowStatus($"Switched to {repository.ProviderName}: {repository.CurrentLocation}");
}
@@ -221,6 +230,9 @@ public async Task SaveClipsStorageAsync()
})
.ToList();
+ // Folders persist first so the orphan sweep in SaveClipsAsync
+ // sees up-to-date marker files when reclaiming empty directories.
+ await _repository.SaveFoldersAsync(Folders.ToList());
await _repository.SaveClipsAsync(clipData);
foreach (var c in FileMakerClips) c.MarkSaved();
@@ -287,16 +299,52 @@ public void DeleteSelectedClip()
ShowStatus($"Deleted clip '{name}'");
}
- public void NewScriptCommand() => CreateNewClip("New Script", "Mac-XMSS", "script");
+ public void NewScriptCommand() => CreateNewClip("New Script", "Mac-XMSC", "script");
+
+ public void NewScriptStepsCommand() => CreateNewClip("New Script Steps", "Mac-XMSS", "script steps");
public void NewTableCommand() => CreateNewClip("New Table", "Mac-XMTB", "table");
+ ///
+ /// Create an empty folder under the current target folder. The user is
+ /// prompted for the folder name; cancelling or supplying a blank/colliding
+ /// name aborts.
+ ///
+ public async Task NewFolderCommand()
+ {
+ var parent = TargetFolderPath;
+
+ var entered = await _prompt.PromptAsync("New folder", "Folder name:", "New Folder");
+ if (entered is null) return;
+
+ var trimmed = entered.Trim();
+ if (trimmed.Length == 0)
+ {
+ ShowStatus("Folder name cannot be empty", isError: true);
+ return;
+ }
+
+ var path = Combine(parent, new[] { trimmed });
+ if (Folders.Any(f => FolderPathsEqual(f.Path, path)))
+ {
+ ShowStatus($"Folder '{trimmed}' already exists at this level", isError: true);
+ return;
+ }
+
+ Folders.Add(new FolderData(path));
+ ShowStatus($"Created folder '{trimmed}'");
+ }
+
private void CreateNewClip(string name, string format, string kind)
{
try
{
+ var folderPath = TargetFolderPath;
var seed = ClipTypeRegistry.For(format).DefaultXml(name);
- var vm = new ClipViewModel(Clip.FromXml(name, format, seed));
+ var vm = new ClipViewModel(Clip.FromXml(name, format, seed))
+ {
+ FolderPath = folderPath,
+ };
FileMakerClips.Add(vm);
SelectedClip = vm;
ShowStatus($"Created new {kind}");
@@ -334,17 +382,30 @@ public async Task CopyAsClass()
}
}
- // Pick a clip name that doesn't collide with anything already loaded.
- // Returns the input when it's free, otherwise appends "(2)", "(3)", … until
- // a free slot is found.
- private string UniqueClipName(string desired)
+ // Pick a clip name that doesn't collide with anything already loaded in
+ // the same folder. Folder paths are compared case-insensitively, matching
+ // the tree's grouping rules.
+ private string UniqueClipName(string desired, IReadOnlyList folderPath)
{
- if (FileMakerClips.All(c => c.Clip.Name != desired)) return desired;
+ bool Collides(string candidate) => FileMakerClips.Any(c =>
+ c.Clip.Name == candidate && FolderPathsEqual(c.FolderPath, folderPath));
+
+ if (!Collides(desired)) return desired;
for (var n = 2; ; n++)
{
var candidate = $"{desired} ({n})";
- if (FileMakerClips.All(c => c.Clip.Name != candidate)) return candidate;
+ if (!Collides(candidate)) return candidate;
+ }
+ }
+
+ private static bool FolderPathsEqual(IReadOnlyList a, IReadOnlyList b)
+ {
+ if (a.Count != b.Count) return false;
+ for (var i = 0; i < a.Count; i++)
+ {
+ if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase)) return false;
}
+ return true;
}
public async Task PasteFileMakerClipData()
@@ -355,6 +416,11 @@ public async Task PasteFileMakerClipData()
int count = 0;
ClipViewModel? lastAdded = null;
+ // Group pastes land relative to the current target folder so the
+ // user can drop a folder into the spot they're looking at — whether
+ // that's a selected clip's folder or an explicitly tapped empty one.
+ var pasteRoot = TargetFolderPath;
+
foreach (var format in formats.Where(f => f.StartsWith("Mac-", StringComparison.CurrentCultureIgnoreCase)).Distinct())
{
if (string.IsNullOrEmpty(format)) continue;
@@ -363,16 +429,42 @@ public async Task PasteFileMakerClipData()
if (clipData is not byte[] dataObj) continue;
- var clip = Clip.FromWireBytes("new-clip", format, dataObj);
+ var rawClip = Clip.FromWireBytes("new-clip", format, dataObj);
+
+ var decomposed = GroupPasteDecomposer.TryDecompose(rawClip.Xml);
+ if (decomposed is { Entries.Count: > 0 })
+ {
+ foreach (var folder in decomposed.Folders)
+ {
+ var fullPath = Combine(pasteRoot, folder.Path);
+ UpsertFolder(folder with { Path = fullPath });
+ }
+
+ foreach (var entry in decomposed.Entries)
+ {
+ var entryClip = Clip.FromXml("new-clip", format, entry.Xml);
+ if (FileMakerClips.Any(k => k.Clip.Xml == entryClip.Xml &&
+ FolderPathsEqual(k.FolderPath, Combine(pasteRoot, entry.FolderPath))))
+ continue;
+
+ var folderPath = Combine(pasteRoot, entry.FolderPath);
+ entryClip = entryClip.Rename(UniqueClipName(entry.Name, folderPath));
+
+ lastAdded = new ClipViewModel(entryClip) { FolderPath = folderPath };
+ FileMakerClips.Add(lastAdded);
+ count++;
+ }
+ continue;
+ }
// don't add duplicates
- if (FileMakerClips.Any(k => k.Clip.Xml == clip.Xml)) continue;
+ if (FileMakerClips.Any(k => k.Clip.Xml == rawClip.Xml)) continue;
- var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(clip.Xml);
+ var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(rawClip.Xml);
var desired = string.IsNullOrWhiteSpace(sourceName) ? "new-clip" : sourceName;
- clip = clip.Rename(UniqueClipName(desired));
+ var singleClip = rawClip.Rename(UniqueClipName(desired, pasteRoot));
- lastAdded = new ClipViewModel(clip);
+ lastAdded = new ClipViewModel(singleClip) { FolderPath = pasteRoot };
FileMakerClips.Add(lastAdded);
count++;
}
@@ -391,6 +483,16 @@ public async Task PasteFileMakerClipData()
}
}
+ private static IReadOnlyList Combine(IReadOnlyList root, IReadOnlyList sub)
+ {
+ if (root.Count == 0) return sub;
+ if (sub.Count == 0) return root;
+ var combined = new string[root.Count + sub.Count];
+ for (var i = 0; i < root.Count; i++) combined[i] = root[i];
+ for (var i = 0; i < sub.Count; i++) combined[root.Count + i] = sub[i];
+ return combined;
+ }
+
public async Task CopySelectedToClip()
{
if (SelectedClip is not ClipViewModel data)
@@ -519,6 +621,27 @@ public static string Version
public ObservableCollection FileMakerClips { get; set; }
+ ///
+ /// Folder metadata for every materialized folder — empty folders the user
+ /// created locally and the FileMaker <Group> attributes captured
+ /// when a Group paste landed. The tree builder consults this collection so
+ /// empty folders still render.
+ ///
+ public ObservableCollection Folders { get; }
+
+ private void UpsertFolder(FolderData folder)
+ {
+ for (var i = 0; i < Folders.Count; i++)
+ {
+ if (FolderPathsEqual(Folders[i].Path, folder.Path))
+ {
+ Folders[i] = folder;
+ return;
+ }
+ }
+ Folders.Add(folder);
+ }
+
///
/// VS Code-style open tabs — the right-side editor area binds to this.
///
@@ -542,11 +665,48 @@ public ClipViewModel? SelectedClip
{
StatusMessage = "";
if (value is null) { OpenTabs.ActiveTab = null; NotifyPropertyChanged(); return; }
+ SelectedFolderPath = null;
OpenTabs.OpenAsPermanent(value);
NotifyPropertyChanged();
}
}
+ private IReadOnlyList? _selectedFolderPath;
+
+ ///
+ /// Folder path tapped in the tree, if any. New clip/folder/paste operations
+ /// land relative to this path (it takes priority over the selected clip's
+ /// own folder). Cleared when a clip is opened.
+ ///
+ public IReadOnlyList? SelectedFolderPath
+ {
+ get => _selectedFolderPath;
+ set
+ {
+ _selectedFolderPath = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(TargetFolderPath));
+ }
+ }
+
+ ///
+ /// Where the next "new" or "paste" operation will land. Resolves to the
+ /// explicitly tapped folder, else the selected clip's folder, else root.
+ ///
+ public IReadOnlyList TargetFolderPath =>
+ SelectedFolderPath ?? SelectedClip?.FolderPath ?? [];
+
+ ///
+ /// Select a folder path as the active target for new clip / paste /
+ /// new folder operations. An empty path means "the repository root".
+ /// The tab strip is intentionally left alone — navigating the tree should
+ /// not close whatever the user is editing.
+ ///
+ public void OpenFolderAsSelection(IReadOnlyList folderPath)
+ {
+ SelectedFolderPath = folderPath;
+ }
+
/// True when the status bar should display a parse-fidelity summary for the selected clip.
public bool ParseFidelityVisible => SelectedClip is not null;
@@ -597,9 +757,28 @@ public string SearchText
private void RebuildTree()
{
- var nodes = ClipTreeNodeViewModel.Build(FileMakerClips, _searchText);
+ var nodes = ClipTreeNodeViewModel.Build(FileMakerClips, _searchText, Folders);
+ var root = ClipTreeNodeViewModel.Folder(RootNodeLabel, []);
+ foreach (var n in nodes) root.Children.Add(n);
RootNodes.Clear();
- foreach (var n in nodes) RootNodes.Add(n);
+ RootNodes.Add(root);
+ }
+
+ ///
+ /// Label for the synthetic root node shown at the top of the tree. Derived
+ /// from the current repository location's leaf segment so it adapts when
+ /// the user switches folders; falls back to a generic label for repos
+ /// whose location string isn't a filesystem path.
+ ///
+ private string RootNodeLabel
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(_currentPath)) return "All Clips";
+ var trimmed = _currentPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var leaf = Path.GetFileName(trimmed);
+ return string.IsNullOrEmpty(leaf) ? "All Clips" : leaf;
+ }
}
private string _currentPath;
@@ -610,6 +789,7 @@ public string CurrentPath
{
_currentPath = value;
NotifyPropertyChanged();
+ RebuildTree();
}
}
diff --git a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
index 7f6b780..35d93e5 100644
--- a/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
+++ b/tests/SharpFM.Plugin.Tests/PluginHostTests.cs
@@ -49,7 +49,7 @@ public void SelectedClip_ReturnsClipData_WhenClipSelected()
Assert.NotNull(host.SelectedClip);
Assert.Equal("New Script", host.SelectedClip!.Name);
- Assert.Equal("Mac-XMSS", host.SelectedClip.ClipType);
+ Assert.Equal("Mac-XMSC", host.SelectedClip.ClipType);
}
[Fact]
@@ -125,7 +125,7 @@ public void SelectedClip_ReturnsFreshClipData()
Assert.NotNull(clip);
Assert.Equal("New Script", clip!.Name);
- Assert.Equal("Mac-XMSS", clip.ClipType);
+ Assert.Equal("Mac-XMSC", clip.ClipType);
}
[Fact]
diff --git a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
index 05cd0ed..6a1c709 100644
--- a/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
+++ b/tests/SharpFM.Tests/Models/ClipRepositoryTests.cs
@@ -240,6 +240,244 @@ public async Task SaveClipsAsync_DeletesOrphans_AcrossSubdirectories()
finally { Directory.Delete(dir, true); }
}
+ [Fact]
+ public async Task SaveFoldersAsync_WritesMarkerWithMetadata()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ var folders = new List
+ {
+ new(new[] { "Group A" }) { Id = 42, IncludeInMenu = false, GroupCollapsed = true }
+ };
+
+ await repo.SaveFoldersAsync(folders);
+
+ var marker = Path.Combine(dir, "Group A", ".sharpfm-folder.json");
+ Assert.True(File.Exists(marker));
+ var contents = File.ReadAllText(marker);
+ Assert.Contains("\"id\": 42", contents);
+ Assert.Contains("\"includeInMenu\": false", contents);
+ Assert.Contains("\"groupCollapsed\": true", contents);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task LoadFoldersAsync_ReadsMarkerMetadata()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var sub = Path.Combine(dir, "Group A");
+ Directory.CreateDirectory(sub);
+ File.WriteAllText(Path.Combine(sub, ".sharpfm-folder.json"),
+ "{\"id\": 7, \"includeInMenu\": false, \"groupCollapsed\": true}");
+
+ var repo = new ClipRepository(dir);
+ var folders = await repo.LoadFoldersAsync();
+
+ var f = Assert.Single(folders);
+ Assert.Equal(new[] { "Group A" }, f.Path);
+ Assert.Equal(7, f.Id);
+ Assert.False(f.IncludeInMenu);
+ Assert.True(f.GroupCollapsed);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveFoldersAsync_EmptyFolder_CreatesDirectory()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveFoldersAsync([new(new[] { "Empty" })]);
+
+ Assert.True(Directory.Exists(Path.Combine(dir, "Empty")));
+ Assert.True(File.Exists(Path.Combine(dir, "Empty", ".sharpfm-folder.json")));
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task LoadClipsAsync_IgnoresFolderMarkerFile()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var sub = Path.Combine(dir, "Group");
+ Directory.CreateDirectory(sub);
+ File.WriteAllText(Path.Combine(sub, ".sharpfm-folder.json"), "{}");
+ File.WriteAllText(Path.Combine(sub, "Real.Mac-XMSS"), "");
+
+ var repo = new ClipRepository(dir);
+ var clips = await repo.LoadClipsAsync();
+
+ var clip = Assert.Single(clips);
+ Assert.Equal("Real", clip.Name);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveClipsAsync_KeepsEmptyFolderWithMarker()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var sub = Path.Combine(dir, "KeepMe");
+ Directory.CreateDirectory(sub);
+ File.WriteAllText(Path.Combine(sub, ".sharpfm-folder.json"), "{}");
+
+ var repo = new ClipRepository(dir);
+ await repo.SaveClipsAsync([new("Root", "Mac-XMSS", "")]);
+
+ Assert.True(Directory.Exists(sub));
+ Assert.True(File.Exists(Path.Combine(sub, ".sharpfm-folder.json")));
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task SaveFoldersAsync_DeletesOrphanedMarkers()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var a = Path.Combine(dir, "A");
+ var b = Path.Combine(dir, "B");
+ Directory.CreateDirectory(a);
+ Directory.CreateDirectory(b);
+ File.WriteAllText(Path.Combine(a, ".sharpfm-folder.json"), "{}");
+ File.WriteAllText(Path.Combine(b, ".sharpfm-folder.json"), "{}");
+
+ var repo = new ClipRepository(dir);
+ await repo.SaveFoldersAsync([new(new[] { "A" })]);
+
+ Assert.True(File.Exists(Path.Combine(a, ".sharpfm-folder.json")));
+ Assert.False(File.Exists(Path.Combine(b, ".sharpfm-folder.json")));
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task FolderRoundtrip_PreservesMetadata()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveFoldersAsync([
+ new(new[] { "Outer", "Inner" }) { Id = 99, IncludeInMenu = false, GroupCollapsed = true },
+ new(new[] { "Outer" }) { Id = 1 }
+ ]);
+
+ var loaded = await repo.LoadFoldersAsync();
+
+ Assert.Equal(2, loaded.Count);
+ var inner = loaded.Single(f => f.Path.Count == 2);
+ Assert.Equal(new[] { "Outer", "Inner" }, inner.Path);
+ Assert.Equal(99, inner.Id);
+ Assert.False(inner.IncludeInMenu);
+ Assert.True(inner.GroupCollapsed);
+
+ var outer = loaded.Single(f => f.Path.Count == 1);
+ Assert.Equal(1, outer.Id);
+ Assert.True(outer.IncludeInMenu);
+ Assert.False(outer.GroupCollapsed);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task ClipName_WithSlash_RoundTrips()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveClipsAsync([new("Go-To-Record/Request/Page", "Mac-XMSC", "")]);
+
+ var loaded = await repo.LoadClipsAsync();
+
+ var clip = Assert.Single(loaded);
+ Assert.Equal("Go-To-Record/Request/Page", clip.Name);
+ Assert.Empty(clip.FolderPath);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task ClipName_WithMultipleInvalidChars_RoundTrips()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ var weirdName = "a/b\\c:d*e?f\"gi|j";
+ await repo.SaveClipsAsync([new(weirdName, "Mac-XMSS", "")]);
+
+ var loaded = await repo.LoadClipsAsync();
+ Assert.Equal(weirdName, Assert.Single(loaded).Name);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task ClipName_LiteralPercent_RoundTrips()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveClipsAsync([new("100% done", "Mac-XMSS", "")]);
+
+ var loaded = await repo.LoadClipsAsync();
+ Assert.Equal("100% done", Assert.Single(loaded).Name);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task FolderSegment_WithSlash_RoundTrips()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveClipsAsync([new("Inside", "Mac-XMSC", "")
+ { FolderPath = new[] { "Date/Time", "Helpers" } }]);
+
+ var loaded = await repo.LoadClipsAsync();
+ var clip = Assert.Single(loaded);
+ Assert.Equal(new[] { "Date/Time", "Helpers" }, clip.FolderPath);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
+ [Fact]
+ public async Task FolderMetadata_WithSlashInSegment_RoundTrips()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var repo = new ClipRepository(dir);
+ await repo.SaveFoldersAsync([
+ new(new[] { "Date/Time" }) { Id = 7, IncludeInMenu = false }
+ ]);
+
+ var loaded = await repo.LoadFoldersAsync();
+ var folder = Assert.Single(loaded);
+ Assert.Equal(new[] { "Date/Time" }, folder.Path);
+ Assert.Equal(7, folder.Id);
+ Assert.False(folder.IncludeInMenu);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
[Fact]
public async Task SaveClipsAsync_RejectsTraversalSegments()
{
diff --git a/tests/SharpFM.Tests/Models/GroupPasteDecomposerTests.cs b/tests/SharpFM.Tests/Models/GroupPasteDecomposerTests.cs
new file mode 100644
index 0000000..ea81c15
--- /dev/null
+++ b/tests/SharpFM.Tests/Models/GroupPasteDecomposerTests.cs
@@ -0,0 +1,199 @@
+using System.Linq;
+using System.Xml.Linq;
+using SharpFM.Model;
+using Xunit;
+
+namespace SharpFM.Tests.Models;
+
+public class GroupPasteDecomposerTests
+{
+ [Fact]
+ public void NoGroups_ReturnsNull()
+ {
+ var xml = "";
+ Assert.Null(GroupPasteDecomposer.TryDecompose(xml));
+ }
+
+ [Fact]
+ public void SingleGroupWithOneScript_EmitsOneEntryUnderFolder()
+ {
+ var xml = """
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml);
+
+ Assert.NotNull(result);
+ var entry = Assert.Single(result!.Entries);
+ Assert.Equal("FizzBuzz", entry.Name);
+ Assert.Equal(new[] { "Paste Targets" }, entry.FolderPath);
+
+ var doc = XDocument.Parse(entry.Xml);
+ Assert.Equal("fmxmlsnippet", doc.Root!.Name.LocalName);
+ var script = doc.Root.Element("Script")!;
+ Assert.Equal("FizzBuzz", script.Attribute("name")!.Value);
+ Assert.Equal("19", script.Attribute("id")!.Value);
+ Assert.Null(doc.Root.Element("Group"));
+ }
+
+ [Fact]
+ public void MultipleScriptsInGroup_EmitsOneEntryPerScript()
+ {
+ var xml = """
+
+
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml);
+
+ Assert.NotNull(result);
+ Assert.Equal(3, result!.Entries.Count);
+ Assert.All(result.Entries, e => Assert.Equal(new[] { "Utilities" }, e.FolderPath));
+ Assert.Equal(new[] { "Alpha", "Beta", "Gamma" }, result.Entries.Select(e => e.Name));
+ }
+
+ [Fact]
+ public void NestedGroups_ProduceNestedFolders()
+ {
+ var xml = """
+
+
+
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml);
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result!.Entries.Count);
+ var outer = result.Entries.Single(e => e.Name == "OuterScript");
+ Assert.Equal(new[] { "Outer" }, outer.FolderPath);
+ var inner = result.Entries.Single(e => e.Name == "InnerScript");
+ Assert.Equal(new[] { "Outer", "Inner" }, inner.FolderPath);
+ }
+
+ [Fact]
+ public void LooseScriptAlongsideGroup_StaysAtRoot()
+ {
+ var xml = """
+
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml);
+
+ Assert.NotNull(result);
+ Assert.Equal(2, result!.Entries.Count);
+ var loose = result.Entries.Single(e => e.Name == "Loose");
+ Assert.Empty(loose.FolderPath);
+ var inside = result.Entries.Single(e => e.Name == "Inside");
+ Assert.Equal(new[] { "Folder" }, inside.FolderPath);
+ }
+
+ [Fact]
+ public void Decompose_CapturesGroupAttributes()
+ {
+ var xml = """
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml);
+
+ Assert.NotNull(result);
+ var folder = Assert.Single(result!.Folders);
+ Assert.Equal(new[] { "Paste Targets" }, folder.Path);
+ Assert.Equal(16, folder.Id);
+ Assert.False(folder.IncludeInMenu);
+ Assert.True(folder.GroupCollapsed);
+ }
+
+ [Fact]
+ public void Decompose_NestedGroups_EmitsFolderPerLevel()
+ {
+ var xml = """
+
+
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml)!;
+
+ Assert.Equal(2, result.Folders.Count);
+ var outer = result.Folders.Single(f => f.Path.Count == 1);
+ Assert.Equal("Outer", outer.Path[0]);
+ Assert.Equal(1, outer.Id);
+ Assert.True(outer.IncludeInMenu);
+ Assert.False(outer.GroupCollapsed);
+
+ var inner = result.Folders.Single(f => f.Path.Count == 2);
+ Assert.Equal(new[] { "Outer", "Inner" }, inner.Path);
+ Assert.Equal(2, inner.Id);
+ Assert.False(inner.IncludeInMenu);
+ Assert.True(inner.GroupCollapsed);
+ }
+
+ [Fact]
+ public void Decompose_DefaultGroupAttributes_AreSensible()
+ {
+ var xml = """
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml)!;
+ var folder = Assert.Single(result.Folders);
+ Assert.Equal(9, folder.Id);
+ Assert.True(folder.IncludeInMenu);
+ Assert.False(folder.GroupCollapsed);
+ }
+
+ [Fact]
+ public void EachEntryXml_WrapsScriptInFmxmlsnippet()
+ {
+ var xml = """
+
+
+
+
+
+ """;
+
+ var result = GroupPasteDecomposer.TryDecompose(xml)!;
+ var entry = Assert.Single(result.Entries);
+ var doc = XDocument.Parse(entry.Xml);
+ Assert.Equal("FMObjectList", doc.Root!.Attribute("type")?.Value);
+ Assert.NotNull(doc.Root.Element("Script")!.Element("Step"));
+ }
+}
diff --git a/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
index d0e0c2a..8bb8d06 100644
--- a/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/ClipTreeNodeViewModelTests.cs
@@ -80,6 +80,70 @@ public void Build_Search_FiltersClipsAndExpandsMatchingFolders()
Assert.DoesNotContain(nodes, n => n.IsClip && n.Name == "RootMatch");
}
+ [Fact]
+ public void Build_EmptyFolder_RendersAsFolderNode()
+ {
+ var folders = new[] { new FolderData(new[] { "Empty" }) };
+
+ var nodes = ClipTreeNodeViewModel.Build([], folders: folders);
+
+ var empty = Assert.Single(nodes);
+ Assert.True(empty.IsFolder);
+ Assert.Equal("Empty", empty.Name);
+ Assert.Equal(new[] { "Empty" }, empty.Path);
+ Assert.Empty(empty.Children);
+ }
+
+ [Fact]
+ public void Build_NestedFolderNode_CarriesFullPath()
+ {
+ var folders = new[] { new FolderData(new[] { "Outer", "Inner" }) };
+
+ var nodes = ClipTreeNodeViewModel.Build([], folders: folders);
+
+ var outer = Assert.Single(nodes);
+ Assert.Equal(new[] { "Outer" }, outer.Path);
+ var inner = outer.Children.Single();
+ Assert.Equal(new[] { "Outer", "Inner" }, inner.Path);
+ }
+
+ [Fact]
+ public void Build_ClipDerivedFolder_CarriesPath()
+ {
+ var clips = new[] { Clip("X", "Outer", "Inner") };
+
+ var nodes = ClipTreeNodeViewModel.Build(clips);
+
+ var outer = Assert.Single(nodes);
+ Assert.Equal(new[] { "Outer" }, outer.Path);
+ var inner = outer.Children.Single(c => c.IsFolder);
+ Assert.Equal(new[] { "Outer", "Inner" }, inner.Path);
+ }
+
+ [Fact]
+ public void Build_EmptyFolderInsideClipFolder_RendersAsChild()
+ {
+ var clips = new[] { Clip("Sibling", "Scripts") };
+ var folders = new[] { new FolderData(new[] { "Scripts", "Drafts" }) };
+
+ var nodes = ClipTreeNodeViewModel.Build(clips, folders: folders);
+
+ var scripts = Assert.Single(nodes);
+ var drafts = scripts.Children.Single(c => c.IsFolder);
+ Assert.Equal("Drafts", drafts.Name);
+ Assert.Empty(drafts.Children);
+ }
+
+ [Fact]
+ public void Build_FilterActive_HidesEmptyFolders()
+ {
+ var folders = new[] { new FolderData(new[] { "Empty" }) };
+
+ var nodes = ClipTreeNodeViewModel.Build([], "needle", folders);
+
+ Assert.Empty(nodes);
+ }
+
[Fact]
public void Build_IsStable_WhenFolderPathCasingDiffers()
{
diff --git a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
index 958d82c..3ca8a2f 100644
--- a/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
+++ b/tests/SharpFM.Tests/ViewModels/MainWindowViewModelTests.cs
@@ -51,8 +51,13 @@ private static MainWindowViewModel CreateVm(
prompt);
}
- private sealed class FakeInputPrompt(string? answer) : IInputPrompt
+ private sealed class FakeInputPrompt(params string?[]? answers) : IInputPrompt
{
+ // A single explicit `null` argument resolves to `params` = null rather
+ // than a one-element array; treat both the same as "always cancel".
+ private readonly Queue _answers = new(
+ answers ?? new string?[] { null });
+
public string? LastTitle { get; private set; }
public string? LastPrompt { get; private set; }
public string? LastDefault { get; private set; }
@@ -62,6 +67,7 @@ private sealed class FakeInputPrompt(string? answer) : IInputPrompt
LastTitle = title;
LastPrompt = prompt;
LastDefault = defaultValue;
+ var answer = _answers.Count > 0 ? _answers.Dequeue() : null;
return Task.FromResult(answer);
}
}
@@ -74,9 +80,19 @@ public void NewScriptCommand_AddsScriptClip()
vm.NewScriptCommand();
Assert.Equal(initialCount + 1, vm.FileMakerClips.Count);
Assert.True(vm.SelectedClip?.IsScriptClip);
+ Assert.Equal("Mac-XMSC", vm.SelectedClip!.ClipType);
Assert.Contains("Created new script", vm.StatusMessage);
}
+ [Fact]
+ public void NewScriptStepsCommand_AddsBareStepsClip()
+ {
+ var vm = CreateVm();
+ vm.NewScriptStepsCommand();
+ Assert.True(vm.SelectedClip?.IsScriptClip);
+ Assert.Equal("Mac-XMSS", vm.SelectedClip!.ClipType);
+ }
+
[Fact]
public void NewTableCommand_AddsTableClip()
{
@@ -165,6 +181,218 @@ public async Task RenameSelectedClip_CancelledPrompt_KeepsExistingName()
Assert.Equal(original, vm.SelectedClip!.Clip.Name);
}
+ [Fact]
+ public async Task NewFolderCommand_PromptsAndAddsEmptyFolderAtRoot()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt("Drafts"));
+ ResetRepoState(vm);
+
+ await vm.NewFolderCommand();
+
+ var folder = Assert.Single(vm.Folders);
+ Assert.Equal(new[] { "Drafts" }, folder.Path);
+ Assert.Contains(TopLevel(vm), n => n.IsFolder && n.Name == "Drafts");
+ Assert.Contains("Created folder", vm.StatusMessage);
+ }
+
+ [Fact]
+ public async Task NewFolderCommand_RelativeToSelectedClipFolder()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt("Sub"));
+ ResetRepoState(vm);
+ vm.NewScriptCommand();
+ vm.SelectedClip!.FolderPath = new[] { "Top" };
+
+ await vm.NewFolderCommand();
+
+ var folder = vm.Folders.Single(f => f.Path.Count == 2);
+ Assert.Equal(new[] { "Top", "Sub" }, folder.Path);
+ }
+
+ [Fact]
+ public async Task NewFolderCommand_NestsInsideSelectedFolder()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt("Outer", "Inner"));
+ ResetRepoState(vm);
+ await vm.NewFolderCommand();
+ vm.OpenFolderAsSelection(new[] { "Outer" });
+ await vm.NewFolderCommand();
+
+ Assert.Contains(vm.Folders, f =>
+ f.Path.Count == 2 && f.Path[0] == "Outer" && f.Path[1] == "Inner");
+ }
+
+ [Fact]
+ public async Task NewFolderCommand_TwiceAtRoot_ProducesSiblings()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt("A", "B"));
+ ResetRepoState(vm);
+
+ await vm.NewFolderCommand();
+ await vm.NewFolderCommand();
+
+ Assert.Equal(2, vm.Folders.Count);
+ Assert.All(vm.Folders, f => Assert.Single(f.Path));
+ }
+
+ [Fact]
+ public void NewScriptCommand_LandsInSelectedFolder()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+ vm.OpenFolderAsSelection(new[] { "Drafts" });
+
+ vm.NewScriptCommand();
+
+ var script = Assert.Single(vm.FileMakerClips);
+ Assert.Equal(new[] { "Drafts" }, script.FolderPath);
+ }
+
+ [Fact]
+ public void NewTableCommand_LandsInSelectedFolder()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+ vm.OpenFolderAsSelection(new[] { "Schemas" });
+
+ vm.NewTableCommand();
+
+ var table = Assert.Single(vm.FileMakerClips);
+ Assert.Equal(new[] { "Schemas" }, table.FolderPath);
+ }
+
+ [Fact]
+ public async Task PasteFileMakerClipData_LandsInSelectedFolder()
+ {
+ var clipboard = new MockClipboardService();
+ clipboard.ClipboardData["Mac-XMSS"] = BuildClipBytes(
+ "");
+ var vm = CreateVm(clipboard);
+ ResetRepoState(vm);
+ vm.OpenFolderAsSelection(new[] { "Drop Here" });
+
+ await vm.PasteFileMakerClipData();
+
+ var pasted = Assert.Single(vm.FileMakerClips);
+ Assert.Equal(new[] { "Drop Here" }, pasted.FolderPath);
+ }
+
+ [Fact]
+ public void SelectedFolderPath_ClearsWhenClipSelected()
+ {
+ var vm = CreateVm();
+ vm.OpenFolderAsSelection(new[] { "Folder" });
+ Assert.NotNull(vm.SelectedFolderPath);
+
+ vm.NewScriptCommand();
+
+ Assert.Null(vm.SelectedFolderPath);
+ }
+
+ [Fact]
+ public void OpenFolderAsSelection_KeepsActiveTab()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+ vm.NewScriptCommand();
+ var openClip = vm.SelectedClip;
+ Assert.NotNull(openClip);
+
+ vm.OpenFolderAsSelection(new[] { "Schemas" });
+
+ Assert.Same(openClip, vm.SelectedClip);
+ Assert.Equal(new[] { "Schemas" }, vm.TargetFolderPath);
+ }
+
+ [Fact]
+ public void RootNodes_AlwaysContainsSyntheticRoot_WithPathEmpty()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+
+ var root = Assert.Single(vm.RootNodes);
+ Assert.True(root.IsFolder);
+ Assert.Empty(root.Path);
+ Assert.Empty(root.Children);
+ }
+
+ [Fact]
+ public void RootNodes_RootWraps_AllTopLevelItems()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+ // Root-level clip
+ vm.NewScriptCommand();
+ // Folder under root
+ vm.OpenFolderAsSelection(new[] { "Foo" });
+ vm.NewScriptCommand();
+
+ var root = Assert.Single(vm.RootNodes);
+ Assert.Contains(root.Children, c => c.IsFolder && c.Name == "Foo");
+ Assert.Contains(root.Children, c => c.IsClip && c.Name == "New Script");
+ }
+
+ [Fact]
+ public void TargetFolderPath_PrefersExplicitFolderOverSelectedClipFolder()
+ {
+ var vm = CreateVm();
+ ResetRepoState(vm);
+ vm.NewScriptCommand();
+ vm.SelectedClip!.FolderPath = new[] { "Original" };
+
+ vm.OpenFolderAsSelection(new[] { "Override" });
+
+ Assert.Equal(new[] { "Override" }, vm.TargetFolderPath);
+ }
+
+
+ [Fact]
+ public async Task NewFolderCommand_CancelledPrompt_AddsNothing()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt(null));
+ ResetRepoState(vm);
+
+ await vm.NewFolderCommand();
+
+ Assert.Empty(vm.Folders);
+ }
+
+ [Fact]
+ public async Task NewFolderCommand_BlankName_ShowsError()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt(" "));
+ ResetRepoState(vm);
+
+ await vm.NewFolderCommand();
+
+ Assert.Empty(vm.Folders);
+ Assert.Contains("cannot be empty", vm.StatusMessage);
+ }
+
+ [Fact]
+ public async Task NewFolderCommand_DuplicateName_ShowsError()
+ {
+ var vm = CreateVm(prompt: new FakeInputPrompt("Dup", "Dup"));
+ ResetRepoState(vm);
+ await vm.NewFolderCommand();
+ Assert.Single(vm.Folders);
+
+ await vm.NewFolderCommand();
+
+ Assert.Single(vm.Folders);
+ Assert.Contains("already exists", vm.StatusMessage);
+ }
+
+ // Tests that assert exact catalog sizes need to start from a clean slate.
+ // CreateVm constructs a MainWindowViewModel that eagerly loads from the
+ // default LocalApplicationData repo path, so anything left there by prior
+ // user sessions or earlier tests would leak in otherwise.
+ private static void ResetRepoState(MainWindowViewModel vm)
+ {
+ vm.FileMakerClips.Clear();
+ vm.Folders.Clear();
+ }
+
[Fact]
public async Task CopySelectedToClip_NoSelection_ShowsStatus()
{
@@ -277,6 +505,133 @@ private static byte[] BuildClipBytes(string xml)
return BitConverter.GetBytes(payload.Length).Concat(payload).ToArray();
}
+ [Fact]
+ public async Task PasteFileMakerClipData_GroupPaste_CapturesFolderMetadata()
+ {
+ var clipboard = new MockClipboardService();
+ clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes(
+ "" +
+ "" +
+ "");
+ var vm = CreateVm(clipboard);
+ ResetRepoState(vm);
+
+ await vm.PasteFileMakerClipData();
+
+ var folder = Assert.Single(vm.Folders);
+ Assert.Equal(new[] { "Paste Targets" }, folder.Path);
+ Assert.Equal(16, folder.Id);
+ Assert.False(folder.IncludeInMenu);
+ Assert.True(folder.GroupCollapsed);
+ }
+
+ [Fact]
+ public async Task PasteFileMakerClipData_GroupWithSingleScript_CreatesClipUnderFolder()
+ {
+ var clipboard = new MockClipboardService();
+ clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes(
+ "" +
+ "" +
+ "");
+ var vm = CreateVm(clipboard);
+ vm.FileMakerClips.Clear();
+
+ await vm.PasteFileMakerClipData();
+
+ var pasted = Assert.Single(vm.FileMakerClips);
+ Assert.Equal("FizzBuzz", pasted.Clip.Name);
+ Assert.Equal(new[] { "Paste Targets" }, pasted.FolderPath);
+ }
+
+ [Fact]
+ public async Task PasteFileMakerClipData_GroupWithMultipleScripts_CreatesAllUnderSameFolder()
+ {
+ var clipboard = new MockClipboardService();
+ clipboard.ClipboardData["Mac-XMSC"] = BuildClipBytes(
+ "" +
+ "