diff --git a/App.axaml.cs b/App.axaml.cs index c31a05b..dddbecf 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -24,6 +24,7 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + EmbeddedAssets.WarmUp(); desktop.MainWindow = services.GetRequiredService().Create(); SystemMonospaceFonts.WarmUp(); } diff --git a/Services/DocumentFileIdentity.cs b/Services/DocumentFileIdentity.cs new file mode 100644 index 0000000..4c87fac --- /dev/null +++ b/Services/DocumentFileIdentity.cs @@ -0,0 +1,10 @@ +namespace Inlay; + +internal static class DocumentFileIdentity +{ + public static StringComparer Comparer { get; } = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + public static bool Matches(IDocumentFile? file, string identity) => + file is not null && Comparer.Equals(file.Identity, identity); +} diff --git a/Services/EmbeddedAssets.cs b/Services/EmbeddedAssets.cs new file mode 100644 index 0000000..811150f --- /dev/null +++ b/Services/EmbeddedAssets.cs @@ -0,0 +1,16 @@ +using Avalonia.Platform; + +namespace Inlay; + +internal static class EmbeddedAssets +{ + public static Uri Uri(string name) => new($"avares://Inlay/Assets/{name}"); + + // The toolbar's Svg controls each load on their own thread as the first window + // is built. Reading one asset here builds this assembly's resource table once, + // on the UI thread, before those loads start racing for it. + public static void WarmUp() + { + using var stream = AssetLoader.Open(Uri("new-document.svg")); + } +} diff --git a/Services/TabDragDrop.cs b/Services/TabDragDrop.cs new file mode 100644 index 0000000..5b4fe9d --- /dev/null +++ b/Services/TabDragDrop.cs @@ -0,0 +1,13 @@ +using Avalonia.Input; +using Inlay.ViewModels; + +namespace Inlay; + +internal sealed class TabDragPayload +{ + public static readonly DataFormat Format = + DataFormat.CreateInProcessFormat("application/x-inlay-tab"); + + public required MainWindow SourceWindow { get; init; } + public required DocumentTabViewModel SourceTab { get; init; } +} diff --git a/Tests/Behavior/Application/ApplicationBehaviorTests.cs b/Tests/Behavior/Application/ApplicationBehaviorTests.cs index c08282b..81dcff5 100644 --- a/Tests/Behavior/Application/ApplicationBehaviorTests.cs +++ b/Tests/Behavior/Application/ApplicationBehaviorTests.cs @@ -118,10 +118,32 @@ public void NewWindowsHaveIndependentDocumentState() } } + [AvaloniaFact] + public void ClosingAWindowReleasesItsTabsWithoutDisturbingTheBoundSelection() + { + var (window, viewModel) = MainWindowTestHost.CreateWindow(); + var tab = viewModel.SelectedDocument!; + var editor = MainWindowTestHost.FindEditor(window); + + window.Close(); + + // The window is still bound to Documents while it closes, so emptying the + // collection here would push a null selection back through SelectedDocument + // and every SelectedDocument.* binding would fail. + Assert.Same(tab, viewModel.SelectedDocument); + Assert.Single(viewModel.Documents); + + // The tab is disposed all the same, so its editor no longer feeds it changes. + editor.Text = "Edited after the window closed"; + Assert.False(tab.IsDirty); + } + private static MainWindow CreateWindow(FakeInteractionService interaction) => +#pragma warning disable CA2000 // Ownership passes to the caller. new(new MainWindowViewModel( new JsonTemplateDocumentService(), new FakeStorageService(), interaction, new FakeApplicationService())); +#pragma warning restore CA2000 } diff --git a/Tests/Behavior/MainWindow/LineLengthBehaviorTests.cs b/Tests/Behavior/MainWindow/LineLengthBehaviorTests.cs index 73734dd..4906a43 100644 --- a/Tests/Behavior/MainWindow/LineLengthBehaviorTests.cs +++ b/Tests/Behavior/MainWindow/LineLengthBehaviorTests.cs @@ -56,7 +56,7 @@ public void FlyoutControlsUpdateTheSelectedDocumentAndEditor() [AvaloniaFact] public async Task OpeningADocumentAppliesItsLineSettingsWithoutMarkingItDirty() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var file = new MemoryDocumentFile("configured.itd"); file.SetRawContents(""" { diff --git a/Tests/Behavior/MainWindow/MainWindowCommandTests.cs b/Tests/Behavior/MainWindow/MainWindowCommandTests.cs index bc693a7..aadf848 100644 --- a/Tests/Behavior/MainWindow/MainWindowCommandTests.cs +++ b/Tests/Behavior/MainWindow/MainWindowCommandTests.cs @@ -203,7 +203,7 @@ public void ViewCommandsUpdateRenderedState() [AvaloniaFact] public async Task FontCommandUpdatesTheRenderedEditorFamily() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var initialFont = context.ViewModel.EditorFontFamily; context.Interaction.SelectedFont = new FontFamily("DejaVu Sans Mono"); var window = new MainWindow(context.ViewModel); diff --git a/Tests/Behavior/MainWindow/MainWindowTabDragDropTests.cs b/Tests/Behavior/MainWindow/MainWindowTabDragDropTests.cs new file mode 100644 index 0000000..43e8f89 --- /dev/null +++ b/Tests/Behavior/MainWindow/MainWindowTabDragDropTests.cs @@ -0,0 +1,366 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Inlay.ViewModels; +using Xunit; + +namespace Inlay.Tests; + +public sealed class MainWindowTabDragDropTests +{ + [AvaloniaFact] + public void DroppingTabPastAnotherTabsMidpointMovesItAfterThatTab() + { + var (window, viewModel) = MainWindowTestHost.CreateWindow(); + try + { + viewModel.AddNewDocument(); + viewModel.AddNewDocument(); + Settle(window); + + var first = viewModel.Documents[0]; + var second = viewModel.Documents[1]; + var third = viewModel.Documents[2]; + + Drop(window, first, RightOf(window, second)); + + Assert.Equal([second, first, third], viewModel.Documents); + Assert.Same(first, viewModel.SelectedDocument); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DroppingTabBeforeAnotherTabsMidpointMovesItAheadOfThatTab() + { + var (window, viewModel) = MainWindowTestHost.CreateWindow(); + try + { + viewModel.AddNewDocument(); + viewModel.AddNewDocument(); + Settle(window); + + var first = viewModel.Documents[0]; + var second = viewModel.Documents[1]; + var third = viewModel.Documents[2]; + + Drop(window, third, LeftOf(window, first)); + + Assert.Equal([third, first, second], viewModel.Documents); + Assert.Same(third, viewModel.SelectedDocument); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DragOverShowsDropIndicatorAndDragLeaveHidesIt() + { + var (window, viewModel) = MainWindowTestHost.CreateWindow(); + try + { + viewModel.AddNewDocument(); + Settle(window); + + var second = viewModel.Documents[1]; + using var transfer = CreateTransfer(window, viewModel.Documents[0]); + + var point = TabBarPoint(window, LeftOf(window, second)); + RaiseDrag(window, RawDragEventType.DragEnter, point, transfer); + RaiseDrag(window, RawDragEventType.DragOver, point, transfer); + Assert.True(window.TabDropIndicator.IsVisible); + + RaiseDrag(window, RawDragEventType.DragLeave, point, transfer); + Assert.False(window.TabDropIndicator.IsVisible); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DroppingTabOnAnotherWindowsTabBarCopiesItIntoThatSlot() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + MainWindowTestHost.FindEditor(source).Text = "Hello from the source window"; + targetViewModel.AddNewDocument(); + Settle(source, target); + + var sourceTab = sourceViewModel.Documents[0]; + var targetFirst = targetViewModel.Documents[0]; + + Drop(target, sourceTab, LeftOf(target, targetFirst), source); + + Assert.Single(sourceViewModel.Documents); + Assert.Same(sourceTab, sourceViewModel.Documents[0]); + + Assert.Equal(3, targetViewModel.Documents.Count); + var copy = targetViewModel.Documents[0]; + Assert.Same(copy, targetViewModel.SelectedDocument); + Assert.NotSame(sourceTab, copy); + Assert.Equal("Hello from the source window", MainWindowTestHost.FindEditor(target).Text); + Assert.True(copy.IsDirty); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public void DroppingTabOutsideTheTabBarOnAnotherWindowAppendsTheCopy() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + MainWindowTestHost.FindEditor(source).Text = "Dropped on the editor"; + targetViewModel.AddNewDocument(); + Settle(source, target); + + var editorCentre = MainWindowTestHost.GetCenter(MainWindowTestHost.FindEditor(target), target); + using var transfer = CreateTransfer(source, sourceViewModel.Documents[0]); + RaiseDragSequence(target, editorCentre, transfer); + Settle(target); + + Assert.Equal(3, targetViewModel.Documents.Count); + Assert.Same(targetViewModel.Documents[2], targetViewModel.SelectedDocument); + Assert.Equal("Dropped on the editor", MainWindowTestHost.FindEditor(target).Text); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public void DroppingTabOnAWindowHoldingOnlyAPristineUntitledReplacesIt() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + MainWindowTestHost.FindEditor(source).Text = "Shared text"; + Settle(source, target); + + Assert.True(Assert.Single(targetViewModel.Documents).IsEmptyUntitled()); + + Drop(target, sourceViewModel.Documents[0], LeftOf(target, targetViewModel.Documents[0]), source); + + Assert.Single(targetViewModel.Documents); + Assert.Equal("Shared text", MainWindowTestHost.FindEditor(target).Text); + Assert.True(targetViewModel.Documents[0].IsDirty); + Assert.Single(sourceViewModel.Documents); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public void DroppingTheSameTabTwiceSelectsTheExistingCopyInsteadOfDuplicating() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + MainWindowTestHost.FindEditor(source).Text = "Single instance tab"; + targetViewModel.AddNewDocument(); + Settle(source, target); + + var sourceTab = sourceViewModel.Documents[0]; + var slot = LeftOf(target, targetViewModel.Documents[0]); + + Drop(target, sourceTab, slot, source); + Assert.Equal(3, targetViewModel.Documents.Count); + + Drop(target, sourceTab, slot, source); + Assert.Equal(3, targetViewModel.Documents.Count); + Assert.Single(sourceViewModel.Documents); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public void AWindowRejectsATabItAlreadyHoldsACopyOf() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + var sourceTab = sourceViewModel.Documents[0]; + var payload = new TabDragPayload { SourceWindow = source, SourceTab = sourceTab }; + + Assert.True(target.CanAcceptTabDrop(payload)); + Assert.True(source.CanAcceptTabDrop(payload)); + + var copy = targetViewModel.CopyDocument(sourceTab); + + Assert.False(target.CanAcceptTabDrop(payload)); + Assert.False(source.CanAcceptTabDrop( + new TabDragPayload { SourceWindow = target, SourceTab = copy })); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public async Task SavingACopiedDocumentWarnsTheOtherWindowThatAnotherWindowChangedIt() + { + var (source, sourceViewModel) = MainWindowTestHost.CreateWindow(); + var (target, targetViewModel) = MainWindowTestHost.CreateWindow(); + try + { + var file = new MemoryDocumentFile("drag-drop-sample.itd"); + file.SetDocumentText("Initial text"); + var sourceTab = sourceViewModel.CopyDocument( + new Inlay.Models.TemplateDocument + { + Content = [Inlay.Models.DocumentPart.PlainText("Initial text")] + }, + file, + isDirty: false); + Settle(source, target); + + Drop(target, sourceTab, LeftOf(target, targetViewModel.Documents[0]), source); + Assert.Equal("drag-drop-sample.itd", targetViewModel.SelectedDocument!.FileName); + + MainWindowTestHost.FindEditor(source).Text = "Changed in the source window"; + await MainWindowViewModelTestContext.ObserveAsync( + sourceViewModel.SaveCommand.Execute(), + TestContext.Current.CancellationToken); + + targetViewModel.CheckSelectedDocumentForExternalChanges(); + + Assert.True(targetViewModel.SelectedDocument.HasExternalChanges); + Assert.True(targetViewModel.SelectedDocument.ChangedInAnotherWindow); + Assert.Equal( + "drag-drop-sample.itd was changed in another window. Reload it or ignore the changes?", + targetViewModel.SelectedDocument.ExternalChangesMessage); + } + finally + { + source.Close(); + target.Close(); + } + } + + [AvaloniaFact] + public void PressingAndReleasingOnATabWithoutMovingLeavesTheOrderAlone() + { + var (window, viewModel) = MainWindowTestHost.CreateWindow(); + try + { + viewModel.AddNewDocument(); + Settle(window); + + var first = viewModel.Documents[0]; + var second = viewModel.Documents[1]; + var centre = MainWindowTestHost.GetCenter(FindTabItem(window, first), window); + + window.MouseDown(centre, MouseButton.Left, RawInputModifiers.None); + window.MouseMove(centre + new Vector(2, 0)); + window.MouseUp(centre + new Vector(2, 0), MouseButton.Left, RawInputModifiers.None); + Settle(window); + + Assert.Equal([first, second], viewModel.Documents); + } + finally + { + window.Close(); + } + } + + private static void Drop( + MainWindow target, + DocumentTabViewModel tab, + Point pointInTarget, + MainWindow? sourceWindow = null) + { + using var transfer = CreateTransfer(sourceWindow ?? target, tab); + RaiseDragSequence(target, TabBarPoint(target, pointInTarget), transfer); + Settle(target); + } + + private static DataTransfer CreateTransfer(MainWindow sourceWindow, DocumentTabViewModel tab) + { + var transfer = new DataTransfer(); + transfer.Add(DataTransferItem.Create( + TabDragPayload.Format, + new TabDragPayload { SourceWindow = sourceWindow, SourceTab = tab })); + return transfer; + } + + // Avalonia only routes DragOver and Drop once a drag has entered the window. + private static void RaiseDragSequence(MainWindow window, Point point, DataTransfer transfer) + { + RaiseDrag(window, RawDragEventType.DragEnter, point, transfer); + RaiseDrag(window, RawDragEventType.DragOver, point, transfer); + RaiseDrag(window, RawDragEventType.Drop, point, transfer); + } + + private static void RaiseDrag( + MainWindow window, + RawDragEventType type, + Point point, + DataTransfer transfer) => + window.DragDrop(point, type, transfer, DragDropEffects.Move | DragDropEffects.Copy, RawInputModifiers.None); + + // Drag positions are computed against the tab strip, but the headless drag API + // hit-tests from the window, so keep the vertical position on the tab bar. + private static Point TabBarPoint(MainWindow window, Point point) => + new(point.X, MainWindowTestHost.GetCenter(window.TabBar, window).Y); + + private static Point LeftOf(MainWindow window, DocumentTabViewModel tab) + { + var item = FindTabItem(window, tab); + return MainWindowTestHost.GetCenter(item, window) - new Vector(item.Bounds.Width / 2 - 2, 0); + } + + private static Point RightOf(MainWindow window, DocumentTabViewModel tab) + { + var item = FindTabItem(window, tab); + return MainWindowTestHost.GetCenter(item, window) + new Vector(item.Bounds.Width / 2 - 2, 0); + } + + private static void Settle(params MainWindow[] windows) + { + foreach (var window in windows) + { + window.UpdateLayout(); + } + + Dispatcher.UIThread.RunJobs(); + } + + private static TabStripItem FindTabItem(MainWindow window, DocumentTabViewModel document) => + Assert.Single( + window.GetVisualDescendants().OfType(), + item => ReferenceEquals(item.DataContext, document)); +} diff --git a/Tests/Infrastructure/MainWindowViewModelTestContext.cs b/Tests/Infrastructure/MainWindowViewModelTestContext.cs index 857e277..3653372 100644 --- a/Tests/Infrastructure/MainWindowViewModelTestContext.cs +++ b/Tests/Infrastructure/MainWindowViewModelTestContext.cs @@ -10,18 +10,22 @@ internal sealed record MainWindowViewModelTestContext( FakeStorageService Storage, FakeInteractionService Interaction, FakeApplicationService Application, - FakeEditorAdapter Editor) + FakeEditorAdapter Editor) : IDisposable { + public void Dispose() => ViewModel.Dispose(); + public static MainWindowViewModelTestContext Create() { var storage = new FakeStorageService(); var interaction = new FakeInteractionService(); var application = new FakeApplicationService(); +#pragma warning disable CA2000 // Ownership passes to the caller. var viewModel = new MainWindowViewModel( new JsonTemplateDocumentService(), storage, interaction, application); +#pragma warning restore CA2000 var editor = new FakeEditorAdapter(); viewModel.Editor!.Attach(editor); return new MainWindowViewModelTestContext( @@ -160,12 +164,23 @@ public Task OpenWriteAsync() } Contents.Position = 0; - return Task.FromResult(new NonClosingStream(Contents)); + return Task.FromResult( + new NonClosingStream(Contents, () => _lastWriteTimeUtc = _lastWriteTimeUtc.AddTicks(1))); } } -internal sealed class NonClosingStream(Stream inner) : Stream +internal sealed class NonClosingStream(Stream inner, Action? onDispose = null) : Stream { + protected override void Dispose(bool disposing) + { + if (disposing) + { + onDispose?.Invoke(); + } + + base.Dispose(disposing); + } + public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => inner.CanWrite; diff --git a/Tests/Unit/ViewModels/MainWindow/ApplicationCommandTests.cs b/Tests/Unit/ViewModels/MainWindow/ApplicationCommandTests.cs index f71e3cf..09ff5a4 100644 --- a/Tests/Unit/ViewModels/MainWindow/ApplicationCommandTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/ApplicationCommandTests.cs @@ -7,7 +7,7 @@ public sealed class ApplicationCommandTests [Fact] public void CommandsReachTheApplicationService() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); TestCommand.Execute(context.ViewModel.NewWindowCommand.Execute()); TestCommand.Execute(context.ViewModel.ExitCommand.Execute()); @@ -19,7 +19,7 @@ public void CommandsReachTheApplicationService() [Fact] public async Task CancellingFontDialogKeepsTheCurrentFont() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var initialFont = context.ViewModel.EditorFontFamily; await MainWindowViewModelTestContext.ObserveAsync( diff --git a/Tests/Unit/ViewModels/MainWindow/DocumentClosingTests.cs b/Tests/Unit/ViewModels/MainWindow/DocumentClosingTests.cs index 4704b31..ddf50f6 100644 --- a/Tests/Unit/ViewModels/MainWindow/DocumentClosingTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/DocumentClosingTests.cs @@ -8,7 +8,7 @@ public sealed class DocumentClosingTests [Fact] public async Task ClosingDirtyTabHonorsCancelChoice() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var tab = context.ViewModel.SelectedDocument!; tab.Editor.ReportContentChanged(); context.Interaction.UnsavedChoice = UnsavedChoice.Cancel; @@ -23,7 +23,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task ClosingOnlyTabThroughTabCommandSelectsReplacementFirst() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var original = context.ViewModel.SelectedDocument!; await MainWindowViewModelTestContext.ObserveAsync( @@ -47,7 +47,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [InlineData(1, true)] public async Task CloseHonorsUnsavedChoice(int choiceValue, bool expected) { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.Editor!.ReportContentChanged(); context.Interaction.UnsavedChoice = (UnsavedChoice)choiceValue; @@ -57,7 +57,7 @@ public async Task CloseHonorsUnsavedChoice(int choiceValue, bool expected) [Fact] public async Task SaveFailurePreventsClosingADirtyDocument() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var document = context.ViewModel.SelectedDocument!; document.Editor.ReportContentChanged(); context.Interaction.UnsavedChoice = UnsavedChoice.Save; @@ -74,7 +74,7 @@ public async Task SaveFailurePreventsClosingADirtyDocument() [Fact] public async Task SuccessfulSaveAllowsClosingADirtyDocument() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var document = context.ViewModel.SelectedDocument!; document.Editor.ReportContentChanged(); context.Interaction.UnsavedChoice = UnsavedChoice.Save; @@ -91,7 +91,7 @@ public async Task SuccessfulSaveAllowsClosingADirtyDocument() [Fact] public async Task ClosingMultipleDirtyDocumentsStopsAtCancel() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.SelectedDocument!.Editor.ReportContentChanged(); context.ViewModel.AddNewDocument(); var second = context.ViewModel.SelectedDocument!; diff --git a/Tests/Unit/ViewModels/MainWindow/DocumentLifecycleTests.cs b/Tests/Unit/ViewModels/MainWindow/DocumentLifecycleTests.cs index 76d2eea..045e878 100644 --- a/Tests/Unit/ViewModels/MainWindow/DocumentLifecycleTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/DocumentLifecycleTests.cs @@ -7,7 +7,7 @@ public sealed class DocumentLifecycleTests [Fact] public async Task NewCreatesAndSelectsAnotherDocumentTab() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var original = context.ViewModel.SelectedDocument; await MainWindowViewModelTestContext.ObserveAsync( @@ -24,7 +24,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task UntitledNumbersRemainAssignedAndInteriorGapsAreNotReused() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.AddNewDocument(); context.ViewModel.AddNewDocument(); var third = context.ViewModel.SelectedDocument!; @@ -47,7 +47,7 @@ public async Task UntitledNumbersRemainAssignedAndInteriorGapsAreNotReused() [Fact] public async Task ClosingTheHighestUntitledNumberMakesItAvailableAgain() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.AddNewDocument(); context.ViewModel.AddNewDocument(); context.ViewModel.AddNewDocument(); @@ -61,7 +61,7 @@ public async Task ClosingTheHighestUntitledNumberMakesItAvailableAgain() [Fact] public async Task SavingTheHighestUntitledNumberMakesItAvailableAgain() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.AddNewDocument(); context.Storage.SaveFile = new MemoryDocumentFile("named.itd"); diff --git a/Tests/Unit/ViewModels/MainWindow/DocumentStateTests.cs b/Tests/Unit/ViewModels/MainWindow/DocumentStateTests.cs index 737a01f..1ce7802 100644 --- a/Tests/Unit/ViewModels/MainWindow/DocumentStateTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/DocumentStateTests.cs @@ -8,7 +8,7 @@ public sealed class DocumentStateTests [Fact] public void EditingUpdatesDirtyStateTitleAndTabHeader() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.Editor!.ReportContentChanged(); @@ -20,7 +20,7 @@ public void EditingUpdatesDirtyStateTitleAndTabHeader() [Fact] public void UnsavedTabHeaderUsesAndUpdatesATruncatedFirstLine() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.Editor.Document = new TemplateDocument { Content = [DocumentPart.PlainText(" 123456789012345678901234567890\nSecond line")] @@ -46,7 +46,7 @@ public void UnsavedTabHeaderUsesAndUpdatesATruncatedFirstLine() [InlineData(100, 40)] public void ZoomStopsAtItsLimits(int steps, double expectedSize) { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.AdjustEditorZoom(steps); diff --git a/Tests/Unit/ViewModels/MainWindow/ExternalChangeTests.cs b/Tests/Unit/ViewModels/MainWindow/ExternalChangeTests.cs index 7cdffdc..ba8c78f 100644 --- a/Tests/Unit/ViewModels/MainWindow/ExternalChangeTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/ExternalChangeTests.cs @@ -1,4 +1,5 @@ using System.Windows.Input; +using Inlay.Models; using Xunit; namespace Inlay.Tests; @@ -8,7 +9,7 @@ public sealed class ExternalChangeTests [Fact] public async Task ExternalChangesDisableEditCommands() { - var context = await OpenDocument(); + using var context = await OpenDocument(); var viewModel = context.Test.ViewModel; var editingCommands = new (string Name, ICommand Command)[] { @@ -47,7 +48,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task FocusCheckShowsInlineExternalChangeChoiceAndMarksDocumentDirty() { - var context = await OpenDocument(); + using var context = await OpenDocument(); context.File.SetDocumentText("Changed elsewhere"); Assert.False(context.Test.ViewModel.SelectedDocument!.IsDirty); @@ -70,7 +71,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task ReloadingAnExternalChangeReplacesContentAndClearsDirtyState() { - var context = await OpenDocument(); + using var context = await OpenDocument(); var editor = new FakeEditorAdapter(); context.Test.ViewModel.SelectedDocument!.Editor.Attach(editor); context.File.SetDocumentText("Changed elsewhere"); @@ -88,7 +89,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task SelectingATabChecksThatFileForExternalChanges() { - var context = await OpenDocument(); + using var context = await OpenDocument(); var fileTab = context.Test.ViewModel.SelectedDocument!; context.Test.ViewModel.AddNewDocument(); context.File.SetDocumentText("Changed elsewhere"); @@ -105,7 +106,7 @@ public async Task SelectingATabChecksThatFileForExternalChanges() [Fact] public async Task ReloadFailurePreservesTheExternalChangeWarning() { - var context = await OpenDocument(); + using var context = await OpenDocument(); context.File.SetRawContents("{not valid json"); context.Test.ViewModel.CheckSelectedDocumentForExternalChanges(); @@ -122,9 +123,64 @@ await MainWindowViewModelTestContext.ObserveAsync( Assert.NotEmpty(message.Message); } + [Fact] + public async Task ExternalChangesMessageShowsChangedInAnotherWindowWhenSavedByAnotherTab() + { + using var context1 = await OpenDocument(); + var vm1 = context1.Test.ViewModel; + + using var context2 = MainWindowViewModelTestContext.Create(); + var vm2 = context2.ViewModel; + + var copiedTab = vm2.CopyDocument(vm1.SelectedDocument!); + Assert.NotNull(copiedTab); + + vm1.SelectedDocument!.Editor.LoadDocument(new TemplateDocument + { + Content = [DocumentPart.PlainText("Updated text from window 1")] + }); + await MainWindowViewModelTestContext.ObserveAsync( + vm1.SaveCommand.Execute(), + TestContext.Current.CancellationToken); + + vm2.CheckSelectedDocumentForExternalChanges(); + + Assert.True(vm2.SelectedDocument!.HasExternalChanges); + Assert.True(vm2.SelectedDocument.ChangedInAnotherWindow); + Assert.StartsWith("opened.itd was changed in another window.", vm2.SelectedDocument.ExternalChangesMessage, StringComparison.Ordinal); + Assert.Equal("opened.itd was changed in another window. Reload it or ignore the changes?", vm2.SelectedDocument.ExternalChangesMessage); + + using var context3 = MainWindowViewModelTestContext.Create(); + var copiedWarning = context3.ViewModel.CopyDocument(vm2.SelectedDocument); + Assert.True(copiedWarning.HasExternalChanges); + Assert.True(copiedWarning.ChangedInAnotherWindow); + Assert.False(copiedWarning.CanEdit); + + await MainWindowViewModelTestContext.ObserveAsync( + vm2.IgnoreExternalChangesCommand.Execute(vm2.SelectedDocument), + TestContext.Current.CancellationToken); + Assert.False(vm2.SelectedDocument.HasExternalChanges); + Assert.False(vm2.SelectedDocument.ChangedInAnotherWindow); + } + + [Fact] + public async Task ExternalChangesMessageShowsChangedOutsideInlayWhenSavedExternally() + { + using var context = await OpenDocument(); + context.File.SetDocumentText("Direct external change"); + context.Test.ViewModel.CheckSelectedDocumentForExternalChanges(); + + Assert.True(context.Test.ViewModel.SelectedDocument!.HasExternalChanges); + Assert.False(context.Test.ViewModel.SelectedDocument.ChangedInAnotherWindow); + Assert.StartsWith("opened.itd changed outside Inlay.", context.Test.ViewModel.SelectedDocument.ExternalChangesMessage, StringComparison.Ordinal); + Assert.Equal("opened.itd changed outside Inlay. Reload it or ignore the changes?", context.Test.ViewModel.SelectedDocument.ExternalChangesMessage); + } + private static async Task OpenDocument() { +#pragma warning disable CA2000 // Ownership passes to the caller. var test = MainWindowViewModelTestContext.Create(); +#pragma warning restore CA2000 var file = new MemoryDocumentFile("opened.itd"); file.SetDocumentText("Original"); test.Storage.OpenFile = file; @@ -138,5 +194,8 @@ await MainWindowViewModelTestContext.ObserveAsync( private sealed record OpenedDocumentContext( MainWindowViewModelTestContext Test, - MemoryDocumentFile File); + MemoryDocumentFile File) : IDisposable + { + public void Dispose() => Test.Dispose(); + } } diff --git a/Tests/Unit/ViewModels/MainWindow/FileOperationFailureTests.cs b/Tests/Unit/ViewModels/MainWindow/FileOperationFailureTests.cs index 17b75a0..1e1b698 100644 --- a/Tests/Unit/ViewModels/MainWindow/FileOperationFailureTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/FileOperationFailureTests.cs @@ -7,7 +7,7 @@ public sealed class FileOperationFailureTests [Fact] public async Task CancellingSaveAsLeavesTheDocumentDirty() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.Editor.ReportContentChanged(); context.Storage.CancelSaveAs = true; @@ -22,7 +22,7 @@ public async Task CancellingSaveAsLeavesTheDocumentDirty() [Fact] public async Task SaveAsRequestsANewFileForANamedDocument() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var openedFile = new MemoryDocumentFile("opened.itd"); openedFile.SetDocumentText("Original"); context.Storage.OpenFile = openedFile; @@ -41,7 +41,7 @@ public async Task SaveAsRequestsANewFileForANamedDocument() [Fact] public async Task SaveFailureReportsTheErrorAndKeepsTheDocumentDirty() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.Editor.ReportContentChanged(); context.Storage.SaveFile.OpenWriteException = new IOException("Disk is full"); @@ -56,7 +56,7 @@ public async Task SaveFailureReportsTheErrorAndKeepsTheDocumentDirty() [Fact] public async Task InvalidOpenContentReportsTheErrorWithoutReplacingTheCurrentTab() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var originalTab = context.ViewModel.SelectedDocument; var file = new MemoryDocumentFile("broken.itd"); file.SetRawContents("{not valid json"); diff --git a/Tests/Unit/ViewModels/MainWindow/FileOperationTests.cs b/Tests/Unit/ViewModels/MainWindow/FileOperationTests.cs index 7341a8e..250b17b 100644 --- a/Tests/Unit/ViewModels/MainWindow/FileOperationTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/FileOperationTests.cs @@ -7,7 +7,7 @@ public sealed class FileOperationTests [Fact] public async Task SavePersistsDocumentStateAndClearsDirtyState() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); context.ViewModel.Editor.ShowLineLengthIndicators = true; context.ViewModel.Editor.EnforceHardLineLengthLimit = true; context.ViewModel.Editor.SoftLineLengthLimit = 88; @@ -37,7 +37,7 @@ await MainWindowViewModelTestContext.ObserveAsync( [Fact] public async Task OpeningAnOpenFileSelectsItsExistingTab() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var file = new MemoryDocumentFile("opened.itd", "shared-file"); file.SetDocumentText("Opened once"); context.Storage.OpenFile = file; @@ -54,7 +54,7 @@ public async Task OpeningAnOpenFileSelectsItsExistingTab() [Fact] public async Task OpeningAFileReplacesTheOnlyEmptyUntitledTab() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var emptyTab = context.ViewModel.SelectedDocument; var file = new MemoryDocumentFile("opened.itd"); file.SetDocumentText("Opened content"); @@ -71,7 +71,7 @@ public async Task OpeningAFileReplacesTheOnlyEmptyUntitledTab() [Fact] public async Task OpeningAFileKeepsADirtyUntitledTab() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var untitledTab = context.ViewModel.SelectedDocument!; untitledTab.Editor.ReportContentChanged(); var file = new MemoryDocumentFile("opened.itd"); diff --git a/Tests/Unit/ViewModels/MainWindow/LineLengthDocumentTests.cs b/Tests/Unit/ViewModels/MainWindow/LineLengthDocumentTests.cs index 378fda1..00c37b7 100644 --- a/Tests/Unit/ViewModels/MainWindow/LineLengthDocumentTests.cs +++ b/Tests/Unit/ViewModels/MainWindow/LineLengthDocumentTests.cs @@ -7,7 +7,7 @@ public sealed class LineLengthDocumentTests [Fact] public void EachDocumentKeepsItsOwnLineLengthSettings() { - var context = MainWindowViewModelTestContext.Create(); + using var context = MainWindowViewModelTestContext.Create(); var first = context.ViewModel.SelectedDocument!; first.Editor.ShowLineLengthIndicators = true; first.Editor.EnforceHardLineLengthLimit = true; diff --git a/Tests/Unit/ViewModels/MainWindow/TabMoveAndCopyTests.cs b/Tests/Unit/ViewModels/MainWindow/TabMoveAndCopyTests.cs new file mode 100644 index 0000000..c9592c6 --- /dev/null +++ b/Tests/Unit/ViewModels/MainWindow/TabMoveAndCopyTests.cs @@ -0,0 +1,244 @@ +using Inlay.Models; +using Inlay.ViewModels; +using Xunit; + +namespace Inlay.Tests; + +public sealed class TabMoveAndCopyTests +{ + [Fact] + public void ReorderDocumentMovesTabsIntoTheSlotAndSelectsThem() + { + using var context = MainWindowViewModelTestContext.Create(); + context.ViewModel.AddNewDocument(); + context.ViewModel.AddNewDocument(); + + var first = context.ViewModel.Documents[0]; + var second = context.ViewModel.Documents[1]; + var third = context.ViewModel.Documents[2]; + + // Slot 2 is the gap between the second and third tabs. + context.ViewModel.ReorderDocument(first, 2); + Assert.Equal([second, first, third], context.ViewModel.Documents); + Assert.Same(first, context.ViewModel.SelectedDocument); + + context.ViewModel.ReorderDocument(third, 0); + Assert.Equal([third, second, first], context.ViewModel.Documents); + Assert.Same(third, context.ViewModel.SelectedDocument); + } + + [Fact] + public void ReorderDocumentClampsOutOfRangeSlotsAndIgnoresForeignTabs() + { + using var context = MainWindowViewModelTestContext.Create(); + context.ViewModel.AddNewDocument(); + var originalOrder = context.ViewModel.Documents.ToList(); + var first = context.ViewModel.Documents[0]; + + context.ViewModel.ReorderDocument(first, 0); + Assert.Equal(originalOrder, context.ViewModel.Documents); + + context.ViewModel.ReorderDocument(first, 99); + Assert.Equal([originalOrder[1], first], context.ViewModel.Documents); + + using var other = MainWindowViewModelTestContext.Create(); + var foreignTab = other.ViewModel.Documents[0]; + context.ViewModel.ReorderDocument(foreignTab, 0); + Assert.Equal([originalOrder[1], first], context.ViewModel.Documents); + } + + [Fact] + public void CopyDocumentReplacesSinglePristineUntitledDocument() + { + using var context = MainWindowViewModelTestContext.Create(); + Assert.Single(context.ViewModel.Documents); + Assert.True(context.ViewModel.Documents[0].IsEmptyUntitled()); + + var document = new TemplateDocument + { + Content = [DocumentPart.PlainText("Copied text")] + }; + + var copiedTab = context.ViewModel.CopyDocument(document, null, isDirty: false); + + Assert.Single(context.ViewModel.Documents); + Assert.Same(copiedTab, context.ViewModel.SelectedDocument); + Assert.Equal("Copied text", copiedTab.Editor.ExportDocument().Content[0].Text); + } + + [Fact] + public void CopyDocumentInsertsAtSpecifiedSlotAndPreservesDirtyState() + { + using var context = MainWindowViewModelTestContext.Create(); + context.ViewModel.AddNewDocument(); + context.ViewModel.AddNewDocument(); + + var document = new TemplateDocument + { + Content = [DocumentPart.PlainText("New dirty document")] + }; + + var copiedTab = context.ViewModel.CopyDocument(document, null, isDirty: true, targetIndex: 1); + + Assert.Equal(4, context.ViewModel.Documents.Count); + Assert.Same(copiedTab, context.ViewModel.Documents[1]); + Assert.Same(copiedTab, context.ViewModel.SelectedDocument); + Assert.True(copiedTab.IsDirty); + Assert.EndsWith("*", copiedTab.Header, StringComparison.Ordinal); + } + + [Fact] + public void CopyDocumentWithExistingFileSelectsAlreadyOpenTab() + { + using var context = MainWindowViewModelTestContext.Create(); + var file = new MemoryDocumentFile("document.itd"); + var document = new TemplateDocument { Content = [DocumentPart.PlainText("File content")] }; + + var firstCopy = context.ViewModel.CopyDocument(document, file, isDirty: false); + context.ViewModel.AddNewDocument(); + + Assert.Equal(2, context.ViewModel.Documents.Count); + Assert.NotSame(firstCopy, context.ViewModel.SelectedDocument); + + var secondCopy = context.ViewModel.CopyDocument(document, file, isDirty: false); + + Assert.Equal(2, context.ViewModel.Documents.Count); + Assert.Same(firstCopy, secondCopy); + Assert.Same(firstCopy, context.ViewModel.SelectedDocument); + } + + [Fact] + public void CopyDocumentPreservesTemplatePartsAndLineLengthSettings() + { + using var context = MainWindowViewModelTestContext.Create(); + var document = new TemplateDocument + { + LineLength = new LineLengthSettings + { + Show = true, + Enforce = true, + SoftLimit = 70, + HardLimit = 90 + }, + Content = + [ + DocumentPart.PlainText("Hello "), + DocumentPart.Template(["World", "Inlay"], 1) + ] + }; + + var copiedTab = context.ViewModel.CopyDocument(document, null, isDirty: false); + var exported = copiedTab.Editor.ExportDocument(); + + Assert.True(exported.LineLength.Show); + Assert.True(exported.LineLength.Enforce); + Assert.Equal(70, exported.LineLength.SoftLimit); + Assert.Equal(90, exported.LineLength.HardLimit); + Assert.Equal(2, exported.Content.Count); + Assert.Equal("World", exported.Content[1].Options?[0]); + Assert.Equal("Inlay", exported.Content[1].Options?[1]); + Assert.Equal(1, exported.Content[1].SelectedIndex); + } + + [Fact] + public void CopyDocumentWithMatchingDocumentIdSelectsExistingTabAndDoesNotDuplicate() + { + using var context = MainWindowViewModelTestContext.Create(); + var document = new TemplateDocument + { + Content = [DocumentPart.PlainText("Doc with ID")] + }; + var id = Guid.NewGuid(); + + var firstCopy = context.ViewModel.CopyDocument(document, null, isDirty: false, documentId: id); + context.ViewModel.AddNewDocument(); + + Assert.Equal(2, context.ViewModel.Documents.Count); + Assert.NotSame(firstCopy, context.ViewModel.SelectedDocument); + + var secondCopy = context.ViewModel.CopyDocument(document, null, isDirty: false, documentId: id); + + Assert.Equal(2, context.ViewModel.Documents.Count); + Assert.Same(firstCopy, secondCopy); + Assert.Same(firstCopy, context.ViewModel.SelectedDocument); + } + + [Fact] + public void ContainsDocumentMatchesOnDocumentIdOrOnFileIdentity() + { + using var context = MainWindowViewModelTestContext.Create(); + using var other = MainWindowViewModelTestContext.Create(); + + var untitled = context.ViewModel.Documents[0]; + Assert.True(context.ViewModel.ContainsDocument(untitled)); + Assert.False(other.ViewModel.ContainsDocument(untitled)); + + // A copy keeps the document id, so the origin still recognises it. + var copy = other.ViewModel.CopyDocument(untitled); + Assert.True(context.ViewModel.ContainsDocument(copy)); + + // A different document holding the same file counts as the same document too. + var file = new MemoryDocumentFile("test.itd"); + var fileDoc = new TemplateDocument { Content = [DocumentPart.PlainText("File text")] }; + var fileTab = context.ViewModel.CopyDocument(fileDoc, file, isDirty: false); + var sameFileElsewhere = other.ViewModel.CopyDocument(fileDoc, file, isDirty: false); + Assert.NotSame(fileTab, sameFileElsewhere); + Assert.True(context.ViewModel.ContainsDocument(sameFileElsewhere)); + + using var unrelated = MainWindowViewModelTestContext.Create(); + Assert.False(context.ViewModel.ContainsDocument(unrelated.ViewModel.Documents[0])); + } + + [Fact] + public async Task CopyDocumentPreservesExternalChangeProtection() + { + using var source = MainWindowViewModelTestContext.Create(); + var file = new MemoryDocumentFile("source.itd"); + file.SetDocumentText("Original"); + source.Storage.OpenFile = file; + await MainWindowViewModelTestContext.ObserveAsync( + source.ViewModel.OpenCommand.Execute(), + TestContext.Current.CancellationToken); + + file.SetDocumentText("Changed externally"); + source.ViewModel.CheckSelectedDocumentForExternalChanges(); + + using var target = MainWindowViewModelTestContext.Create(); + var copiedTab = target.ViewModel.CopyDocument(source.ViewModel.SelectedDocument!); + + Assert.True(copiedTab.IsDirty); + Assert.True(copiedTab.HasExternalChanges); + Assert.False(copiedTab.ChangedInAnotherWindow); + Assert.False(copiedTab.CanEdit); + } + + [Fact] + public async Task CopyDocumentDetectsFileChangeAfterSnapshotWasCaptured() + { + using var source = MainWindowViewModelTestContext.Create(); + var file = new MemoryDocumentFile("source.itd"); + file.SetDocumentText("Original"); + source.Storage.OpenFile = file; + await MainWindowViewModelTestContext.ObserveAsync( + source.ViewModel.OpenCommand.Execute(), + TestContext.Current.CancellationToken); + + var sourceTab = source.ViewModel.SelectedDocument!; + var document = sourceTab.Editor.ExportDocument(); + var fileState = sourceTab.CaptureFileState(); + file.SetDocumentText("Changed during drag"); + + using var target = MainWindowViewModelTestContext.Create(); + var copiedTab = target.ViewModel.CopyDocument( + document, + file, + isDirty: false, + documentId: sourceTab.DocumentId, + fileState: fileState); + + Assert.True(copiedTab.IsDirty); + Assert.True(copiedTab.HasExternalChanges); + Assert.False(copiedTab.ChangedInAnotherWindow); + Assert.False(copiedTab.CanEdit); + } +} diff --git a/ViewModels/DocumentTabViewModel.cs b/ViewModels/DocumentTabViewModel.cs index 9f2185d..3d370e1 100644 --- a/ViewModels/DocumentTabViewModel.cs +++ b/ViewModels/DocumentTabViewModel.cs @@ -1,12 +1,20 @@ +using System.Threading; using Inlay.Models; using ReactiveUI; using ReactiveUI.SourceGenerators; namespace Inlay.ViewModels; +internal readonly record struct DocumentFileState( + DocumentFileVersion? KnownVersion, + bool HasExternalChanges, + bool ChangedInAnotherWindow); + internal sealed partial class DocumentTabViewModel : ReactiveObject, IDisposable { private const int UntitledPreviewMaxLength = 24; + private static readonly Lock LiveTabsLock = new(); + private static readonly List LiveTabs = []; private readonly Func _closeAsync; private readonly int _untitledOrdinal; private DocumentFileVersion? _knownFileVersion; @@ -16,16 +24,41 @@ public DocumentTabViewModel( TemplateDocument document, Func closeAsync, IDocumentFile? file = null, - int untitledOrdinal = 1) + int untitledOrdinal = 1, + Guid? documentId = null, + DocumentFileState? fileState = null, + bool isDirty = false) { _closeAsync = closeAsync; _untitledOrdinal = untitledOrdinal; + DocumentId = documentId ?? Guid.NewGuid(); File = file; Editor.ContentChanged += MarkDirty; ApplyDocument(document); - RefreshFileVersion(); + if (fileState is { } state) + { + _knownFileVersion = state.KnownVersion; + HasExternalChanges = state.HasExternalChanges; + ChangedInAnotherWindow = state.ChangedInAnotherWindow; + } + else + { + RefreshFileVersion(); + } + + if (isDirty) + { + MarkDirty(); + } + + lock (LiveTabsLock) + { + LiveTabs.Add(this); + } } + public Guid DocumentId { get; } + [Reactive(SetModifier = AccessModifier.Private)] private string _header = "Untitled"; @@ -33,6 +66,7 @@ public DocumentTabViewModel( private bool _isDirty; private bool _hasExternalChanges; + private bool _changedInAnotherWindow; public TemplateEditorViewModel Editor { get; } = new(); @@ -42,7 +76,25 @@ public DocumentTabViewModel( public string FileName => File?.Name ?? "Untitled"; - public string ExternalChangesMessage => $"{FileName} changed outside Inlay. Reload it or ignore the changes?"; + public bool ChangedInAnotherWindow + { + get => _changedInAnotherWindow; + private set + { + if (_changedInAnotherWindow == value) + { + return; + } + + this.RaiseAndSetIfChanged(ref _changedInAnotherWindow, value); + this.RaisePropertyChanged(nameof(ExternalChangesMessage)); + } + } + + public string ExternalChangesMessage => + ChangedInAnotherWindow + ? $"{FileName} was changed in another window. Reload it or ignore the changes?" + : $"{FileName} changed outside Inlay. Reload it or ignore the changes?"; public bool IsEmptyUntitled() => File is null && !IsDirty && Editor.ExportDocument().Content.Count == 0; @@ -64,14 +116,18 @@ private set public bool CanEdit => !HasExternalChanges; + internal DocumentFileState CaptureFileState() => + new(_knownFileVersion, HasExternalChanges, ChangedInAnotherWindow); + public void MarkSaved(IDocumentFile file) { File = file; IsDirty = false; HasExternalChanges = false; + ChangedInAnotherWindow = false; UpdateHeader(); - this.RaisePropertyChanged(nameof(ExternalChangesMessage)); RefreshFileVersion(); + this.RaisePropertyChanged(nameof(ExternalChangesMessage)); } public void CheckForExternalChanges() @@ -90,21 +146,49 @@ public void CheckForExternalChanges() _knownFileVersion = version; MarkDirty(); + ChangedInAnotherWindow = WasWrittenByAnotherTab(version.Value); HasExternalChanges = true; } - public void IgnoreExternalChanges() => HasExternalChanges = false; + public void IgnoreExternalChanges() + { + HasExternalChanges = false; + ChangedInAnotherWindow = false; + } public void Reload(TemplateDocument document) { ApplyDocument(document); HasExternalChanges = false; + ChangedInAnotherWindow = false; RefreshFileVersion(); } public void Dispose() { Editor.ContentChanged -= MarkDirty; + lock (LiveTabsLock) + { + LiveTabs.Remove(this); + } + } + + // A version that some other open tab already knows about was written by Inlay itself, + // not by an external editor. + private bool WasWrittenByAnotherTab(DocumentFileVersion version) + { + if (File is null) + { + return false; + } + + lock (LiveTabsLock) + { + return LiveTabs.Exists(other => + !ReferenceEquals(other, this) && + other._knownFileVersion == version && + DocumentFileIdentity.Matches(other.File, File.Identity)); + } } [ReactiveCommand] diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 8da6f91..b391c35 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -7,7 +7,7 @@ namespace Inlay.ViewModels; -internal sealed partial class MainWindowViewModel : ReactiveObject +internal sealed partial class MainWindowViewModel : ReactiveObject, IDisposable { private const double DefaultEditorFontSize = 15; private static readonly FontFamily DefaultEditorFontFamily = @@ -80,6 +80,98 @@ public DocumentTabViewModel? SelectedDocument public void AddNewDocument() => AddDocument(new TemplateDocument()); + public void ReorderDocument(DocumentTabViewModel document, int targetSlot) + { + var sourceIndex = Documents.IndexOf(document); + if (sourceIndex < 0) + { + return; + } + + // A slot is a gap between tabs, so a slot to the right of the tab being + // moved lands one index earlier once the tab leaves its current place. + var newIndex = Math.Clamp( + targetSlot > sourceIndex ? targetSlot - 1 : targetSlot, + 0, + Documents.Count - 1); + if (newIndex != sourceIndex) + { + Documents.Move(sourceIndex, newIndex); + } + + SelectedDocument = document; + } + + public bool ContainsDocument(DocumentTabViewModel document) => + FindMatchingDocument(document.DocumentId, document.File) is not null; + + public DocumentTabViewModel CopyDocument( + TemplateDocument document, + IDocumentFile? file, + bool isDirty, + int targetIndex = -1, + Guid? documentId = null, + DocumentFileState? fileState = null) + { + var targetDocId = documentId ?? Guid.NewGuid(); + var existingDocument = FindMatchingDocument(targetDocId, file); + if (existingDocument is not null) + { + SelectedDocument = existingDocument; + existingDocument.CheckForExternalChanges(); + return existingDocument; + } + + var shouldReplacePristineUntitled = Documents.Count == 1 && Documents[0].IsEmptyUntitled(); + var untitledOrdinal = file is null && !shouldReplacePristineUntitled + ? NextUntitledOrdinal() + : 1; + + var tab = new DocumentTabViewModel( + document, + CloseDocumentAsync, + file, + untitledOrdinal, + targetDocId, + fileState, + isDirty); + if (fileState is not null) + { + tab.CheckForExternalChanges(); + } + + tab.PropertyChanged += OnDocumentPropertyChanged; + + if (shouldReplacePristineUntitled) + { + var oldTab = Documents[0]; + Documents[0] = tab; + oldTab.PropertyChanged -= OnDocumentPropertyChanged; + oldTab.Dispose(); + } + else if (targetIndex >= 0 && targetIndex <= Documents.Count) + { + Documents.Insert(targetIndex, tab); + } + else + { + Documents.Add(tab); + } + + SelectedDocument = tab; + NotifyDocumentStateChanged(); + return tab; + } + + public DocumentTabViewModel CopyDocument(DocumentTabViewModel sourceTab, int targetIndex = -1) => + CopyDocument( + sourceTab.Editor.ExportDocument(), + sourceTab.File, + sourceTab.IsDirty, + targetIndex, + sourceTab.DocumentId, + sourceTab.CaptureFileState()); + public void AdjustEditorZoom(int steps) { EditorFontSize = Math.Clamp( @@ -137,6 +229,15 @@ public async Task CanCloseAsync() return true; } + public void Dispose() + { + foreach (var document in Documents) + { + document.PropertyChanged -= OnDocumentPropertyChanged; + document.Dispose(); + } + } + [ReactiveCommand] private void New() => AddNewDocument(); @@ -311,14 +412,8 @@ private void AddDocument( IDocumentFile? file = null, bool resetUntitledOrdinal = false) { - var untitledOrdinal = file is null - ? resetUntitledOrdinal - ? 1 - : Documents - .Where(documentTab => documentTab.File is null) - .Select(documentTab => documentTab.UntitledOrdinal) - .DefaultIfEmpty(0) - .Max() + 1 + var untitledOrdinal = file is null && !resetUntitledOrdinal + ? NextUntitledOrdinal() : 1; var tab = new DocumentTabViewModel( document, @@ -336,7 +431,11 @@ private void ReplaceDocument( TemplateDocument document, IDocumentFile file) { - var replacement = new DocumentTabViewModel(document, CloseDocumentAsync, file); + var replacement = new DocumentTabViewModel( + document, + CloseDocumentAsync, + file, + documentId: existingTab.DocumentId); replacement.PropertyChanged += OnDocumentPropertyChanged; var index = Documents.IndexOf(existingTab); @@ -395,8 +494,19 @@ private async Task CanCloseDocumentAsync(DocumentTabViewModel document) } private DocumentTabViewModel? FindOpenDocument(string identity) => + Documents.FirstOrDefault(document => DocumentFileIdentity.Matches(document.File, identity)); + + private DocumentTabViewModel? FindMatchingDocument(Guid documentId, IDocumentFile? file) => Documents.FirstOrDefault(document => - document.File is not null && FileIdentityComparer.Equals(document.File.Identity, identity)); + document.DocumentId == documentId || + (file is not null && DocumentFileIdentity.Matches(document.File, file.Identity))); + + private int NextUntitledOrdinal() => + Documents + .Where(document => document.File is null) + .Select(document => document.UntitledOrdinal) + .DefaultIfEmpty(0) + .Max() + 1; private void OnDocumentPropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -418,7 +528,4 @@ private void UpdateTitle() Title = $"{name} - Inlay"; } - private static StringComparer FileIdentityComparer { get; } = - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - } diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml index 183715a..c4353dc 100644 --- a/Views/MainWindow.axaml +++ b/Views/MainWindow.axaml @@ -245,56 +245,65 @@ ScrollChanged="OnTabsScrollChanged" SizeChanged="OnTabsScrollViewerSizeChanged" DoubleTapped="OnEmptyTabBarDoubleTapped"> - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + VerticalAlignment="Stretch" + IsVisible="False" + IsHitTestVisible="False" + ZIndex="10" /> + DataContext as MainWindowViewModel; public MainWindow() { @@ -31,30 +39,61 @@ public MainWindow(MainWindowViewModel viewModel) private void InitializeView() { InitializeComponent(); + EditorBorder.AddHandler( PointerWheelChangedEvent, OnEditorPointerWheelChanged, RoutingStrategies.Tunnel, handledEventsToo: true); + + DocumentTabs.AddHandler( + PointerPressedEvent, + OnTabPointerPressed, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + AddHandler( + PointerMovedEvent, + OnWindowPointerMoved, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + AddHandler( + PointerReleasedEvent, + OnWindowPointerReleased, + RoutingStrategies.Tunnel, + handledEventsToo: true); + + DragDrop.SetAllowDrop(this, true); + DragDrop.SetAllowDrop(TabBar, true); + + AddHandler(DragDrop.DragOverEvent, OnWindowDragOver); + AddHandler(DragDrop.DropEvent, OnWindowDrop); + + TabBar.AddHandler(DragDrop.DragOverEvent, OnTabsDragOver); + TabBar.AddHandler(DragDrop.DragLeaveEvent, OnTabsDragLeave); + TabBar.AddHandler(DragDrop.DropEvent, OnTabsDrop); + UpdateApplicationIcon(); ActualThemeVariantChanged += (_, _) => UpdateApplicationIcon(); Activated += OnActivated; } - private void OnActivated(object? sender, EventArgs e) + protected override void OnClosed(EventArgs e) { - if (DataContext is MainWindowViewModel viewModel) - { - viewModel.CheckSelectedDocumentForExternalChanges(); - } + base.OnClosed(e); + ViewModel?.Dispose(); } + private void OnActivated(object? sender, EventArgs e) => + ViewModel?.CheckSelectedDocumentForExternalChanges(); + private void UpdateApplicationIcon() { var iconName = ActualThemeVariant == ThemeVariant.Dark ? "inlay-icon-dark.png" : "inlay-icon-light.png"; - using var stream = AssetLoader.Open(new Uri($"avares://Inlay/Assets/{iconName}")); + using var stream = AssetLoader.Open(EmbeddedAssets.Uri(iconName)); Icon = new WindowIcon(stream); } @@ -66,7 +105,7 @@ private void OnEmptyTabBarDoubleTapped(object? sender, TappedEventArgs e) return; } - if (DataContext is MainWindowViewModel viewModel) + if (ViewModel is { } viewModel) { viewModel.AddNewDocument(); e.Handled = true; @@ -110,7 +149,7 @@ private void OnTabsPointerWheelChanged(object? sender, PointerWheelEventArgs e) private void OnEditorPointerWheelChanged(object? sender, PointerWheelEventArgs e) { if ((e.KeyModifiers & KeyModifiers.Control) == 0 || e.Delta.Y == 0 || - DataContext is not MainWindowViewModel viewModel) + ViewModel is not { } viewModel) { return; } @@ -170,22 +209,228 @@ private void BringSelectedTabIntoView() private async void OnTabPointerPressed(object? sender, PointerPressedEventArgs e) { - if (e.GetCurrentPoint(this).Properties.PointerUpdateKind != PointerUpdateKind.MiddleButtonPressed || - e.Source is not Control { DataContext: DocumentTabViewModel document } || - DataContext is not MainWindowViewModel viewModel) + var properties = e.GetCurrentPoint(this).Properties; + if (properties.PointerUpdateKind == PointerUpdateKind.MiddleButtonPressed) + { + if (e.Source is Control { DataContext: DocumentTabViewModel document } && + ViewModel is { } viewModel) + { + e.Handled = true; + await viewModel.CloseDocumentAsync(document); + } + + return; + } + + if (!properties.IsLeftButtonPressed || IsCloseButton(e.Source)) { return; } + if (FindTabItem(e.Source) is { DataContext: DocumentTabViewModel tab } tabItem) + { + _dragCandidate = tab; + _dragCandidateItem = tabItem; + _dragTrigger = e; + _dragStartPoint = e.GetPosition(this); + } + } + + private void OnWindowPointerMoved(object? sender, PointerEventArgs e) + { + if (_dragCandidate is not { } tab || + _dragCandidateItem is not { } tabItem || + _dragTrigger is not { } trigger) + { + return; + } + + var delta = e.GetPosition(this) - _dragStartPoint; + if (Math.Abs(delta.X) < DragThreshold && Math.Abs(delta.Y) < DragThreshold) + { + return; + } + + ClearDragCandidate(); + _ = StartTabDragAsync(trigger, tab, tabItem); + } + + private void OnWindowPointerReleased(object? sender, PointerReleasedEventArgs e) => + ClearDragCandidate(); + + private void ClearDragCandidate() + { + _dragCandidate = null; + _dragCandidateItem = null; + _dragTrigger = null; + } + + private async Task StartTabDragAsync( + PointerPressedEventArgs trigger, + DocumentTabViewModel tab, + TabStripItem tabItem) + { + using var dataTransfer = new DataTransfer(); + dataTransfer.Add(DataTransferItem.Create( + TabDragPayload.Format, + new TabDragPayload { SourceWindow = this, SourceTab = tab })); + + tabItem.Opacity = 0.5; + try + { + await DragDrop.DoDragDropAsync( + trigger, + dataTransfer, + DragDropEffects.Move | DragDropEffects.Copy); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // The platform refused to start a drag; the tab just stays where it is. + } + finally + { + tabItem.Opacity = 1; + HideDropIndicator(); + } + } + + private void OnTabsDragOver(object? sender, DragEventArgs e) + { + if (e.DataTransfer.TryGetValue(TabDragPayload.Format) is not { } payload || + !CanAcceptTabDrop(payload)) + { + e.DragEffects = DragDropEffects.None; + HideDropIndicator(); + return; + } + + e.DragEffects = payload.SourceWindow == this + ? DragDropEffects.Move + : DragDropEffects.Copy; + ShowDropIndicator(CalculateTabDropIndex(e.GetPosition(DocumentTabs))); + e.Handled = true; + } + + private void OnTabsDragLeave(object? sender, DragEventArgs e) => HideDropIndicator(); + + private void OnTabsDrop(object? sender, DragEventArgs e) + { + HideDropIndicator(); + if (e.DataTransfer.TryGetValue(TabDragPayload.Format) is not { } payload || + !CanAcceptTabDrop(payload) || + ViewModel is not { } viewModel) + { + e.DragEffects = DragDropEffects.None; + return; + } + + var targetSlot = CalculateTabDropIndex(e.GetPosition(DocumentTabs)); + if (payload.SourceWindow == this) + { + viewModel.ReorderDocument(payload.SourceTab, targetSlot); + e.DragEffects = DragDropEffects.Move; + } + else + { + viewModel.CopyDocument(payload.SourceTab, targetSlot); + e.DragEffects = DragDropEffects.Copy; + } + + e.Handled = true; + } + + // Dropping anywhere outside the other window's tab bar appends the copy instead of placing it. + private void OnWindowDragOver(object? sender, DragEventArgs e) + { + if (e.Handled) + { + return; + } + + var accepted = e.DataTransfer.TryGetValue(TabDragPayload.Format) is { } payload && + payload.SourceWindow != this && + CanAcceptTabDrop(payload); + e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None; + e.Handled = accepted; + } + + private void OnWindowDrop(object? sender, DragEventArgs e) + { + if (e.Handled) + { + return; + } + + HideDropIndicator(); + if (e.DataTransfer.TryGetValue(TabDragPayload.Format) is not { } payload || + payload.SourceWindow == this || + !CanAcceptTabDrop(payload) || + ViewModel is not { } viewModel) + { + e.DragEffects = DragDropEffects.None; + return; + } + + viewModel.CopyDocument(payload.SourceTab); + e.DragEffects = DragDropEffects.Copy; e.Handled = true; - await viewModel.CloseDocumentAsync(document); } + internal bool CanAcceptTabDrop(TabDragPayload? payload) => + payload is not null && + ViewModel is { } viewModel && + (payload.SourceWindow == this || !viewModel.ContainsDocument(payload.SourceTab)); + + private static bool IsCloseButton(object? source) => + source is Visual visual && + (visual as Button ?? visual.FindAncestorOfType