Expiriments with AvaloniaEdit and tracking document changes

This commit is contained in:
eelke 2025-08-30 19:41:10 +02:00
parent 29a141a971
commit 6325409d25
53 changed files with 643 additions and 627 deletions

51
.ai-guidelines.md Normal file
View file

@ -0,0 +1,51 @@
# pgLabII AI Assistant Guidelines
## Project Context
This is a .NET 8/C# 13 Avalonia cross-platform application for document management.
### Architecture Overview
- **Main Project**: pgLabII (Avalonia UI)
- **Platform Projects**: pgLabII.Desktop
- **Utility Project**: pgLabII.PgUtils
- **Core Components**: DocumentSession, DocumentSessionFactory, LocalDb
## Coding Standards
### C# Guidelines
- Use C# 13 features and modern .NET patterns
- Prefer primary constructors for dependency injection
- Use `var` for obvious types, explicit types for clarity
- Implement proper async/await patterns for I/O operations
- Use nullable reference types consistently
- Prefer "target-typed new"
- Prefer "list initializer" over new
### Avalonia-Specific
- Follow MVVM pattern strictly
- ViewModels should inherit from appropriate base classes
- Use ReactiveUI patterns where applicable
- Make use of annotations to generate bindable properties
- Implement proper data binding
- Consider cross-platform UI constraints
### Project Patterns
- Services should use dependency injection
- Use the DocumentSession pattern for data operations
- Integrate with LocalDb for persistence
- Implement proper error handling and logging
- Consider performance for document operations
- Use FluentResults.Result for expected errors
### Architecture Rules
- Keep platform-specific code in respective platform projects
- Shared business logic in main pgLabII project
- Utilities in pgLabII.PgUtils
- Follow clean architecture principles
## Code Review Focus Areas
1. Memory management for document operations
2. Cross-platform compatibility
3. Proper async patterns
4. Error handling and user feedback
5. Performance considerations for large documents
6. UI responsiveness

View file

@ -6,16 +6,19 @@
<ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="11.3.3" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.3" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.3" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.3" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.3" />
<PackageVersion Include="Avalonia" Version="11.3.4" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="11.3.0" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.4" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.4" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.4" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.4" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.4" />
<PackageVersion Include="Avalonia.iOS" Version="11.2.1" />
<PackageVersion Include="Avalonia.Browser" Version="11.2.1" />
<PackageVersion Include="Avalonia.Android" Version="11.2.1" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.3" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.4" />
<PackageVersion Include="AvaloniaEdit.TextMate" Version="11.3.0" />
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@ -23,19 +26,16 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
<PackageVersion Include="Npgsql" Version="9.0.3" />
<PackageVersion Include="OneOf" Version="3.0.271" />
<PackageVersion Include="Pure.DI" Version="2.1.38" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="2.3.1" />
<PackageVersion Include="ReactiveUI.SourceGenerators.Analyzers.CodeFixes" Version="2.3.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
</Project>

12
ROADMAP.md Normal file
View file

@ -0,0 +1,12 @@
# pgLabII development roadmap
pgLabII is a developer tool for postgresql databases. Its goal is to allow for querying the database and also editing row data.
It also is going to give some user friendly views of the database structure.
## UI overview
There is a window for managing the configuration data of connections to actual database instances.
When a connection is opened a window specific for that database instance is openen. Within this window
the user can open many tabs to query the database and inspects it's tables, indexes etc.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,23 +0,0 @@
using Android.App;
using Android.Content.PM;
using Avalonia;
using Avalonia.Android;
using Avalonia.ReactiveUI;
namespace pgLabII.Android;
[Activity(
Label = "pgLabII.Android",
Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon",
MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
public class MainActivity : AvaloniaMainActivity<App>
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont()
.UseReactiveUI();
}
}

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<application android:label="pgLabII" android:icon="@drawable/Icon" />
</manifest>

View file

@ -1,44 +0,0 @@
Images, layout descriptions, binary blobs and string dictionaries can be included
in your application as resource files. Various Android APIs are designed to
operate on the resource IDs instead of dealing with images, strings or binary blobs
directly.
For example, a sample Android app that contains a user interface layout (main.axml),
an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
would keep its resources in the "Resources" directory of the application:
Resources/
drawable/
icon.png
layout/
main.axml
values/
strings.xml
In order to get the build system to recognize Android resources, set the build action to
"AndroidResource". The native Android APIs do not operate directly with filenames, but
instead operate on resource IDs. When you compile an Android application that uses resources,
the build system will package the resources for distribution and generate a class called "R"
(this is an Android convention) that contains the tokens for each one of the resources
included. For example, for the above Resources layout, this is what the R class would expose:
public class R {
public class drawable {
public const int icon = 0x123;
}
public class layout {
public const int main = 0x456;
}
public class strings {
public const int first_string = 0xabc;
public const int second_string = 0xbcd;
}
}
You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main
to reference the layout/main.axml file, or R.strings.first_string to reference the first
string in the dictionary file values/strings.xml.

