Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Added experimental terminal integration #13631

Draft
wants to merge 65 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
2643352
[WIP]
gave92 Oct 24, 2023
d550200
[WIP]
gave92 Oct 24, 2023
958d508
[WIP]
gave92 Oct 25, 2023
1baa326
Initial work
gave92 Oct 25, 2023
b8a91fd
Add build files
gave92 Oct 25, 2023
afe2401
Added credits to source files
gave92 Oct 25, 2023
225cd5c
Add build files for xterm.js
gave92 Oct 26, 2023
d302f30
Implement copy and resize
gave92 Oct 26, 2023
f05501f
Set background and hide double scrollbars
gave92 Oct 27, 2023
907f783
Toggle button & cleanup
gave92 Oct 27, 2023
5328496
Start from current folder
gave92 Oct 27, 2023
aa841c9
Sync folder to cmd
gave92 Oct 28, 2023
b4f00e1
Implemented up sync button
gave92 Oct 28, 2023
1dbdf7c
Show up/dw buttons only when terminal is open
gave92 Oct 28, 2023
2607f5f
Enable profile selection
gave92 Oct 28, 2023
9544c81
Updated comments
gave92 Oct 29, 2023
775914c
Test theme change
gave92 Oct 29, 2023
3bde3c1
Place classes in project structure
gave92 Nov 1, 2023
c5e6f72
Added missing attribution
gave92 Nov 1, 2023
7d304b2
Fix accessibility test (temporary)
gave92 Nov 1, 2023
3957fdd
Fix webview scrollbars in dark mode
gave92 Nov 1, 2023
a064939
Moved Terminal under Utils
gave92 Nov 1, 2023
90e1a89
Removed extra colon
gave92 Nov 1, 2023
ae07ac7
Fix Release build
gave92 Nov 1, 2023
3128532
Set background color in html
gave92 Nov 1, 2023
7d5e092
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Nov 5, 2023
835cd75
Fix buttons for wsl
gave92 Nov 5, 2023
8699561
Fix webview flashing white
gave92 Nov 5, 2023
a5e9913
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Dec 1, 2023
d86d444
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Apr 13, 2024
62ebf9a
Remove extra commands
gave92 Apr 13, 2024
500ba21
Improve scrollbar
gave92 Apr 13, 2024
fe01e53
Use windows style scrollbars
gave92 Apr 13, 2024
4a09f5c
Code suggestions
gave92 Apr 17, 2024
c52b100
Switch native methods to CsWin32
gave92 Apr 17, 2024
532c70b
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Apr 17, 2024
092560e
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Apr 22, 2024
9ac9c59
Merge branch 'main' of https://github.com/files-community/Files into …
May 1, 2024
58b5043
Added settings under experimental
May 1, 2024
ffb57c7
Merge branch 'main' into terminal
yaira2 May 1, 2024
9df32f4
WIP
May 1, 2024
57376d0
Call AddWebAllowedObject after navigation
gave92 May 8, 2024
d2f437d
Multiple terminals
gave92 May 8, 2024
d08a384
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 May 8, 2024
2ae01a1
Hide pane when no terminals
gave92 May 8, 2024
5da01eb
Switch to CsWin32 for process api
gave92 May 11, 2024
d7d11c3
Add terminal model
gave92 May 11, 2024
a11b318
Minor change
gave92 May 11, 2024
940f5cc
Merge branch 'main' into terminal
gave92 May 11, 2024
3195de9
Minor change
gave92 May 11, 2024
729f2dc
Merge branch 'terminal' of https://github.com/gave92/files-uwp into t…
gave92 May 11, 2024
087c81b
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 May 23, 2024
608b7be
Merge branch 'main' into terminal
yaira2 May 28, 2024
d71e607
Added margin
yaira2 May 28, 2024
f688fbe
Merge branch 'main' into terminal
yaira2 May 28, 2024
4eff052
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Jun 1, 2024
c5caf90
Merge branch 'main' into terminal
gave92 Jun 7, 2024
1b41f66
Use WebView2Ex for CornerRadius
gave92 Jun 7, 2024
a257fc8
Merge branch 'main' of https://github.com/files-community/Files into …
gave92 Jun 18, 2024
f219539
Merge branch 'terminal' of https://github.com/gave92/files-uwp into t…
gave92 Jun 18, 2024
82e9087
Readded Newtonsoft.Json dep
gave92 Jun 18, 2024
b25fe52
Merge branch 'main' into terminal
yaira2 Jun 19, 2024
4a6d925
Revert "Use WebView2Ex for CornerRadius"
gave92 Jun 19, 2024
a223806
Stupid solution n.1
gave92 Jun 19, 2024
327544c
Merge branch 'terminal' of https://github.com/gave92/files-uwp into t…
gave92 Jun 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Files.App/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ public static string WithEnding(this string str, string ending)
return result;
}

