refactor: rewrite OpenAI integration

- use `OpenAI` and `Azure.AI.OpenAI`
- use streaming response
- re-design `AIAssistant`
This commit is contained in:
leo 2024-12-24 15:51:27 +08:00
parent f06b1d5d51
commit 3c5b9a353e
No known key found for this signature in database
14 changed files with 375 additions and 322 deletions

View file

@ -140,11 +140,11 @@ This software supports using OpenAI or other AI service that has an OpenAI comap
For `OpenAI`:
* `Server` must be `https://api.openai.com/v1/chat/completions`
* `Server` must be `https://api.openai.com/v1`
For other AI service:
* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1/chat/completions`. For example, when using `Ollama`, it should be `http://localhost:11434/v1/chat/completions` instead of `http://localhost:11434/api/generate`
* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1`. For example, when using `Ollama`, it should be `http://localhost:11434/v1` instead of `http://localhost:11434/api/generate`
* The `API Key` is optional that depends on the service
## External Tools

View file

@ -46,8 +46,6 @@ namespace SourceGit
[JsonSerializable(typeof(Models.ExternalToolPaths))]
[JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))]
[JsonSerializable(typeof(Models.JetBrainsState))]
[JsonSerializable(typeof(Models.OpenAIChatRequest))]
[JsonSerializable(typeof(Models.OpenAIChatResponse))]
[JsonSerializable(typeof(Models.ThemeOverrides))]
[JsonSerializable(typeof(Models.Version))]
[JsonSerializable(typeof(Models.RepositorySettings))]

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Text;
using System.Threading;
using Avalonia.Threading;
namespace SourceGit.Commands
{
/// <summary>
@ -20,82 +22,75 @@ namespace SourceGit.Commands
}
}
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onProgress)
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onResponse)
{
_service = service;
_repo = repo;
_changes = changes;
_cancelToken = cancelToken;
_onProgress = onProgress;
_onResponse = onResponse;
}
public string Result()
public void Exec()
{
try
{
var summarybuilder = new StringBuilder();
var bodyBuilder = new StringBuilder();
var responseBuilder = new StringBuilder();
var summaryBuilder = new StringBuilder();
foreach (var change in _changes)
{
if (_cancelToken.IsCancellationRequested)
return "";
return;
_onProgress?.Invoke($"Analyzing {change.Path}...");
responseBuilder.Append("- ");
summaryBuilder.Append("- ");
var summary = GenerateChangeSummary(change);
summarybuilder.Append("- ");
summarybuilder.Append(summary);
summarybuilder.Append("(file: ");
summarybuilder.Append(change.Path);
summarybuilder.Append(")");
summarybuilder.AppendLine();
var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd();
if (rs.IsSuccess)
{
_service.Chat(
_service.AnalyzeDiffPrompt,
$"Here is the `git diff` output: {rs.StdOut}",
_cancelToken,
update =>
{
responseBuilder.Append(update);
summaryBuilder.Append(update);
_onResponse?.Invoke("Waiting for pre-file analyzing to complated...\n\n" + responseBuilder.ToString());
});
}
bodyBuilder.Append("- ");
bodyBuilder.Append(summary);
bodyBuilder.AppendLine();
responseBuilder.Append("\n");
summaryBuilder.Append("(file: ");
summaryBuilder.Append(change.Path);
summaryBuilder.Append(")\n");
}
if (_cancelToken.IsCancellationRequested)
return "";
return;
_onProgress?.Invoke($"Generating commit message...");
var body = bodyBuilder.ToString();
var subject = GenerateSubject(summarybuilder.ToString());
return string.Format("{0}\n\n{1}", subject, body);
var responseBody = responseBuilder.ToString();
var subjectBuilder = new StringBuilder();
_service.Chat(
_service.GenerateSubjectPrompt,
$"Here are the summaries changes:\n{summaryBuilder}",
_cancelToken,
update =>
{
subjectBuilder.Append(update);
_onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}");
});
}
catch (Exception e)
{
App.RaiseException(_repo, $"Failed to generate commit message: {e}");
return "";
Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}"));
}
}
private string GenerateChangeSummary(Models.Change change)
{
var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd();
var diff = rs.IsSuccess ? rs.StdOut : "unknown change";
var rsp = _service.Chat(_service.AnalyzeDiffPrompt, $"Here is the `git diff` output: {diff}", _cancelToken);
if (rsp != null && rsp.Choices.Count > 0)
return rsp.Choices[0].Message.Content;
return string.Empty;
}
private string GenerateSubject(string summary)
{
var rsp = _service.Chat(_service.GenerateSubjectPrompt, $"Here are the summaries changes:\n{summary}", _cancelToken);
if (rsp != null && rsp.Choices.Count > 0)
return rsp.Choices[0].Message.Content;
return string.Empty;
}
private Models.OpenAIService _service;
private string _repo;
private List<Models.Change> _changes;
private CancellationToken _cancelToken;
private Action<string> _onProgress;
private Action<string> _onResponse;
}
}