View file

@ -1,66 +0,0 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="128dp"
android:height="128dp"
android:viewportWidth="128"
android:viewportHeight="128">
<group
android:name="wrapper"
android:translateX="21"
android:translateY="21">
<group android:name="group">
<path
android:name="path"
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
android:strokeWidth="1"
android:fillType="evenOdd"/>
<path
android:name="path_2"
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
android:strokeWidth="1"/>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_2">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="1000"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
</animated-vector>

View file

@ -1,71 +0,0 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="128dp"
android:height="128dp"
android:viewportWidth="128"
android:viewportHeight="128">
<group
android:name="wrapper"
android:translateX="21"
android:translateY="21">
<group android:name="group">
<path
android:name="path"
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"/>
<path
android:name="path_1"
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"
android:fillType="evenOdd"/>
<path
android:name="path_2"
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
android:fillColor="#00ffffff"
android:strokeWidth="1"/>
</group>
</group>
</vector>
</aapt:attr>
<target android:name="path_2">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:startOffset="100"
android:duration="900"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:duration="500"
android:valueFrom="#00ffffff"
android:valueTo="#f9f9fb"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="path_1">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillColor"
android:startOffset="100"
android:duration="900"
android:valueFrom="#00ffffff"
android:valueTo="#161c2d"
android:valueType="colorType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
</animated-vector>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/splash_background"/>
</item>
<item android:drawable="@drawable/icon"
android:width="120dp"
android:height="120dp"
android:gravity="center" />
</layer-list>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#212121</color>
</resources>

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="MyTheme">
</style>
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/avalonia_anim</item>
<item name="android:windowSplashScreenAnimationDuration">1000</item>
<item name="postSplashScreenTheme">@style/MyTheme.Main</item>
</style>
<style name="MyTheme.Main"
parent ="MyTheme.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
</style>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="splash_background">#FFFFFF</color>
</resources>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<style name="MyTheme">
</style>
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowBackground">@drawable/splash_screen</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View file

@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-android</TargetFramework>
<SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ApplicationId>com.CompanyName.pgLabII</ApplicationId>
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
</PropertyGroup>
<ItemGroup>
<AndroidResource Include="Icon.png">
<Link>Resources\drawable\Icon.png</Link>
</AndroidResource>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Android" />
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\pgLabII\pgLabII.csproj" />
</ItemGroup>
</Project>

View file

@ -3,7 +3,7 @@
<OutputType>WinExe</OutputType>
<!--If you are willing to use Windows/MacOS native APIs you will need to create 3 projects.
One for Windows with net8.0-windows TFM, one for MacOS with net8.0-macos and one with net8.0 TFM for Linux.-->
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<Platforms>AnyCPU;x64</Platforms>

View file

@ -14,7 +14,8 @@ public class PqConnectionStringParserTests
tokenizer
.AddString(kw)
.AddEquals()
.AddString(val);
.AddString(val)
.AddEof();
}
[Fact]

View file