/// <summary>
/// Compares two strings for equality, but assumes that null string is equal to an empty string.
/// </summary>
public static bool NullableEqualTo(this string original, string other,
StringComparison stringComparison = StringComparison.Ordinal) => string.IsNullOrEmpty(original)
? string.IsNullOrEmpty(other)
: original.Equals(other, stringComparison);

private static readonly ResourceMap resourcesTree = new ResourceManager().MainResourceMap.TryGetSubtree("Resources");

private static readonly ConcurrentDictionary<string, string> cachedResources = new();
Expand Down
222 changes: 222 additions & 0 deletions src/Files.App/Extensions/WebView2Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Copyright (c) Mahmoud Al-Qudsi, NeoSmart Technoogies. All rights reserved.
// Licensed under the MIT License.

using CommunityToolkit.WinUI;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using Newtonsoft.Json;
using System.Reflection;
using System.Text;
using Windows.Foundation;

namespace Files.App.Extensions
{
/// <summary>
/// Code modified from https://gist.github.com/mqudsi/ceb4ecee76eb4c32238a438664783480
/// </summary>
public static class WebView2Extensions
{
public static void Navigate(this WebView2 webview, Uri url)
{
webview.Source = url;
}

private enum PropertyAction
{
Read = 0,
Write = 1,
}

private struct WebMessage
{
public Guid Guid { get; set; }
}

private struct MethodWebMessage
{
public string Id { get; set; }
public string Method { get; set; }
public string Args { get; set; }
}

private struct PropertyWebMessage
{
public string Id { get; set; }
public string Property { get; set; }
public PropertyAction Action { get; set; }
public string Value { get; set; }
}

public static List<TypedEventHandler<WebView2, CoreWebView2WebMessageReceivedEventArgs>> _handlers = new List<TypedEventHandler<WebView2, CoreWebView2WebMessageReceivedEventArgs>>();
gave92 marked this conversation as resolved.
Show resolved Hide resolved
public static async Task AddWebAllowedObject<T>(this WebView2 webview, string name, T @object)
{
var sb = new StringBuilder();
sb.AppendLine($"window.{name} = {{ ");

// Test webview for our sanity
await webview.ExecuteScriptAsync($@"console.log(""Sanity check from iMessage"");");
gave92 marked this conversation as resolved.
Show resolved Hide resolved

var methodsGuid = Guid.NewGuid();
var methodInfo = typeof(T).GetMethods(BindingFlags.Public | BindingFlags.Instance);
var methods = new Dictionary<string, MethodInfo>(methodInfo.Length);
foreach (var method in methodInfo)
{
var functionName = $"{char.ToLower(method.Name[0])}{method.Name.Substring(1)}";
sb.AppendLine($@"{functionName}: function() {{ window.chrome.webview.postMessage(JSON.stringify({{ guid: ""{methodsGuid}"", id: this._callbackIndex++, method: ""{functionName}"", args: JSON.stringify([...arguments]) }})); const promise = new Promise((accept, reject) => this._callbacks.set(this._callbackIndex, {{ accept: accept, reject: reject }})); return promise; }},");
methods.Add(functionName, method);
}

var propertiesGuid = Guid.NewGuid();
var propertyInfo = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
var properties = new Dictionary<string, PropertyInfo>(propertyInfo.Length);
//foreach (var property in propertyInfo)
//{
// var propertyName = $"{char.ToLower(property.Name[0])}{property.Name.Substring(1)}";
// if (property.CanRead)
// {
// sb.AppendLine($@"get {propertyName}() {{ window.chrome.webview.postMessage(JSON.stringify({{ guid: ""{propertiesGuid}"", id: this._callbackIndex++, property: ""{propertyName}"", action: ""{(int) PropertyAction.Read}"" }})); const promise = new Promise((accept, reject) => this._callbacks.set(this._callbackIndex, {{ accept: accept, reject: reject }})); return promise; }},");
// }
// if (property.CanWrite)
// {
// sb.AppendLine($@"set {propertyName}(value) {{ window.chrome.webview.postMessage(JSON.stringify({{ guid: ""{propertiesGuid}"", id: this._callbackIndex++, property: ""{propertyName}"", action: ""{(int)PropertyAction.Write}"", value: JSON.stringify(value) }})); const promise = new Promise((accept, reject) => this._callbacks.set(this._callbackIndex, {{ accept: accept, reject: reject }})); return promise; }},");

// }
// properties[propertyName] = property;
//}

// Add a map<int, (promiseAccept, promiseReject)> to the object used to resolve results
sb.AppendLine($@"_callbacks: new Map(),");
// And a shared counter to index into that map
sb.Append($@"_callbackIndex: 0,");

sb.AppendLine("}");

try
{
//await webview.ExecuteScriptAsync($"try {{ {sb} }} catch (ex) {{ console.error(ex); }}").AsTask();
await webview.ExecuteScriptAsync($"{sb}").AsTask();
}
catch (Exception ex)
{
// So we can see it in the JS debugger
}

var handler = (TypedEventHandler<WebView2, CoreWebView2WebMessageReceivedEventArgs>)(async (_, e) =>
{
var message = JsonConvert.DeserializeObject<WebMessage>(e.TryGetWebMessageAsString());
if (message.Guid == methodsGuid)
{
var methodMessage = JsonConvert.DeserializeObject<MethodWebMessage>(e.TryGetWebMessageAsString());
var method = methods[methodMessage.Method];
try
{
var result = method.Invoke(@object, JsonConvert.DeserializeObject<object[]>(methodMessage.Args));
if (result is object)
{
var resultType = result.GetType();
dynamic task = null;
if (resultType.Name.StartsWith("TaskToAsyncOperationAdapter")
|| resultType.IsInstanceOfType(typeof(IAsyncInfo)))
{
// IAsyncOperation that needs to be converted to a task first
if (resultType.GenericTypeArguments.Length > 0)
{
var asTask = typeof(WindowsRuntimeSystemExtensions)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where(method => method.GetParameters().Length == 1
&& method.Name == "AsTask"
&& method.ToString().Contains("Windows.Foundation.IAsyncOperation`1[TResult]"))
.FirstOrDefault();

//var asTask = typeof(WindowsRuntimeSystemExtensions)
// .GetMethod(nameof(WindowsRuntimeSystemExtensions.AsTask),
// new[] { typeof(IAsyncOperation<>).MakeGenericType(resultType.GenericTypeArguments[0]) }
// );

asTask = asTask.MakeGenericMethod(resultType.GenericTypeArguments[0]);
task = (Task)asTask.Invoke(null, new[] { result });
}
else
{
task = WindowsRuntimeSystemExtensions.AsTask((dynamic)result);
}
}
else
{
var awaiter = resultType.GetMethod(nameof(Task.GetAwaiter));
if (awaiter is object)
{
task = result;
}
}
if (task is object)
{
result = await task;
}
}
var json = JsonConvert.SerializeObject(result);
await webview.ExecuteScriptAsync($@"{name}._callbacks.get({methodMessage.Id}).accept(JSON.parse({json})); {name}._callbacks.delete({methodMessage.Id});");
}
catch (Exception ex)
{
var json = JsonConvert.SerializeObject(ex, new JsonSerializerSettings() { Error = (_, e) => e.ErrorContext.Handled = true });
await webview.ExecuteScriptAsync($@"{name}._callbacks.get({methodMessage.Id}).reject(JSON.parse({json})); {name}._callbacks.delete({methodMessage.Id});");
//throw;
}
}
else if (message.Guid == propertiesGuid)
{
var propertyMessage = JsonConvert.DeserializeObject<PropertyWebMessage>(e.TryGetWebMessageAsString());
var property = properties[propertyMessage.Property];
try
{
object result;
if (propertyMessage.Action == PropertyAction.Read)
{
result = property.GetValue(@object);
}
else
{
var value = JsonConvert.DeserializeObject(propertyMessage.Value, property.PropertyType);
property.SetValue(@object, value);
result = new object();
}

var json = JsonConvert.SerializeObject(result);
await webview.ExecuteScriptAsync($@"{name}._callbacks.get({propertyMessage.Id}).accept(JSON.parse({json})); {name}._callbacks.delete({propertyMessage.Id});");
}
catch (Exception ex)
{
var json = JsonConvert.SerializeObject(ex, new JsonSerializerSettings() { Error = (_, e) => e.ErrorContext.Handled = true });
//await webview.ExecuteScriptAsync($@"{name}._callbacks.get({propertyMessage.Id}).reject(JSON.parse({json})); {name}._callbacks.delete({propertyMessage.Id});");
//throw;
}
}
});

_handlers.Add(handler);
webview.WebMessageReceived += handler;
}

public static async Task<string> InvokeScriptAsync(this WebView2 webview, string function, params object[] args)
{
var array = JsonConvert.SerializeObject(args);
string result = null;
// Tested and checked: this dispatch is required, even though the web view is in a different process
await webview.DispatcherQueue.EnqueueAsync(async () =>
{
var script = $"{function}(...{array});";
try
{
result = await webview.ExecuteScriptAsync(script).AsTask();
result = JsonConvert.DeserializeObject<string>(result);
}
catch (Exception ex)
{
}
}, Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal);

return result;
}
}
}
4 changes: 4 additions & 0 deletions src/Files.App/Files.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
<Content Include="Resources\PropertiesInformation.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Utils\Terminal\UI\bundle.js" />
<Content Include="Utils\Terminal\UI\index.html" />
<Content Include="Utils\Terminal\UI\style.css" />
<Content Include="Utils\Terminal\UI\xterm.css" />
<Content Update="Assets\FilesOpenDialog\SetFilesAsDefault.reg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
Expand Down
68 changes: 68 additions & 0 deletions src/Files.App/UserControls/StatusBar.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<ColumnDefinition />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<StackPanel
Expand Down Expand Up @@ -387,5 +388,72 @@
</Button.Flyout>
</Button>

