diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78be2dbbc4..c056a46e2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file.
- [Multiple] Store GitHub Discussions links and display in UIs (#4111 by: HebaruSan)
- [GUI] Chinese translation fixes (#4115, #4116, #4131 by: zhouyiqing0304; reviewed: HebaruSan)
- [Multiple] Visually indicate to users that they should click Refresh (#4133 by: HebaruSan)
+- [Multiple] Option to clone smaller instances with junction points (Windows) or symbolic links (Unix) (#4129 by: HebaruSan)
### Bugfixes
diff --git a/Cmdline/Action/GameInstance.cs b/Cmdline/Action/GameInstance.cs
index 2690379f85..afcdabe4f0 100644
--- a/Cmdline/Action/GameInstance.cs
+++ b/Cmdline/Action/GameInstance.cs
@@ -97,6 +97,9 @@ internal class AddOptions : CommonOptions
internal class CloneOptions : CommonOptions
{
+ [Option("share-stock", DefaultValue = false, HelpText = "Use junction points (Windows) or symbolic links (Unix) for stock dirs instead of copying")]
+ public bool shareStock { get; set; }
+
[ValueOption(0)] public string nameOrPath { get; set; }
[ValueOption(1)] public string new_name { get; set; }
[ValueOption(2)] public string new_path { get; set; }
@@ -341,7 +344,7 @@ private int CloneInstall(CloneOptions options)
if (instance.Name == instanceNameOrPath)
{
// Found it, now clone it.
- Manager.CloneInstance(instance, newName, newPath);
+ Manager.CloneInstance(instance, newName, newPath, options.shareStock);
break;
}
}
@@ -350,7 +353,7 @@ private int CloneInstall(CloneOptions options)
// If it's valid, go on.
else if (Manager.InstanceAt(instanceNameOrPath) is CKAN.GameInstance instance && instance.Valid)
{
- Manager.CloneInstance(instance, newName, newPath);
+ Manager.CloneInstance(instance, newName, newPath, options.shareStock);
}
// There is no instance with this name or at this path.
else
diff --git a/Core/DirectoryLink.cs b/Core/DirectoryLink.cs
new file mode 100644
index 0000000000..a2b8820bfe
--- /dev/null
+++ b/Core/DirectoryLink.cs
@@ -0,0 +1,167 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.Win32.SafeHandles;
+
+using ChinhDo.Transactions.FileManager;
+
+namespace CKAN
+{
+ ///
+ /// Junctions on Windows, symbolic links on Unix
+ ///
+ public static class DirectoryLink
+ {
+ public static void Create(string target, string link, TxFileManager txMgr)
+ {
+ if (!CreateImpl(target, link, txMgr))
+ {
+ throw new Kraken(Platform.IsWindows
+ ? $"Failed to create junction at {link}: {Marshal.GetLastWin32Error()}"
+ : $"Failed to create symbolic link at {link}");
+ }
+ }
+
+ private static bool CreateImpl(string target, string link, TxFileManager txMgr)
+ => Platform.IsWindows ? CreateJunction(link, target, txMgr)
+ : symlink(target, link) == 0;
+
+ [DllImport("libc")]
+ private static extern int symlink(string target, string link);
+
+ private static bool CreateJunction(string link, string target, TxFileManager txMgr)
+ {
+ // A junction is a directory with some extra magic attached
+ if (!txMgr.DirectoryExists(link))
+ {
+ txMgr.CreateDirectory(link);
+ }
+ using (var h = CreateFile(link, GenericWrite, FileShare.Read | FileShare.Write, IntPtr.Zero,
+ FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero))
+ {
+ if (!h.IsInvalid)
+ {
+ var junctionInfo = ReparseDataBuffer.FromPath(target, out int byteCount);
+ return DeviceIoControl(h, FSCTL_SET_REPARSE_POINT,
+ ref junctionInfo, byteCount + 20,
+ null, 0,
+ out _, IntPtr.Zero);
+ }
+ }
+ return false;
+ }
+
+ public static bool TryGetTarget(string link, out string target)
+ {
+ target = null;
+ var fi = new DirectoryInfo(link);
+ if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ {
+ if (Platform.IsWindows)
+ {
+ var h = CreateFile(link, 0, FileShare.Read, IntPtr.Zero,
+ FileMode.Open, BackupSemantics | OpenReparsePoint, IntPtr.Zero);
+ if (!h.IsInvalid)
+ {
+ if (DeviceIoControl(h, FSCTL_GET_REPARSE_POINT,
+ null, 0,
+ out ReparseDataBuffer junctionInfo, Marshal.SizeOf(typeof(ReparseDataBuffer)),
+ out _, IntPtr.Zero))
+ {
+ if (junctionInfo.ReparseTag == IO_REPARSE_TAG_MOUNT_POINT)
+ {
+ target = junctionInfo.PathBuffer.TrimStart("\\\\?\\");
+ }
+ }
+ h.Close();
+ }
+ }
+ else
+ {
+ var bytes = new byte[1024];
+ var result = readlink(link, bytes, bytes.Length);
+ if (result > 0)
+ {
+ target = Encoding.UTF8.GetString(bytes);
+ }
+ }
+ }
+ return !string.IsNullOrEmpty(target);
+ }
+
+ private const uint GenericWrite = 0x40000000u;
+ private const uint BackupSemantics = 0x02000000u;
+ private const uint OpenReparsePoint = 0x00200000u;
+ private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4u;
+ private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003u;
+ private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8u;
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern bool DeviceIoControl(SafeFileHandle hDevice,
+ uint IoControlCode,
+ ref ReparseDataBuffer InBuffer,
+ int nInBufferSize,
+ byte[] OutBuffer,
+ int nOutBufferSize,
+ out int pBytesReturned,
+ IntPtr Overlapped);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
+ private static extern bool DeviceIoControl(SafeFileHandle hDevice,
+ uint IoControlCode,
+ byte[] InBuffer,
+ int nInBufferSize,
+ out ReparseDataBuffer OutBuffer,
+ int nOutBufferSize,
+ out int pBytesReturned,
+ IntPtr Overlapped);
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
+ private struct ReparseDataBuffer
+ {
+ public uint ReparseTag;
+ public ushort ReparseDataLength;
+ private readonly ushort Reserved;
+ public ushort SubstituteNameOffset;
+ public ushort SubstituteNameLength;
+ public ushort PrintNameOffset;
+ public ushort PrintNameLength;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 8184)]
+ public string PathBuffer;
+
+ public static ReparseDataBuffer FromPath(string target, out int byteCount)
+ {
+ var fullTarget = $@"\??\{Path.GetFullPath(target)}";
+ byteCount = Encoding.Unicode.GetByteCount(fullTarget);
+ return new ReparseDataBuffer
+ {
+ ReparseTag = IO_REPARSE_TAG_MOUNT_POINT,
+ ReparseDataLength = (ushort)(byteCount + 12),
+ SubstituteNameOffset = 0,
+ SubstituteNameLength = (ushort)byteCount,
+ PrintNameOffset = (ushort)(byteCount + 2),
+ PrintNameLength = 0,
+ PathBuffer = fullTarget,
+ };
+ }
+ }
+
+ [DllImport("libc")]
+ private static extern int readlink(string link, byte[] buf, int bufsize);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
+ private static extern SafeFileHandle CreateFile([MarshalAs(UnmanagedType.LPTStr)] string filename,
+ [MarshalAs(UnmanagedType.U4)] uint access,
+ [MarshalAs(UnmanagedType.U4)] FileShare share,
+ IntPtr securityAttributes,
+ [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
+ [MarshalAs(UnmanagedType.U4)] uint flagsAndAttributes,
+ IntPtr templateFile);
+
+ private static string TrimStart(this string orig, string toRemove)
+ => orig.StartsWith(toRemove) ? orig.Remove(0, toRemove.Length)
+ : orig;
+
+ }
+}
diff --git a/Core/GameInstanceManager.cs b/Core/GameInstanceManager.cs
index 7fc0ec9dd2..f8ccd759a2 100644
--- a/Core/GameInstanceManager.cs
+++ b/Core/GameInstanceManager.cs
@@ -239,7 +239,10 @@ public GameInstance AddInstance(string path, string name, IUser user)
/// Thrown by CopyDirectory() if directory doesn't exist. Should never be thrown here.
/// Thrown by CopyDirectory() if the target folder already exists and is not empty.
/// Thrown by CopyDirectory() if something goes wrong during the process.
- public void CloneInstance(GameInstance existingInstance, string newName, string newPath)
+ public void CloneInstance(GameInstance existingInstance,
+ string newName,
+ string newPath,
+ bool shareStockFolders = false)
{
if (HasInstance(newName))
{
@@ -252,11 +255,13 @@ public void CloneInstance(GameInstance existingInstance, string newName, string
}
log.Debug("Copying directory.");
- Utilities.CopyDirectory(existingInstance.GameDir(), newPath, true);
+ Utilities.CopyDirectory(existingInstance.GameDir(), newPath,
+ shareStockFolders ? existingInstance.game.StockFolders
+ : Array.Empty(),
+ existingInstance.game.LeaveEmptyInClones);
// Add the new instance to the config
- GameInstance new_instance = new GameInstance(existingInstance.game, newPath, newName, User);
- AddInstance(new_instance);
+ AddInstance(new GameInstance(existingInstance.game, newPath, newName, User));
}
///
diff --git a/Core/Games/IGame.cs b/Core/Games/IGame.cs
index 58ff2f6a91..80244a2647 100644
--- a/Core/Games/IGame.cs
+++ b/Core/Games/IGame.cs
@@ -24,10 +24,11 @@ public interface IGame
string PrimaryModDirectoryRelative { get; }
string[] AlternateModDirectoriesRelative { get; }
string PrimaryModDirectory(GameInstance inst);
- string[] StockFolders { get; }
- string[] ReservedPaths { get; }
- string[] CreateableDirs { get; }
- string[] AutoRemovableDirs { get; }
+ string[] StockFolders { get; }
+ string[] LeaveEmptyInClones { get; }
+ string[] ReservedPaths { get; }
+ string[] CreateableDirs { get; }
+ string[] AutoRemovableDirs { get; }
bool IsReservedDirectory(GameInstance inst, string path);
bool AllowInstallationIn(string name, out string path);
void RebuildSubdirectories(string absGameRoot);
diff --git a/Core/Games/KerbalSpaceProgram.cs b/Core/Games/KerbalSpaceProgram.cs
index e29eee84b6..e6208b4a45 100644
--- a/Core/Games/KerbalSpaceProgram.cs
+++ b/Core/Games/KerbalSpaceProgram.cs
@@ -65,6 +65,17 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};
+ public string[] LeaveEmptyInClones => new string[]
+ {
+ "saves",
+ "Screenshots",
+ "thumbs",
+ "Missions",
+ "Logs",
+ "CKAN/history",
+ "CKAN/downloads",
+ };
+
public string[] ReservedPaths => new string[]
{
"GameData", "Ships", "Missions"
diff --git a/Core/Games/KerbalSpaceProgram2.cs b/Core/Games/KerbalSpaceProgram2.cs
index a10029b1a1..640df65000 100644
--- a/Core/Games/KerbalSpaceProgram2.cs
+++ b/Core/Games/KerbalSpaceProgram2.cs
@@ -59,6 +59,12 @@ public string PrimaryModDirectory(GameInstance inst)
"PDLauncher",
};
+ public string[] LeaveEmptyInClones => new string[]
+ {
+ "CKAN/history",
+ "CKAN/downloads",
+ };
+
public string[] ReservedPaths => Array.Empty();
public string[] CreateableDirs => new string[]
diff --git a/Core/Utilities.cs b/Core/Utilities.cs
index f687cdb4c9..923a5a4125 100644
--- a/Core/Utilities.cs
+++ b/Core/Utilities.cs
@@ -1,7 +1,8 @@
using System;
+using System.Linq;
using System.IO;
using System.Diagnostics;
-using System.Transactions;
+using System.Collections.Generic;
using ChinhDo.Transactions.FileManager;
using log4net;
@@ -40,34 +41,39 @@ public static T DefaultIfThrows(Func func)
}
///
- /// Copies a directory and optionally its subdirectories as a transaction.
+ /// Copies a directory and its subdirectories as a transaction
///
- /// Source directory path.
- /// Destination directory path.
- /// Copy sub dirs recursively if set to true.
- public static void CopyDirectory(string sourceDirPath, string destDirPath, bool copySubDirs)
+ /// Source directory path
+ /// Destination directory path
+ /// Relative subdirs that should be symlinked to the originals instead of copied
+ public static void CopyDirectory(string sourceDirPath,
+ string destDirPath,
+ string[] subFolderRelPathsToSymlink,
+ string[] subFolderRelPathsToLeaveEmpty)
{
- TxFileManager file_transaction = new TxFileManager();
- using (TransactionScope transaction = CkanTransaction.CreateTransactionScope())
+ using (var transaction = CkanTransaction.CreateTransactionScope())
{
- _CopyDirectory(sourceDirPath, destDirPath, copySubDirs, file_transaction);
+ CopyDirectory(sourceDirPath, destDirPath, new TxFileManager(),
+ subFolderRelPathsToSymlink, subFolderRelPathsToLeaveEmpty);
transaction.Complete();
}
}
-
- private static void _CopyDirectory(string sourceDirPath, string destDirPath, bool copySubDirs, TxFileManager file_transaction)
+ private static void CopyDirectory(string sourceDirPath,
+ string destDirPath,
+ TxFileManager file_transaction,
+ string[] subFolderRelPathsToSymlink,
+ string[] subFolderRelPathsToLeaveEmpty)
{
- DirectoryInfo sourceDir = new DirectoryInfo(sourceDirPath);
-
+ var sourceDir = new DirectoryInfo(sourceDirPath);
if (!sourceDir.Exists)
{
throw new DirectoryNotFoundKraken(
sourceDirPath,
- "Source directory does not exist or could not be found.");
+ $"Source directory {sourceDirPath} does not exist or could not be found.");
}
- // If the destination directory doesn't exist, create it.
+ // If the destination directory doesn't exist, create it
if (!Directory.Exists(destDirPath))
{
file_transaction.CreateDirectory(destDirPath);
@@ -77,9 +83,8 @@ private static void _CopyDirectory(string sourceDirPath, string destDirPath, boo
throw new PathErrorKraken(destDirPath, "Directory not empty: ");
}
- // Get the files in the directory and copy them to the new location.
- FileInfo[] files = sourceDir.GetFiles();
- foreach (FileInfo file in files)
+ // Get the files in the directory and copy them to the new location
+ foreach (var file in sourceDir.GetFiles())
{
if (file.Name == "registry.locked")
{
@@ -91,26 +96,45 @@ private static void _CopyDirectory(string sourceDirPath, string destDirPath, boo
continue;
}
- string temppath = Path.Combine(destDirPath, file.Name);
- file_transaction.Copy(file.FullName, temppath, false);
+ file_transaction.Copy(file.FullName, Path.Combine(destDirPath, file.Name), false);
}
// Create all first level subdirectories
- DirectoryInfo[] dirs = sourceDir.GetDirectories();
-
- foreach (DirectoryInfo subdir in dirs)
+ foreach (var subdir in sourceDir.GetDirectories())
{
- string temppath = Path.Combine(destDirPath, subdir.Name);
- file_transaction.CreateDirectory(temppath);
-
- // If copying subdirectories, copy their contents to new location.
- if (copySubDirs)
+ var temppath = Path.Combine(destDirPath, subdir.Name);
+ // If already a sym link, replicate it in the new location
+ if (DirectoryLink.TryGetTarget(subdir.FullName, out string existingLinkTarget))
+ {
+ DirectoryLink.Create(existingLinkTarget, temppath, file_transaction);
+ }
+ else
{
- _CopyDirectory(subdir.FullName, temppath, copySubDirs, file_transaction);
+ if (subFolderRelPathsToSymlink.Contains(subdir.Name, Platform.PathComparer))
+ {
+ DirectoryLink.Create(subdir.FullName, temppath, file_transaction);
+ }
+ else
+ {
+ file_transaction.CreateDirectory(temppath);
+
+ if (!subFolderRelPathsToLeaveEmpty.Contains(subdir.Name, Platform.PathComparer))
+ {
+ // Copy subdir contents to new location
+ CopyDirectory(subdir.FullName, temppath, file_transaction,
+ SubPaths(subdir.Name, subFolderRelPathsToSymlink).ToArray(),
+ SubPaths(subdir.Name, subFolderRelPathsToLeaveEmpty).ToArray());
+ }
+ }
}
}
}
+ // Select only paths within subdir, prune prefixes
+ private static IEnumerable SubPaths(string parent, string[] paths)
+ => paths.Where(p => p.StartsWith($"{parent}/", Platform.PathComparison))
+ .Select(p => p.Remove(0, parent.Length + 1));
+
///
/// Launch a URL. For YEARS this was done by Process.Start in a
/// cross-platform way, but Microsoft chose to break that,
diff --git a/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs b/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs
index f426a89be3..dbe1c2d058 100644
--- a/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs
+++ b/GUI/Dialogs/CloneGameInstanceDialog.Designer.cs
@@ -30,6 +30,7 @@ private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
System.ComponentModel.ComponentResourceManager resources = new SingleAssemblyComponentResourceManager(typeof(CloneGameInstanceDialog));
+ this.ToolTip = new System.Windows.Forms.ToolTip();
this.labelOldInstance = new System.Windows.Forms.Label();
this.comboBoxKnownInstance = new System.Windows.Forms.ComboBox();
this.labelOldPath = new System.Windows.Forms.Label();
@@ -42,12 +43,20 @@ private void InitializeComponent()
this.buttonPathBrowser = new System.Windows.Forms.Button();
this.checkBoxSetAsDefault = new System.Windows.Forms.CheckBox();
this.checkBoxSwitchInstance = new System.Windows.Forms.CheckBox();
+ this.checkBoxShareStock = new System.Windows.Forms.CheckBox();
this.buttonOK = new System.Windows.Forms.Button();
this.buttonCancel = new System.Windows.Forms.Button();
this.progressBar = new System.Windows.Forms.ProgressBar();
this.folderBrowserDialogNewPath = new System.Windows.Forms.FolderBrowserDialog();
this.SuspendLayout();
//
+ // ToolTip
+ //
+ this.ToolTip.AutoPopDelay = 10000;
+ this.ToolTip.InitialDelay = 250;
+ this.ToolTip.ReshowDelay = 250;
+ this.ToolTip.ShowAlways = true;
+ //
// labelOldInstance
//
this.labelOldInstance.AutoSize = true;
@@ -154,7 +163,7 @@ private void InitializeComponent()
//
this.checkBoxSetAsDefault.AutoSize = true;
this.checkBoxSetAsDefault.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
- this.checkBoxSetAsDefault.Location = new System.Drawing.Point(12, 144);
+ this.checkBoxSetAsDefault.Location = new System.Drawing.Point(181, 144);
this.checkBoxSetAsDefault.Name = "checkBoxSetAsDefault";
this.checkBoxSetAsDefault.Size = new System.Drawing.Size(157, 17);
this.checkBoxSetAsDefault.TabIndex = 19;
@@ -167,20 +176,33 @@ private void InitializeComponent()
this.checkBoxSwitchInstance.Checked = true;
this.checkBoxSwitchInstance.CheckState = System.Windows.Forms.CheckState.Checked;
this.checkBoxSwitchInstance.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
- this.checkBoxSwitchInstance.Location = new System.Drawing.Point(181, 144);
+ this.checkBoxSwitchInstance.Location = new System.Drawing.Point(181, 174);
this.checkBoxSwitchInstance.Name = "checkBoxSwitchInstance";
this.checkBoxSwitchInstance.Size = new System.Drawing.Size(136, 17);
this.checkBoxSwitchInstance.TabIndex = 20;
this.checkBoxSwitchInstance.UseVisualStyleBackColor = true;
resources.ApplyResources(this.checkBoxSwitchInstance, "checkBoxSwitchInstance");
//
+ // checkBoxShareStock
+ //
+ this.checkBoxShareStock.AutoSize = true;
+ this.checkBoxShareStock.Checked = true;
+ this.checkBoxShareStock.CheckState = System.Windows.Forms.CheckState.Checked;
+ this.checkBoxShareStock.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
+ this.checkBoxShareStock.Location = new System.Drawing.Point(181, 204);
+ this.checkBoxShareStock.Name = "checkBoxShareStock";
+ this.checkBoxShareStock.Size = new System.Drawing.Size(136, 17);
+ this.checkBoxShareStock.TabIndex = 21;
+ this.checkBoxShareStock.UseVisualStyleBackColor = true;
+ resources.ApplyResources(this.checkBoxShareStock, "checkBoxShareStock");
+ //
// buttonOK
//
this.buttonOK.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
- this.buttonOK.Location = new System.Drawing.Point(256, 170);
+ this.buttonOK.Location = new System.Drawing.Point(256, 230);
this.buttonOK.Name = "buttonOK";
this.buttonOK.Size = new System.Drawing.Size(75, 23);
- this.buttonOK.TabIndex = 21;
+ this.buttonOK.TabIndex = 22;
this.buttonOK.UseVisualStyleBackColor = true;
this.buttonOK.Click += new System.EventHandler(this.buttonOK_Click);
resources.ApplyResources(this.buttonOK, "buttonOK");
@@ -188,10 +210,10 @@ private void InitializeComponent()
// buttonCancel
//
this.buttonCancel.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
- this.buttonCancel.Location = new System.Drawing.Point(337, 170);
+ this.buttonCancel.Location = new System.Drawing.Point(337, 230);
this.buttonCancel.Name = "buttonCancel";
this.buttonCancel.Size = new System.Drawing.Size(75, 23);
- this.buttonCancel.TabIndex = 22;
+ this.buttonCancel.TabIndex = 23;
this.buttonCancel.UseVisualStyleBackColor = true;
this.buttonCancel.Click += new System.EventHandler(this.buttonCancel_Click);
resources.ApplyResources(this.buttonCancel, "buttonCancel");
@@ -204,7 +226,7 @@ private void InitializeComponent()
this.progressBar.Name = "progressBar";
this.progressBar.Size = new System.Drawing.Size(230, 23);
this.progressBar.Style = System.Windows.Forms.ProgressBarStyle.Marquee;
- this.progressBar.TabIndex = 23;
+ this.progressBar.TabIndex = 24;
this.progressBar.Visible = false;
//
// CloneGameInstanceDialog
@@ -213,7 +235,7 @@ private void InitializeComponent()
this.AllowDrop = true;
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
- this.ClientSize = new System.Drawing.Size(424, 205);
+ this.ClientSize = new System.Drawing.Size(424, 265);
this.Controls.Add(this.labelOldInstance);
this.Controls.Add(this.comboBoxKnownInstance);
this.Controls.Add(this.labelOldPath);
@@ -222,6 +244,7 @@ private void InitializeComponent()
this.Controls.Add(this.progressBar);
this.Controls.Add(this.buttonPathBrowser);
this.Controls.Add(this.checkBoxSwitchInstance);
+ this.Controls.Add(this.checkBoxShareStock);
this.Controls.Add(this.textBoxNewPath);
this.Controls.Add(this.labelNewPath);
this.Controls.Add(this.checkBoxSetAsDefault);
@@ -247,6 +270,7 @@ private void InitializeComponent()
#endregion
+ private System.Windows.Forms.ToolTip ToolTip;
private System.Windows.Forms.Label labelOldInstance;
private System.Windows.Forms.ComboBox comboBoxKnownInstance;
private System.Windows.Forms.Label labelOldPath;
@@ -259,6 +283,7 @@ private void InitializeComponent()
private System.Windows.Forms.Button buttonPathBrowser;
private System.Windows.Forms.CheckBox checkBoxSetAsDefault;
private System.Windows.Forms.CheckBox checkBoxSwitchInstance;
+ private System.Windows.Forms.CheckBox checkBoxShareStock;
private System.Windows.Forms.Button buttonOK;
private System.Windows.Forms.Button buttonCancel;
private System.Windows.Forms.FolderBrowserDialog folderBrowserDialogNewPath;
diff --git a/GUI/Dialogs/CloneGameInstanceDialog.cs b/GUI/Dialogs/CloneGameInstanceDialog.cs
index fca4476845..96ff730d98 100644
--- a/GUI/Dialogs/CloneGameInstanceDialog.cs
+++ b/GUI/Dialogs/CloneGameInstanceDialog.cs
@@ -15,7 +15,7 @@
namespace CKAN.GUI
{
///
- /// The GUI implementation of clone and fake.
+ /// The GUI implementation of clone.
/// It's a separate window, handling the whole process.
///
#if NET5_0_OR_GREATER
@@ -34,6 +34,8 @@ public CloneGameInstanceDialog(GameInstanceManager manager, IUser user, string s
InitializeComponent();
+ ToolTip.SetToolTip(checkBoxShareStock, Properties.Resources.CloneGameInstanceToolTipShareStock);
+
// Populate the instances combobox with names of known instances
comboBoxKnownInstance.DataSource = new string[] { "" }
.Concat(manager.Instances.Values
@@ -138,7 +140,7 @@ await Task.Run(() =>
{
if (instanceToClone.Valid)
{
- manager.CloneInstance(instanceToClone, newName, newPath);
+ manager.CloneInstance(instanceToClone, newName, newPath, checkBoxShareStock.Checked);
}
else
{
diff --git a/GUI/Dialogs/CloneGameInstanceDialog.resx b/GUI/Dialogs/CloneGameInstanceDialog.resx
index bd30c662e0..a40d752b1e 100644
--- a/GUI/Dialogs/CloneGameInstanceDialog.resx
+++ b/GUI/Dialogs/CloneGameInstanceDialog.resx
@@ -125,7 +125,8 @@
Select...
Set new instance as default
Switch to new instance
- Create
+ Share stock files
+ Clone
Cancel
Clone Game Instance
diff --git a/GUI/Properties/Resources.resx b/GUI/Properties/Resources.resx
index 5644a4ddf9..1a2abb16ee 100644
--- a/GUI/Properties/Resources.resx
+++ b/GUI/Properties/Resources.resx
@@ -133,6 +133,8 @@
Creating new instance...
This name is already used.
Successfully created instance.
+ Create junction points (Windows) or symbolic links (Unix) to stock directories instead of copying them.
+If you choose this option, DO NOT move or delete the old instance!!
<NONE>
The game has been updated since you last reviewed your compatible game versions. Please make sure that settings are correct.
{0} (previous game version: {1})
diff --git a/Tests/Core/GameInstance.cs b/Tests/Core/GameInstance.cs
index 1a70b9e7e4..7bfea76e50 100644
--- a/Tests/Core/GameInstance.cs
+++ b/Tests/Core/GameInstance.cs
@@ -22,7 +22,7 @@ public void Setup()
{
ksp_dir = TestData.NewTempDir();
nullUser = new NullUser();
- CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), ksp_dir, true);
+ CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), ksp_dir, Array.Empty(), Array.Empty());
ksp = new GameInstance(new KerbalSpaceProgram(), ksp_dir, "test", nullUser);
}
@@ -111,7 +111,7 @@ public void Valid_MissingVersionData_False()
}";
// Generate a valid game dir except for missing buildID.txt and readme.txt
- CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, true);
+ CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, Array.Empty(), Array.Empty());
File.Delete(buildid);
File.Delete(readme);
@@ -143,7 +143,7 @@ public void Constructor_NullMainCompatVer_NoCrash()
}";
// Generate a valid game dir except for missing buildID.txt and readme.txt
- CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, true);
+ CKAN.Utilities.CopyDirectory(TestData.good_ksp_dir(), gamedir, Array.Empty(), Array.Empty());
File.Delete(buildid);
File.Delete(readme);
diff --git a/Tests/Core/Utilities.cs b/Tests/Core/Utilities.cs
index cc7ba0d8b7..106abd0a9d 100644
--- a/Tests/Core/Utilities.cs
+++ b/Tests/Core/Utilities.cs
@@ -1,7 +1,10 @@
+using System;
using System.IO;
-using CKAN;
+
using NUnit.Framework;
+
using Tests.Data;
+using CKAN;
namespace Tests.Core
{
@@ -25,36 +28,39 @@ public void EmptyTempDir()
[Test]
public void CopyDirectory_Recursive_DestHasAllContents()
{
- CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, true);
+ CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, Array.Empty(), Array.Empty());
+ var fi = new FileInfo(Path.Combine(tempDir, "GameData"));
+ Assert.IsFalse(fi.Attributes.HasFlag(FileAttributes.ReparsePoint),
+ "GameData should not be a symlink");
Assert.IsTrue(UtilStatic.CompareFiles(
Path.Combine(goodKspDir, "GameData", "README.md"),
- Path.Combine(tempDir, "GameData", "README.md")));
+ Path.Combine(tempDir, "GameData", "README.md")));
Assert.IsTrue(UtilStatic.CompareFiles(
Path.Combine(goodKspDir, "buildID.txt"),
- Path.Combine(tempDir, "buildID.txt")));
+ Path.Combine(tempDir, "buildID.txt")));
Assert.IsTrue(UtilStatic.CompareFiles(
Path.Combine(goodKspDir, "readme.txt"),
- Path.Combine(tempDir, "readme.txt")));
+ Path.Combine(tempDir, "readme.txt")));
}
[Test]
- public void CopyDirectory_NotRecursive_DestHasOnlyFirstLevelFiles()
+ public void CopyDirectory_WithSymlinks_MakesSymlinks()
{
- CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, false);
+ // Arrange / Act
+ CKAN.Utilities.CopyDirectory(Path.Combine(TestData.DataDir(), "KSP"), tempDir,
+ new string[] { "KSP-0.25/GameData" }, Array.Empty());
- Assert.IsFalse(File.Exists(Path.Combine(tempDir, "GameData", "README.md")));
- // The following assertion is per se already included in the above assertion,
- // but this also tests CompareFiles, so no harm in including this.
- Assert.IsFalse(UtilStatic.CompareFiles(
- Path.Combine(goodKspDir, "GameData", "README.md"),
- Path.Combine(tempDir, "GameData", "README.md")));
- Assert.IsTrue(UtilStatic.CompareFiles(
- Path.Combine(goodKspDir, "buildID.txt"),
- Path.Combine(tempDir, "buildID.txt")));
- Assert.IsTrue(UtilStatic.CompareFiles(
- Path.Combine(goodKspDir, "readme.txt"),
- Path.Combine(tempDir, "readme.txt")));
+ // Assert
+ var fi1 = new FileInfo(Path.Combine(tempDir, "KSP-0.25"));
+ Assert.IsFalse(fi1.Attributes.HasFlag(FileAttributes.ReparsePoint),
+ "KSP-0.25 should not be a symlink");
+ var fi2 = new FileInfo(Path.Combine(tempDir, "KSP-0.25", "GameData"));
+ Assert.IsTrue(fi2.Attributes.HasFlag(FileAttributes.ReparsePoint),
+ "KSP-0.25/GameData should be a symlink");
+ var fi3 = new FileInfo(Path.Combine(tempDir, "KSP-0.25", "GameData", "README.md"));
+ Assert.IsFalse(fi3.Attributes.HasFlag(FileAttributes.ReparsePoint),
+ "KSP-0.25/GameData/README.md should not be a symlink");
}
[Test]
@@ -63,7 +69,7 @@ public void CopyDirectory_SourceNotExisting_ThrowsDirectoryNotFoundKraken()
var sourceDir = "/gibberish/DOESNTEXIST/hopefully";
// Act and Assert
- Assert.Throws(() => CKAN.Utilities.CopyDirectory(sourceDir, tempDir, true));
+ Assert.Throws(() => CKAN.Utilities.CopyDirectory(sourceDir, tempDir, Array.Empty(), Array.Empty()));
}
[Test]
@@ -71,7 +77,7 @@ public void CopyDirectory_DestNotEmpty_ThrowsException()
{
File.WriteAllText(Path.Combine(tempDir, "thatsafile"), "not empty");
- Assert.Throws(() => CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, true));
+ Assert.Throws(() => CKAN.Utilities.CopyDirectory(goodKspDir, tempDir, Array.Empty(), Array.Empty()));
}
}
}
diff --git a/Tests/Data/DisposableKSP.cs b/Tests/Data/DisposableKSP.cs
index 90c0f08e1d..02a366d9df 100644
--- a/Tests/Data/DisposableKSP.cs
+++ b/Tests/Data/DisposableKSP.cs
@@ -27,7 +27,7 @@ public class DisposableKSP : IDisposable
public DisposableKSP()
{
_disposableDir = TestData.NewTempDir();
- Utilities.CopyDirectory(_goodKsp, _disposableDir, true);
+ Utilities.CopyDirectory(_goodKsp, _disposableDir, Array.Empty(), Array.Empty());
KSP = new GameInstance(new KerbalSpaceProgram(), _disposableDir, "disposable", new NullUser());
Logging.Initialize();
}