@ -1,4 +1,5 @@
using pgLabII.PgUtils.ConnectionStrings;
using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
@ -11,18 +12,18 @@ public class PqConnectionStringTokenizerTests
public void GetKeyword_Success(string input, string expected)
{
PqConnectionStringTokenizer subject = new(input);
Assert.Equal(expected, subject.GetKeyword());
var result = subject.GetKeyword();
ResultAssert.Success(result, expected);
}
[Theory]
[InlineData("=")]
[InlineData("")]
[InlineData(" ")]
public void GetKeyword_Throws(string input)
public void GetKeyword_Errors(string input)
{
PqConnectionStringTokenizer subject = new(input);
Assert.Throws<PqConnectionStringParserException>(() => subject.GetKeyword());
ResultAssert.Failed(subject.GetKeyword());
}
[Theory]
@ -36,7 +37,7 @@ public class PqConnectionStringTokenizerTests
{
PqConnectionStringTokenizer subject = new(input);
Assert.Equal(expected, subject.Eof);
Assert.Equal(expected, subject.IsEof);
}
[Theory]
@ -46,7 +47,7 @@ public class PqConnectionStringTokenizerTests
public void ConsumeEquals_Success(string input)
{
PqConnectionStringTokenizer subject = new(input);
subject.ConsumeEquals();
ResultAssert.Success(subject.ConsumeEquals());
}
[Theory]
@ -57,7 +58,8 @@ public class PqConnectionStringTokenizerTests
public void ConsumeEquals_Throws(string input)
{
PqConnectionStringTokenizer subject = new(input);
Assert.Throws<PqConnectionStringParserException>(() => subject.ConsumeEquals());
var result = subject.ConsumeEquals();
ResultAssert.Failed(result);
}
[Theory]
@ -69,7 +71,8 @@ public class PqConnectionStringTokenizerTests
public void GetValue_Success(string input, string expected)
{
PqConnectionStringTokenizer subject = new(input);
Assert.Equal(expected, subject.GetValue());
var result = subject.GetValue();
ResultAssert.Success(result, expected);
}
[Theory]
@ -80,6 +83,7 @@ public class PqConnectionStringTokenizerTests
public void GetValue_Throws(string input)
{
PqConnectionStringTokenizer subject = new(input);
Assert.Throws<PqConnectionStringParserException>(() => subject.GetValue());
var result = subject.GetValue();
ResultAssert.Failed(result);
}
}

View file

@ -0,0 +1,38 @@
using FluentResults;
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
public static class ResultAssert
{
public static void Success(Result result)
{
Assert.True(result.IsSuccess);
}
public static void Success<T>(Result<T> result)
{
Assert.True(result.IsSuccess);
}
public static void Success<T>(Result<T> result, T expected)
{
Assert.True(result.IsSuccess);
Assert.Equal(expected, result.Value);
}
public static void Success<T>(Result<T> result, Action<T> assert)
{
Assert.True(result.IsSuccess);
assert(result.Value);
}
public static void Failed(Result result)
{
Assert.True(result.IsFailed);
}
public static void Failed<T>(Result<T> result)
{
Assert.True(result.IsFailed);
}
}

View file

@ -1,84 +1,93 @@
using pgLabII.PgUtils.ConnectionStrings;
using FluentResults;
using pgLabII.PgUtils.ConnectionStrings;
using OneOf;
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
using Elem = OneOf<PqToken, string, IError>;
internal class UnitTestTokenizer : IPqConnectionStringTokenizer
{
private readonly struct Elem(PqToken result, object? output)
{
public readonly PqToken Result = result;
public readonly object? Output = output;
}
private readonly List<Elem> _tokens = [];
private readonly List<Elem> _tokens = new();
private int _position = 0;
public UnitTestTokenizer AddString(string? output)
public UnitTestTokenizer AddString(string output)
{
_tokens.Add(new(PqToken.String, output));
_tokens.Add(output);
return this;
}
public UnitTestTokenizer AddException(Exception? output)
public UnitTestTokenizer AddError(Error error)
{
_tokens.Add(new(PqToken.Exception, output));
_tokens.Add(error);
return this;
}
public UnitTestTokenizer AddEquals()
{
_tokens.Add(new(PqToken.Equals, null));
_tokens.Add(PqToken.Equals);
return this;
}
public void AddEof()
{
_tokens.Add(PqToken.Eof);
}
// note we do no whitespace at end tests here
public bool Eof => _position >= _tokens.Count;
public string GetKeyword()
public bool IsEof
{
EnsureNotEof();
var elem = Consume();
if (elem.Result == PqToken.String)
return (string)elem.Output!;
throw new Exception("Unexpected call to GetKeyword");
get
{
EnsureNotEol();
return _tokens[_position].IsT0 && _tokens[_position].AsT0 == PqToken.Eof;
}
}
public void ConsumeEquals()
public Result<string> GetKeyword()
{
EnsureNotEof();
EnsureNotEol();
var elem = Consume();
if (elem.Result == PqToken.Equals)
return;
throw new Exception("Unexpected call to ConsumeEquals");
return elem.Match<Result<string>>(
token => throw new Exception("Unexpected call to GetKeyword"),
str => str,
error => Result.Fail(error));
}
public string GetValue()
public Result ConsumeEquals()
{
EnsureNotEof();
EnsureNotEol();
var elem = Consume();
if (elem.Result == PqToken.String)
return (string)elem.Output!;
throw new Exception("Unexpected call to GetValue");
return elem.Match<Result>(
token =>
{
if (token != PqToken.Equals)
throw new Exception("Unexpected call to GetKeyword");
return Result.Ok();
},
str => throw new Exception("Unexpected call to ConsumeEquals"),
error => Result.Fail(error));
}
public Result<string> GetValue()
{
EnsureNotEol();
var elem = Consume();
return elem.Match<Result<string>>(
token => throw new Exception("Unexpected call to GetValue"),
str => str,
error => Result.Fail(error));
}
private Elem Consume()
{
var elem = _tokens[_position++];
if (elem.Result == PqToken.Exception)
{
throw (Exception)elem.Output!;
}
return elem;
return _tokens[_position++];
}
private void EnsureNotEof()
private void EnsureNotEol()
{
if (Eof)
throw new Exception("unexpected eof in test, wrong parser call?");
if (_position >= _tokens.Count)
throw new Exception("unexpected end of list in test, wrong parser call?");
}
}

