diff --git a/.ai-guidelines.md b/.ai-guidelines.md new file mode 100644 index 0000000..82b8c08 --- /dev/null +++ b/.ai-guidelines.md @@ -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 \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 94ea5dc..6a883b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,16 +6,19 @@ - - - - - - + + + + + + + - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -23,19 +26,16 @@ + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..2b2be0c --- /dev/null +++ b/ROADMAP.md @@ -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. + + diff --git a/pgLabII.Android/Icon.png b/pgLabII.Android/Icon.png deleted file mode 100644 index 41a2a61..0000000 Binary files a/pgLabII.Android/Icon.png and /dev/null differ diff --git a/pgLabII.Android/MainActivity.cs b/pgLabII.Android/MainActivity.cs deleted file mode 100644 index e1efa06..0000000 --- a/pgLabII.Android/MainActivity.cs +++ /dev/null @@ -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 -{ - protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) - { - return base.CustomizeAppBuilder(builder) - .WithInterFont() - .UseReactiveUI(); - } -} diff --git a/pgLabII.Android/Properties/AndroidManifest.xml b/pgLabII.Android/Properties/AndroidManifest.xml deleted file mode 100644 index a77e007..0000000 --- a/pgLabII.Android/Properties/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/pgLabII.Android/Resources/AboutResources.txt b/pgLabII.Android/Resources/AboutResources.txt deleted file mode 100644 index c2bca97..0000000 --- a/pgLabII.Android/Resources/AboutResources.txt +++ /dev/null @@ -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. \ No newline at end of file diff --git a/pgLabII.Android/Resources/drawable-night-v31/avalonia_anim.xml b/pgLabII.Android/Resources/drawable-night-v31/avalonia_anim.xml deleted file mode 100644 index dde4b5a..0000000 --- a/pgLabII.Android/Resources/drawable-night-v31/avalonia_anim.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pgLabII.Android/Resources/drawable-v31/avalonia_anim.xml b/pgLabII.Android/Resources/drawable-v31/avalonia_anim.xml deleted file mode 100644 index 94f27d9..0000000 --- a/pgLabII.Android/Resources/drawable-v31/avalonia_anim.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pgLabII.Android/Resources/drawable/splash_screen.xml b/pgLabII.Android/Resources/drawable/splash_screen.xml deleted file mode 100644 index 2e920b4..0000000 --- a/pgLabII.Android/Resources/drawable/splash_screen.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/pgLabII.Android/Resources/values-night/colors.xml b/pgLabII.Android/Resources/values-night/colors.xml deleted file mode 100644 index 3d47b6f..0000000 --- a/pgLabII.Android/Resources/values-night/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #212121 - diff --git a/pgLabII.Android/Resources/values-v31/styles.xml b/pgLabII.Android/Resources/values-v31/styles.xml deleted file mode 100644 index d5ecec4..0000000 --- a/pgLabII.Android/Resources/values-v31/styles.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/pgLabII.Android/Resources/values/colors.xml b/pgLabII.Android/Resources/values/colors.xml deleted file mode 100644 index 59279d5..0000000 --- a/pgLabII.Android/Resources/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FFFFFF - diff --git a/pgLabII.Android/Resources/values/styles.xml b/pgLabII.Android/Resources/values/styles.xml deleted file mode 100644 index 6e534de..0000000 --- a/pgLabII.Android/Resources/values/styles.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - diff --git a/pgLabII.Android/pgLabII.Android.csproj b/pgLabII.Android/pgLabII.Android.csproj deleted file mode 100644 index 562450e..0000000 --- a/pgLabII.Android/pgLabII.Android.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - Exe - net8.0-android - 21 - enable - com.CompanyName.pgLabII - 1 - 1.0 - apk - false - - - - - Resources\drawable\Icon.png - - - - - - - - - - - - diff --git a/pgLabII.Desktop/pgLabII.Desktop.csproj b/pgLabII.Desktop/pgLabII.Desktop.csproj index b997c85..9002229 100644 --- a/pgLabII.Desktop/pgLabII.Desktop.csproj +++ b/pgLabII.Desktop/pgLabII.Desktop.csproj @@ -3,7 +3,7 @@ WinExe - net8.0 + net9.0 enable true AnyCPU;x64 diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs index 34a4440..d953ffc 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs @@ -14,7 +14,8 @@ public class PqConnectionStringParserTests tokenizer .AddString(kw) .AddEquals() - .AddString(val); + .AddString(val) + .AddEof(); } [Fact] diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs index 7ec407e..5cc6954 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs @@ -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(() => 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(() => 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(() => subject.GetValue()); + var result = subject.GetValue(); + ResultAssert.Failed(result); } } diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/ResultAssert.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/ResultAssert.cs new file mode 100644 index 0000000..252d675 --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/ResultAssert.cs @@ -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(Result result) + { + Assert.True(result.IsSuccess); + } + + public static void Success(Result result, T expected) + { + Assert.True(result.IsSuccess); + Assert.Equal(expected, result.Value); + } + + public static void Success(Result result, Action assert) + { + Assert.True(result.IsSuccess); + assert(result.Value); + } + + public static void Failed(Result result) + { + Assert.True(result.IsFailed); + } + + public static void Failed(Result result) + { + Assert.True(result.IsFailed); + } +} diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs index f3922e2..93e6789 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs @@ -1,84 +1,93 @@ -using pgLabII.PgUtils.ConnectionStrings; +using FluentResults; +using pgLabII.PgUtils.ConnectionStrings; +using OneOf; namespace pgLabII.PgUtils.Tests.ConnectionStrings; +using Elem = OneOf; + 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 _tokens = []; + private readonly List _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 GetKeyword() { - EnsureNotEof(); + EnsureNotEol(); var elem = Consume(); - if (elem.Result == PqToken.Equals) - return; - - throw new Exception("Unexpected call to ConsumeEquals"); + return elem.Match>( + 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( + 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 GetValue() + { + EnsureNotEol(); + var elem = Consume(); + return elem.Match>( + 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?"); } } diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs index 6bcb053..8c85e5f 100644 --- a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs @@ -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(() => + { + 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(() => _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(() => _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] diff --git a/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj b/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj index a99e569..6c549da 100644 --- a/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj +++ b/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj.DotSettings b/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj.DotSettings new file mode 100644 index 0000000..1a62760 --- /dev/null +++ b/pgLabII.PgUtils.Tests/pgLabII.PgUtils.Tests.csproj.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs index 98c42a6..346a9a6 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs @@ -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 GetKeyword(); + Result ConsumeEquals(); + Result GetValue(); } diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/KeywordMapping.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/KeywordMapping.cs deleted file mode 100644 index 995134e..0000000 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/KeywordMapping.cs +++ /dev/null @@ -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, -} - diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs index 1cd51a8..cb83332 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs @@ -1,8 +1,12 @@ using System.Collections.ObjectModel; +using FluentResults; using Npgsql; namespace pgLabII.PgUtils.ConnectionStrings; +/// +/// Parser for converting a libpq style connection string into a dictionary of key value pairs +/// public ref struct PqConnectionStringParser { // Note possible keywords @@ -51,64 +55,38 @@ public ref struct PqConnectionStringParser ).Parse(); } - private readonly IPqConnectionStringTokenizer tokenizer; - private readonly Dictionary result = new(); + private readonly IPqConnectionStringTokenizer _tokenizer; + private readonly Dictionary _result = new(); public PqConnectionStringParser(IPqConnectionStringTokenizer tokenizer) { - this.tokenizer = tokenizer; + this._tokenizer = tokenizer; } public IDictionary 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 v) diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs deleted file mode 100644 index 0a80ba6..0000000 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs +++ /dev/null @@ -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) - { - } -} diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs index b8d7107..fd46bb8 100644 --- a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs @@ -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 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 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 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 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}"); } } diff --git a/pgLabII.PgUtils/pgLabII.PgUtils.csproj b/pgLabII.PgUtils/pgLabII.PgUtils.csproj index d44f26c..f70bcc6 100644 --- a/pgLabII.PgUtils/pgLabII.PgUtils.csproj +++ b/pgLabII.PgUtils/pgLabII.PgUtils.csproj @@ -8,6 +8,7 @@ + diff --git a/pgLabII.iOS/AppDelegate.cs b/pgLabII.iOS/AppDelegate.cs deleted file mode 100644 index 2e21b1f..0000000 --- a/pgLabII.iOS/AppDelegate.cs +++ /dev/null @@ -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 -#pragma warning restore CA1711 // Identifiers should not have incorrect suffix -{ - protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) - { - return base.CustomizeAppBuilder(builder) - .WithInterFont() - .UseReactiveUI(); - } -} diff --git a/pgLabII.iOS/Entitlements.plist b/pgLabII.iOS/Entitlements.plist deleted file mode 100644 index 0c67376..0000000 --- a/pgLabII.iOS/Entitlements.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/pgLabII.iOS/Info.plist b/pgLabII.iOS/Info.plist deleted file mode 100644 index 9ef8ec5..0000000 --- a/pgLabII.iOS/Info.plist +++ /dev/null @@ -1,43 +0,0 @@ - - - - - CFBundleDisplayName - pgLabII - CFBundleIdentifier - companyName.pgLabII - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1.0 - LSRequiresIPhoneOS - - MinimumOSVersion - 13.0 - UIDeviceFamily - - 1 - 2 - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/pgLabII.iOS/Main.cs b/pgLabII.iOS/Main.cs deleted file mode 100644 index 01ff99a..0000000 --- a/pgLabII.iOS/Main.cs +++ /dev/null @@ -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)); - } -} diff --git a/pgLabII.iOS/Resources/LaunchScreen.xib b/pgLabII.iOS/Resources/LaunchScreen.xib deleted file mode 100644 index f610a15..0000000 --- a/pgLabII.iOS/Resources/LaunchScreen.xib +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pgLabII.iOS/pgLabII.iOS.csproj b/pgLabII.iOS/pgLabII.iOS.csproj deleted file mode 100644 index 2e94356..0000000 --- a/pgLabII.iOS/pgLabII.iOS.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - Exe - net8.0-ios - 13.0 - enable - - - - - - - - - - diff --git a/pgLabII/App.axaml b/pgLabII/App.axaml index 9af82b4..ef1df69 100644 --- a/pgLabII/App.axaml +++ b/pgLabII/App.axaml @@ -14,6 +14,7 @@ + \ No newline at end of file diff --git a/pgLabII/Contracts/IEditHistoryManager.cs b/pgLabII/Contracts/IEditHistoryManager.cs new file mode 100644 index 0000000..d93ddf8 --- /dev/null +++ b/pgLabII/Contracts/IEditHistoryManager.cs @@ -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 GetHistory(); +} diff --git a/pgLabII/EditHistoryManager/EditBuffer.cs b/pgLabII/EditHistoryManager/EditBuffer.cs new file mode 100644 index 0000000..60e8f54 --- /dev/null +++ b/pgLabII/EditHistoryManager/EditBuffer.cs @@ -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; } +} diff --git a/pgLabII/EditHistoryManager/EditHistoryManager.cs b/pgLabII/EditHistoryManager/EditHistoryManager.cs new file mode 100644 index 0000000..45ba53d --- /dev/null +++ b/pgLabII/EditHistoryManager/EditHistoryManager.cs @@ -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 _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 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(); + } + } + } + +} diff --git a/pgLabII/EditHistoryManager/EditOperation.cs b/pgLabII/EditHistoryManager/EditOperation.cs new file mode 100644 index 0000000..b0fb10c --- /dev/null +++ b/pgLabII/EditHistoryManager/EditOperation.cs @@ -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; } +} diff --git a/pgLabII/Infra/LocalDb.cs b/pgLabII/Infra/LocalDb.cs index cb4c492..7dd09b4 100644 --- a/pgLabII/Infra/LocalDb.cs +++ b/pgLabII/Infra/LocalDb.cs @@ -6,10 +6,12 @@ using pgLabII.Model; namespace pgLabII.Infra; -internal class LocalDb : DbContext +public class LocalDb : DbContext { public DbSet ServerConfigurations => Set(); - + public DbSet Documents => Set(); + public DbSet EditHistory => Set(); + public string DbPath { get; } public LocalDb() @@ -26,9 +28,10 @@ internal class LocalDb : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { - - new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity()); + new ServerUserEntityConfiguration().Configure(modelBuilder.Entity()); + new DocumentEntityConfiguration().Configure(modelBuilder.Entity()); + new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity()); } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) @@ -56,3 +59,26 @@ public class ServerUserEntityConfiguration : IEntityTypeConfiguration e.Id); } } + +public class DocumentEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder b) + { + b.HasKey(e => e.Id); + b.Property(e => e.Timestamp).IsRequired(); + b.HasIndex(e => new { e.DocumentId, e.Timestamp}); + } +} diff --git a/pgLabII/Model/Document.cs b/pgLabII/Model/Document.cs new file mode 100644 index 0000000..01e3f62 --- /dev/null +++ b/pgLabII/Model/Document.cs @@ -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 EditHistory { get; set; } = []; +} diff --git a/pgLabII/Model/EditHistoryEntry.cs b/pgLabII/Model/EditHistoryEntry.cs new file mode 100644 index 0000000..750d2c9 --- /dev/null +++ b/pgLabII/Model/EditHistoryEntry.cs @@ -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; +} diff --git a/pgLabII/ServiceCollectionExtensions.cs b/pgLabII/ServiceCollectionExtensions.cs index aab7efc..babfa67 100644 --- a/pgLabII/ServiceCollectionExtensions.cs +++ b/pgLabII/ServiceCollectionExtensions.cs @@ -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(); collection.AddTransient(); collection.AddScoped(); + collection.AddTransient(); + collection.AddTransient(); } } diff --git a/pgLabII/Services/DocumentSession.cs b/pgLabII/Services/DocumentSession.cs new file mode 100644 index 0000000..221d479 --- /dev/null +++ b/pgLabII/Services/DocumentSession.cs @@ -0,0 +1,6 @@ +namespace pgLabII.Services; + +public class DocumentSession +{ + +} diff --git a/pgLabII/Services/DocumentSessionFactory.cs b/pgLabII/Services/DocumentSessionFactory.cs new file mode 100644 index 0000000..a16640b --- /dev/null +++ b/pgLabII/Services/DocumentSessionFactory.cs @@ -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(); + } +} diff --git a/pgLabII/ViewModels/CodeEditorViewModel.cs b/pgLabII/ViewModels/CodeEditorViewModel.cs new file mode 100644 index 0000000..082233b --- /dev/null +++ b/pgLabII/ViewModels/CodeEditorViewModel.cs @@ -0,0 +1,10 @@ +using AvaloniaEdit.Document; +using ReactiveUI.SourceGenerators; + +namespace pgLabII.ViewModels; + +public partial class CodeEditorViewModel : ViewModelBase +{ + [Reactive] private TextDocument _document = new(); + +} diff --git a/pgLabII/Views/Controls/CodeEditorView.axaml b/pgLabII/Views/Controls/CodeEditorView.axaml new file mode 100644 index 0000000..9e63fa7 --- /dev/null +++ b/pgLabII/Views/Controls/CodeEditorView.axaml @@ -0,0 +1,18 @@ + + + + + - - + + + + + + diff --git a/pgLabII/pgLabII.csproj b/pgLabII/pgLabII.csproj index 5c03e58..f2c36f9 100644 --- a/pgLabII/pgLabII.csproj +++ b/pgLabII/pgLabII.csproj @@ -1,6 +1,6 @@  - net8.0 + net9.0 enable latest true @@ -13,6 +13,7 @@ + @@ -22,6 +23,8 @@ All + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/pgLabII/pgLabII.csproj.DotSettings b/pgLabII/pgLabII.csproj.DotSettings index 127787e..c7563b2 100644 --- a/pgLabII/pgLabII.csproj.DotSettings +++ b/pgLabII/pgLabII.csproj.DotSettings @@ -1,2 +1,3 @@  - True \ No newline at end of file + True + False \ No newline at end of file