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 57 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 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
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
5 changes: 5 additions & 0 deletions src/Files.App/Data/Contracts/IGeneralSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ public interface IGeneralSettingsService : IBaseSettingsService, INotifyProperty
/// </summary>
bool LeaveAppRunning { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to enable terminal integration.
/// </summary>
bool IsTerminalIntegrationEnabled { get; set; }

/// <summary>
/// Gets or sets a value indicating the default option to resolve conflicts.
/// </summary>
Expand Down
21 changes: 21 additions & 0 deletions src/Files.App/Data/Models/TerminalModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.UI.Xaml.Controls;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Files.App.Data.Models
{
public class TerminalModel : IDisposable
{
public string Id { get; init; }
public string Name { get; init; }
public Control Control { get; init; }

public void Dispose()
{
(Control as IDisposable)?.Dispose();
}
}
}
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
220 changes: 220 additions & 0 deletions src/Files.App/Extensions/WebView2Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// 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
{
using WebViewMessageReceivedHandler = TypedEventHandler<WebView2, CoreWebView2WebMessageReceivedEventArgs>;

/// <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<WebViewMessageReceivedHandler> _handlers = new();
public static async Task AddWebAllowedObject<T>(this WebView2 webview, string name, T @object)
{
var sb = new StringBuilder();
sb.AppendLine($"window.{name} = {{ ");

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 = (WebViewMessageReceivedHandler)(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 @@ -55,6 +55,10 @@
<Content Update="Assets\Resources\**">
<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
25 changes: 25 additions & 0 deletions src/Files.App/NativeMethods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ WNDPROC
WNDCLASSEXW
RegisterClassEx
CreateWindowEx
ShowWindow
DestroyWindow
GetModuleHandle
RECT
Expand Down Expand Up @@ -70,3 +71,27 @@ D3D11CreateDevice
IDXGIDevice
DCompositionCreateDevice
IDCompositionDevice
CreateDirectoryFromApp
CreateFile
// Console api
AllocConsole
GetConsoleWindow
GetStdHandle
SetConsoleMode
GetConsoleMode
COORD
CreatePseudoConsole
ResizePseudoConsole
ClosePseudoConsole
CreatePipe
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
STARTUPINFOEXW
STARTUPINFOW
PROCESS_CREATION_FLAGS
PROCESS_INFORMATION
SECURITY_ATTRIBUTES
CloseHandle
DeleteProcThreadAttributeList
UpdateProcThreadAttribute
InitializeProcThreadAttributeList
CreateProcess
8 changes: 7 additions & 1 deletion src/Files.App/Services/Settings/GeneralSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ public bool ShowCopyPath
get => Get(true);
set => Set(value);
}

public bool ShowCreateFolderWithSelection
{
get => Get(true);
Expand All @@ -251,6 +251,12 @@ public bool LeaveAppRunning
set => Set(value);
}

public bool IsTerminalIntegrationEnabled
{
get => Get(false);
set => Set(value);
}

public FileNameConflictResolveOptionType ConflictsResolveOption
{
get => (FileNameConflictResolveOptionType)Get((long)FileNameConflictResolveOptionType.GenerateNewName);
Expand Down
3 changes: 3 additions & 0 deletions src/Files.App/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -3829,4 +3829,7 @@
<data name="EditInNotepadDescription" xml:space="preserve">
<value>Edit the selected file in Notepad</value>
</data>
<data name="SettingsTerminalIntegration" xml:space="preserve">
<value>Enable Terminal integration</value>
</data>
yaira2 marked this conversation as resolved.
Show resolved Hide resolved
</root>