View file

@ -1,27 +1,40 @@
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
using FluentResults;
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
public class UnitTestTokenizerTests
{
private readonly UnitTestTokenizer _sut = new();
[Fact]
public void Eof_True()
public void IsEof_Throws()
{
Assert.True(_sut.Eof);
Assert.Throws<Exception>(() =>
{
bool _ = _sut.IsEof;
});
}
[Fact]
public void IsEof_True()
{
_sut.AddEof();
Assert.True(_sut.IsEof);
}
[Fact]
public void Eof_False()
{
_sut.AddString("a");
Assert.False(_sut.Eof);
Assert.False(_sut.IsEof);
}
[Fact]
public void GetKeyword_Success()
{
_sut.AddString("a");
Assert.Equal("a", _sut.GetKeyword());
var result = _sut.GetKeyword();
ResultAssert.Success(result, "a");
}
[Fact]
@ -34,15 +47,17 @@ public class UnitTestTokenizerTests
[Fact]
public void GetKeyword_SimulatesException()
{
_sut.AddException(new ArgumentNullException());
Assert.Throws<ArgumentNullException>(() => _sut.GetKeyword());
_sut.AddError(new("test"));
var result = _sut.GetKeyword();
ResultAssert.Failed(result);
}
[Fact]
public void GetValue_Success()
{
_sut.AddString("a");
Assert.Equal("a", _sut.GetValue());
var result = _sut.GetValue();
ResultAssert.Success(result, "a");
}
[Fact]
@ -55,15 +70,17 @@ public class UnitTestTokenizerTests
[Fact]
public void GetValue_SimulatesException()
{
_sut.AddException(new ArgumentNullException());
Assert.Throws<ArgumentNullException>(() => _sut.GetValue());
_sut.AddError(new("test"));
var result = _sut.GetValue();
ResultAssert.Failed(result);
}
[Fact]
public void ConsumeEquals_Success()
{
_sut.AddEquals();
_sut.ConsumeEquals();
var result = _sut.ConsumeEquals();
ResultAssert.Success(result);
}
[Fact]

View file

@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="OneOf" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>

View file

@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=acls/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=connectionstrings_005Cutil/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -1,9 +1,11 @@
namespace pgLabII.PgUtils.ConnectionStrings;
using FluentResults;
namespace pgLabII.PgUtils.ConnectionStrings;
public interface IPqConnectionStringTokenizer
{
bool Eof { get; }
string GetKeyword();
void ConsumeEquals();
string GetValue();
bool IsEof { get; }
Result<string> GetKeyword();
Result ConsumeEquals();
Result<string> GetValue();
}

View file

@ -1,18 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace pgLabII.PgUtils.ConnectionStrings.Pq;
enum Keyword
{
Host,
HostAddr,
Port,
DatabaseName,
UserName,
Password,
}

View file

@ -1,8 +1,12 @@
using System.Collections.ObjectModel;
using FluentResults;
using Npgsql;
namespace pgLabII.PgUtils.ConnectionStrings;
/// <summary>
/// Parser for converting a libpq style connection string into a dictionary of key value pairs
/// </summary>
public ref struct PqConnectionStringParser
{
// Note possible keywords
@ -51,64 +55,38 @@ public ref struct PqConnectionStringParser
).Parse();
}
private readonly IPqConnectionStringTokenizer tokenizer;
private readonly Dictionary<string, string> result = new();
private readonly IPqConnectionStringTokenizer _tokenizer;
private readonly Dictionary<string, string> _result = new();
public PqConnectionStringParser(IPqConnectionStringTokenizer tokenizer)
{
this.tokenizer = tokenizer;
this._tokenizer = tokenizer;
}
public IDictionary<string, string> Parse()
{
result.Clear();
_result.Clear();
while (!tokenizer.Eof)
while (!_tokenizer.IsEof)
ParsePair();
return result;
return _result;
}
private void ParsePair()
private Result ParsePair()
{
string kw = tokenizer.GetKeyword();
tokenizer.ConsumeEquals();
string v = tokenizer.GetValue();
result.Add(kw, v);
//switch (kw)
//{
// case "host":
// case "hostaddr":
// result.Host = v.ToString();
// break;
// case "port":
// result.Port = int.Parse(v);
// break;
// case "dbname":
// result.Database = v.ToString();
// break;
// case "user":
// result.Username = v.ToString();
// break;
// case "password":
// result.Password = v.ToString();
// break;
// case "connect_timeout":
// result.Timeout = int.Parse(v);
// break;
// case "application_name":
// result.ApplicationName = v.ToString();
// break;
// case "options":
// result.Options = v.ToString();
// break;
// case "sslmode":
// result.SslMode = ToSslMode(v);
// break;
// default:
// // Todo what do we do with values we do not support/recognize?
// break;
//}
var kwResult = _tokenizer.GetKeyword();
if (kwResult.IsFailed)
return kwResult.ToResult();
var result = _tokenizer.ConsumeEquals();
if (result.IsFailed)
return result;
var valResult = _tokenizer.GetValue();
if (valResult.IsFailed)
return valResult.ToResult();
_result.Add(kwResult.Value, valResult.Value);
return Result.Ok();
}
private SslMode ToSslMode(ReadOnlySpan<char> v)

