mirror of
https://github.com/aharabada/glitchy-engine-beef.git
synced 2026-09-05 13:01:52 +00:00
Find Rider installations
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
GlitchyEditor/src/CodeEditors/RiderPathLocator.bf (and the platform specific implementations)
|
||||
are a Beef port of the Rider path locator from the JetBrains "Rider Source Code Access" Unreal Engine plugin:
|
||||
|
||||
https://github.com/JetBrains/RiderSourceCodeAccess
|
||||
Source/RiderSourceCodeAccess/Private/RiderPathLocator/
|
||||
|
||||
The original work and this port are licensed under the Apache License, Version 2.0,
|
||||
reproduced in full below.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0.txt
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,440 @@
|
||||
// Beef port of JetBrains "Rider Source Code Access":
|
||||
// https://github.com/JetBrains/RiderSourceCodeAccess/tree/main/Source/RiderSourceCodeAccess/Private/RiderPathLocator
|
||||
//
|
||||
// Copyright Epic Games, Inc. All Rights Reserved.
|
||||
// Licensed under the Apache License, Version 2.0 - see LICENSE-RiderPathLocator.txt
|
||||
//
|
||||
// Changes from the original: rewritten in Beef against corlib instead of the
|
||||
// Unreal Engine types (FString/TArray/TOptional/FPaths/FRegexPattern/FJsonSerializer/...).
|
||||
// The "plugins/rider-cpp" requirement, the ESupportUproject state and the
|
||||
// RiderLocations.txt resource-file lookup were removed because they only make sense for Unreal projects.
|
||||
//
|
||||
// This file holds the platform independent part, the platform specific parts live in the extensions (e.g. RiderPathLocatorWindows.bf).
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using Beefy.utils;
|
||||
|
||||
namespace GlitchyEditor.CodeEditors;
|
||||
|
||||
/// A dot-separated version number, e.g. "2024.1.2" or the build number "241.15989.155".
|
||||
struct RiderVersion : IHashable
|
||||
{
|
||||
public const int32 InvalidVersion = -1;
|
||||
|
||||
private const int32 MaxParts = 4;
|
||||
|
||||
private int32[MaxParts] _parts;
|
||||
private int32 _count;
|
||||
|
||||
public bool IsInitialized => _count != 0;
|
||||
|
||||
public int32 Major => _count >= 1 ? _parts[0] : InvalidVersion;
|
||||
public int32 Minor => _count >= 2 ? _parts[1] : InvalidVersion;
|
||||
public int32 Patch => _count >= 3 ? _parts[2] : InvalidVersion;
|
||||
|
||||
public static RiderVersion Parse(StringView versionString)
|
||||
{
|
||||
RiderVersion version = .();
|
||||
|
||||
for (StringView part in versionString.Split('.'))
|
||||
{
|
||||
if (version._count >= MaxParts)
|
||||
break;
|
||||
|
||||
// Note: the original uses FCString::Atoi, which yields 0 for garbage instead of failing.
|
||||
int32 value = 0;
|
||||
if (int32.Parse(part) case .Ok(let parsedValue))
|
||||
value = parsedValue;
|
||||
|
||||
version._parts[version._count] = value;
|
||||
version._count++;
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// Compares two versions element by element.
|
||||
/// @returns A negative value if lhs is older than rhs, a positive value if it is newer; otherwise 0.
|
||||
public static int Compare(Self lhs, Self rhs)
|
||||
{
|
||||
int32 sharedCount = Math.Min(lhs._count, rhs._count);
|
||||
|
||||
for (int32 i < sharedCount)
|
||||
{
|
||||
if (lhs._parts[i] != rhs._parts[i])
|
||||
return lhs._parts[i] < rhs._parts[i] ? -1 : 1;
|
||||
}
|
||||
|
||||
// Equal prefix: the shorter version is the older one, so that "5.0" is older than "5.0.1".
|
||||
return lhs._count <=> rhs._count;
|
||||
}
|
||||
|
||||
public static bool operator<(Self lhs, Self rhs) => Compare(lhs, rhs) < 0;
|
||||
|
||||
public static bool operator==(Self lhs, Self rhs) => Compare(lhs, rhs) == 0;
|
||||
|
||||
public static bool operator!=(Self lhs, Self rhs) => Compare(lhs, rhs) != 0;
|
||||
|
||||
public int GetHashCode()
|
||||
{
|
||||
int hash = 0;
|
||||
|
||||
for (int32 i < _count)
|
||||
hash = (hash * 31) + (int)_parts[i];
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
public override void ToString(String strBuffer)
|
||||
{
|
||||
if (_count == 0)
|
||||
{
|
||||
strBuffer.Append("<unknown>");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int32 i < _count)
|
||||
{
|
||||
if (i != 0)
|
||||
strBuffer.Append('.');
|
||||
|
||||
_parts[i].ToString(strBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Rider installation was set up.
|
||||
enum RiderInstallType
|
||||
{
|
||||
/// Installed by the regular Rider installer.
|
||||
Installed,
|
||||
/// Installed through the JetBrains Toolbox.
|
||||
Toolbox,
|
||||
/// Provided by the user.
|
||||
Custom
|
||||
}
|
||||
|
||||
/// A single Rider installation found on this machine.
|
||||
class RiderInstallInfo
|
||||
{
|
||||
/// The full path of the Rider executable (rider64.exe).
|
||||
public String Path ~ delete _;
|
||||
public RiderVersion Version;
|
||||
public RiderInstallType InstallType;
|
||||
|
||||
public this(StringView path, RiderVersion version, RiderInstallType installType)
|
||||
{
|
||||
Path = new String(path);
|
||||
Version = version;
|
||||
InstallType = installType;
|
||||
}
|
||||
}
|
||||
|
||||
/// Locates the JetBrains Rider installations on this machine.
|
||||
static class RiderPathLocator
|
||||
{
|
||||
/// Maximum directory depth we descend into while looking for the Rider executable.
|
||||
/// The original search is unbounded, but it searches locations like "%LOCALAPPDATA%/Programs".
|
||||
protected const int MaxSearchDepth = 8;
|
||||
/// Maximum number of directories visited by a single recursive search.
|
||||
protected const int MaxSearchedDirectories = 4096;
|
||||
|
||||
/// Creates the installation info for the given Rider executable.
|
||||
/// @param riderExePath The path of a rider64.exe.
|
||||
/// @returns The installation info; or .Err if the given path is not a usable Rider installation.
|
||||
public static extern Result<RiderInstallInfo> GetInstallInfoFromRiderPath(StringView riderExePath, RiderInstallType installType);
|
||||
|
||||
/// Collects every Rider installation that can be found on this machine.
|
||||
/// The entries are deduplicated by path and sorted, newest version first.
|
||||
/// @param outInstalls Receives the installations. The caller takes ownership of the entries.
|
||||
public static extern void CollectAllPaths(List<RiderInstallInfo> outInstalls);
|
||||
|
||||
/// Gets the file name of the Rider executable on this platform (e.g. "rider64.exe").
|
||||
protected static extern StringView GetRiderExecutableName();
|
||||
|
||||
/// Gets the default location the Toolbox V2 installs IDEs into.
|
||||
protected static extern Result<void> GetDefaultIdeInstallLocationForToolboxV2(String outPath);
|
||||
|
||||
/// Moves the located installations into outInstalls, dropping the ones that were found more
|
||||
/// than once, and sorts them so that the newest installation comes first.
|
||||
/// @param found The located installations. The entries are moved into outInstalls or deleted.
|
||||
/// @param outInstalls Receives the installations. The caller takes ownership of the entries.
|
||||
protected static void DeduplicateAndSort(List<RiderInstallInfo> found, List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
// The same installation usually shows up through more than one source.
|
||||
for (int i < found.Count)
|
||||
{
|
||||
RiderInstallInfo install = found[i];
|
||||
|
||||
bool isDuplicate = false;
|
||||
|
||||
for (RiderInstallInfo existing in outInstalls)
|
||||
{
|
||||
if (IsSamePath(existing.Path, install.Path))
|
||||
{
|
||||
isDuplicate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate)
|
||||
continue;
|
||||
|
||||
outInstalls.Add(install);
|
||||
// Ownership moved to outInstalls, make sure the caller's cleanup doesn't delete it.
|
||||
found[i] = null;
|
||||
}
|
||||
|
||||
// Newest first, so that the caller can simply take the first entry.
|
||||
outInstalls.Sort(scope (lhs, rhs) => RiderVersion.Compare(rhs.Version, lhs.Version));
|
||||
}
|
||||
|
||||
/// Gets the installation directory of Rider from the path of its executable.
|
||||
/// The executable is expected to live in a "bin" directory, i.e. "<riderDirectory>/bin/rider64.exe".
|
||||
protected static Result<void> GetRiderDirectory(StringView riderExePath, String outDirectory)
|
||||
{
|
||||
// The original matches the regex "(.*)(?:\\|/)bin" against the executable path,
|
||||
// which is the same as taking the grandparent directory of a file inside "bin".
|
||||
String binDirectory = scope .();
|
||||
Try!(Path.GetDirectoryPath(riderExePath, binDirectory));
|
||||
|
||||
String binDirectoryName = scope .();
|
||||
Path.GetFileName(binDirectory, binDirectoryName);
|
||||
|
||||
if (StringView.Compare(binDirectoryName, "bin", true) != 0)
|
||||
return .Err;
|
||||
|
||||
return Path.GetDirectoryPath(binDirectory, outDirectory);
|
||||
}
|
||||
|
||||
/// Compares two paths, ignoring case and the difference between the directory separators.
|
||||
protected static bool IsSamePath(StringView left, StringView right)
|
||||
{
|
||||
if (left.Length != right.Length)
|
||||
return false;
|
||||
|
||||
for (int i < left.Length)
|
||||
{
|
||||
char8 leftChar = left[i];
|
||||
char8 rightChar = right[i];
|
||||
|
||||
if (Path.IsDirectorySeparatorChar(leftChar) && Path.IsDirectorySeparatorChar(rightChar))
|
||||
continue;
|
||||
|
||||
if (leftChar.ToLower != rightChar.ToLower)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Collects the Rider installations managed by the JetBrains Toolbox at the given location.
|
||||
protected static void CollectPathsFromToolbox(StringView toolboxPath, List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
if (toolboxPath.IsEmpty || !Directory.Exists(toolboxPath))
|
||||
return;
|
||||
|
||||
String customInstallLocation = scope .();
|
||||
ExtractPathFromSettingsJson(toolboxPath, customInstallLocation);
|
||||
|
||||
if (!customInstallLocation.IsEmpty)
|
||||
{
|
||||
// Toolbox V1 puts the IDEs into an "apps" subdirectory of the install location.
|
||||
String appsPath = scope .();
|
||||
Path.Combine(appsPath, customInstallLocation, "apps");
|
||||
|
||||
int countBefore = outInstalls.Count;
|
||||
CollectPathsFromToolboxDirectory(appsPath, outInstalls);
|
||||
|
||||
if (outInstalls.Count != countBefore)
|
||||
return;
|
||||
|
||||
// Toolbox V2 puts them directly into the install location.
|
||||
CollectPathsFromToolboxDirectory(customInstallLocation, outInstalls);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Toolbox V1 default install location.
|
||||
String defaultAppsPath = scope .();
|
||||
Path.Combine(defaultAppsPath, toolboxPath, "apps");
|
||||
|
||||
int countBefore = outInstalls.Count;
|
||||
CollectPathsFromToolboxDirectory(defaultAppsPath, outInstalls);
|
||||
|
||||
if (outInstalls.Count != countBefore)
|
||||
return;
|
||||
|
||||
// Toolbox V2 default install location.
|
||||
String defaultInstallLocation = scope .();
|
||||
if (GetDefaultIdeInstallLocationForToolboxV2(defaultInstallLocation) case .Ok)
|
||||
CollectPathsFromToolboxDirectory(defaultInstallLocation, outInstalls);
|
||||
}
|
||||
|
||||
/// Searches the given directory for Rider executables and creates the installation infos for them.
|
||||
protected static void CollectPathsFromToolboxDirectory(StringView toolboxRiderRootPath, List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
if (toolboxRiderRootPath.IsEmpty || !Directory.Exists(toolboxRiderRootPath))
|
||||
return;
|
||||
|
||||
List<String> riderPaths = scope .();
|
||||
defer { ClearAndDeleteItems!(riderPaths); }
|
||||
|
||||
int searchedDirectories = 0;
|
||||
FindFilesRecursive(toolboxRiderRootPath, GetRiderExecutableName(), riderPaths, MaxSearchDepth, ref searchedDirectories);
|
||||
|
||||
// Reused across the iterations, see the note in FindFilesRecursive.
|
||||
String historyJsonPath = scope .();
|
||||
|
||||
for (String riderPath in riderPaths)
|
||||
{
|
||||
if (!(GetInstallInfoFromRiderPath(riderPath, .Toolbox) case .Ok(let installInfo)))
|
||||
continue;
|
||||
|
||||
// The Toolbox leaves the directories of old versions behind after an update, so we
|
||||
// check which build it actually considers the current one for this installation.
|
||||
historyJsonPath.Clear();
|
||||
|
||||
if (GetHistoryJsonPath(riderPath, historyJsonPath) case .Ok)
|
||||
{
|
||||
RiderVersion lastBuildVersion = GetLastBuildVersion(historyJsonPath);
|
||||
|
||||
if (lastBuildVersion.IsInitialized && installInfo.Version != lastBuildVersion)
|
||||
{
|
||||
delete installInfo;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
outInstalls.Add(installInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively searches the given directory for files with the given name.
|
||||
/// @remarks corlib's Directory.EnumerateFiles doesn't search subdirectories.
|
||||
protected static void FindFilesRecursive(StringView directory, StringView fileName, List<String> outPaths,
|
||||
int remainingDepth, ref int searchedDirectories)
|
||||
{
|
||||
if (remainingDepth <= 0 || searchedDirectories >= MaxSearchedDirectories)
|
||||
return;
|
||||
|
||||
searchedDirectories++;
|
||||
|
||||
for (FileFindEntry entry in Directory.EnumerateFiles(directory, fileName))
|
||||
{
|
||||
String path = new String();
|
||||
entry.GetFilePath(path);
|
||||
outPaths.Add(path);
|
||||
}
|
||||
|
||||
// Reused across the iterations: a "scope" allocation inside a loop body lives until the
|
||||
// enclosing method returns, so allocating per iteration would grow the stack unboundedly.
|
||||
String entryName = scope .();
|
||||
String subDirectory = scope .();
|
||||
|
||||
for (FileFindEntry entry in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
entryName.Clear();
|
||||
entry.GetFileName(entryName);
|
||||
|
||||
if (entryName == "." || entryName == "..")
|
||||
continue;
|
||||
|
||||
subDirectory.Clear();
|
||||
entry.GetFilePath(subDirectory);
|
||||
|
||||
FindFilesRecursive(subDirectory, fileName, outPaths, remainingDepth - 1, ref searchedDirectories);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the custom install location out of the Toolbox' ".settings.json".
|
||||
protected static void ExtractPathFromSettingsJson(StringView toolboxPath, String outInstallLocation)
|
||||
{
|
||||
String settingsJsonPath = scope .();
|
||||
Path.Combine(settingsJsonPath, toolboxPath, ".settings.json");
|
||||
|
||||
if (!File.Exists(settingsJsonPath))
|
||||
return;
|
||||
|
||||
StructuredData data = scope .();
|
||||
if (data.Load(settingsJsonPath) case .Err)
|
||||
return;
|
||||
|
||||
data.GetString("install_location", outInstallLocation);
|
||||
}
|
||||
|
||||
/// Reads the version of a Rider installation out of its "product-info.json".
|
||||
protected static RiderVersion ParseProductInfoJson(StringView productInfoJsonPath)
|
||||
{
|
||||
StructuredData data = scope .();
|
||||
if (data.Load(productInfoJsonPath) case .Err)
|
||||
return .();
|
||||
|
||||
String buildNumber = scope .();
|
||||
data.GetString("buildNumber", buildNumber);
|
||||
|
||||
return RiderVersion.Parse(buildNumber);
|
||||
}
|
||||
|
||||
/// Walks up from the given Rider executable looking for the Toolbox' ".history.json".
|
||||
protected static Result<void> GetHistoryJsonPath(StringView riderPath, String outPath)
|
||||
{
|
||||
String directory = scope .();
|
||||
Try!(Path.GetDirectoryPath(riderPath, directory));
|
||||
|
||||
String parentDirectory = scope .();
|
||||
String historyPath = scope .();
|
||||
|
||||
// Bounded so that we don't walk all the way up to the drive root.
|
||||
for (int i < 10)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
break;
|
||||
|
||||
historyPath.Clear();
|
||||
Path.Combine(historyPath, directory, ".history.json");
|
||||
|
||||
if (File.Exists(historyPath))
|
||||
{
|
||||
outPath.Append(historyPath);
|
||||
return .Ok;
|
||||
}
|
||||
|
||||
parentDirectory.Clear();
|
||||
if (Path.GetDirectoryPath(directory, parentDirectory) case .Err)
|
||||
break;
|
||||
|
||||
if (parentDirectory.IsEmpty || parentDirectory == directory)
|
||||
break;
|
||||
|
||||
directory.Set(parentDirectory);
|
||||
}
|
||||
|
||||
return .Err;
|
||||
}
|
||||
|
||||
/// Reads the build number of the most recent entry of the Toolbox' ".history.json".
|
||||
protected static RiderVersion GetLastBuildVersion(StringView historyJsonPath)
|
||||
{
|
||||
StructuredData data = scope .();
|
||||
if (data.Load(historyJsonPath) case .Err)
|
||||
return .();
|
||||
|
||||
String lastBuild = scope .();
|
||||
|
||||
// The most recent build is the last entry of the "history" array.
|
||||
for (data.Enumerate("history"))
|
||||
{
|
||||
lastBuild.Clear();
|
||||
|
||||
using (data.Open("item"))
|
||||
{
|
||||
data.GetString("build", lastBuild);
|
||||
}
|
||||
}
|
||||
|
||||
return RiderVersion.Parse(lastBuild);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Windows implementation of the RiderPathLocator, see RiderPathLocator.bf for the platform independent part and the license of the original code.
|
||||
|
||||
#if BF_PLATFORM_WINDOWS
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace GlitchyEditor.CodeEditors;
|
||||
|
||||
extension RiderPathLocator
|
||||
{
|
||||
private const String RiderExecutableName = "rider64.exe";
|
||||
|
||||
/// The maximum length of a registry key name.
|
||||
private const int MaxRegistryKeyNameLength = 255;
|
||||
|
||||
[Import("advapi32.lib"), CLink, CallingConvention(.Stdcall)]
|
||||
private static extern int32 RegEnumKeyExW(Windows.HKey hKey, uint32 dwIndex, char16* lpName,
|
||||
uint32* lpcchName, uint32* lpReserved, char16* lpClass, uint32* lpcchClass,
|
||||
void* lpftLastWriteTime);
|
||||
|
||||
protected static override StringView GetRiderExecutableName() => RiderExecutableName;
|
||||
|
||||
public static override Result<RiderInstallInfo> GetInstallInfoFromRiderPath(StringView riderExePath, RiderInstallType installType)
|
||||
{
|
||||
if (!File.Exists(riderExePath))
|
||||
return .Err;
|
||||
|
||||
String riderDirectory = scope .();
|
||||
Try!(GetRiderDirectory(riderExePath, riderDirectory));
|
||||
|
||||
RiderVersion version = .();
|
||||
|
||||
String productInfoPath = scope .();
|
||||
Path.Combine(productInfoPath, riderDirectory, "product-info.json");
|
||||
|
||||
if (File.Exists(productInfoPath))
|
||||
version = ParseProductInfoJson(productInfoPath);
|
||||
|
||||
if (!version.IsInitialized)
|
||||
{
|
||||
// Toolbox installations are laid out as "<...>/<build number>/bin/rider64.exe",
|
||||
// so the name of the installation directory is the version.
|
||||
String directoryName = scope .();
|
||||
Path.GetFileName(riderDirectory, directoryName);
|
||||
|
||||
version = RiderVersion.Parse(directoryName);
|
||||
}
|
||||
|
||||
return new RiderInstallInfo(riderExePath, version, installType);
|
||||
}
|
||||
|
||||
public static override void CollectAllPaths(List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
const String UninstallKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const String UninstallKey32 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const String RiderKey = @"SOFTWARE\JetBrains\Rider";
|
||||
const String RiderKey32 = @"SOFTWARE\WOW6432Node\JetBrains\Rider";
|
||||
|
||||
List<RiderInstallInfo> found = scope .();
|
||||
// Everything that isn't moved into outInstalls (i.e. the duplicates) is deleted here.
|
||||
defer { ClearAndDeleteItems!(found); }
|
||||
|
||||
// Regular installations register themselves in the uninstall list.
|
||||
CollectPathsFromRegistry(Windows.HKEY_CURRENT_USER, UninstallKey, "InstallLocation", "Rider", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_LOCAL_MACHINE, UninstallKey, "InstallLocation", "Rider", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_CURRENT_USER, UninstallKey32, "InstallLocation", "Rider", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_LOCAL_MACHINE, UninstallKey32, "InstallLocation", "Rider", found);
|
||||
|
||||
// dotUltimate installations use their own key.
|
||||
CollectPathsFromRegistry(Windows.HKEY_CURRENT_USER, RiderKey, "InstallDir", "", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_LOCAL_MACHINE, RiderKey, "InstallDir", "", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_CURRENT_USER, RiderKey32, "InstallDir", "", found);
|
||||
CollectPathsFromRegistry(Windows.HKEY_LOCAL_MACHINE, RiderKey32, "InstallDir", "", found);
|
||||
|
||||
// Toolbox installations aren't registered anywhere, we have to go looking for them.
|
||||
String defaultToolboxPath = scope .();
|
||||
if (GetToolboxPathFromEnvironment(defaultToolboxPath) case .Ok)
|
||||
CollectPathsFromToolbox(defaultToolboxPath, found);
|
||||
|
||||
CollectPathsFromToolboxRegistry(Windows.HKEY_CURRENT_USER, @"Software\JetBrains\Toolbox\", found);
|
||||
CollectPathsFromToolboxRegistry(Windows.HKEY_LOCAL_MACHINE, @"Software\JetBrains\Toolbox\", found);
|
||||
CollectPathsFromToolboxRegistry(Windows.HKEY_CURRENT_USER, @"Software\JetBrains s.r.o.\JetBrainsToolbox\", found);
|
||||
CollectPathsFromToolboxRegistry(Windows.HKEY_LOCAL_MACHINE, @"Software\JetBrains s.r.o.\JetBrainsToolbox\", found);
|
||||
|
||||
DeduplicateAndSort(found, outInstalls);
|
||||
}
|
||||
|
||||
/// Enumerates the subkeys of the given registry key and creates an installation info for
|
||||
/// every subkey that points at a Rider installation.
|
||||
/// @param rootKey The registry hive to look in.
|
||||
/// @param subKeyPath The key whose subkeys will be enumerated.
|
||||
/// @param valueName The name of the value that holds the installation directory.
|
||||
/// @param keyNameFilter If not empty, only subkeys whose name contains this string are looked at.
|
||||
private static void CollectPathsFromRegistry(Windows.HKey rootKey, StringView subKeyPath,
|
||||
StringView valueName, StringView keyNameFilter, List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
Windows.HKey key;
|
||||
if (Windows.RegOpenKeyExW(rootKey, subKeyPath.ToScopedNativeWChar!(), 0, (.)Windows.KEY_READ, out key) != Windows.ERROR_SUCCESS)
|
||||
return;
|
||||
|
||||
defer Windows.RegCloseKey(key);
|
||||
|
||||
List<String> subKeyNames = scope .();
|
||||
defer { ClearAndDeleteItems!(subKeyNames); }
|
||||
|
||||
if (EnumerateSubKeys(key, subKeyNames) case .Err)
|
||||
return;
|
||||
|
||||
// All of these are reused across the iterations: a "scope" allocation inside a loop body
|
||||
// lives until the enclosing method returns, and the uninstall key has hundreds of subkeys.
|
||||
char16[MaxRegistryKeyNameLength + 1] subKeyNameBuffer = ?;
|
||||
String installLocation = scope .();
|
||||
String exePath = scope .();
|
||||
|
||||
for (String subKeyName in subKeyNames)
|
||||
{
|
||||
if (!keyNameFilter.IsEmpty && !subKeyName.Contains(keyNameFilter))
|
||||
continue;
|
||||
|
||||
int encodedLength = UTF16.GetEncodedLen(subKeyName);
|
||||
if (encodedLength > MaxRegistryKeyNameLength)
|
||||
continue;
|
||||
|
||||
UTF16.Encode(subKeyName, &subKeyNameBuffer, encodedLength).IgnoreError();
|
||||
subKeyNameBuffer[encodedLength] = 0;
|
||||
|
||||
Windows.HKey subKey;
|
||||
if (Windows.RegOpenKeyExW(key, &subKeyNameBuffer, 0, (.)Windows.KEY_READ, out subKey) != Windows.ERROR_SUCCESS)
|
||||
continue;
|
||||
|
||||
installLocation.Clear();
|
||||
Result<void> valueResult = subKey.GetValue(valueName, installLocation);
|
||||
|
||||
Windows.RegCloseKey(subKey);
|
||||
|
||||
if (valueResult case .Err)
|
||||
continue;
|
||||
|
||||
if (installLocation.IsWhiteSpace)
|
||||
continue;
|
||||
|
||||
exePath.Clear();
|
||||
Path.Combine(exePath, installLocation, "bin", RiderExecutableName);
|
||||
|
||||
if (GetInstallInfoFromRiderPath(exePath, .Installed) case .Ok(let installInfo))
|
||||
outInstalls.Add(installInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerates the names of all subkeys of the given registry key.
|
||||
/// @remarks Windows.HKey.EnumValue enumerates values (and is unimplemented), there is no
|
||||
/// counterpart for subkeys in corlib.
|
||||
private static Result<void> EnumerateSubKeys(Windows.HKey key, List<String> outNames)
|
||||
{
|
||||
// Reused across the iterations, see the note in CollectPathsFromRegistry.
|
||||
char16[MaxRegistryKeyNameLength + 1] nameBuffer = ?;
|
||||
|
||||
for (uint32 index = 0; true; index++)
|
||||
{
|
||||
uint32 nameLength = nameBuffer.Count;
|
||||
|
||||
int32 result = RegEnumKeyExW(key, index, &nameBuffer, &nameLength, null, null, null, null);
|
||||
|
||||
if (result == Windows.ERROR_NO_MORE_ITEMS)
|
||||
break;
|
||||
|
||||
if (result != Windows.ERROR_SUCCESS)
|
||||
return .Err;
|
||||
|
||||
// RegEnumKeyExW null-terminates, but be explicit about it since we reuse the buffer.
|
||||
nameBuffer[Math.Min((int)nameLength, MaxRegistryKeyNameLength)] = 0;
|
||||
|
||||
outNames.Add(new String(&nameBuffer));
|
||||
}
|
||||
|
||||
return .Ok;
|
||||
}
|
||||
|
||||
/// Gets the default location of the JetBrains Toolbox ("%LOCALAPPDATA%/JetBrains/Toolbox").
|
||||
private static Result<void> GetToolboxPathFromEnvironment(String outPath)
|
||||
{
|
||||
String localAppData = scope .();
|
||||
Try!(Environment.GetEnvironmentVariable("LOCALAPPDATA", localAppData));
|
||||
|
||||
Path.Combine(outPath, localAppData, "JetBrains", "Toolbox");
|
||||
|
||||
return .Ok;
|
||||
}
|
||||
|
||||
protected static override Result<void> GetDefaultIdeInstallLocationForToolboxV2(String outPath)
|
||||
{
|
||||
String localAppData = scope .();
|
||||
Try!(Environment.GetEnvironmentVariable("LOCALAPPDATA", localAppData));
|
||||
|
||||
Path.Combine(outPath, localAppData, "Programs");
|
||||
|
||||
return .Ok;
|
||||
}
|
||||
|
||||
/// Looks up the location of the JetBrains Toolbox in the registry and collects the Rider
|
||||
/// installations it manages.
|
||||
private static void CollectPathsFromToolboxRegistry(Windows.HKey rootKey, StringView subKeyPath, List<RiderInstallInfo> outInstalls)
|
||||
{
|
||||
Windows.HKey key;
|
||||
if (Windows.RegOpenKeyExW(rootKey, subKeyPath.ToScopedNativeWChar!(), 0, (.)Windows.KEY_READ, out key) != Windows.ERROR_SUCCESS)
|
||||
return;
|
||||
|
||||
defer Windows.RegCloseKey(key);
|
||||
|
||||
// The default value of the key contains the path of the Toolbox' "bin" directory.
|
||||
String toolboxBinPath = scope .();
|
||||
if (key.GetValue("", toolboxBinPath) case .Err)
|
||||
return;
|
||||
|
||||
if (toolboxBinPath.IsWhiteSpace)
|
||||
return;
|
||||
|
||||
String binDirectoryName = scope .();
|
||||
Path.GetFileName(toolboxBinPath, binDirectoryName);
|
||||
|
||||
if (StringView.Compare(binDirectoryName, "bin", true) != 0)
|
||||
return;
|
||||
|
||||
String toolboxPath = scope .();
|
||||
if (Path.GetDirectoryPath(toolboxBinPath, toolboxPath) case .Err)
|
||||
return;
|
||||
|
||||
CollectPathsFromToolbox(toolboxPath, outInstalls);
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -604,7 +604,13 @@ namespace GlitchyEditor.EditWindows
|
||||
|
||||
if (ImGui.MenuItem("Open C# Project..."))
|
||||
{
|
||||
RiderIdeAdapter.OpenScriptProject();
|
||||
switch (Application.Instance.Settings.ScriptSettings.SelectedIde)
|
||||
{
|
||||
case .VisualStudio:
|
||||
VisualStudioIdeAdapter.OpenScriptProject();
|
||||
case .Rider:
|
||||
RiderIdeAdapter.OpenScriptProject();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.AttachTooltip("Opens the C# Solution of this project.");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using GlitchyEngine;
|
||||
using Bon;
|
||||
using GlitchyEditor;
|
||||
using GlitchyEditor.CodeEditors;
|
||||
using System.Collections;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
@@ -69,6 +70,7 @@ class ScriptSettings
|
||||
public ScriptIde SelectedIde;
|
||||
|
||||
private bool _lookedForVs = false;
|
||||
private bool _lookedForRider = false;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
@@ -78,6 +80,11 @@ class ScriptSettings
|
||||
FindVisualStudio();
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!_lookedForRider && String.IsNullOrWhiteSpace(RiderPath))
|
||||
{
|
||||
FindRider();
|
||||
}
|
||||
}
|
||||
|
||||
void FindVisualStudio()
|
||||
@@ -154,6 +161,63 @@ class ScriptSettings
|
||||
Log.EngineLogger.Error("Couldn't automatically determine location of Visual Studio: Failed to launch vswhere.exe");
|
||||
}
|
||||
}
|
||||
|
||||
void FindRider()
|
||||
{
|
||||
_lookedForRider = true;
|
||||
|
||||
Thread thread = new Thread(new => FindRiderImpl);
|
||||
thread.AutoDelete = true;
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
void FindRiderImpl()
|
||||
{
|
||||
List<RiderInstallInfo> installs = new .();
|
||||
|
||||
RiderPathLocator.CollectAllPaths(installs);
|
||||
|
||||
// Don't touch the settings or the log from this thread: the editor's log window appends to
|
||||
// a plain List and isn't synchronized, so logging from here corrupts it.
|
||||
Application.Instance.InvokeOnMainThread(new () => ReportRiderInstalls(installs));
|
||||
}
|
||||
|
||||
/// Logs the located Rider installations and stores the newest one in RiderPath.
|
||||
/// @param installs The located installations, newest first. Takes ownership.
|
||||
bool ReportRiderInstalls(List<RiderInstallInfo> installs)
|
||||
{
|
||||
defer { DeleteContainerAndItems!(installs); }
|
||||
|
||||
for (RiderInstallInfo install in installs)
|
||||
{
|
||||
Log.EngineLogger.Info($"Found Rider {install.Version} at \"{install.Path}\" ({install.InstallType})");
|
||||
}
|
||||
|
||||
if (installs.IsEmpty)
|
||||
{
|
||||
Log.EngineLogger.Warning("Couldn't automatically determine location of Rider: No installation found.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// CollectAllPaths sorts the installations, newest first.
|
||||
RiderInstallInfo newestInstall = installs.Front;
|
||||
|
||||
if (RiderPath == null)
|
||||
{
|
||||
RiderPath = new String(newestInstall.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
RiderPath..Clear().Append(newestInstall.Path);
|
||||
}
|
||||
|
||||
Log.EngineLogger.Info($"Located Rider: {RiderPath}");
|
||||
|
||||
Apply();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[Reflect]
|
||||
|
||||
@@ -299,24 +299,62 @@ namespace GlitchyEditor
|
||||
|
||||
_settingsChanged = true;
|
||||
}
|
||||
case when fieldType.IsEnum:
|
||||
|
||||
/*if (setting.EditorType == .FilePath)
|
||||
int64 underlyingValue = 0;
|
||||
|
||||
// Read the enum fields value from the settings-object
|
||||
var enumValueResult = fieldInfo.GetValueReference(setting.SettingsObject);
|
||||
|
||||
if (enumValueResult not case .Ok(var enumValueReference))
|
||||
break;
|
||||
|
||||
// Get underlying integer value from field reference
|
||||
switch (fieldInfo.FieldType.Size)
|
||||
{
|
||||
ImGui.SameLine();
|
||||
case 1: underlyingValue = *(int8*)enumValueReference.DataPtr;
|
||||
case 2: underlyingValue = *(int16*)enumValueReference.DataPtr;
|
||||
case 4: underlyingValue = *(int32*)enumValueReference.DataPtr;
|
||||
case 8: underlyingValue = *(int64*)enumValueReference.DataPtr;
|
||||
}
|
||||
|
||||
if (ImGui.Button("..."))
|
||||
// TODO: handle multiple Flags (as a separate enum type handler though!)
|
||||
|
||||
StringView currentName = "";
|
||||
|
||||
for (let (name, value) in Enum.GetEnumerator(fieldInfo.FieldType))
|
||||
{
|
||||
if (value == underlyingValue)
|
||||
{
|
||||
OpenFileDialog ofd = scope .();
|
||||
ofd.InitialDirectory = value;
|
||||
ofd.SetFilter("All Files (*.*)|*.*");
|
||||
ofd.Multiselect = false;
|
||||
currentName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ofd.ShowDialog() case .Ok(let result) && result == .OK)
|
||||
if (ImGui.BeginCombo(scope $"##{setting.Name}", currentName.ToScopeCStr!()))
|
||||
{
|
||||
for (let (name, value) in Enum.GetEnumerator(fieldInfo.FieldType))
|
||||
{
|
||||
bool isSelected = (name == currentName);
|
||||
|
||||
if (ImGui.Selectable(name.ToScopeCStr!(), isSelected))
|
||||
{
|
||||
SetSettingValue!(new String(ofd.FileNames[0]));
|
||||
// Set value in field reference
|
||||
switch (fieldInfo.FieldType.Size)
|
||||
{
|
||||
case 1: *(int8*)enumValueReference.DataPtr = (int8)value;
|
||||
case 2: *(int16*)enumValueReference.DataPtr = (int16)value;
|
||||
case 4: *(int32*)enumValueReference.DataPtr = (int32)value;
|
||||
case 8: *(int64*)enumValueReference.DataPtr = (int64)value;
|
||||
}
|
||||
|
||||
if (!isSelected)
|
||||
_settingsChanged = true;
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
}
|
||||
|
||||
if (setting.Name == _SettingToHighlight)
|
||||
|
||||
Reference in New Issue
Block a user