diff --git a/src/CosmosDbExplorer.Core/Helpers/FoldFinder.cs b/src/CosmosDbExplorer.Core/Helpers/FoldFinder.cs new file mode 100644 index 0000000..58153a5 --- /dev/null +++ b/src/CosmosDbExplorer.Core/Helpers/FoldFinder.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace CosmosDbExplorer.Core.Helpers +{ + /// + /// Based on Daniel Gimendez's answer for "Code folding in RichTextBox". Altered to fit our use case + /// Source: https://stackoverflow.com/questions/18156451/code-folding-in-richtextbox + /// + public class FoldFinder + { + private readonly bool _foldableRoot; + private readonly IList _delimiters; + private readonly Regex _scanner; + + public FoldFinder(IList delimiters, bool foldableRoot = true) + { + _foldableRoot = foldableRoot; + _delimiters = delimiters; + _scanner = RegexifyDelimiters(delimiters); + } + + public int FirstErrorOffset { get; private set; } = -1; + + public IList Scan(string code, int start = 0, int end = -1, bool throwOnError = true) + { + FirstErrorOffset = -1; + + var positions = new List(); + var stack = new Stack(); + + int regexGroupIndex; + bool isStartToken; + Delimiter matchedDelimiter; + var currentItem = default(FoldStackItem); + + foreach (Match match in _scanner.Matches(code, start)) + { + if (!_foldableRoot && match.Index == 0) + { + continue; + } + + // the pattern for every group is that 0 corresponds to SectionDelimter, 1 corresponds to Start + // and 2 corresponds to End. + regexGroupIndex = match.Groups.Cast().Select((g, i) => new { g.Success, Index = i }) + .Where(r => r.Success && r.Index > 0).First().Index; + + matchedDelimiter = _delimiters[(regexGroupIndex - 1) / 3]; + isStartToken = match.Groups[regexGroupIndex + 1].Success; + + if (isStartToken) + { + currentItem = new FoldStackItem() + { + Delimter = matchedDelimiter, + Position = new FoldMatch() { Start = match.Index } + }; + stack.Push(currentItem); + } + else + { + if (stack.Count == 0) + { + if (FirstErrorOffset == -1) + { + FirstErrorOffset = match.Index; + } + + if (throwOnError) + { + throw new Exception(string.Format("Unexpected end of string at {0}", match.Index)); + } + + break; + } + + currentItem = stack.Pop(); + if (currentItem.Delimter == matchedDelimiter) + { + currentItem.Position.End = match.Index + match.Length; + //Only add folding if it spans more than 2 lines + if (HasAtLeastNLines(code[currentItem.Position.Start..currentItem.Position.End], 2)) + { + positions.Add(currentItem.Position); + } + + // if searching for an end, and we've passed it, and the stack is empty then quit. + if (end > -1 && currentItem.Position.End >= end && stack.Count == 0) + { + break; + } + } + else + { + if (FirstErrorOffset == -1) + { + FirstErrorOffset = match.Index; + } + + if (throwOnError) + { + throw new Exception(string.Format("Invalid Ending Token at {0}", match.Index)); + } + + break; + } + } + } + + if (stack.Count > 0 && throwOnError) + { + if (FirstErrorOffset == -1) + { + FirstErrorOffset = code.Length; + } + + throw new Exception("Not enough closing symbols."); + } + + return positions; + } + + private static bool HasAtLeastNLines(string search, int n = 1) + { + int count = 0, index = 0; + if (n <= 1) + { + return (search?.Length ?? 0) > 0; + } + + while ((index = search.IndexOf("\r\n", index, StringComparison.Ordinal)) != -1) + { + index += 2; + count++; + if (count + 1 >= n) + { + return true; + } + } + + return false; + } + + private static Regex RegexifyDelimiters(IList delimiters) + { + return new Regex( + string.Join("|", delimiters.Select(d => + string.Format("((\\{0})|(\\{1}))", d.Start, d.End))), RegexOptions.Compiled | RegexOptions.Multiline); + } + + private class FoldStackItem + { + public FoldMatch Position; + public Delimiter Delimter; + } + } + + public class FoldMatch + { + public int Start; + public int End; + } + + public class Delimiter + { + public string Start; + public string End; + } +} diff --git a/src/CosmosDbExplorer.Core/Helpers/TimedDebounce.cs b/src/CosmosDbExplorer.Core/Helpers/TimedDebounce.cs new file mode 100644 index 0000000..107da0e --- /dev/null +++ b/src/CosmosDbExplorer.Core/Helpers/TimedDebounce.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; + +namespace CosmosDbExplorer.Core.Helpers +{ + public class TimedDebounce + { + public event EventHandler Idled = delegate { }; + public int WaitingMilliSeconds { get; set; } + private readonly Timer _timer; + + public TimedDebounce(int waitingMilliSeconds = 600) + { + WaitingMilliSeconds = waitingMilliSeconds; + _timer = new Timer(p => Idled(this, EventArgs.Empty)); + } + + public void DebounceEvent() => _timer.Change(WaitingMilliSeconds, Timeout.Infinite); + } +} diff --git a/src/CosmosDbExplorer/AvalonEdit/BraceFoldingStrategy.cs b/src/CosmosDbExplorer/AvalonEdit/BraceFoldingStrategy.cs index 191976e..e91bc35 100644 --- a/src/CosmosDbExplorer/AvalonEdit/BraceFoldingStrategy.cs +++ b/src/CosmosDbExplorer/AvalonEdit/BraceFoldingStrategy.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using CosmosDbExplorer.Core.Helpers; using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Folding; @@ -9,23 +11,18 @@ namespace CosmosDbExplorer.AvalonEdit /// public class BraceFoldingStrategy { - /// - /// Gets/Sets the opening brace. The default value is '{'. - /// - public char OpeningBrace { get; set; } - - /// - /// Gets/Sets the closing brace. The default value is '}'. - /// - public char ClosingBrace { get; set; } - + private readonly FoldFinder _foldFinder; /// /// Creates a new BraceFoldingStrategy. /// public BraceFoldingStrategy() { - OpeningBrace = '{'; - ClosingBrace = '}'; + _foldFinder = new FoldFinder(new List { + //Json Object Delimiters + new Delimiter { Start = "{", End = "}" }, + //Json Array Delimiters + new Delimiter { Start = "[", End = "]" } + }, false); } public void UpdateFoldings(FoldingManager manager, TextDocument document) @@ -39,47 +36,13 @@ public void UpdateFoldings(FoldingManager manager, TextDocument document) /// /// /// - public IEnumerable CreateNewFoldings(TextDocument document, out int firstErrorOffset) - { - firstErrorOffset = -1; - return CreateNewFoldings(document); - } - - /// - /// Create s for the specified document. - /// - /// - public IEnumerable CreateNewFoldings(ITextSource document) + private IEnumerable CreateNewFoldings(TextDocument document, out int firstErrorOffset) { - var newFoldings = new List(); - - var startOffsets = new Stack(); - var lastNewLineOffset = 0; - var openingBrace = OpeningBrace; - var closingBrace = ClosingBrace; - for (var i = 0; i < document.TextLength; i++) - { - var c = document.GetCharAt(i); - if (c == openingBrace) - { - startOffsets.Push(i); - } - else if (c == closingBrace && startOffsets.Count > 0) - { - var startOffset = startOffsets.Pop(); - // don't fold if opening and closing brace are on the same line - if (startOffset < lastNewLineOffset) - { - newFoldings.Add(new NewFolding(startOffset, i + 1)); - } - } - else if (c == '\n' || c == '\r') - { - lastNewLineOffset = i + 1; - } - } - newFoldings.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset)); - return newFoldings; + var result = _foldFinder.Scan(document.Text, throwOnError: false) + .OrderBy(o => o.Start) + .Select(o => new NewFolding(o.Start, o.End)); + firstErrorOffset = _foldFinder.FirstErrorOffset; + return result; } } } diff --git a/src/CosmosDbExplorer/Behaviors/AvalonTextEditorBraceFoldingBehavior.cs b/src/CosmosDbExplorer/Behaviors/AvalonTextEditorBraceFoldingBehavior.cs index 9e21f64..8952d9e 100644 --- a/src/CosmosDbExplorer/Behaviors/AvalonTextEditorBraceFoldingBehavior.cs +++ b/src/CosmosDbExplorer/Behaviors/AvalonTextEditorBraceFoldingBehavior.cs @@ -2,6 +2,7 @@ using System.Windows; using System.Windows.Threading; using CosmosDbExplorer.AvalonEdit; +using CosmosDbExplorer.Core.Helpers; using ICSharpCode.AvalonEdit; using ICSharpCode.AvalonEdit.Folding; using Microsoft.Xaml.Behaviors; @@ -12,53 +13,43 @@ public class AvalonTextEditorBraceFoldingBehavior : Behavior { private readonly BraceFoldingStrategy _foldingStrategy = new(); private FoldingManager? _foldingManager; - private readonly DispatcherTimer _timer = new(); + private readonly TimedDebounce _timer = new() { WaitingMilliSeconds = 500 }; protected override void OnAttached() { base.OnAttached(); - - //_timer.Interval = System.TimeSpan.FromMilliseconds(Interval); - _timer.Tick += OnTimerTicked; - - //if (UseFolding) - //{ - // _timer.Start(); - //} + _timer.Idled += OnTextChangedIdle; + AssociatedObject.TextChanged += OnTextChanged; } - protected override void OnDetaching() { - _timer.Stop(); - _timer.Tick -= OnTimerTicked; - - if (_foldingManager is not null) - { - FoldingManager.Uninstall(_foldingManager); - } - + _timer.Idled -= OnTextChangedIdle; + AssociatedObject.TextChanged -= OnTextChanged; + ReleaseFoldingManager(); base.OnDetaching(); } - public int Interval + private void OnTextChanged(object? sender, EventArgs e) { - get { return (int)GetValue(IntervalProperty); } - set { SetValue(IntervalProperty, value); } - } + if (_foldingManager is null || AssociatedObject is null) + { + return; + } - public static readonly DependencyProperty IntervalProperty = - DependencyProperty.Register( - "Interval", - typeof(int), - typeof(AvalonTextEditorBraceFoldingBehavior), - new PropertyMetadata(1000, OnIntervalPropertyChanged)); + _timer.DebounceEvent(); + } - private static void OnIntervalPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + private void OnTextChangedIdle(object? sender, EventArgs e) { - if (d is AvalonTextEditorBraceFoldingBehavior behavior) + if (_foldingManager is null) { - behavior.OnIntervalChanged(); + return; } + + Dispatcher.Invoke(() => + { + _foldingStrategy.UpdateFoldings(_foldingManager, AssociatedObject.Document); + }); } public bool UseFolding @@ -87,47 +78,29 @@ private void OnUseFoldingChanged() { if (UseFolding) { - _timer.Start(); + InitFoldingManager(); } else { - if (_foldingManager != null) - { - _foldingManager.Clear(); - FoldingManager.Uninstall(_foldingManager); - } - - _timer.Stop(); + ReleaseFoldingManager(); } } - private void OnIntervalChanged() + private void InitFoldingManager() { - if (AssociatedObject is null) + if (_foldingManager is null && AssociatedObject?.TextArea is not null) { - return; + _foldingManager = FoldingManager.Install(AssociatedObject.TextArea); } - - _timer.Stop(); - _timer.Interval = System.TimeSpan.FromMilliseconds(Interval); - _timer.Start(); } - private void OnTimerTicked(object? sender, System.EventArgs e) + private void ReleaseFoldingManager() { - if (AssociatedObject is null) - { - return; - } - - if (_foldingManager == null && AssociatedObject?.TextArea?.Document?.Text != null) - { - _foldingManager = FoldingManager.Install(AssociatedObject.TextArea); - } - - if (_foldingManager != null) + if (_foldingManager is not null) { - _foldingStrategy.UpdateFoldings(_foldingManager, AssociatedObject.Document); + _foldingManager.Clear(); + FoldingManager.Uninstall(_foldingManager); + _foldingManager = null; } } } diff --git a/src/CosmosDbExplorer/Views/ImportDocumentView.xaml b/src/CosmosDbExplorer/Views/ImportDocumentView.xaml index b238cb8..dbf1a27 100644 --- a/src/CosmosDbExplorer/Views/ImportDocumentView.xaml +++ b/src/CosmosDbExplorer/Views/ImportDocumentView.xaml @@ -9,12 +9,12 @@ xmlns:behaviors="clr-namespace:CosmosDbExplorer.Behaviors" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> - + - + diff --git a/src/CosmosDbExplorer/Views/JsonEditorView.xaml.cs b/src/CosmosDbExplorer/Views/JsonEditorView.xaml.cs index 62d28f0..0c34450 100644 --- a/src/CosmosDbExplorer/Views/JsonEditorView.xaml.cs +++ b/src/CosmosDbExplorer/Views/JsonEditorView.xaml.cs @@ -1,17 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; +using System.Windows; using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; namespace CosmosDbExplorer.Views { @@ -41,8 +29,6 @@ private static void OnZoomLevelChanged(DependencyObject d, DependencyPropertyCha target.zoomBehavior.ZoomLevel = value; } - - public bool UseFolding { get { return (bool)GetValue(UseFoldingProperty); } @@ -59,24 +45,5 @@ private static void OnUseFoldingChanged(DependencyObject d, DependencyPropertyCh var target = (JsonEditorView)d; target.foldingBehavior.UseFolding = value; } - - - - public int FoldingInterval - { - get { return (int)GetValue(FoldingIntervalProperty); } - set { SetValue(FoldingIntervalProperty, value); } - } - - // Using a DependencyProperty as the backing store for FoldingInterval. This enables animation, styling, binding, etc... - public static readonly DependencyProperty FoldingIntervalProperty = - DependencyProperty.Register("FoldingInterval", typeof(int), typeof(JsonEditorView), new PropertyMetadata(1000, OnFoldingIntervalChanged)); - - private static void OnFoldingIntervalChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - var value = (int)e.NewValue; - var target = (JsonEditorView)d; - target.foldingBehavior.Interval = value; - } } }