View file

@ -1,16 +0,0 @@
namespace pgLabII.PgUtils.ConnectionStrings;
public class PqConnectionStringParserException : Exception
{
public PqConnectionStringParserException()
{
}
public PqConnectionStringParserException(string? message) : base(message)
{
}
public PqConnectionStringParserException(string? message, Exception? innerException) : base(message, innerException)
{
}
}

View file

@ -1,4 +1,5 @@
using System.Text;
using FluentResults;
using static System.Net.Mime.MediaTypeNames;
namespace pgLabII.PgUtils.ConnectionStrings;
@ -8,7 +9,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
private readonly string input;
private int position = 0;
public bool Eof
public bool IsEof
{
get
{
@ -23,37 +24,38 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
position = 0;
}
public string GetKeyword()
public Result<string> GetKeyword()
{
if (Eof)
throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}");
if (IsEof)
return Result.Fail($"Unexpected end of file was expecting a keyword at position {position}");
return GetString(forKeyword: true);
}
public void ConsumeEquals()
public Result ConsumeEquals()
{
ConsumeWhitespace();
if (position < input.Length && input[position] == '=')
{
position++;
return Result.Ok();
}
else
throw new PqConnectionStringParserException($"Was expecting '=' after keyword at position {position}");
return Result.Fail($"Was expecting '=' after keyword at position {position}");
}
public string GetValue()
public Result<string> GetValue()
{
if (Eof)
throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}");
if (IsEof)
return Result.Fail($"Unexpected end of file was expecting a keyword at position {position}");
return GetString(forKeyword: false);
}
private string GetString(bool forKeyword)
private Result<string> GetString(bool forKeyword)
{
if (forKeyword && input[position] == '=')
throw new PqConnectionStringParserException($"Unexpected '=' was expecting keyword at position {position}");
return Result.Fail($"Unexpected '=' was expecting keyword at position {position}");
if (input[position] == '\'')
return ParseQuotedText();
@ -75,7 +77,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
return input.Substring(start, position - start);
}
private string ParseQuotedText()
private Result<string> ParseQuotedText()
{
bool escape = false;
StringBuilder sb = new();
@ -93,7 +95,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
escape = false;
break;
default:
throw new PqConnectionStringParserException($"Invalid escape sequence at position {position}");
return Result.Fail($"Invalid escape sequence at position {position}");
}
}
else
@ -113,6 +115,6 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
}
}
}
throw new PqConnectionStringParserException($"Missing end quote on value starting at {start}");
return Result.Fail($"Missing end quote on value starting at {start}");
}
}

View file

@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentResults" />
<PackageReference Include="Npgsql" />
</ItemGroup>

View file