<StackPanel
x:Name="TerminalPanel"
Grid.Column="3"
VerticalAlignment="Center"
x:Load="True"
Orientation="Horizontal"
Spacing="4">

<Button
x:Name="SyncFolderUp"
Height="24"
Padding="8,0,8,0"
VerticalAlignment="Center"
x:Load="{x:Bind MainPageViewModel.IsTerminalViewOpen, Mode=OneWay}"
Background="Transparent"
BorderBrush="Transparent"
Command="{x:Bind MainPageViewModel.TerminalSyncUpCommand}">
yaira2 marked this conversation as resolved.
Show resolved Hide resolved
<Button.Content>
<FontIcon FontSize="12" Glyph="&#xE110;" />
</Button.Content>
</Button>

<Button
x:Name="SyncFolderDown"
Height="24"
Padding="8,0,8,0"
VerticalAlignment="Center"
x:Load="{x:Bind MainPageViewModel.IsTerminalViewOpen, Mode=OneWay}"
Background="Transparent"
BorderBrush="Transparent"
Command="{x:Bind MainPageViewModel.TerminalSyncDownCommand}">
yaira2 marked this conversation as resolved.
Show resolved Hide resolved
<Button.Content>
<FontIcon FontSize="12" Glyph="&#xE74B;" />
</Button.Content>
</Button>