View file

@ -1,81 +1,13 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.ClientModel;
using System.Threading;
using Azure.AI.OpenAI;
using CommunityToolkit.Mvvm.ComponentModel;
using OpenAI;
using OpenAI.Chat;
namespace SourceGit.Models
{
public class OpenAIChatMessage
{
[JsonPropertyName("role")]
public string Role
{
get;
set;
}
[JsonPropertyName("content")]
public string Content
{
get;
set;
}
}
public class OpenAIChatChoice
{
[JsonPropertyName("index")]
public int Index
{
get;
set;
}
[JsonPropertyName("message")]
public OpenAIChatMessage Message
{
get;
set;
}
}
public class OpenAIChatResponse
{
[JsonPropertyName("choices")]
public List<OpenAIChatChoice> Choices
{
get;
set;
} = [];
}
public class OpenAIChatRequest
{
[JsonPropertyName("model")]
public string Model
{
get;
set;
}
[JsonPropertyName("messages")]
public List<OpenAIChatMessage> Messages
{
get;
set;
} = [];
public void AddMessage(string role, string content)
{
Messages.Add(new OpenAIChatMessage { Role = role, Content = content });
}
}
public class OpenAIService : ObservableObject
{
public string Name
@ -147,45 +79,39 @@ namespace SourceGit.Models
""";
}
public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation)
public void Chat(string prompt, string question, CancellationToken cancellation, Action<string> onUpdate)
{
var chat = new OpenAIChatRequest() { Model = Model };
chat.AddMessage("user", prompt);
chat.AddMessage("user", question);
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) };
if (!string.IsNullOrEmpty(ApiKey))
Uri server = new(Server);
ApiKeyCredential key = new(ApiKey);
ChatClient client = null;
if (Server.Contains("openai.azure.com/", StringComparison.Ordinal))
{
if (Server.Contains("openai.azure.com/", StringComparison.Ordinal))
client.DefaultRequestHeaders.Add("api-key", ApiKey);
else
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
var azure = new AzureOpenAIClient(server, key);
client = azure.GetChatClient(Model);
}
else
{
var openai = new OpenAIClient(key, new() { Endpoint = server });
client = openai.GetChatClient(Model);
}
var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest), Encoding.UTF8, "application/json");
try
{
var task = client.PostAsync(Server, req, cancellation);
task.Wait(cancellation);
var updates = client.CompleteChatStreaming([
new UserChatMessage(prompt),
new UserChatMessage(question),
], null, cancellation);
var rsp = task.Result;
var reader = rsp.Content.ReadAsStringAsync(cancellation);
reader.Wait(cancellation);
var body = reader.Result;
if (!rsp.IsSuccessStatusCode)
foreach (var update in updates)
{
throw new Exception($"AI service returns error code {rsp.StatusCode}. Body: {body ?? string.Empty}");
if (update.ContentUpdate.Count > 0)
onUpdate.Invoke(update.ContentUpdate[0].Text);
}
return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse);
}
catch
{
if (cancellation.IsCancellationRequested)
return null;
throw;
if (!cancellation.IsCancellationRequested)
throw;
}
}

View file

@ -19,7 +19,9 @@
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">Track Branch:</x:String>
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">Tracking remote branch</x:String>
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI Assistant</x:String>
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">RE-GENERATE</x:String>
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">Use AI to generate commit message</x:String>
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">APPLY AS COMMIT MESSAGE</x:String>
<x:String x:Key="Text.Apply" xml:space="preserve">Patch</x:String>
<x:String x:Key="Text.Apply.Error" xml:space="preserve">Error</x:String>
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">Raise errors and refuses to apply the patch</x:String>

View file

@ -22,7 +22,9 @@
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">跟踪分支</x:String>
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">设置上游跟踪分支</x:String>
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI助手</x:String>
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">重新生成</x:String>
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">使用AI助手生成提交信息</x:String>
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">应用本次生成</x:String>
<x:String x:Key="Text.Apply" xml:space="preserve">应用补丁(apply)</x:String>
<x:String x:Key="Text.Apply.Error" xml:space="preserve">错误</x:String>
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">输出错误,并终止应用补丁</x:String>

View file

@ -22,7 +22,9 @@
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">追蹤分支</x:String>
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">設定遠端追蹤分支</x:String>
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI 助理</x:String>
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">重新產生</x:String>
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">使用 AI 產生提交訊息</x:String>
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">套用為提交訊息</x:String>
<x:String x:Key="Text.Apply" xml:space="preserve">套用修補檔 (apply patch)</x:String>
<x:String x:Key="Text.Apply.Error" xml:space="preserve">錯誤</x:String>
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">輸出錯誤,並中止套用修補檔</x:String>

View file

@ -24,6 +24,7 @@
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<PropertyGroup Condition="'$(DisableUpdateDetection)' == 'true'">
@ -48,8 +49,10 @@
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.1.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.1.0" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-rc4.5" />
<PackageReference Include="OpenAI" Version="2.1.0" />
<PackageReference Include="TextMateSharp" Version="1.0.65" />
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.65" />
</ItemGroup>

View file

@ -1162,6 +1162,25 @@ namespace SourceGit.ViewModels
App.GetLauncer()?.OpenRepositoryInTab(node, null);
}
public AvaloniaList<Models.OpenAIService> GetPreferedOpenAIServices()
{
var services = Preference.Instance.OpenAIServices;
if (services == null || services.Count == 0)
return [];
if (services.Count == 1)
return services;
var prefered = _settings.PreferedOpenAIService;
foreach (var service in services)
{
if (service.Name.Equals(prefered, StringComparison.Ordinal))
return [service];
}
return services;
}
public ContextMenu CreateContextMenuForGitFlow()
{
var menu = new ContextMenu();

View file

@ -1056,7 +1056,7 @@ namespace SourceGit.ViewModels
var menu = new ContextMenu();
var ai = null as MenuItem;
var services = GetPreferedOpenAIServices();
var services = _repo.GetPreferedOpenAIServices();
if (services.Count > 0)
{
ai = new MenuItem();
@ -1067,7 +1067,7 @@ namespace SourceGit.ViewModels
{
ai.Click += (_, e) =>
{
var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _selectedStaged, generated => CommitMessage = generated);
var dialog = new Views.AIAssistant(services[0], _repo.FullPath, this, _selectedStaged);
App.OpenDialog(dialog);
e.Handled = true;
};
@ -1082,7 +1082,7 @@ namespace SourceGit.ViewModels
item.Header = service.Name;
item.Click += (_, e) =>
{
var dialog = new Views.AIAssistant(dup, _repo.FullPath, _selectedStaged, generated => CommitMessage = generated);
var dialog = new Views.AIAssistant(dup, _repo.FullPath, this, _selectedStaged);
App.OpenDialog(dialog);
e.Handled = true;
};
@ -1447,7 +1447,7 @@ namespace SourceGit.ViewModels
return null;
}
var services = GetPreferedOpenAIServices();
var services = _repo.GetPreferedOpenAIServices();
if (services.Count == 0)
{
App.RaiseException(_repo.FullPath, "Bad configuration for OpenAI");
@ -1456,7 +1456,7 @@ namespace SourceGit.ViewModels
if (services.Count == 1)
{
var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _staged, generated => CommitMessage = generated);
var dialog = new Views.AIAssistant(services[0], _repo.FullPath, this, _staged);
App.OpenDialog(dialog);
return null;
}
@ -1472,7 +1472,7 @@ namespace SourceGit.ViewModels
item.Header = service.Name;
item.Click += (_, e) =>
{
var dialog = new Views.AIAssistant(dup, _repo.FullPath, _staged, generated => CommitMessage = generated);
var dialog = new Views.AIAssistant(dup, _repo.FullPath, this, _staged);
App.OpenDialog(dialog);
e.Handled = true;
};
@ -1588,25 +1588,6 @@ namespace SourceGit.ViewModels
return false;
}
private IList<Models.OpenAIService> GetPreferedOpenAIServices()
{
var services = Preference.Instance.OpenAIServices;
if (services == null || services.Count == 0)
return [];
if (services.Count == 1)
return services;
var prefered = _repo.Settings.PreferedOpenAIService;
foreach (var service in services)
{
if (service.Name.Equals(prefered, StringComparison.Ordinal))
return [service];
}
return services;
}
private Repository _repo = null;
private bool _isLoadingData = false;
private bool _isStaging = false;

View file

@ -10,7 +10,7 @@
x:Name="ThisControl"
Icon="/App.ico"
Title="{DynamicResource Text.AIAssistant}"
Width="400" SizeToContent="Height"
Width="520" SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto">
@ -36,18 +36,33 @@
IsVisible="{OnPlatform True, macOS=False}"/>
</Grid>
<!-- Animated Icon -->
<v:LoadingIcon Grid.Row="1"
Width="24" Height="24"
Margin="0,16,0,0"/>
<!-- AI response -->
<v:AIResponseView Grid.Row="1"
x:Name="TxtResponse"
Margin="8"
Height="320"
BorderThickness="1"
BorderBrush="{DynamicResource Brush.Border2}"
Background="{DynamicResource Brush.Contents}"/>
<!-- Message -->
<TextBlock Grid.Row="2"
x:Name="ProgressMessage"
Margin="16"
FontSize="{Binding Source={x:Static vm:Preference.Instance}, Path=DefaultFontSize, Converter={x:Static c:DoubleConverters.Decrease}}"
HorizontalAlignment="Center"
Text="Generating commit message... Please wait!"
TextTrimming="CharacterEllipsis"/>
<!-- Options -->
<Border Grid.Row="2" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<v:LoadingIcon x:Name="IconInProgress" Width="14" Height="14" Margin="0,0,8,0"/>
<Button Classes="flat"
x:Name="BtnGenerateCommitMessage"
Height="28"
Margin="0,0,8,0"
Padding="12,0"
Content="{DynamicResource Text.AIAssistant.Use}"
Click="OnGenerateCommitMessage"/>
<Button Classes="flat"
x:Name="BtnRegenerate"
Height="28"
Padding="12,0"
Content="{DynamicResource Text.AIAssistant.Regen}"
Click="OnRegen"/>
</StackPanel>
</Border>
</Grid>
</v:ChromelessWindow>

View file

@ -3,26 +3,112 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.TextMate;
using AvaloniaEdit;
namespace SourceGit.Views
{
public class AIResponseView : TextEditor
{
protected override Type StyleKeyOverride => typeof(TextEditor);
public AIResponseView() : base(new TextArea(), new TextDocument())
{
IsReadOnly = true;
ShowLineNumbers = false;
WordWrap = true;
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
TextArea.TextView.Margin = new Thickness(4, 0);
TextArea.TextView.Options.EnableHyperlinks = false;
TextArea.TextView.Options.EnableEmailHyperlinks = false;
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
if (_textMate == null)
{
_textMate = Models.TextMateHelper.CreateForEditor(this);
Models.TextMateHelper.SetGrammarByFileName(_textMate, "README.md");
}
}
protected override void OnUnloaded(RoutedEventArgs e)
{
base.OnUnloaded(e);
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
if (_textMate != null)
{
_textMate.Dispose();
_textMate = null;
}
GC.Collect();
}
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
{
var selected = SelectedText;
if (string.IsNullOrEmpty(selected))
return;
var copy = new MenuItem() { Header = App.Text("Copy") };
copy.Click += (_, ev) =>
{
App.CopyText(selected);
ev.Handled = true;
};
if (this.FindResource("Icons.Copy") is Geometry geo)
{
copy.Icon = new Avalonia.Controls.Shapes.Path()
{
Width = 10,
Height = 10,
Stretch = Stretch.Uniform,
Data = geo,
};
}
var menu = new ContextMenu();
menu.Items.Add(copy);
menu.Open(TextArea.TextView);
e.Handled = true;
}
private TextMate.Installation _textMate = null;
}
public partial class AIAssistant : ChromelessWindow
{
public AIAssistant()
{
_cancel = new CancellationTokenSource();
InitializeComponent();
}
public AIAssistant(Models.OpenAIService service, string repo, List<Models.Change> changes, Action<string> onDone)
public AIAssistant(Models.OpenAIService service, string repo, ViewModels.WorkingCopy wc, List<Models.Change> changes)
{
_service = service;
_repo = repo;
_wc = wc;
_changes = changes;
_onDone = onDone;
_cancel = new CancellationTokenSource();
InitializeComponent();
}
@ -30,39 +116,63 @@ namespace SourceGit.Views
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
if (string.IsNullOrEmpty(_repo))
return;
Task.Run(() =>
{
var message = new Commands.GenerateCommitMessage(_service, _repo, _changes, _cancel.Token, SetDescription).Result();
if (_cancel.IsCancellationRequested)
return;
Dispatcher.UIThread.Invoke(() =>
{
_onDone?.Invoke(message);
Close();
});
}, _cancel.Token);
Generate();
}
protected override void OnClosing(WindowClosingEventArgs e)
{
base.OnClosing(e);
_cancel.Cancel();
_cancel?.Cancel();
}
private void SetDescription(string message)
private void OnGenerateCommitMessage(object sender, RoutedEventArgs e)
{
Dispatcher.UIThread.Invoke(() => ProgressMessage.Text = message);
if (_wc != null)
_wc.CommitMessage = TxtResponse.Text;
Close();
}
private void OnRegen(object sender, RoutedEventArgs e)
{
TxtResponse.Text = string.Empty;
Generate();
e.Handled = true;
}
private void Generate()
{
if (_repo == null)
return;
IconInProgress.IsVisible = true;
BtnGenerateCommitMessage.IsEnabled = false;
BtnRegenerate.IsEnabled = false;
_cancel = new CancellationTokenSource();
Task.Run(() =>
{
new Commands.GenerateCommitMessage(_service, _repo, _changes, _cancel.Token, message =>
{
Dispatcher.UIThread.Invoke(() => TxtResponse.Text = message);
}).Exec();
if (!_cancel.IsCancellationRequested)
{
Dispatcher.UIThread.Invoke(() =>
{
IconInProgress.IsVisible = false;
BtnGenerateCommitMessage.IsEnabled = true;
BtnRegenerate.IsEnabled = true;
});
}
}, _cancel.Token);
}
private Models.OpenAIService _service;
private string _repo;
private ViewModels.WorkingCopy _wc;
private List<Models.Change> _changes;
private Action<string> _onDone;
private CancellationTokenSource _cancel;
}
}

View file

@ -1,7 +1,117 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.TextMate;
namespace SourceGit.Views
{
public class RevisionTextFileView : TextEditor
{
protected override Type StyleKeyOverride => typeof(TextEditor);
public RevisionTextFileView() : base(new TextArea(), new TextDocument())
{
IsReadOnly = true;
ShowLineNumbers = true;
WordWrap = false;
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
TextArea.LeftMargins[0].Margin = new Thickness(8, 0);
TextArea.TextView.Margin = new Thickness(4, 0);
TextArea.TextView.Options.EnableHyperlinks = false;
TextArea.TextView.Options.EnableEmailHyperlinks = false;
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
UpdateTextMate();
}
protected override void OnUnloaded(RoutedEventArgs e)
{
base.OnUnloaded(e);
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
if (_textMate != null)
{
_textMate.Dispose();
_textMate = null;
}
GC.Collect();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is Models.RevisionTextFile source)
{
UpdateTextMate();
Text = source.Content;
}
else
{
Text = string.Empty;
}
}
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
{
var selected = SelectedText;
if (string.IsNullOrEmpty(selected))
return;
var copy = new MenuItem() { Header = App.Text("Copy") };
copy.Click += (_, ev) =>
{
App.CopyText(selected);
ev.Handled = true;
};
if (this.FindResource("Icons.Copy") is Geometry geo)
{
copy.Icon = new Avalonia.Controls.Shapes.Path()
{
Width = 10,
Height = 10,
Stretch = Stretch.Uniform,
Data = geo,
};
}
var menu = new ContextMenu();
menu.Items.Add(copy);
menu.Open(TextArea.TextView);
e.Handled = true;
}
private void UpdateTextMate()
{
if (_textMate == null)
_textMate = Models.TextMateHelper.CreateForEditor(this);
if (DataContext is Models.RevisionTextFile file)
Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
}
private TextMate.Installation _textMate = null;
}
public partial class RevisionFileContentViewer : UserControl
{
public RevisionFileContentViewer()

View file

@ -1,118 +1,8 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.TextMate;
namespace SourceGit.Views
{
public class RevisionTextFileView : TextEditor
{
protected override Type StyleKeyOverride => typeof(TextEditor);
public RevisionTextFileView() : base(new TextArea(), new TextDocument())
{
IsReadOnly = true;
ShowLineNumbers = true;
WordWrap = false;
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
TextArea.LeftMargins[0].Margin = new Thickness(8, 0);
TextArea.TextView.Margin = new Thickness(4, 0);
TextArea.TextView.Options.EnableHyperlinks = false;
TextArea.TextView.Options.EnableEmailHyperlinks = false;
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
UpdateTextMate();
}
protected override void OnUnloaded(RoutedEventArgs e)
{
base.OnUnloaded(e);
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
if (_textMate != null)
{
_textMate.Dispose();
_textMate = null;
}
GC.Collect();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is Models.RevisionTextFile source)
{
UpdateTextMate();
Text = source.Content;
}
else
{
Text = string.Empty;
}
}
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
{
var selected = SelectedText;
if (string.IsNullOrEmpty(selected))
return;
var copy = new MenuItem() { Header = App.Text("Copy") };
copy.Click += (_, ev) =>
{
App.CopyText(selected);
ev.Handled = true;
};
if (this.FindResource("Icons.Copy") is Geometry geo)
{
copy.Icon = new Avalonia.Controls.Shapes.Path()
{
Width = 10,
Height = 10,
Stretch = Stretch.Uniform,
Data = geo,
};
}
var menu = new ContextMenu();
menu.Items.Add(copy);
menu.Open(TextArea.TextView);
e.Handled = true;
}
private void UpdateTextMate()
{
if (_textMate == null)
_textMate = Models.TextMateHelper.CreateForEditor(this);
if (DataContext is Models.RevisionTextFile file)
Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
}
private TextMate.Installation _textMate = null;
}
public partial class RevisionFiles : UserControl
{
public RevisionFiles()