@ -1,25 +0,0 @@
using Foundation;
using UIKit;
using Avalonia;
using Avalonia.Controls;
using Avalonia.iOS;
using Avalonia.Media;
using Avalonia.ReactiveUI;
namespace pgLabII.iOS;
// The UIApplicationDelegate for the application. This class is responsible for launching the
// User Interface of the application, as well as listening (and optionally responding) to
// application events from iOS.
[Register("AppDelegate")]
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
public partial class AppDelegate : AvaloniaAppDelegate<App>
#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
{
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
{
return base.CustomizeAppBuilder(builder)
.WithInterFont()
.UseReactiveUI();
}
}

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View file

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>pgLabII</string>
<key>CFBundleIdentifier</key>
<string>companyName.pgLabII</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>13.0</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -1,14 +0,0 @@
using UIKit;
namespace pgLabII.iOS;
public class Application
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View file

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="6214" systemVersion="14A314h" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="6207" />
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1" />
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" />
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder" />
<view contentMode="scaleToFill" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="480" height="480" />
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" Copyright (c) 2022 " textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines"
minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
<rect key="frame" x="20" y="439" width="441" height="21" />
<fontDescription key="fontDescription" type="system" pointSize="17" />
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
<nil key="highlightedColor" />
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="pgLabII" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines"
minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
<rect key="frame" x="20" y="140" width="441" height="43" />
<fontDescription key="fontDescription" type="boldSystem" pointSize="36" />
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
<nil key="highlightedColor" />
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite" />
<constraints>
<constraint firstItem="kId-c2-rCX" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="bottom" multiplier="1/3" constant="1" id="5cJ-9S-tgC" />
<constraint firstAttribute="centerX" secondItem="kId-c2-rCX" secondAttribute="centerX" id="Koa-jz-hwk" />
<constraint firstAttribute="bottom" secondItem="8ie-xW-0ye" secondAttribute="bottom" constant="20" id="Kzo-t9-V3l" />
<constraint firstItem="8ie-xW-0ye" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="MfP-vx-nX0" />
<constraint firstAttribute="centerX" secondItem="8ie-xW-0ye" secondAttribute="centerX" id="ZEH-qu-HZ9" />
<constraint firstItem="kId-c2-rCX" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="fvb-Df-36g" />
</constraints>
<nil key="simulatedStatusBarMetrics" />
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics" />
<point key="canvasLocation" x="548" y="455" />
</view>
</objects>
</document>

View file

@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-ios</TargetFramework>
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.iOS" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\pgLabII\pgLabII.csproj" />
</ItemGroup>
</Project>

View file

@ -14,6 +14,7 @@
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles>
</Application>

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using pgLabII.Model;
using pgLabII.Views.Controls;
namespace pgLabII;
public interface IEditHistoryManager
{
void AddEdit(int offset, string insertedText, string removedText);
void FlushBuffer();
void SaveToDatabase();
IReadOnlyList<EditHistoryEntry> GetHistory();
}

View file

@ -0,0 +1,12 @@
using System;
using System.Text;
namespace pgLabII;
public class EditBuffer
{
public int CurrentOffset { get; set; }
public StringBuilder InsertedText { get; set; } = new();
public StringBuilder RemovedText { get; set; } = new();
public DateTime LastEdit { get; set; }
}

View file