<SplitButton
x:Name="ToggleTerminal"
Height="24"
Padding="8,0,8,0"
VerticalAlignment="Center"
x:Load="True"
AutomationProperties.Name="Toggle terminal"
Background="Transparent"
BorderBrush="Transparent"
Command="{x:Bind MainPageViewModel.TerminalToggleCommand}">
<SplitButton.Content>
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="12" Glyph="&#xE756;" />
<TextBlock Text="{x:Bind MainPageViewModel.TerminalSelectedProfile.Name, Mode=OneWay}" />
</StackPanel>
</SplitButton.Content>
<SplitButton.Flyout>
<Flyout Placement="Top">
<ListView
x:Name="ShellProfileList"
Margin="-16"
Padding="4"
DisplayMemberPath="Name"
ItemsSource="{x:Bind MainPageViewModel.TerminalProfiles, Mode=OneWay}"
SelectedItem="{x:Bind MainPageViewModel.TerminalSelectedProfile, Mode=TwoWay}"
SelectionMode="Single" />
</Flyout>
</SplitButton.Flyout>
</SplitButton>
</StackPanel>

</Grid>
</UserControl>
2 changes: 2 additions & 0 deletions src/Files.App/UserControls/StatusBar.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public sealed partial class StatusBar : UserControl
{
public ICommandManager Commands { get; } = Ioc.Default.GetRequiredService<ICommandManager>();

public MainPageViewModel MainPageViewModel { get; } = Ioc.Default.GetRequiredService<MainPageViewModel>();

public DirectoryPropertiesViewModel? DirectoryPropertiesViewModel
{
get => (DirectoryPropertiesViewModel)GetValue(DirectoryPropertiesViewModelProperty);
Expand Down
25 changes: 25 additions & 0 deletions src/Files.App/UserControls/TerminalView.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!-- Copyright (c) 2023 Files Community. Licensed under the MIT License. See the LICENSE. -->
<UserControl
x:Class="Files.App.UserControls.TerminalView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Files.App.Terminal"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="300"
d:DesignWidth="400"
ActualThemeChanged="TerminalView_ActualThemeChanged"
Unloaded="TerminalView_Unloaded"
mc:Ignorable="d">

<Border x:Name="RootGrid">

<WebView2
x:Name="WebViewControl"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
DefaultBackgroundColor="Transparent"
Loaded="WebViewControl_Loaded" />

</Border>
</UserControl>