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 = " + + + """; + + 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)!; + 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); + 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( + "" + + "