@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Timers;
using pgLabII.Infra;
using pgLabII.Model;
namespace pgLabII;
public class EditHistoryManager : IEditHistoryManager
{
private readonly LocalDb _db;
private readonly Document _document;
private readonly List<EditHistoryEntry> _pendingEdits = new();
private EditBuffer? _currentBuffer;
private const int BufferTimeoutMs = 500;
private readonly Timer _idleFlushTimer;
private readonly object _sync = new object();
public EditHistoryManager(LocalDb db, Document document)
{
_db = db;
_document = document;
_idleFlushTimer = new Timer(BufferTimeoutMs)
{
AutoReset = false,
Enabled = false
};
_idleFlushTimer.Elapsed += OnIdleFlushTimerElapsed;
}
public void Dispose()
{
_idleFlushTimer.Elapsed -= OnIdleFlushTimerElapsed;
_idleFlushTimer.Dispose();
// Final flush on dispose to avoid losing last edits
lock (_sync)
{
FlushBuffer();
if (_pendingEdits.Count > 0)
{
SaveToDatabase();
}
}
}
public void AddEdit(int offset, string insertedText, string removedText)
{
var now = DateTime.UtcNow;
bool isSimpleEdit = (insertedText.Length <= 1 && removedText.Length <= 1);
if (TryCombineWithBuffer(offset, insertedText, removedText, now, isSimpleEdit))
{
RestartIdleTimer();
return;
}
FlushBuffer();
CreateNewBuffer(offset, insertedText, removedText, now);
RestartIdleTimer();
}
public void FlushBuffer()
{
if (_currentBuffer == null) return;
var edit = new EditHistoryEntry
{
DocumentId = _document.Id,
Offset = _currentBuffer.CurrentOffset,
InsertedText = _currentBuffer.InsertedText.ToString(),
RemovedText = _currentBuffer.RemovedText.ToString(),
Timestamp = DateTime.UtcNow
};
_pendingEdits.Add(edit);
_currentBuffer = null;
if (_pendingEdits.Count >= 10)
{
SaveToDatabase();
}
}
public void SaveToDatabase()
{
_db.EditHistory.AddRange(_pendingEdits);
_db.SaveChanges();
_pendingEdits.Clear();
}
public IReadOnlyList<EditHistoryEntry> GetHistory() => _pendingEdits.AsReadOnly();
private bool TryCombineWithBuffer(int offset, string insertedText, string removedText, DateTime now, bool isSimpleEdit)
{
// Try to combine with current buffer if it's a simple edit
if (_currentBuffer != null &&
isSimpleEdit &&
(now - _currentBuffer.LastEdit).TotalMilliseconds < BufferTimeoutMs)
{
// For consecutive inserts
if (insertedText.Length == 1 &&
offset == _currentBuffer.CurrentOffset + _currentBuffer.InsertedText.Length)
{
_currentBuffer.InsertedText.Append(insertedText);
_currentBuffer.LastEdit = now;
return true;
}
// For consecutive deletes or backspaces
if (removedText.Length == 1 &&
(offset == _currentBuffer.CurrentOffset - 1 || // Backspace
offset == _currentBuffer.CurrentOffset)) // Delete
{
if (offset < _currentBuffer.CurrentOffset)
{
_currentBuffer.RemovedText.Insert(0, removedText);
_currentBuffer.CurrentOffset = offset;
}
else
{
_currentBuffer.RemovedText.Append(removedText);
}
_currentBuffer.LastEdit = now;
return true;
}
}
return false;
}
private void CreateNewBuffer(int offset, string insertedText, string removedText, DateTime now)
{
_currentBuffer = new EditBuffer
{
CurrentOffset = offset,
LastEdit = now
};
_currentBuffer.InsertedText.Append(insertedText);
_currentBuffer.RemovedText.Append(removedText);
}
private void RestartIdleTimer()
{
_idleFlushTimer.Stop();
_idleFlushTimer.Interval = BufferTimeoutMs;
_idleFlushTimer.Start();
}
private void OnIdleFlushTimerElapsed(object? sender, ElapsedEventArgs e)
{
lock (_sync)
{
// On inactivity, flush any buffered edit and persist remaining pending edits.
FlushBuffer();
if (_pendingEdits.Count > 0)
{
SaveToDatabase();
}
}
}
}

View file

@ -0,0 +1,11 @@
using System;
namespace pgLabII;
public class EditOperation
{
public required int Offset { get; set; }
public required string InsertedText { get; set; }
public required string RemovedText { get; set; }
public DateTime Timestamp { get; set; }
}

View file

@ -6,9 +6,11 @@ using pgLabII.Model;
namespace pgLabII.Infra;
internal class LocalDb : DbContext
public class LocalDb : DbContext
{
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
public DbSet<Document> Documents => Set<Document>();
public DbSet<EditHistoryEntry> EditHistory => Set<EditHistoryEntry>();
public string DbPath { get; }
@ -26,9 +28,10 @@ internal class LocalDb : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
new ServerUserEntityConfiguration().Configure(modelBuilder.Entity<ServerUser>());
new DocumentEntityConfiguration().Configure(modelBuilder.Entity<Document>());
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity<EditHistoryEntry>());
}
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
@ -56,3 +59,26 @@ public class ServerUserEntityConfiguration : IEntityTypeConfiguration<ServerUser
b.HasKey(e => e.Id);
}
}
public class DocumentEntityConfiguration : IEntityTypeConfiguration<Document>
{
public void Configure(EntityTypeBuilder<Document> b)
{
b.HasKey(e => e.Id);
b.Property(e => e.OriginalFilename).IsRequired();
b.Property(e => e.BaseCopyFilename).IsRequired();
b.HasMany(e => e.EditHistory)
.WithOne(e => e.Document)
.HasForeignKey(e => e.DocumentId);
}
}
public class EditHistoryEntityConfiguration : IEntityTypeConfiguration<EditHistoryEntry>
{
public void Configure(EntityTypeBuilder<EditHistoryEntry> b)
{
b.HasKey(e => e.Id);
b.Property(e => e.Timestamp).IsRequired();
b.HasIndex(e => new { e.DocumentId, e.Timestamp});
}
}

15
pgLabII/Model/Document.cs Normal file
View file

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace pgLabII.Model;
public class Document
{
public int Id { get; set; }
public required string OriginalFilename { get; set; }
public required string BaseCopyFilename { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public List<EditHistoryEntry> EditHistory { get; set; } = [];
}

View file

@ -0,0 +1,14 @@
using System;
namespace pgLabII.Model;
public class EditHistoryEntry
{
public int Id { get; set; }
public int DocumentId { get; set; }
public Document Document { get; set; } = null!;
public DateTime Timestamp { get; set; }
public int Offset { get; set; }
public string InsertedText { get; set; } = string.Empty;
public string RemovedText { get; set; } = string.Empty;
}

View file

@ -2,6 +2,7 @@
using pgLabII.Infra;
using pgLabII.ViewModels;
using pgLabII.Views;
using pgLabII.Views.Controls;
namespace pgLabII;
@ -13,6 +14,8 @@ internal static class ServiceCollectionExtensions
collection.AddTransient<MainViewModel>();
collection.AddTransient<ServerListViewModel>();
collection.AddScoped<LocalDb>();
collection.AddTransient<CodeEditorView>();
collection.AddTransient<IEditHistoryManager, EditHistoryManager>();
}
}

View file

@ -0,0 +1,6 @@
namespace pgLabII.Services;
public class DocumentSession
{
}

View file

@ -0,0 +1,18 @@
using System;
using pgLabII.Infra;
namespace pgLabII.Services;
public class DocumentSessionFactory(
LocalDb localDb)
{
public DocumentSession CreateNew()
{
throw new NotImplementedException();
}
public DocumentSession Open(string path)
{
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,10 @@
using AvaloniaEdit.Document;
using ReactiveUI.SourceGenerators;
namespace pgLabII.ViewModels;
public partial class CodeEditorViewModel : ViewModelBase
{
[Reactive] private TextDocument _document = new();
}

View file

@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
x:Class="pgLabII.Views.Controls.CodeEditorView">
<DockPanel LastChildFill="True">
<!-- Toolbar -->
<StackPanel Orientation="Horizontal" DockPanel.Dock="Top">
<Button Name="SaveButton" Content="Save" Click="OnSaveClicked"/>
</StackPanel>
<!-- Editor -->
<avaloniaEdit:TextEditor Name="Editor"
ShowLineNumbers="True"
/>
</DockPanel>
</UserControl>

View file

@ -0,0 +1,50 @@
using System.IO;
using Avalonia;
using Avalonia.Controls;
using AvaloniaEdit.Document;
using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars;
namespace pgLabII.Views.Controls;
public partial class CodeEditorView : UserControl
{
private TextMate.Installation? _textMate;
private readonly IEditHistoryManager _editHistoryManager = new EditHistoryManager(
new(),
new()
{
OriginalFilename = "",
BaseCopyFilename = "",
});
public CodeEditorView()
{
InitializeComponent();
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
_textMate = Editor.InstallTextMate(registryOptions);
_textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id));
Editor.Document.Changed += DocumentChanged;
}
private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
// Example save logic
File.WriteAllText("final.sql", Editor.Text);
}
private void DocumentChanged(object? sender, DocumentChangeEventArgs e)
{
_editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text);
}
}

View file

@ -9,6 +9,7 @@ public partial class ServerListView : UserControl
{
//DataContext = new ServerListViewModel();
InitializeComponent();
}
}

View file

@ -3,6 +3,8 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="pgLabII.Views.SingleDatabaseWindow"
Title="SingleDatabaseWindow"
@ -45,11 +47,12 @@
<TabControl.ContentTemplate>
<DataTemplate DataType="viewModels:QueryToolViewModel">
<StackPanel Orientation="Vertical">
<TextBox Text="{Binding Query, Mode=TwoWay}" />
<Button Command="{Binding EditCommand}">TEST</Button>
</StackPanel>
<DockPanel LastChildFill="True">
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
<controls:CodeEditorView />
</DockPanel>
</DataTemplate>
</TabControl.ContentTemplate>

View file

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
@ -22,6 +23,8 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="Avalonia.ReactiveUI" />
<PackageReference Include="AvaloniaEdit.TextMate" />
<PackageReference Include="FluentResults" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View file

@ -1,2 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=views_005Ccontrols/@EntryIndexedValue">False</s:Boolean></wpf:ResourceDictionary>