Bladeren bron

Initial package management support

Brian Fiete 9 maanden geleden
bovenliggende
commit
4870c6fdd8

+ 133 - 28
BeefBuild/src/BuildApp.bf

@@ -10,12 +10,22 @@ namespace BeefBuild
 {
 	class BuildApp : IDEApp
 	{
+		public enum MainVerbState
+		{
+			None,
+			UpdateList,
+			End
+		}
+
 		const int cProgressSize = 30;
 		int mProgressIdx = 0;		
 		public bool mIsTest;
 		public bool mTestIncludeIgnored;
 		public bool mDidRun;
 		public bool mWantsGenerate = false;
+		public bool mHandledVerb;
+		public String mRunArgs ~ delete _;
+		MainVerbState mMainVerbState;
 
 		/*void Test()
 		{
@@ -112,20 +122,33 @@ namespace BeefBuild
 						OutputErrorLine("The project '{}' is not empty, but '-generate' was specified.", mWorkspace.mStartupProject.mProjectName);
 				}
 			}
+		}
 
-			if (!mFailed)
+		public override bool HandleCommandLineParam(String key, String value)
+		{
+			if (mRunArgs != null)
 			{
-				if (mIsTest)
+				if (!mRunArgs.IsEmpty)
+					mRunArgs.Append(" ");
+				if (value != null)
+				{
+					String qKey = scope .(key);
+					String qValue = scope .(value);
+					IDEApp.QuoteIfNeeded(qKey);
+					IDEApp.QuoteIfNeeded(qValue);
+					mRunArgs.Append(qKey);
+					mRunArgs.Append('=');
+					mRunArgs.Append(qValue);
+				}
+				else
 				{
-					RunTests(mTestIncludeIgnored, false);
+					String qKey = scope .(key);
+					IDEApp.QuoteIfNeeded(qKey);
+					mRunArgs.Append(qKey);
 				}
-				else if (mVerb != .New)
-					Compile(.Normal, null);
+				return true;
 			}
-		}
 
-		public override bool HandleCommandLineParam(String key, String value)
-		{
 			if (key.StartsWith("--"))
 				key.Remove(0, 1);
 
@@ -133,6 +156,10 @@ namespace BeefBuild
 			{
 				switch (key)
 				{
+				case "-args":
+					if (mRunArgs == null)
+						mRunArgs = new .();
+					return true;
 				case "-new":
 					mVerb = .New;
 					return true;
@@ -157,12 +184,63 @@ namespace BeefBuild
 				case "-noir":
 					mConfig_NoIR = true;
 					return true;
+				case "-update":
+					if (mWantUpdateVersionLocks == null)
+						mWantUpdateVersionLocks = new .();
+					return true;
 				case "-version":
 					mVerb = .GetVersion;
 					return true;
 				case "-crash":
 					Runtime.FatalError("-crash specified on command line");
 				}
+
+				if (!key.StartsWith('-'))
+				{
+					switch (mMainVerbState)
+					{
+					case .None:
+						mMainVerbState = .End;
+						switch (key)
+						{
+						case "build":
+							mVerb = .None;
+						case "new":
+							mVerb = .New;
+						case "generate":
+							mWantsGenerate = true;
+						case "run":
+							if (mVerbosity == .Default)
+								mVerbosity = .Minimal;
+							mVerb = .Run;
+						case "test":
+							mIsTest = true;
+						case "testall":
+							mIsTest = true;
+							mTestIncludeIgnored = true;
+						case "clean":
+							mWantsClean = true;
+						case "version":
+							mVerb = .GetVersion;
+						case "crash":
+							Runtime.FatalError("-crash specified on command line");
+						case "update":
+							mVerb = .Update;
+							mWantUpdateVersionLocks = new .();
+							mMainVerbState = .UpdateList;
+						default:
+							mMainVerbState = .None;
+						}
+						if (mMainVerbState != .None)
+							return true;
+					case .UpdateList:
+						mWantUpdateVersionLocks.Add(new .(key));
+						return true;
+					case .End:
+						return false;
+					default:
+					}
+				}
 			}
 			else
 			{
@@ -188,6 +266,11 @@ namespace BeefBuild
 				case "-platform":
 					mPlatformName.Set(value);
 					return true;
+				case "-update":
+					if (mWantUpdateVersionLocks == null)
+						mWantUpdateVersionLocks = new .();
+					mWantUpdateVersionLocks.Add(new .(value));
+					return true;
 				case "-verbosity":
 				    if (value == "quiet")
 					    mVerbosity = .Quiet;
@@ -281,33 +364,55 @@ namespace BeefBuild
 		{
 			base.Update(batchStart);
 
-			if (mCompilingBeef)
+			if (mWorkspace.mProjectLoadState != .Loaded)
 			{
-				WriteProgress(mBfBuildCompiler.GetCompletionPercentage());
+				// Wait for workspace to complete loading
 			}
-
-			if ((!IsCompiling) && (!AreTestsRunning()))
+			else
 			{
-				if ((mVerb == .Run) && (!mDidRun) && (!mFailed))
+				if ((!mFailed) && (!mHandledVerb))
 				{
-					let curPath = scope String();
-					Directory.GetCurrentDirectory(curPath);
-
-					let workspaceOptions = gApp.GetCurWorkspaceOptions();
-					let options = gApp.GetCurProjectOptions(mWorkspace.mStartupProject);
-					let targetPaths = scope List<String>();
-					defer ClearAndDeleteItems(targetPaths);
-					this.[Friend]GetTargetPaths(mWorkspace.mStartupProject, gApp.mPlatformName, workspaceOptions, options, targetPaths);
-					if (targetPaths.IsEmpty)
-						return;
+					mHandledVerb = true;
+					if (mIsTest)
+					{
+						RunTests(mTestIncludeIgnored, false);
+					}
+					else if (mVerb == .Update)
+					{
+						// No-op here
+					}
+					else if (mVerb != .New)
+						Compile(.Normal, null);
+				}
 
-					ExecutionQueueCmd executionCmd = QueueRun(targetPaths[0], "", curPath);
-					executionCmd.mIsTargetRun = true;
-					mDidRun = true;
-					return;
+				if (mCompilingBeef)
+				{
+					WriteProgress(mBfBuildCompiler.GetCompletionPercentage());
 				}
 
-				Stop();
+				if ((!IsCompiling) && (!AreTestsRunning()))
+				{
+					if ((mVerb == .Run) && (!mDidRun) && (!mFailed))
+					{
+						let curPath = scope String();
+						Directory.GetCurrentDirectory(curPath);
+
+						let workspaceOptions = gApp.GetCurWorkspaceOptions();
+						let options = gApp.GetCurProjectOptions(mWorkspace.mStartupProject);
+						let targetPaths = scope List<String>();
+						defer ClearAndDeleteItems(targetPaths);
+						this.[Friend]GetTargetPaths(mWorkspace.mStartupProject, gApp.mPlatformName, workspaceOptions, options, targetPaths);
+						if (targetPaths.IsEmpty)
+							return;
+
+						ExecutionQueueCmd executionCmd = QueueRun(targetPaths[0], mRunArgs ?? "", curPath);
+						executionCmd.mIsTargetRun = true;
+						mDidRun = true;
+						return;
+					}
+
+					Stop();
+				}
 			}
 		}
 	}

+ 10 - 0
IDE/src/BeefConfig.bf

@@ -123,6 +123,7 @@ namespace IDE
 		List<String> mConfigPathQueue = new List<String>() ~ DeleteContainerAndItems!(_);
 		List<LibDirectory> mLibDirectories = new List<LibDirectory>() ~ DeleteContainerAndItems!(_);
 		List<FileSystemWatcher> mWatchers = new .() ~ DeleteContainerAndItems!(_);
+		public String mManagedLibPath = new .() ~ delete _;
 		public bool mLibsChanged;
 
 		void LibsChanged()
@@ -221,6 +222,15 @@ namespace IDE
 				}
 			}
 
+			data.GetString("ManagedLibDir", mManagedLibPath);
+			if ((mManagedLibPath.IsEmpty) && (!mLibDirectories.IsEmpty))
+			{
+				var libPath = Path.GetAbsolutePath(mLibDirectories[0].mPath, gApp.mInstallDir, .. scope .());
+				var managedPath = Path.GetAbsolutePath("../BeefManaged", libPath, .. scope .());
+				if (Directory.Exists(managedPath))
+					mManagedLibPath.Set(managedPath);
+			}
+
 			mConfigFiles.Add(configFile);
 
 			return .Ok;

+ 282 - 29
IDE/src/IDEApp.bf

@@ -76,6 +76,7 @@ namespace IDE
 		OpenOrNew,
 		Test,
 		Run,
+		Update,
 		GetVersion
 	}
 
@@ -182,6 +183,7 @@ namespace IDE
 		public MainFrame mMainFrame;
 		public GlobalUndoManager mGlobalUndoManager = new GlobalUndoManager() ~ delete _;
 		public SourceControl mSourceControl = new SourceControl() ~ delete _;
+		public GitManager mGitManager = new .() ~ delete _;
 
 		public WidgetWindow mPopupWindow;
 		public RecentFileSelector mRecentFileSelector;
@@ -223,6 +225,7 @@ namespace IDE
 		public WakaTime mWakaTime ~ delete _;
 
 		public PackMan mPackMan = new PackMan() ~ delete _;
+		public HashSet<String> mWantUpdateVersionLocks ~ DeleteContainerAndItems!(_);
 		public Settings mSettings = new Settings() ~ delete _;
 		public Workspace mWorkspace = new Workspace() ~ delete _;
 		public FileWatcher mFileWatcher = new FileWatcher() ~ delete _;
@@ -2145,6 +2148,91 @@ namespace IDE
 			return true;
 		}
 
+		bool SaveWorkspaceLockData(bool force = false)
+		{
+			if ((mWorkspace.mProjectLockMap.IsEmpty) && (!force))
+				return true;
+
+			StructuredData sd = scope StructuredData();
+			sd.CreateNew();
+			sd.Add("FileVersion", 1);
+			using (sd.CreateObject("Locks"))
+			{
+				List<String> projectNames = scope .(mWorkspace.mProjectLockMap.Keys);
+				projectNames.Sort();
+
+				for (var projectName in projectNames)
+				{
+					var lock = mWorkspace.mProjectLockMap[projectName];
+					switch (lock)
+					{
+					case .Git(let url, let tag, let hash):
+						using (sd.CreateObject(projectName))
+						{
+							using (sd.CreateObject("Git"))
+							{
+								sd.Add("URL", url);
+								sd.Add("Tag", tag);
+								sd.Add("Hash", hash);
+							}
+						}
+					default:
+					}
+				}
+			}
+
+			String jsonString = scope String();
+			sd.ToTOML(jsonString);
+
+			String lockFileName = scope String();
+			GetWorkspaceLockFileName(lockFileName);
+			if (lockFileName.IsEmpty)
+				return false;
+			return SafeWriteTextFile(lockFileName, jsonString);
+		}
+
+		bool LoadWorkspaceLockData()
+		{
+			String lockFilePath = scope String();
+			GetWorkspaceLockFileName(lockFilePath);
+			if (lockFilePath.IsEmpty)
+				return true;
+
+			var sd = scope StructuredData();
+			if (sd.Load(lockFilePath) case .Err)
+				return false;
+
+			for (var projectName in sd.Enumerate("Locks"))
+			{
+				Workspace.Lock lock = default;
+				if (sd.Contains("Git"))
+				{
+					using (sd.Open("Git"))
+					{
+						var url = sd.GetString("URL", .. new .());
+						var tag = sd.GetString("Tag", .. new .());
+						var hash = sd.GetString("Hash", .. new .());
+						lock = .Git(url, tag, hash);
+					}
+				}
+
+				mWorkspace.SetLock(projectName, lock);
+			}
+
+			return true;
+		}
+
+		bool GetWorkspaceLockFileName(String outResult)
+		{
+			if (mWorkspace.mDir == null)
+				return false;
+			if (mWorkspace.mCompositeFile != null)
+				outResult.Append(mWorkspace.mCompositeFile.mFilePath, ".bfuser");
+			else
+				outResult.Append(mWorkspace.mDir, "/BeefSpace_Lock.toml");
+			return true;
+		}
+
 		void GetDefaultLayoutDataFileName(String outResult)
 		{
 			outResult.Append(mInstallDir, "/DefaultLayout.toml");
@@ -2377,6 +2465,17 @@ namespace IDE
 			project.mDependencies.Add(dep);
 		}
 
+		public void ProjectCreated(Project project)
+		{
+			mProjectPanel.InitProject(project, mProjectPanel.GetSelectedWorkspaceFolder());
+			mProjectPanel.Sort();
+			mWorkspace.FixOptions();
+			mWorkspace.mHasChanged = true;
+
+			mWorkspace.ClearProjectNameCache();
+			CurrentWorkspaceConfigChanged();
+		}
+
 		public Project CreateProject(String projName, String projDir, Project.TargetType targetType)
 		{
 			Project project = new Project();
@@ -2391,13 +2490,8 @@ namespace IDE
 			AddNewProjectToWorkspace(project);
 			project.FinishCreate();
 
-			mProjectPanel.InitProject(project, mProjectPanel.GetSelectedWorkspaceFolder());
-			mProjectPanel.Sort();
-			mWorkspace.FixOptions();
-			mWorkspace.mHasChanged = true;
+			ProjectCreated(project);
 
-			mWorkspace.ClearProjectNameCache();
-			CurrentWorkspaceConfigChanged();
 			return project;
 		}
 
@@ -2517,6 +2611,8 @@ namespace IDE
 
 			mBookmarkManager.Clear();
 
+			mPackMan.CancelAll();
+
 			OutputLine("Workspace closed.");
 		}
 
@@ -2757,9 +2853,16 @@ namespace IDE
 				return .Err;
 		}
 
+		public void CheckDependenciesLoaded()
+		{
+			for (var project in mWorkspace.mProjects)
+				project.CheckDependenciesLoaded();
+		}
+
 		void FlushDeferredLoadProjects(bool addToUI = false)
 		{
 			bool hasDeferredProjects = false;
+			bool loadFailed = false;
 
 			while (true)
 			{
@@ -2767,11 +2870,29 @@ namespace IDE
 				for (int projectIdx = 0; projectIdx < mWorkspace.mProjects.Count; projectIdx++)
 				{
 					var project = mWorkspace.mProjects[projectIdx];
+
+					if (project.mDeferState == .Searching)
+					{
+						if (mPackMan.mFailed)
+						{
+							// Just let it fail now
+							LoadFailed();
+							project.mDeferState = .None;
+							project.mFailed = true;
+							loadFailed = true;
+						}
+						else
+						{
+							hasDeferredProjects = true;
+						}
+					}
+
 					if ((project.mDeferState == .ReadyToLoad) || (project.mDeferState == .Pending))
 					{
 						hadLoad = true;
 
 						var projectPath = project.mProjectPath;
+						
 						if (project.mDeferState == .Pending)
 						{
 							hasDeferredProjects = true;
@@ -2794,9 +2915,26 @@ namespace IDE
 			}
 
 			if (hasDeferredProjects)
+			{
 				mWorkspace.mProjectLoadState = .Preparing;
+			}
 			else
+			{
 				mWorkspace.mProjectLoadState = .Loaded;
+				SaveWorkspaceLockData();
+				CheckDependenciesLoaded();
+			}
+
+			if (loadFailed)
+			{
+				mProjectPanel.RebuildUI();
+			}
+		}
+
+		public void CancelWorkspaceLoading()
+		{
+			mPackMan.CancelAll();
+			FlushDeferredLoadProjects();
 		}
 
 		protected void LoadWorkspace(BeefVerb verb)
@@ -2937,6 +3075,7 @@ namespace IDE
 			}
 			else
 			{
+				LoadWorkspaceLockData();
 				mWorkspace.mProjectFileEntries.Add(new .(workspaceFileName));
 
 				if (mVerb == .New)
@@ -3044,9 +3183,10 @@ namespace IDE
 				outRelaunchCmd.Append(" -safe");
 		}
 
-		public void RetryProjectLoad(Project project)
+		public void RetryProjectLoad(Project project, bool reloadConfig)
 		{
-			LoadConfig();
+			if (reloadConfig)
+				LoadConfig();
 
 			var projectPath = project.mProjectPath;
 			if (!project.Load(projectPath))
@@ -3054,6 +3194,8 @@ namespace IDE
 				Fail(scope String()..AppendF("Failed to load project '{0}' from '{1}'", project.mProjectName, projectPath));
 				LoadFailed();
 				project.mFailed = true;
+				FlushDeferredLoadProjects();
+				mProjectPanel?.RebuildUI();
 			}
 			else
 			{
@@ -3061,7 +3203,7 @@ namespace IDE
 				mWorkspace.FixOptions();
 
 				project.mFailed = false;
-				mProjectPanel.RebuildUI();
+				mProjectPanel?.RebuildUI();
 				CurrentWorkspaceConfigChanged();
 			}
 		}
@@ -3080,7 +3222,17 @@ namespace IDE
 			String verConfigDir = mWorkspace.mDir;
 
 			if (let project = mWorkspace.FindProject(projectName))
+			{
+				switch (useVerSpec)
+				{
+				case .Git(let url, let ver):
+					if (ver != null)
+						mPackMan.UpdateGitConstraint(url, ver);
+				default:
+				}
+
 				return project;
+			}
 
 			if (useVerSpec case .SemVer)
 			{
@@ -3153,27 +3305,24 @@ namespace IDE
 			case .SemVer(let semVer):
 				//
 			case .Git(let url, let ver):
-				var verReference = new Project.VerReference();
-				verReference.mSrcProjectName = new String(projectName);
-				verReference.mVerSpec = _.Duplicate();
-				project.mVerReferences.Add(verReference);
-
+			
 				var checkPath = scope String();
 				if (mPackMan.CheckLock(projectName, checkPath))
 				{
 					projectFilePath = scope:: String(checkPath);
 				}
 				else
+				{
+					mPackMan.GetWithVersion(projectName, url, ver);
 					isDeferredLoad = true;
+				}
 			default:
 				Fail("Invalid version specifier");
 				return .Err(.InvalidVersionSpec);
 			}
 
 			if ((projectFilePath == null) && (!isDeferredLoad))
-			{
 				return .Err(.NotFound);
-			}
 
 			if (isDeferredLoad)
 			{
@@ -3197,6 +3346,59 @@ namespace IDE
 			return .Ok(project);
 		}
 
+		public void UpdateProjectVersionLocks(params Span<StringView> projectNames)
+		{
+			bool removedLock = false;
+
+			for (var projectName in projectNames)
+			{
+				if (var kv = gApp.mWorkspace.mProjectLockMap.GetAndRemoveAlt(projectName))
+				{
+					removedLock = true;
+					delete kv.key;
+					kv.value.Dispose();
+				}
+			}
+
+			if (removedLock)
+			{
+				if (SaveAll())
+				{
+					SaveWorkspaceLockData(true);
+					CloseOldBeefManaged();
+					ReloadWorkspace();
+				}
+			}
+		}
+
+		public void UpdateProjectVersionLocks(Span<String> projectNames)
+		{
+			List<StringView> svNames = scope .();
+			for (var name in projectNames)
+				svNames.Add(name);
+			UpdateProjectVersionLocks(params (Span<StringView>)svNames);
+		}
+
+		public void NotifyProjectVersionLocks(Span<String> projectNames)
+		{
+			if (projectNames.IsEmpty)
+				return;
+
+			String message = scope .();
+			message.Append((projectNames.Length == 1) ? "Project " : "Projects ");
+			for (var projectName in projectNames)
+			{
+				if (@projectName.Index > 0)
+					message.Append(", ");
+				message.AppendF($"'{projectName}'");
+			}
+
+			message.Append((projectNames.Length == 1) ? " has " : " have ");
+
+			message.AppendF("modified version constraints. Use 'Update Version Lock' in the project or workspace right-click menus to apply the new constraints.");
+			MessageDialog("Version Constraints Modified", message, DarkTheme.sDarkTheme.mIconWarning);
+		}
+
 		protected void WorkspaceLoaded()
 		{
 			scope AutoBeefPerf("IDE.WorkspaceLoaded");
@@ -3848,9 +4050,9 @@ namespace IDE
 			return dialog;
 		}
 
-		public void MessageDialog(String title, String text)
+		public void MessageDialog(String title, String text, Image icon = null)
 		{
-			Dialog dialog = ThemeFactory.mDefault.CreateDialog(title, text);
+			Dialog dialog = ThemeFactory.mDefault.CreateDialog(title, text, icon);
 			dialog.mDefaultButton = dialog.AddButton("OK");
 			dialog.mEscButton = dialog.mDefaultButton;
 			dialog.PopupWindow(mMainWindow);
@@ -7433,6 +7635,37 @@ namespace IDE
 				CloseDocument(activeDocumentPanel);
 		}
 
+		public void CloseOldBeefManaged()
+		{
+			List<SourceViewPanel> pendingClosePanels = scope .();
+			WithSourceViewPanels(scope (sourceViewPanel) =>
+				{
+					if (sourceViewPanel.mProjectSource != null)
+					{
+						var checkHash = gApp.mPackMan.GetHashFromFilePath(sourceViewPanel.mFilePath, .. scope .());
+						if (!checkHash.IsEmpty)
+						{
+							bool foundHash = false;
+
+							if (gApp.mWorkspace.mProjectLockMap.TryGet(sourceViewPanel.mProjectSource.mProject.mProjectName, ?, var lock))
+							{
+								if (lock case .Git(let url, let tag, let hash))
+								{
+									if (hash == checkHash)
+										foundHash = true;
+								}
+							}
+
+							if (!foundHash)
+								pendingClosePanels.Add(sourceViewPanel);
+						}
+					}
+				});
+
+			for (var sourceViewPanel in pendingClosePanels)
+				CloseDocument(sourceViewPanel);
+		}
+
 		public SourceViewPanel ShowProjectItem(ProjectItem projectItem, bool showTemp = true, bool setFocus = true)
 		{
 			if (projectItem is ProjectSource)
@@ -7845,7 +8078,7 @@ namespace IDE
 				case "-autoshutdown":
 					mDebugAutoShutdownCounter = 200;
 				case "-new":
-					mVerb = .New;
+					mVerb = .Open;
 				case "-testNoExit":
 					mExitWhenTestScriptDone = false;
 				case "-firstRun":
@@ -8090,15 +8323,16 @@ namespace IDE
 #endif
 		}
 
-		public virtual void OutputErrorLine(String format, params Object[] args)
+		public virtual void OutputErrorLine(StringView format, params Object[] args)
 		{
 			mWantShowOutput = true;
 			var errStr = scope String();
-			errStr.Append("ERROR: ", format);
+			errStr.Append("ERROR: ");
+			errStr.Append(format);
 			OutputLineSmart(errStr, params args);
 		}
 
-		public virtual void OutputWarnLine(String format, params Object[] args)
+		public virtual void OutputWarnLine(StringView format, params Object[] args)
 		{
 			var warnStr = scope String();
 			warnStr.AppendF(format, params args);
@@ -8113,7 +8347,7 @@ namespace IDE
 			OutputLine(outStr);
 		}
 
-		public virtual void OutputLineSmart(String format, params Object[] args)
+		public virtual void OutputLineSmart(StringView format, params Object[] args)
 		{
 			String outStr;
 			if (args.Count > 0)
@@ -10027,7 +10261,7 @@ namespace IDE
 #endif
 			}
 			mWorkspace.ClearProjectNameCache();
-			mProjectPanel.RehupProjects();
+			mProjectPanel?.RehupProjects();
 		}
 
 		/*public string GetClangDepConfigName(Project project)
@@ -10064,8 +10298,8 @@ namespace IDE
 					RemoveProjectItems(project);
 				}
 
-				mBfResolveCompiler.QueueDeferredResolveAll();
-				mBfResolveCompiler.QueueRefreshViewCommand(.FullRefresh);
+				mBfResolveCompiler?.QueueDeferredResolveAll();
+				mBfResolveCompiler?.QueueRefreshViewCommand(.FullRefresh);
 				return;
 			}
 
@@ -10093,11 +10327,14 @@ namespace IDE
 		{
 			mWantsBeefClean = true;
 
+			var checkDeclName = (project.mProjectName !== project.mProjectNameDecl) && (mWorkspace.FindProject(project.mProjectNameDecl) == project);
+
 			for (var checkProject in mWorkspace.mProjects)
 			{
 				for (var dep in checkProject.mDependencies)
 				{
-					if (dep.mProjectName == project.mProjectName)
+					if ((dep.mProjectName == project.mProjectName) ||
+						((checkDeclName) && (dep.mProjectName == project.mProjectNameDecl)))
 					{
 						dep.mProjectName.Set(newName);
 						checkProject.SetChanged();
@@ -10115,14 +10352,27 @@ namespace IDE
 			}
 
 			project.mProjectName.Set(newName);
+			if (project.mProjectNameDecl != project.mProjectName)
+				delete project.mProjectNameDecl;
+			project.mProjectNameDecl = project.mProjectName;
 			project.SetChanged();
 			mWorkspace.ClearProjectNameCache();
+
+			mProjectPanel.RebuildUI();
 		}
 
 		public void RemoveProject(Project project)
 		{
 			RemoveProjectItems(project);
 
+			if (mWorkspace.mProjectLockMap.GetAndRemove(project.mProjectName) case .Ok(let kv))
+			{
+				delete kv.key;
+				kv.value.Dispose();
+				if (mWorkspace.mProjectLockMap.IsEmpty)
+					SaveWorkspaceLockData(true);
+			}
+
 			project.mDeleted = true;
 			mWorkspace.SetChanged();
 			mWorkspace.mProjects.Remove(project);
@@ -10911,7 +11161,7 @@ namespace IDE
 			}
 		}
 
-		static void QuoteIfNeeded(String str)
+		protected static void QuoteIfNeeded(String str)
 		{
 			if (!str.Contains(' '))
 				return;
@@ -12398,6 +12648,8 @@ namespace IDE
 			base.Init();
 			mSettings.Apply();
 
+			mGitManager.Init();
+
 			//Yoop();
 
 			/*for (int i = 0; i < 100*1024*1024; i++)
@@ -14865,6 +15117,8 @@ namespace IDE
 			if (mLongUpdateProfileId != 0)
 				DoLongUpdateCheck();
 
+			mGitManager.Update();
+			mPackMan.Update();
 			if (mWakaTime != null)
 				mWakaTime.Update();
 			if (mFindResultsPanel != null)
@@ -15053,7 +15307,6 @@ namespace IDE
 		[Import("user32.lib"), CLink, CallingConvention(.Stdcall)]
 		public static extern bool MessageBeep(MessageBeepType type);
 #endif
-
 	}
 
 	static

+ 24 - 0
IDE/src/IDEUtils.bf

@@ -9,6 +9,7 @@ using Beefy;
 using Beefy.gfx;
 using Beefy.theme.dark;
 using IDE.ui;
+using System.Diagnostics;
 
 namespace IDE
 {
@@ -128,6 +129,29 @@ namespace IDE
 			return true;
         }
 
+		public static void SafeKill(int processId)
+		{
+			var beefConExe = scope $"{gApp.mInstallDir}/BeefCon.exe";
+
+			ProcessStartInfo procInfo = scope ProcessStartInfo();
+			procInfo.UseShellExecute = false;
+			procInfo.SetFileName(beefConExe);
+			procInfo.SetArguments(scope $"{processId} kill");
+			procInfo.ActivateWindow = false;
+
+			var process = scope SpawnedProcess();
+			process.Start(procInfo).IgnoreError();
+		}
+
+		public static void SafeKill(SpawnedProcess process)
+		{
+			if (process.WaitFor(0))
+				return;
+			SafeKill(process.ProcessId);
+			if (!process.WaitFor(2000))
+				process.Kill();
+		}
+
 		public static bool IsDirectoryEmpty(StringView dirPath)
 		{
 			for (let entry in Directory.Enumerate(scope String()..AppendF("{}/*.*", dirPath), .Directories | .Files))

+ 102 - 11
IDE/src/Project.bf

@@ -1091,9 +1091,13 @@ namespace IDE
         public class GeneralOptions
         {
 			[Reflect]
+			public String mProjectNameDecl; // Points to mProjectNameDecl in Project
+			[Reflect]
             public TargetType mTargetType;
 			[Reflect]
 			public List<String> mAliases = new .() ~ DeleteContainerAndItems!(_);
+			[Reflect]
+			public SemVer mVersion = new SemVer("") ~ delete _;
         }
 
 		public class BeefGlobalOptions
@@ -1321,6 +1325,7 @@ namespace IDE
 		{
 			public VerSpec mVerSpec ~ _.Dispose();
 			public String mProjectName ~ delete _;
+			public bool mDependencyChecked;
 		}
 
 		public enum DeferState
@@ -1337,13 +1342,20 @@ namespace IDE
 			public VerSpec mVerSpec ~ _.Dispose();
 		}
 
+		public class ManagedInfo
+		{
+			public SemVer mVersion = new .("") ~ delete _;
+			public String mInfo ~ delete _;
+		}
+
 		public Monitor mMonitor = new Monitor() ~ delete _;
         public String mNamespace = new String() ~ delete _;
         public String mProjectDir = new String() ~ delete _;
         public String mProjectName = new String() ~ delete _;
+		public String mProjectNameDecl = mProjectName ~ { if (mProjectNameDecl != mProjectName) delete _; }
         public String mProjectPath = new String() ~ delete _;
+		public ManagedInfo mManagedInfo ~ delete _;
 		public DeferState mDeferState;
-		public List<VerReference> mVerReferences = new .() ~ DeleteContainerAndItems!(_);
 
         //public String mLastImportDir = new String() ~ delete _;
         public bool mHasChanged;
@@ -1410,6 +1422,16 @@ namespace IDE
 			}
 		}
 
+		public SemVer Version
+		{
+			get
+			{
+				if (mManagedInfo != null)
+					return mManagedInfo.mVersion;
+				return mGeneralOptions.mVersion;
+			}
+		}
+
 		void SetupDefaultOptions(Options options)
 		{
 			options.mBuildOptions.mOtherLinkFlags.Set("$(LinkFlags)");
@@ -1443,6 +1465,7 @@ namespace IDE
 				mGeneralOptions.mTargetType = .CustomBuild;
 			SetupDefaultConfigs();
 
+			mGeneralOptions.mProjectNameDecl = mProjectNameDecl;
 			mBeefGlobalOptions.mStartupObject.Set("Program");
         }
 
@@ -1526,6 +1549,21 @@ namespace IDE
 				Path.GetDirectoryPath(mProjectPath, mProjectDir);
 	            if (structuredData.Load(ProjectFileName) case .Err)
 	                return false;
+
+				String managedText = scope .();
+				if (File.ReadAllText(scope $"{mProjectDir}/BeefManaged.toml", managedText) case .Ok)
+				{
+					mManagedInfo = new .();
+					mManagedInfo.mInfo = new .(managedText);
+
+					StructuredData msd = scope .();
+					if (msd.LoadFromString(managedText) case .Ok)
+					{
+						if (mManagedInfo.mVersion.mVersion == null)
+							mManagedInfo.mVersion.mVersion = new .();
+						msd.GetString("Version", mManagedInfo.mVersion.mVersion);
+					}
+				}
 			}
 			else
 			{
@@ -1615,7 +1653,8 @@ namespace IDE
 			using (data.CreateObject("Project"))
 			{
 				if (!IsSingleFile)
-					data.Add("Name", mProjectName);
+					data.Add("Name", mProjectNameDecl);
+				data.ConditionalAdd("Version", mGeneralOptions.mVersion.mVersion);
 				data.ConditionalAdd("TargetType", mGeneralOptions.mTargetType, GetDefaultTargetType());
 				data.ConditionalAdd("StartupObject", mBeefGlobalOptions.mStartupObject, IsSingleFile ? "Program" : "");
 				var defaultNamespace = scope String();
@@ -1955,7 +1994,24 @@ namespace IDE
 			using (data.Open("Project"))
 			{
 				if (!IsSingleFile)
-					data.GetString("Name", mProjectName);
+				{
+					var projectName = data.GetString("Name", .. scope .());
+					if ((!mProjectName.IsEmpty) && (projectName != mProjectName))
+					{
+						// If the name we specified clashes with the delclared project name in the config
+						if (mProjectNameDecl === mProjectName)
+						{
+							mProjectNameDecl = new .(projectName);
+							mGeneralOptions.mProjectNameDecl = mProjectNameDecl;
+							gApp.mWorkspace.ClearProjectNameCache();
+						}
+						else
+							mProjectNameDecl.Set(projectName);
+					}
+					else
+						mProjectName.Set(projectName);
+				}
+				data.GetString("Version", mGeneralOptions.mVersion.mVersion);
 				ReadStrings("Aliases", mGeneralOptions.mAliases);
 				data.GetString("StartupObject", mBeefGlobalOptions.mStartupObject, IsSingleFile ? "Program" : "");
 				var defaultNamespace = scope String();
@@ -2029,7 +2085,7 @@ namespace IDE
 				{
 				case .Ok(let project):
 				case .Err(let err):
-					gApp.OutputLineSmart("ERROR: Unable to load project '{0}' specified in project '{1}'", dep.mProjectName, mProjectName);
+					// Give an error later
 				}
 			}
 
@@ -2232,6 +2288,35 @@ namespace IDE
 			mRootFolder.StartWatching();
         }
 
+		public void CheckDependenciesLoaded()
+		{
+			for (var dep in mDependencies)
+			{
+				if (!dep.mDependencyChecked)
+				{
+					var project = gApp.mWorkspace.FindProject(dep.mProjectName);
+					if (project != null)
+					{
+						var projectVersion = project.Version;
+						if (!projectVersion.mVersion.IsEmpty)
+						{
+							if (dep.mVerSpec case .Git(let url, let ver))
+							{
+								if (!SemVer.IsVersionMatch(projectVersion, ver))
+									gApp.OutputLineSmart($"WARNING: Project '{mProjectName}' has version constraint '{ver}' for '{dep.mProjectName}' which is not satisfied by selected version '{projectVersion}'");
+							}
+						}
+					}
+					else
+					{
+						gApp.OutputLineSmart("ERROR: Unable to load project '{0}' specified in project '{1}'", dep.mProjectName, mProjectName);
+					}
+
+					dep.mDependencyChecked = true;
+				}
+			}
+		}
+
 		public void FinishCreate(bool allowCreateDir = true)
 		{
 			if (!mRootFolder.mIsWatching)
@@ -2414,29 +2499,35 @@ namespace IDE
             }
         }
 
-        public bool HasDependency(String projectName, bool checkRecursively = true)
+        public VerSpec* GetDependency(String projectName, bool checkRecursively = true)
         {
 			HashSet<Project> checkedProject = scope .();
 
-			bool CheckDependency(Project project)
+			VerSpec* CheckDependency(Project project)
 			{
 				if (!checkedProject.Add(project))
-					return false;
+					return null;
 
 				for (var dependency in project.mDependencies)
 				{
 				    if (dependency.mProjectName == projectName)
-				        return true;
+				        return &dependency.mVerSpec;
 					let depProject = gApp.mWorkspace.FindProject(dependency.mProjectName);
-					if ((depProject != null) && (checkRecursively) && (CheckDependency(depProject)))
-						return true;
+					if ((depProject != null) && (checkRecursively))
+					{
+						var verSpec = CheckDependency(depProject);
+						if (verSpec != null)
+							return verSpec;
+					}
 				}
-				return false;
+				return null;
 			}
 
             return CheckDependency(this);
         }
 
+		public bool HasDependency(String projectName, bool checkRecursively = true) => GetDependency(projectName, checkRecursively) != null;
+
 		public void SetupDefault(Options options, String configName, String platformName)
 		{
 			bool isRelease = configName.Contains("Release");

+ 65 - 22
IDE/src/Workspace.bf

@@ -223,7 +223,8 @@ namespace IDE
 			None,
 			Loaded,
 			ReadyToLoad,
-			Preparing
+			Preparing,
+			Failed
 		}
 
         public class ConfigSelection : IHashable, IEquatable
@@ -488,26 +489,25 @@ namespace IDE
 			public VerSpec mVerSpec ~ _.Dispose();
 		}
 
-		public class Lock
+		public enum Lock
 		{
-			public enum Location
-			{
-				case Cache;
-				case Local(String path);
+			case Cache;
+			case Local(String path);
+			case Git(String url, String tag, String hash);
 
-				public void Dispose()
+			public void Dispose()
+			{
+				switch (this)
 				{
-					switch (this)
-					{
-					case .Cache:
-					case .Local(let path):
-						delete path;
-					}
+				case .Cache:
+				case .Local(let path):
+					delete path;
+				case Git(var url, var tag, var hash):
+					delete url;
+					delete tag;
+					delete hash;
 				}
 			}
-
-			public String mVersion ~ delete _;
-			public Location mLocation ~ _.Dispose(); 
 		}
 
 		public Monitor mMonitor = new Monitor() ~ delete _;
@@ -519,7 +519,15 @@ namespace IDE
 		public List<ProjectSpec> mProjectSpecs = new .() ~ DeleteContainerAndItems!(_);
 		public List<ProjectFileEntry> mProjectFileEntries = new .() ~ DeleteContainerAndItems!(_);
 		public Dictionary<String, Project> mProjectNameMap = new .() ~ DeleteDictionaryAndKeys!(_);
-		public Dictionary<String, Lock> mProjectLockMap = new .() ~ DeleteDictionaryAndKeysAndValues!(_);
+		public Dictionary<String, Lock> mProjectLockMap = new .() ~
+			{
+				for (var kv in ref _)
+				{
+					delete kv.key;
+					kv.valueRef.Dispose();
+				}
+				delete _;
+			}
         public Project mStartupProject;
 		public bool mLoading;
 		public bool mNeedsCreate;
@@ -578,6 +586,20 @@ namespace IDE
 			ClearAndDeleteItems(mPlatforms);
 		}
 
+		public void SetLock(StringView projectName, Lock lock)
+		{
+			if (mProjectLockMap.TryAddAlt(projectName, var keyPtr, var valuePtr))
+			{
+				*keyPtr = new .(projectName);
+				*valuePtr = lock;
+			}
+			else
+			{
+				valuePtr.Dispose();
+				*valuePtr = lock;
+			}
+		}
+
 		public void GetPlatformList(List<String> outList)
 		{
 			if (mPlatforms.IsEmpty)
@@ -947,16 +969,33 @@ namespace IDE
 		{
 			using (mMonitor.Enter())
 			{
+				int GetNamePriority(StringView name, Project project)
+				{
+					if (project.mProjectName == name)
+						return 2;
+					if (project.mProjectNameDecl == name)
+						return 1;
+					return 0;
+				}
+
 				void Add(String name, Project project)
 				{
-					bool added = mProjectNameMap.TryAdd(name, var keyPtr, var valuePtr);
-					if (!added)
-						return;
-					*keyPtr = new String(name);
-					*valuePtr = project;
+					if (mProjectNameMap.TryAdd(name, var keyPtr, var valuePtr))
+					{
+						*keyPtr = new String(name);
+						*valuePtr = project;
+					}
+					else
+					{
+						if (GetNamePriority(name, project) > GetNamePriority(*keyPtr, *valuePtr))
+							*valuePtr = project;
+					}
 				}
 	
 				Add(project.mProjectName, project);
+
+				if (project.mProjectNameDecl !== project.mProjectName)
+					Add(project.mProjectNameDecl, project);
 	
 				for (var alias in project.mGeneralOptions.mAliases)
 					Add(alias, project);
@@ -979,7 +1018,11 @@ namespace IDE
 					}
 
 					for (var project in mProjects)
+					{
 						Add(project.mProjectName, project);
+						if (project.mProjectName != project.mProjectNameDecl)
+							Add(project.mProjectNameDecl, project);
+					}
 					
 					for (var project in mProjects)
 					{

+ 81 - 0
IDE/src/ui/BuildPropertiesDialog.bf

@@ -10,6 +10,87 @@ namespace IDE.ui
 
 	class BuildPropertiesDialog : TargetedPropertiesDialog
 	{
+		protected class DependencyEntry : IEquatable, IMultiValued
+		{
+			public bool mUse;
+			public String mURL ~ delete _;
+			public String mVersion ~ delete _;
+
+			public this()
+			{
+
+			}
+
+			public ~this()
+			{
+			}
+
+			public this(DependencyEntry val)
+			{
+				mUse = val.mUse;
+				if (val.mURL != null)
+					mURL = new .(val.mURL);
+				if (val.mVersion != null)
+					mVersion = new .(val.mVersion);
+			}
+
+			public bool Equals(Object val)
+			{
+				if (var rhsDE = val as DependencyEntry)
+				{
+					return
+						(mUse == rhsDE.mUse) &&
+						(mURL == rhsDE.mURL) &&
+						(mVersion == rhsDE.mVersion);
+				}
+				return false;
+			}
+
+			public void GetValue(int idx, String outValue)
+			{
+				if ((idx == 1) && (mURL != null))
+					outValue.Set(mURL);
+				if ((idx == 2) && (mVersion != null))
+					outValue.Set(mVersion);
+			}
+
+			public bool SetValue(int idx, StringView value)
+			{
+				if (idx == 1)
+				{
+					if (value.IsEmpty)
+					{
+						DeleteAndNullify!(mURL);
+					}
+					else
+					{
+						String.NewOrSet!(mURL, value);
+						mURL.Trim();
+					}
+				}
+				if (idx == 2)
+				{
+					if (value.IsEmpty)
+					{
+						DeleteAndNullify!(mVersion);
+					}
+					else
+					{
+						String.NewOrSet!(mVersion, value);
+						mVersion.Trim();
+					}
+				}
+				return true;
+			}
+
+			public void Set(DependencyEntry value)
+			{
+				mUse = value.mUse;
+				SetValue(1, value.mURL);
+				SetValue(2, value.mVersion);
+			}
+		}
+
 		protected class DistinctOptionBuilder
 		{
 			BuildPropertiesDialog mDialog;

+ 48 - 1
IDE/src/ui/ProjectPanel.bf

@@ -145,6 +145,7 @@ namespace IDE.ui
         bool mImportFolderDeferred;
         bool mImportProjectDeferred;
 		bool mImportInstalledDeferred;
+		bool mImportRemoteDeferred;
         public Dictionary<ListViewItem, ProjectItem> mListViewToProjectMap = new .() ~ delete _;
         public Dictionary<ProjectItem, ProjectListViewItem> mProjectToListViewMap = new .() ~ delete _;
 		public Dictionary<ListViewItem, WorkspaceFolder> mListViewToWorkspaceFolderMap = new .() ~ delete _;
@@ -2904,6 +2905,15 @@ namespace IDE.ui
 #endif
 		}
 
+		void ImportRemoteProject()
+		{
+#if !CLI
+			RemoteProjectDialog dialog = new .();
+			dialog.Init();
+			dialog.PopupWindow(gApp.mMainWindow);
+#endif
+		}
+
         public void ShowProjectProperties(Project project)
         {
             var projectProperties = new ProjectProperties(project);
@@ -3081,6 +3091,14 @@ namespace IDE.ui
 					});
 					if (gApp.IsCompiling)
 						anItem.SetDisabled(true);
+
+					anItem = menu.AddItem("Add From Remote...");
+					anItem.mOnMenuItemSelected.Add(new (item) => {
+						mImportRemoteDeferred = true;
+					});
+					if (gApp.IsCompiling)
+						anItem.SetDisabled(true);
+
 					anItem = menu.AddItem("New Folder");
 					anItem.mOnMenuItemSelected.Add(new (item) => {
 						var workspaceFolder = GetSelectedWorkspaceFolder();
@@ -3110,7 +3128,18 @@ namespace IDE.ui
 				}
 				else if (gApp.mWorkspace.IsInitialized)
 				{
+					var item = menu.AddItem("Update Version Locks");
+					item.mDisabled = gApp.mWorkspace.mProjectLockMap.IsEmpty;
+					item.mOnMenuItemSelected.Add(new (item) =>
+					    {
+							List<StringView> projectNames = scope .();
+							for (var projectName in gApp.mWorkspace.mProjectLockMap.Keys)
+								projectNames.Add(projectName);
+							gApp.UpdateProjectVersionLocks(params (Span<StringView>)projectNames);
+					    });
+
 					AddOpenContainingFolder();
+
 					menu.AddItem();
 
 	                AddWorkspaceMenuItems();
@@ -3140,7 +3169,7 @@ namespace IDE.ui
 							    {
 									var projectItem = GetSelectedProjectItem();
 									if (projectItem != null)
-							        	gApp.RetryProjectLoad(projectItem.mProject);
+							        	gApp.RetryProjectLoad(projectItem.mProject, true);
 							    });
 							menu.AddItem();
 							//handled = true;
@@ -3168,6 +3197,18 @@ namespace IDE.ui
 									SetAsStartupProject(projectItem.mProject);
 						    });
 
+						item = menu.AddItem("Update Version Lock");
+						item.mDisabled = (projectItem == null) || (!gApp.mWorkspace.mProjectLockMap.ContainsKey(projectItem.mProject.mProjectName));
+						item.mOnMenuItemSelected.Add(new (item) =>
+						    {
+								var projectItem = GetSelectedProjectItem();
+								if (projectItem != null)
+								{
+									let project = projectItem.mProject;
+									gApp.UpdateProjectVersionLocks(project.mProjectName);
+								}
+						    });
+
 						item = menu.AddItem("Lock Project");
 						if (projectItem.mProject.mLocked)
 							item.mIconImage = DarkTheme.sDarkTheme.GetImage(.Check);
@@ -3570,6 +3611,12 @@ namespace IDE.ui
 				ImportInstalledProject();
 			}
 
+			if (mImportRemoteDeferred)
+			{
+				mImportRemoteDeferred= false;
+				ImportRemoteProject();
+			}
+
 			ValidateCutClipboard();
         }
 

+ 198 - 26
IDE/src/ui/ProjectProperties.bf

@@ -10,13 +10,12 @@ using Beefy.events;
 using Beefy.theme.dark;
 using Beefy.gfx;
 using Beefy.geom;
+using IDE.Util;
 
 namespace IDE.ui
 {
     public class ProjectProperties : BuildPropertiesDialog
     {   
-		ValueContainer<String> mVC;
-
         enum CategoryType
         {
 			General, ///
@@ -27,6 +26,7 @@ namespace IDE.ui
             Dependencies,
 			Beef_Global,
 			Platform,
+			Managed,
 
 			Targeted, ///
 			Beef_Targeted,
@@ -36,10 +36,11 @@ namespace IDE.ui
 
             COUNT
         }
-        
+
         public Project mProject;
-        Dictionary<String, ValueContainer<bool>> mDependencyValuesMap ~ DeleteDictionaryAndKeysAndValues!(_);
+        Dictionary<String, DependencyEntry> mDependencyValuesMap ~ DeleteDictionaryAndKeysAndValues!(_);
 		Project.Options[] mCurProjectOptions ~ delete _;
+		List<String> mUpdateProjectLocks = new .() ~ DeleteContainerAndItems!(_);
 		float mLockFlashPct;
 		public int32 mNewDebugSessionCountdown;
         
@@ -96,6 +97,7 @@ namespace IDE.ui
 			AddCategoryItem(globalItem, "Dependencies");
 			AddCategoryItem(globalItem, "Beef");
 			AddCategoryItem(globalItem, "Platform");
+			AddCategoryItem(globalItem, "Managed");
 			globalItem.Open(true, true);
 
 			var targetedItem = AddCategoryItem(root, "Targeted");
@@ -149,7 +151,8 @@ namespace IDE.ui
 			case .General,
 				 .Project,
 				 .Dependencies,
-				 .Beef_Global:
+				 .Beef_Global,
+				 .Managed:
 				return .None;
 			case .Platform:
 				return .Platform;
@@ -432,6 +435,7 @@ namespace IDE.ui
 					default:
 					}
 				}
+			case .Managed:
 			case .Build, .Debugging, .Beef_Targeted:
 				DeleteDistinctBuildOptions();
 				DistinctBuildOptions defaultTypeOptions = scope:: .();
@@ -530,7 +534,9 @@ namespace IDE.ui
 			else
 			{
 				mCurPropertiesTargets = new Object[1];
-				if (categoryType == .Project)
+				if (categoryType == .Managed)
+					mCurPropertiesTargets[0] = mProject.mManagedInfo;
+				else if (categoryType == .Project)
 					mCurPropertiesTargets[0] = mProject.mGeneralOptions;
 				else if (categoryType == .Beef_Global)
 					mCurPropertiesTargets[0] = mProject.mBeefGlobalOptions;
@@ -600,6 +606,8 @@ namespace IDE.ui
 						}
 					}
 				}
+				else if (categoryType == CategoryType.Managed)
+					PopulateManagedOptions();
 				else if (categoryType == CategoryType.Build)
 					PopulateBuildOptions();
 				else if (categoryType == CategoryType.Beef_Global				)
@@ -619,6 +627,8 @@ namespace IDE.ui
         void PopulateGeneralOptions()
         {
             var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
+			var (listViewItem, propEntry) = AddPropertiesItem(root, "Project Name", "mProjectNameDecl");
+			AddPropertiesItem(root, "Project Name Aliases", "mAliases");
 			AddPropertiesItem(root, "Target Type", "mTargetType", scope String[]
 				(
 					"Console Application",
@@ -627,9 +637,21 @@ namespace IDE.ui
 					"Custom Build",
 					"Test"
 				));
-			AddPropertiesItem(root, "Project Name Aliases", "mAliases");
+			AddPropertiesItem(root, "Version", "mVersion.mVersion");
         }
 
+		void PopulateManagedOptions()
+		{
+			if (mCurPropertiesTargets[0] == null)
+				return;
+			var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
+			var (listViewItem, propEntry) = AddPropertiesItem(root, "Version", "mVersion.mVersion");
+			propEntry.mReadOnly = true;
+			(listViewItem, propEntry) = AddPropertiesItem(root, "Info", "mInfo");
+			propEntry.mAllowMultiline = true;
+			propEntry.mReadOnly = true;
+		}
+
 		void PopulateWindowsOptions()
 		{
 			var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
@@ -696,7 +718,21 @@ namespace IDE.ui
 
         void PopulateDependencyOptions()
         {
-            mDependencyValuesMap = new Dictionary<String, ValueContainer<bool>>();
+			mPropPage.mPropertiesListView.mColumns[0].Label = "Project";
+			mPropPage.mPropertiesListView.mColumns[0].mMinWidth = GS!(100);
+			mPropPage.mPropertiesListView.mColumns[0].mWidth = GS!(180);
+
+			mPropPage.mPropertiesListView.mColumns[1].Label = "";
+			mPropPage.mPropertiesListView.mColumns[1].mMinWidth = GS!(20);
+			mPropPage.mPropertiesListView.mColumns[1].mWidth = GS!(20);
+
+			mPropPage.mPropertiesListView.AddColumn(180, "Remote URL");
+			mPropPage.mPropertiesListView.mColumns[2].mMinWidth = GS!(100);
+
+			mPropPage.mPropertiesListView.AddColumn(180, "Ver Constraint");
+			mPropPage.mPropertiesListView.mColumns[3].mMinWidth = GS!(100);
+
+            mDependencyValuesMap = new .();
 
             var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
             var category = root;
@@ -719,63 +755,184 @@ namespace IDE.ui
             projectNames.Sort(scope (a, b) => String.Compare(a, b, true));
 
             for (var projectName in projectNames)
-            {                
-                var dependencyContainer = new ValueContainer<bool>();
-                dependencyContainer.mValue = mProject.HasDependency(projectName, false);
-                mDependencyValuesMap[new String(projectName)] = dependencyContainer;
+            {
+				var project = gApp.mWorkspace.FindProject(projectName);
+
+                var dependencyEntry = new DependencyEntry();
+				var verSpec = mProject.GetDependency(projectName, false);
+				if (verSpec != null)
+				{
+	                dependencyEntry.mUse = true;
+					if (verSpec case .Git(let url, let ver))
+					{
+						dependencyEntry.mURL = new .(url);
+						if (ver != null)
+							dependencyEntry.mVersion = new .(ver.mVersion);
+					}
+				}
+                mDependencyValuesMap[new String(projectName)] = dependencyEntry;
                 
                 var (listViewItem, propItem) = AddPropertiesItem(category, projectName);
                 if (IDEApp.sApp.mWorkspace.FindProject(projectName) == null)
                     listViewItem.mTextColor = Color.Mult(DarkTheme.COLOR_TEXT, 0xFFFF6060);
 
-                var subItem = listViewItem.CreateSubItem(1);
+                var subItem = (DarkListViewItem)listViewItem.CreateSubItem(1);
 
                 var checkbox = new DarkCheckBox();
-                checkbox.Checked = dependencyContainer.mValue;
+                checkbox.Checked = dependencyEntry.mUse;
                 checkbox.Resize(0, 0, DarkTheme.sUnitSize, DarkTheme.sUnitSize);
                 subItem.AddWidget(checkbox);
 
 				PropEntry[] propEntries = new PropEntry[1];
 
                 PropEntry propEntry = new PropEntry();
-                propEntry.mTarget = dependencyContainer;
-                //propEntry.mFieldInfo = dependencyContainer.GetType().GetField("mValue").Value;
-                propEntry.mOrigValue = Variant.Create(dependencyContainer.mValue);
-                propEntry.mCurValue = propEntry.mOrigValue;
+                propEntry.mTarget = dependencyEntry;
+                propEntry.mOrigValue = Variant.Create(dependencyEntry);
+                propEntry.mCurValue = Variant.Create(new DependencyEntry(dependencyEntry), true);
 				
                 propEntry.mListViewItem = listViewItem;
                 propEntry.mCheckBox = checkbox;
 				propEntry.mApplyAction = new () =>
 					{
-						if (propEntry.mCurValue.Get<bool>())
+						bool updateProjectLock = false;
+
+						var dependencyEntry = propEntry.mCurValue.Get<DependencyEntry>();
+						if (dependencyEntry.mUse)
 						{
-							if (!mProject.HasDependency(listViewItem.mLabel))
+							VerSpec verSpec = default;
+							if (dependencyEntry.mURL != null)
+								verSpec = .Git(new .(dependencyEntry.mURL), (dependencyEntry.mVersion != null) ? new .(dependencyEntry.mVersion) : null);
+							else if (dependencyEntry.mVersion != null)
+								verSpec = .SemVer(new .(dependencyEntry.mVersion));
+							else
+								verSpec = .SemVer(new .("*"));
+
+							var verSpecPtr = mProject.GetDependency(listViewItem.mLabel);
+							if (verSpecPtr == null)
 							{
+								if (verSpec case .Git(let url, let ver))
+									updateProjectLock = true;
+
 								var dep = new Project.Dependency();
 								dep.mProjectName = new String(listViewItem.mLabel);
-								dep.mVerSpec = .SemVer(new .("*"));
+								dep.mVerSpec = verSpec;
 								mProject.mDependencies.Add(dep);
 							}
+							else
+							{
+								if (*verSpecPtr != verSpec)
+								{
+									if ((*verSpecPtr case .Git) ||
+										(verSpecPtr case .Git))
+										updateProjectLock = true;
+									verSpecPtr.Dispose();
+									*verSpecPtr = verSpec;
+								}
+							}
 						}
 						else
 						{
 							int idx = mProject.mDependencies.FindIndex(scope (dep) => dep.mProjectName == listViewItem.mLabel);
 							if (idx != -1)
 							{
+								var dep = mProject.mDependencies[idx];
+								if (dep.mVerSpec case .Git)
+									updateProjectLock = true;
 								delete mProject.mDependencies[idx];
 								mProject.mDependencies.RemoveAt(idx);
 							}							
 						}
-						propEntry.mOrigValue = propEntry.mCurValue;
+
+						var origDependencyEntry = propEntry.mOrigValue.Get<DependencyEntry>();
+						origDependencyEntry.Set(dependencyEntry);
+
+						if (updateProjectLock)
+							mUpdateProjectLocks.Add(new .(listViewItem.Label));
 					};
 
-                checkbox.mOnMouseUp.Add(new (evt) => { PropEntry.DisposeVariant(ref propEntry.mCurValue); propEntry.mCurValue = Variant.Create(checkbox.Checked); });
+                checkbox.mOnMouseUp.Add(new (evt) =>
+					{
+						var dependencyEntry = propEntry.mCurValue.Get<DependencyEntry>();
+						dependencyEntry.mUse = !dependencyEntry.mUse;
+						if (dependencyEntry.mUse)
+						{
+							var projectName = listViewItem.Label;
+
+							for (var projectSpec in gApp.mWorkspace.mProjectSpecs)
+							{
+								if (projectSpec.mProjectName == projectName)
+								{
+									if (projectSpec.mVerSpec case .Git(let url, let ver))
+									{
+										dependencyEntry.SetValue(1, url);
+										dependencyEntry.SetValue(2, ver.mVersion);
+									}
+								}
+							}
+							var propEntries = mPropPage.mPropEntries[listViewItem];
+							UpdatePropertyValue(propEntries);
+						}
+						else
+						{
+							DeleteAndNullify!(dependencyEntry.mURL);
+							DeleteAndNullify!(dependencyEntry.mVersion);
+							var propEntries = mPropPage.mPropEntries[listViewItem];
+							UpdatePropertyValue(propEntries);
+						}
+
+					});
+
+
+				subItem = (.)listViewItem.GetOrCreateSubItem(2);
+				if (dependencyEntry.mURL != null)
+					subItem.Label = dependencyEntry.mURL;
+				subItem.mOnMouseDown.Add(new => DepPropValueClicked);
+
+				subItem = (.)listViewItem.GetOrCreateSubItem(3);
+				if (dependencyEntry.mVersion != null)
+				{
+					subItem.Label = dependencyEntry.mVersion;
+					if (project != null)
+					{
+						var version = project.Version;
+						if (!version.IsEmpty)
+						{
+							if (!SemVer.IsVersionMatch(version.mVersion, dependencyEntry.mVersion))
+								subItem.mTextColor = Color.Mult(DarkTheme.COLOR_TEXT, 0xFFFFFF60);
+						}
+					}
+				}
+				subItem.mOnMouseDown.Add(new => DepPropValueClicked);
 
 				propEntries[0] = propEntry;
                 mPropPage.mPropEntries[listViewItem] = propEntries;
             }            
         }
 
+		protected void DepPropValueClicked(MouseEvent theEvent)
+		{
+		    DarkListViewItem clickedItem = (DarkListViewItem)theEvent.mSender;
+			if (clickedItem.mColumnIdx == 0)
+			{
+				clickedItem.mListView.SetFocus();
+				clickedItem.mListView.GetRoot().SelectItemExclusively(clickedItem);
+				return;
+			}
+
+			if (theEvent.mX != -1)
+			{
+				clickedItem.mListView.GetRoot().SelectItemExclusively(null);
+			}
+
+		    DarkListViewItem item = (DarkListViewItem)clickedItem;
+			DarkListViewItem rootItem = (DarkListViewItem)clickedItem.GetSubItem(0);
+
+		    PropEntry[] propertyEntries = mPropPage.mPropEntries[rootItem];
+			if (propertyEntries[0].mDisabled)
+				return;
+		    EditValue(item, propertyEntries, clickedItem.mColumnIdx - 1);
+		}
+
 		protected override Object[] PhysAddNewDistinctBuildOptions()
 		{
 			if (mCurProjectOptions == null)
@@ -985,6 +1142,8 @@ namespace IDE.ui
             /*if (!AssertNotCompilingOrRunning())
                 return false;*/
 
+			String newProjectName = scope .();
+
             using (mProject.mMonitor.Enter())
             {
                 for (var targetedConfigData in mConfigDatas)
@@ -1000,9 +1159,18 @@ namespace IDE.ui
 							for (var propEntry in propEntries)
 							{
 	                            if (propEntry.HasChanged())
-	                            {									
-	                                configDataHadChange = true;
-	                                propEntry.ApplyValue();
+	                            {
+									if ((propEntry.mFieldInfo != default) && (propEntry.mFieldInfo.Name == "mProjectNameDecl"))
+									{
+										var newName = propEntry.mCurValue.Get<String>();
+										newProjectName.Append(newName);
+										newProjectName.Trim();
+									}
+									else
+									{
+		                                configDataHadChange = true;
+		                                propEntry.ApplyValue();
+									}
 	                            }
 							}
 							if (propPage == mPropPage)
@@ -1060,6 +1228,9 @@ namespace IDE.ui
 				ClearTargetedData();
 			}
 
+			if (!newProjectName.IsEmpty)
+				gApp.RenameProject(mProject, newProjectName);
+
             return true;
         }
 
@@ -1081,6 +1252,7 @@ namespace IDE.ui
 		{
 			base.Close();
 			SetWorkspaceData(false);
+			gApp.NotifyProjectVersionLocks(mUpdateProjectLocks);
 		}
 
         public override void PopupWindow(WidgetWindow parentWindow, float offsetX = 0, float offsetY = 0)

+ 81 - 35
IDE/src/ui/PropertiesDialog.bf

@@ -114,6 +114,12 @@ namespace IDE.ui
 
     public class PropertiesDialog : IDEDialog
     {
+		public interface IMultiValued
+		{
+			void GetValue(int idx, String outValue);
+			bool SetValue(int idx, StringView value);
+		}
+
 		class OwnedStringList : List<String>
 		{
 
@@ -215,6 +221,7 @@ namespace IDE.ui
 			public String mRelPath ~ delete _;
 			public bool mIsTypeWildcard;
 			public bool mAllowMultiline;
+			public bool mReadOnly;
 			public Insets mEditInsets ~ delete _;
 
 			public ~this()
@@ -305,6 +312,18 @@ namespace IDE.ui
 				    }
 				    return true;
 				}
+				else if (type.IsObject)
+				{
+					var lhsObj = lhs.Get<Object>();
+					var rhsObj = rhs.Get<Object>();
+
+					if ((var lhsEq = lhsObj as IEquatable) && (var rhsEq = rhsObj as IEquatable))
+					{
+						return lhsEq.Equals(rhsEq);
+					}
+
+					return false;
+				}
 				else // Could be an int or enum
 					return Variant.Equals!<int32>(lhs, rhs);
 			}
@@ -815,7 +834,7 @@ namespace IDE.ui
 
             if (mPropEditWidget != null)
             {
-				DarkListViewItem editItem = (DarkListViewItem)mEditingListViewItem.GetSubItem(1);
+				DarkListViewItem editItem = (DarkListViewItem)mEditingListViewItem;
 				let propEntry = mEditingProps[0];
 
 				float xPos;
@@ -871,7 +890,7 @@ namespace IDE.ui
             editWidget.GetText(newValue);
             newValue.Trim();
 
-            DarkListViewItem item = (DarkListViewItem)mEditingListViewItem;
+            DarkListViewItem rootItem = (DarkListViewItem)mEditingListViewItem.GetSubItem(0);
             //DarkListViewItem valueItem = (DarkListViewItem)item.GetSubItem(1);
 
 			if (!editWidget.mEditWidgetContent.HasUndoData())
@@ -920,14 +939,14 @@ namespace IDE.ui
 				{
 					//
 				}
-                else if (editingProp.mListViewItem != item)
+                else if (editingProp.mListViewItem != rootItem)
                 {
                     List<String> curEntries = editingProp.mCurValue.Get<List<String>>();
                     List<String> entries = new List<String>(curEntries.GetEnumerator());
 
                     for (int32 childIdx = 0; childIdx < editingProp.mListViewItem.GetChildCount(); childIdx++)
                     {
-                        if (item == editingProp.mListViewItem.GetChildAtIndex(childIdx))
+                        if (rootItem == editingProp.mListViewItem.GetChildAtIndex(childIdx))
                         {
                             if (childIdx >= entries.Count)
                                 entries.Add(new String(newValue));
@@ -1027,6 +1046,11 @@ namespace IDE.ui
 							setValue = false;
 						}
 					}
+					else if ((curVariantType.IsObject) && (var multiValue = prevValue.Get<Object>() as IMultiValued))
+					{
+						multiValue.SetValue(mEditingListViewItem.mColumnIdx - 1, newValue);
+						setValue = false;
+					}
 					else
                         editingProp.mCurValue = Variant.Create(new String(newValue), true);
 
@@ -1247,37 +1271,49 @@ namespace IDE.ui
 			if (ewc.mIsMultiline)
 				editWidget.InitScrollbars(false, true);
 
+			if (propEntry.mReadOnly)
+				editWidget.mEditWidgetContent.mIsReadOnly = true;
+
 			editWidget.mScrollContentInsets.Set(GS!(3), GS!(3), GS!(1), GS!(3));
 			editWidget.Content.mTextInsets.Set(GS!(-3), GS!(2), 0, GS!(2));
 			//editWidget.RehupSize();
             if (subValueIdx != -1)
             {
-                List<String> stringList = propEntry.mCurValue.Get<List<String>>();
-                if (subValueIdx < stringList.Count)
-                    editWidget.SetText(stringList[subValueIdx]);
-
-                MoveItemWidget moveItemWidget;
-                if (subValueIdx > 0)
-                {
-                    moveItemWidget = new MoveItemWidget();
-                    editWidget.AddWidget(moveItemWidget);
-                    moveItemWidget.Resize(6, editWidget.mY - GS!(16), GS!(20), GS!(20));
-                    moveItemWidget.mArrowDir = -1;
-                    moveItemWidget.mOnMouseDown.Add(new (evt) => { MoveEditingItem(subValueIdx, -1); });
-					if (!ewc.mIsMultiline)
-                    	editWidget.mOnKeyDown.Add(new (evt) => { if (evt.mKeyCode == KeyCode.Up) MoveEditingItem(subValueIdx, -1); });
-                }
-
-                if (subValueIdx < stringList.Count - 1)
-                {
-                    moveItemWidget = new MoveItemWidget();
-                    editWidget.AddWidget(moveItemWidget);
-                    moveItemWidget.Resize(6, editWidget.mY + GS!(16), GS!(20), GS!(20));
-                    moveItemWidget.mArrowDir = 1;
-                    moveItemWidget.mOnMouseDown.Add(new (evt) => { MoveEditingItem(subValueIdx, 1); });
-					if (!ewc.mIsMultiline)
-                    	editWidget.mOnKeyDown.Add(new (evt) => { if (evt.mKeyCode == KeyCode.Down) MoveEditingItem(subValueIdx, 1); });
-                }
+				var obj = propEntry.mCurValue.Get<Object>();
+				if (var multiValued = obj as IMultiValued)
+				{
+					var label = multiValued.GetValue(subValueIdx, .. scope .());
+					editWidget.SetText(label);
+				}
+				else
+				{
+	                List<String> stringList = obj as List<String>;
+	                if (subValueIdx < stringList.Count)
+	                    editWidget.SetText(stringList[subValueIdx]);
+
+	                MoveItemWidget moveItemWidget;
+	                if (subValueIdx > 0)
+	                {
+	                    moveItemWidget = new MoveItemWidget();
+	                    editWidget.AddWidget(moveItemWidget);
+	                    moveItemWidget.Resize(6, editWidget.mY - GS!(16), GS!(20), GS!(20));
+	                    moveItemWidget.mArrowDir = -1;
+	                    moveItemWidget.mOnMouseDown.Add(new (evt) => { MoveEditingItem(subValueIdx, -1); });
+						if (!ewc.mIsMultiline)
+	                    	editWidget.mOnKeyDown.Add(new (evt) => { if (evt.mKeyCode == KeyCode.Up) MoveEditingItem(subValueIdx, -1); });
+	                }
+
+	                if (subValueIdx < stringList.Count - 1)
+	                {
+	                    moveItemWidget = new MoveItemWidget();
+	                    editWidget.AddWidget(moveItemWidget);
+	                    moveItemWidget.Resize(6, editWidget.mY + GS!(16), GS!(20), GS!(20));
+	                    moveItemWidget.mArrowDir = 1;
+	                    moveItemWidget.mOnMouseDown.Add(new (evt) => { MoveEditingItem(subValueIdx, 1); });
+						if (!ewc.mIsMultiline)
+	                    	editWidget.mOnKeyDown.Add(new (evt) => { if (evt.mKeyCode == KeyCode.Down) MoveEditingItem(subValueIdx, 1); });
+	                }
+				}
             }
             else
             {
@@ -1363,8 +1399,8 @@ namespace IDE.ui
 					hasChanged = true;
 			}
 
-            if (propEntry.mFieldInfo == default(FieldInfo))
-                return;
+            /*if (propEntry.mFieldInfo == default(FieldInfo))
+                return;*/
 
 			var curVariantType = propEntry.mCurValue.VariantType;
 
@@ -1516,6 +1552,15 @@ namespace IDE.ui
                 valueItem.Label = allValues;
 				FixLabel(valueItem);
             }
+			else if ((curVariantType.IsObject) && (var multiValue = propEntry.mCurValue.Get<Object>() as IMultiValued))
+			{
+				for (int columnIdx in 1..<propEntry.mListViewItem.mSubItems.Count)
+				{
+					var subItem = propEntry.mListViewItem.GetSubItem(columnIdx);
+					var label = multiValue.GetValue(columnIdx - 1, .. scope .());
+					subItem.Label = label;
+				}
+			}
             else if (propEntry.mCheckBox != null)
             {
                 propEntry.mCheckBox.Checked = propEntry.mCurValue.Get<bool>();
@@ -2026,9 +2071,10 @@ namespace IDE.ui
 				clickedItem.mListView.GetRoot().SelectItemExclusively(null);
 			}
 
-            DarkListViewItem item = (DarkListViewItem)clickedItem.GetSubItem(0);
+            DarkListViewItem item = (DarkListViewItem)clickedItem;
+			DarkListViewItem rootItem = (DarkListViewItem)item.GetSubItem(0);
 
-            PropEntry[] propertyEntries = mPropPage.mPropEntries[item];
+            PropEntry[] propertyEntries = mPropPage.mPropEntries[rootItem];
 			if (propertyEntries[0].mDisabled)
 				return;
             EditValue(item, propertyEntries);
@@ -2039,7 +2085,7 @@ namespace IDE.ui
 			var propEntry = propEntries[0];
             DarkListViewItem parentItem = propEntry.mListViewItem;
             DarkListViewItem clickedItem = (DarkListViewItem)parentItem.GetChildAtIndex(idx);
-            DarkListViewItem item = (DarkListViewItem)clickedItem.GetSubItem(0);
+            DarkListViewItem item = (DarkListViewItem)clickedItem.GetSubItem(1);
             EditValue(item, propEntries, idx);
         }
 

+ 179 - 0
IDE/src/ui/RemoteProjectDialog.bf

@@ -0,0 +1,179 @@
+#pragma warning disable 168
+using System;
+using System.Collections;
+using System.Text;
+using System.Threading.Tasks;
+using System.IO;
+using Beefy;
+using Beefy.gfx;
+using Beefy.theme.dark;
+using Beefy.widgets;
+using Beefy.theme;
+using IDE.Util;
+
+namespace IDE.ui
+{
+    public class RemoteProjectDialog : IDEDialog
+    {
+        public EditWidget mURLEdit;
+        public EditWidget mVersionEdit;
+		public DarkComboBox mTargetComboBox;
+		static String[1] sApplicationTypeNames =
+			.("Git");
+		public bool mNameChanged;
+		public String mDirBase ~ delete _;
+
+        public this()
+        {
+			mTitle = new String("Add Remote Project");
+        }
+
+        public override void CalcSize()
+        {
+            mWidth = GS!(320);
+            mHeight = GS!(200);
+        }
+
+		enum CreateFlags
+		{
+			None,
+			NonEmptyDirOkay = 1,
+		}
+
+		bool CreateProject(CreateFlags createFlags = .None)
+		{
+		    var app = IDEApp.sApp;
+		    String url = scope String();
+		    mURLEdit.GetText(url);
+		    url.Trim();
+
+			if (url.IsEmpty)
+			{
+				mURLEdit.SetFocus();
+			    app.Fail("Invalid URL");
+			    return false;
+			}
+
+			var projName = Path.GetFileName(url, .. scope .());
+
+			var version = mVersionEdit.GetText(.. scope .())..Trim();
+			
+		    var otherProject = app.mWorkspace.FindProject(projName);
+		    if (otherProject != null)
+		    {
+		        mURLEdit.SetFocus();
+		        app.Fail("A project with this name already exists in the workspace.");
+		        return false;                
+		    }
+
+			VerSpec verSpec = .Git(url, scope .(version));
+			if (var project = gApp.AddProject(projName, verSpec))
+			{
+				//gApp.ProjectCreated(project);
+				app.mWorkspace.SetChanged();
+
+				gApp.[Friend]FlushDeferredLoadProjects(true);
+				//gApp.RetryProjectLoad(project, false);
+				//gApp.AddProjectToWorkspace(project);
+
+				var projectSpec = new Workspace.ProjectSpec();
+				projectSpec.mProjectName = new .(project.mProjectName);
+				projectSpec.mVerSpec = .Git(new .(url), new .(version));
+				gApp.mWorkspace.mProjectSpecs.Add(projectSpec);
+			}
+
+		    return true;
+		}
+
+		public void UpdateProjectName()
+		{
+			if (!mNameChanged)
+			{
+				String path = scope .();
+				mURLEdit.GetText(path);
+				path.Trim();
+				if ((path.EndsWith('\\')) || (path.EndsWith('/')))
+					path.RemoveFromEnd(1);
+
+				String projName = scope .();
+				Path.GetFileName(path, projName);
+				mVersionEdit.SetText(projName);
+			}
+		}
+
+        public void Init()
+        {
+            mDefaultButton = AddButton("Create", new (evt) =>
+				{
+					if (!CreateProject()) evt.mCloseDialog = false;
+				});
+            mEscButton = AddButton("Cancel", new (evt) => Close());
+            
+			if (gApp.mWorkspace.IsInitialized)
+				mDirBase = new String(gApp.mWorkspace.mDir);
+			else
+				mDirBase = new String();
+            mURLEdit = new DarkEditWidget();
+			
+			AddEdit(mURLEdit);
+			mURLEdit.mOnContentChanged.Add(new (dlg) =>
+				{
+					
+				});
+
+			mVersionEdit = AddEdit("");
+			mVersionEdit.mOnContentChanged.Add(new (dlg) =>
+				{
+					if (mVersionEdit.mHasFocus)
+						mNameChanged = true;
+				});
+
+			mTargetComboBox = new DarkComboBox();
+			mTargetComboBox.Label = sApplicationTypeNames[0];
+			mTargetComboBox.mPopulateMenuAction.Add(new (dlg) =>
+				{
+					for (var applicationTypeName in sApplicationTypeNames)
+					{
+						var item = dlg.AddItem(applicationTypeName);
+						item.mOnMenuItemSelected.Add(new (item) =>
+							{
+								mTargetComboBox.Label = item.mLabel;
+								MarkDirty();
+							});
+					}
+				});
+			AddWidget(mTargetComboBox);
+			mTabWidgets.Add(mTargetComboBox);
+        }
+
+        public override void PopupWindow(WidgetWindow parentWindow, float offsetX = 0, float offsetY = 0)
+        {
+            base.PopupWindow(parentWindow, offsetX, offsetY);
+            mURLEdit.SetFocus();
+        }
+
+        public override void ResizeComponents()
+        {
+            base.ResizeComponents();
+
+            float curY = mHeight - GS!(30) - mButtonBottomMargin;
+			mVersionEdit.Resize(GS!(16), curY - GS!(36), mWidth - GS!(16) * 2, GS!(24));
+
+			curY -= GS!(50);
+			mURLEdit.Resize(GS!(16), curY - GS!(36), mWidth - GS!(16) * 2, GS!(24));
+
+			curY -= GS!(60);
+			mTargetComboBox.Resize(GS!(16), curY - GS!(36), mWidth - GS!(16) * 2, GS!(28));
+        }
+
+        public override void Draw(Graphics g)
+        {
+            base.Draw(g);
+
+			g.DrawString("Remote Project URL", mURLEdit.mX, mURLEdit.mY - GS!(20));
+            g.DrawString("Version Constraint (Blank for HEAD)", mVersionEdit.mX, mVersionEdit.mY - GS!(20));
+        }
+    }
+
+    
+}

+ 8 - 1
IDE/src/ui/RenameSymbolDialog.bf

@@ -414,7 +414,8 @@ namespace IDE.ui
 
 			if (mGettingSymbolInfo)
 			{
-				gApp.Fail("Cannot rename symbols here");
+				if (gApp.mWorkspace.mProjectLoadState == .Loaded)
+					gApp.Fail("Cannot rename symbols here");
 				mGettingSymbolInfo = false;
 				return;
 			}
@@ -430,6 +431,12 @@ namespace IDE.ui
             if ((mKind == Kind.ShowFileReferences) || (mResolveParams.mLocalId != -1))
             {                
                 mParser = IDEApp.sApp.mBfResolveSystem.FindParser(mSourceViewPanel.mProjectSource);
+				if (mParser == null)
+				{
+					mGettingSymbolInfo = false;
+					return;
+				}
+
 				if ((mResolveParams != null) && (mResolveParams.mLocalId != -1))
 					mParser.SetAutocomplete(mCursorPos);
 				else

+ 1 - 1
IDE/src/ui/SourceViewPanel.bf

@@ -1932,7 +1932,7 @@ namespace IDE.ui
 			}
 			else if (resolveType == ResolveType.GetCurrentLocation)
 			{
-				PrimaryNavigationBar.SetLocation(autocompleteInfo);
+				PrimaryNavigationBar.SetLocation(autocompleteInfo ?? "");
 			}
 			else if ((resolveType == .Autocomplete) || (resolveType == .GetFixits))
 			{

+ 87 - 28
IDE/src/ui/StatusBar.bf

@@ -22,7 +22,7 @@ namespace IDE.ui
 		public DarkButton mSafeModeButton;
 		public bool mWasCompiling;
 		public int mEvalCount;
-		public ImageWidget mCancelSymSrvButton;
+		public ImageWidget mCancelButton;
 		public int mDirtyDelay;
 		public int mStatusBoxUpdateCnt = -1;
 
@@ -117,8 +117,8 @@ namespace IDE.ui
             mConfigComboBox.Resize(mWidth - btnLeft, GS!(0), GS!(120), GS!(24));
             mPlatformComboBox.Resize(mWidth - btnLeft - GS!(120), GS!(0), GS!(120), GS!(24));
 
-			if (mCancelSymSrvButton != null)
-				mCancelSymSrvButton.Resize(GS!(546), 0, GS!(20), GS!(20));
+			if (mCancelButton != null)
+				mCancelButton.Resize(GS!(546), 0, GS!(20), GS!(20));
 
 			if (mSafeModeButton != null)
 			{
@@ -182,30 +182,72 @@ namespace IDE.ui
 			else
 				mEvalCount = 0;
 
+			void ShowCancelButton()
+			{
+				if (mCancelButton == null)
+				{
+					mCancelButton = new ImageWidget();
+					mCancelButton.mImage = DarkTheme.sDarkTheme.GetImage(.Close);
+					mCancelButton.mOverImage = DarkTheme.sDarkTheme.GetImage(.CloseOver);
+					mCancelButton.mOnMouseClick.Add(new (evt) =>
+						{
+							if (gApp.mWorkspace.mProjectLoadState == .Preparing)
+							{
+								gApp.CancelWorkspaceLoading();
+							}
+							else
+								gApp.mDebugger.CancelSymSrv();
+						});
+					AddWidget(mCancelButton);
+					ResizeComponents();
+				}
+			}
+
 			if (debugState == .SearchingSymSrv)
 			{
 				MarkDirtyEx();
+				ShowCancelButton();
+
+				float len = GS!(200);
+				float x = GS!(350);
+				Rect completionRect = Rect(x, GS!(1), len, GS!(17));
 
-				if (mCancelSymSrvButton == null)
+				Point mousePos;
+				if (DarkTooltipManager.CheckMouseover(this, 25, out mousePos, true))
 				{
-					mCancelSymSrvButton = new ImageWidget();
-					mCancelSymSrvButton.mImage = DarkTheme.sDarkTheme.GetImage(.Close);
-					mCancelSymSrvButton.mOverImage = DarkTheme.sDarkTheme.GetImage(.CloseOver);
-					mCancelSymSrvButton.mOnMouseClick.Add(new (evt) => { gApp.mDebugger.CancelSymSrv(); });
-					AddWidget(mCancelSymSrvButton);
-					ResizeComponents();
+					if (completionRect.Contains(mousePos.x, mousePos.y))
+					{
+						DarkTooltipManager.ShowTooltip(gApp.mSymSrvStatus, this, mousePos.x, mousePos.y);
+					}
 				}
+			}
+			else if (gApp.mWorkspace.mProjectLoadState == .Preparing)
+			{
+				MarkDirtyEx();
+				ShowCancelButton();
 
 				float len = GS!(200);
 				float x = GS!(350);
 				Rect completionRect = Rect(x, GS!(1), len, GS!(17));
 
+				String status = scope .();
+
+				for (var workItem in gApp.mPackMan.mWorkItems)
+				{
+					if (workItem.mGitInstance == null)
+						break;
+
+					//DrawCompletion(workItem.mGitInstance.mProgress);
+					status.AppendF($"Retrieving {workItem.mProjectName}: {(int)(workItem.mGitInstance.mProgress * 100)}%");
+				}
+
 				Point mousePos;
 				if (DarkTooltipManager.CheckMouseover(this, 25, out mousePos, true))
 				{
 					if (completionRect.Contains(mousePos.x, mousePos.y))
 					{
-						DarkTooltipManager.ShowTooltip(gApp.mSymSrvStatus, this, mousePos.x, mousePos.y);
+						if (!status.IsEmpty)
+							DarkTooltipManager.ShowTooltip(status, this, mousePos.x, mousePos.y);
 					}
 				}
 			}
@@ -214,10 +256,10 @@ namespace IDE.ui
 				if ((DarkTooltipManager.sTooltip != null) && (DarkTooltipManager.sTooltip.mRelWidget == this))
 					DarkTooltipManager.sTooltip.Close();
 
-				if (mCancelSymSrvButton != null)
+				if (mCancelButton != null)
 				{
-					RemoveAndDelete(mCancelSymSrvButton);
-					mCancelSymSrvButton = null;
+					RemoveAndDelete(mCancelButton);
+					mCancelButton = null;
 				}
 			}
 
@@ -367,6 +409,16 @@ namespace IDE.ui
 
 			float statusLabelPos = (int)GS!(-1.3f);
 
+			void DrawCompletion(float pct)
+			{
+				Rect completionRect = Rect(GS!(200), GS!(2), GS!(120), GS!(15));
+				using (g.PushColor(0xFF000000))
+				    g.FillRect(completionRect.mX, completionRect.mY, completionRect.mWidth, completionRect.mHeight);
+				completionRect.Inflate(GS!(-1), GS!(-1));
+				using (g.PushColor(0xFF00FF00))
+				    g.FillRect(completionRect.mX, completionRect.mY, completionRect.mWidth * pct, completionRect.mHeight);
+			}
+
 			//completionPct = 0.4f;
 			if ((gApp.mDebugger?.mIsComptimeDebug == true) &&
 				((gApp.mDebugger.IsPaused()) || (debugState == .DebugEval)))
@@ -375,12 +427,7 @@ namespace IDE.ui
 			}
             else if (completionPct.HasValue)
             {                
-                Rect completionRect = Rect(GS!(200), GS!(2), GS!(120), GS!(15));
-                using (g.PushColor(0xFF000000))
-                    g.FillRect(completionRect.mX, completionRect.mY, completionRect.mWidth, completionRect.mHeight);
-                completionRect.Inflate(GS!(-1), GS!(-1));
-                using (g.PushColor(0xFF00FF00))
-                    g.FillRect(completionRect.mX, completionRect.mY, completionRect.mWidth * completionPct.Value, completionRect.mHeight);
+                DrawCompletion(completionPct.Value);
             }
             else if ((gApp.mDebugger.mIsRunning) && (gApp.HaveSourcesChanged()))
             {
@@ -394,7 +441,7 @@ namespace IDE.ui
                 g.DrawString("Source Changed", GS!(200), statusLabelPos, FontAlign.Centered, GS!(120));
             }
 
-			void DrawStatusBox(StringView str, int32 updateCnt = -1)
+			void DrawStatusBox(StringView str, int32 updateCnt = -1, bool showCancelButton = false)
 			{
 				if (mStatusBoxUpdateCnt == -1)
 					mStatusBoxUpdateCnt = 0;
@@ -415,8 +462,18 @@ namespace IDE.ui
 				using (g.PushColor(Color.FromHSV(0.1f, 0.5f, (float)Math.Max(pulsePct * 0.15f + 0.3f, 0.3f))))
 				    g.FillRect(completionRect.mX, completionRect.mY, completionRect.mWidth, completionRect.mHeight);
 
-				if (mCancelSymSrvButton != null)
-					mCancelSymSrvButton.mX = completionRect.Right - GS!(16);
+				if (mCancelButton != null)
+				{
+					if (showCancelButton)
+					{
+						mCancelButton.SetVisible(true);
+						mCancelButton.mX = completionRect.Right - GS!(16);
+					}
+					else
+					{
+						mCancelButton.SetVisible(false);
+					}
+				}
 
 				using (g.PushColor(DarkTheme.COLOR_TEXT))
 					g.DrawString(str, x, statusLabelPos, FontAlign.Centered, len);
@@ -429,10 +486,6 @@ namespace IDE.ui
 				chordState.Append(", <Awaiting Key>...");
 				DrawStatusBox(chordState);
 			}
-			else if (mCancelSymSrvButton != null)
-			{
-				DrawStatusBox("Retrieving Debug Symbols...  ");
-			}
 			else if (mEvalCount > 20)
 			{
 				DrawStatusBox("Evaluating Expression");
@@ -451,10 +504,16 @@ namespace IDE.ui
 			}
 			else if (gApp.mWorkspace.mProjectLoadState == .Preparing)
 			{
-				DrawStatusBox("Loading Projects");
+				DrawStatusBox("Loading Projects", -1, true);
+			}
+			else if (mCancelButton != null)
+			{
+				DrawStatusBox("Retrieving Debug Symbols...  ", -1, true);
 			}
 			else if (gApp.mDeferredShowSource != null)
+			{
 				DrawStatusBox("Queued Showing Source");
+			}
 			else
 				mStatusBoxUpdateCnt = -1;
 

+ 234 - 2
IDE/src/ui/WorkspaceProperties.bf

@@ -11,6 +11,7 @@ using Beefy.theme.dark;
 using Beefy.theme;
 using Beefy.events;
 using System.Diagnostics;
+using IDE.Util;
 
 //#define A
 //#define B
@@ -40,6 +41,7 @@ namespace IDE.ui
         enum CategoryType
         {
 			General,
+			Dependencies,
 			Beef_Global,
 
 			Targeted,
@@ -53,6 +55,7 @@ namespace IDE.ui
 
         ConfigDataGroup mCurConfigDataGroup;
         Workspace.Options[] mCurWorkspaceOptions ~ delete _;
+		List<String> mUpdateProjectLocks = new .() ~ DeleteContainerAndItems!(_);
 
         public this()
         {
@@ -62,8 +65,9 @@ namespace IDE.ui
 
             var root = (DarkListViewItem)mCategorySelector.GetRoot();
 			var globalItem = AddCategoryItem(root, "General");
-			var item = AddCategoryItem(globalItem, "Beef");
+			var item = AddCategoryItem(globalItem, "Dependencies");
 			item.Focused = true;
+			AddCategoryItem(globalItem, "Beef");
 			globalItem.Open(true, true);
 
 			var targetedItem = AddCategoryItem(root, "Targeted");
@@ -124,6 +128,7 @@ namespace IDE.ui
 			{
 			case .General,
 				 //.Targeted,
+				 .Dependencies,
 				 .Beef_Global:
 				return .None;
 			default:
@@ -454,7 +459,9 @@ namespace IDE.ui
                 mPropPage.mPropertiesListView.mShowColumnGrid = true;
                 mPropPage.mPropertiesListView.mShowGridLines = true;
 
-				if (categoryType == CategoryType.Beef_Global)
+				if (categoryType == CategoryType.Dependencies)
+					PopulateDependencyOptions();
+				else if (categoryType == CategoryType.Beef_Global)
 					PopulateBeefGlobalOptions();
                 else if (categoryType == CategoryType.Build)
                     PopulateBuildOptions();
@@ -705,6 +712,230 @@ namespace IDE.ui
 			}
 		}
 
+		void PopulateDependencyOptions()
+		{
+			mPropPage.mPropertiesListView.mColumns[0].Label = "Project";
+			mPropPage.mPropertiesListView.mColumns[0].mMinWidth = GS!(100);
+			mPropPage.mPropertiesListView.mColumns[0].mWidth = GS!(180);
+
+			mPropPage.mPropertiesListView.mColumns[1].Label = "";
+			mPropPage.mPropertiesListView.mColumns[1].mMinWidth = GS!(20);
+			mPropPage.mPropertiesListView.mColumns[1].mWidth = GS!(20);
+
+			mPropPage.mPropertiesListView.AddColumn(180, "Remote URL");
+			mPropPage.mPropertiesListView.mColumns[2].mMinWidth = GS!(100);
+
+			mPropPage.mPropertiesListView.AddColumn(180, "Ver Constraint");
+			mPropPage.mPropertiesListView.mColumns[3].mMinWidth = GS!(100);
+
+		    //mDependencyValuesMap = new .();
+
+		    var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
+		    var category = root;
+
+		    List<String> projectNames = scope List<String>();
+		    for (int32 projectIdx = 0; projectIdx < IDEApp.sApp.mWorkspace.mProjects.Count; projectIdx++)
+		    {
+		        var project = IDEApp.sApp.mWorkspace.mProjects[projectIdx];
+		        /*if (project == mProject)
+		            continue;*/
+		        projectNames.Add(project.mProjectName);
+		    }
+
+		    /*for (var dep in mProject.mDependencies)
+		    {
+		        if (!projectNames.Contains(dep.mProjectName))
+		            projectNames.Add(dep.mProjectName);
+		    }*/
+
+
+		    projectNames.Sort(scope (a, b) => String.Compare(a, b, true));
+
+		    for (var projectName in projectNames)
+		    {
+		        var dependencyEntry = new DependencyEntry();
+
+				for (var projectSpec in gApp.mWorkspace.mProjectSpecs)
+				{
+					if (projectSpec.mProjectName == projectName)
+					{
+						dependencyEntry.mUse = true;
+						if (projectSpec.mVerSpec case .Git(let url, let ver))
+						{
+							dependencyEntry.mURL = new .(url);
+							if (ver != null)
+								dependencyEntry.mVersion = new .(ver.mVersion);
+						}
+					}
+				}
+
+				/*var verSpec = mProject.GetDependency(projectName, false);
+				if (verSpec != null)
+				{
+		            dependencyEntry.mUse = true;
+					if (verSpec case .Git(let url, let ver))
+					{
+						dependencyEntry.mURL = new .(url);
+						if (ver != null)
+							dependencyEntry.mVersion = new .(ver.mVersion);
+					}
+				}
+		        mDependencyValuesMap[new String(projectName)] = dependencyEntry;*/
+		        
+		        var (listViewItem, propItem) = AddPropertiesItem(category, projectName);
+		        if (IDEApp.sApp.mWorkspace.FindProject(projectName) == null)
+		            listViewItem.mTextColor = Color.Mult(DarkTheme.COLOR_TEXT, 0xFFFF6060);
+
+		        var subItem = (DarkListViewItem)listViewItem.CreateSubItem(1);
+
+		        var checkbox = new DarkCheckBox();
+		        checkbox.Checked = dependencyEntry.mUse;
+		        checkbox.Resize(0, 0, DarkTheme.sUnitSize, DarkTheme.sUnitSize);
+		        subItem.AddWidget(checkbox);
+
+				PropEntry[] propEntries = new PropEntry[1];
+
+		        PropEntry propEntry = new PropEntry();
+		        propEntry.mTarget = dependencyEntry;
+		        propEntry.mOrigValue = Variant.Create(dependencyEntry, true);
+		        propEntry.mCurValue = Variant.Create(new DependencyEntry(dependencyEntry), true);
+				
+		        propEntry.mListViewItem = listViewItem;
+		        propEntry.mCheckBox = checkbox;
+				propEntry.mApplyAction = new () =>
+					{
+						bool updateProjectLock = false;
+
+						var dependencyEntry = propEntry.mCurValue.Get<DependencyEntry>();
+
+						VerSpec verSpec = default;
+						if (dependencyEntry.mUse)
+						{
+							if (dependencyEntry.mURL != null)
+								verSpec = .Git(new .(dependencyEntry.mURL), (dependencyEntry.mVersion != null) ? new .(dependencyEntry.mVersion) : null);
+							else if (dependencyEntry.mVersion != null)
+								verSpec = .SemVer(new .(dependencyEntry.mVersion));
+							else
+								verSpec = .SemVer(new .("*"));
+						}
+						
+						FindBlock: do
+						{
+							for (var projectSpec in gApp.mWorkspace.mProjectSpecs)
+							{
+								if (projectSpec.mProjectName == projectName)
+								{
+									if (!dependencyEntry.mUse)
+									{
+										if (projectSpec.mVerSpec case .Git)
+											updateProjectLock = true;
+										@projectSpec.Remove();
+										delete projectSpec;
+										break FindBlock;
+									}
+
+									if (projectSpec.mVerSpec != verSpec)
+									{
+										if ((projectSpec.mVerSpec case .Git) ||
+											(verSpec case .Git))
+											updateProjectLock = true;
+									}
+
+									projectSpec.mVerSpec.Dispose();
+									projectSpec.mVerSpec = verSpec;
+									break FindBlock;
+								}
+							}
+
+							if (dependencyEntry.mUse)
+							{
+								Workspace.ProjectSpec projectSpec = new .();
+								projectSpec.mProjectName = new .(projectName);
+								projectSpec.mVerSpec = verSpec;
+								gApp.mWorkspace.mProjectSpecs.Add(projectSpec);
+								if (verSpec case .Git)
+									updateProjectLock = true;
+								var origDependencyEntry = propEntry.mOrigValue.Get<DependencyEntry>();
+								origDependencyEntry.Set(dependencyEntry);
+							}
+						}
+
+						if (updateProjectLock)
+							mUpdateProjectLocks.Add(new .(listViewItem.Label));
+					};
+
+		        checkbox.mOnMouseUp.Add(new (evt) =>
+					{
+						var dependencyEntry = propEntry.mCurValue.Get<DependencyEntry>();
+						dependencyEntry.mUse = !dependencyEntry.mUse;
+						if (dependencyEntry.mUse)
+						{
+							var projectName = listViewItem.Label;
+
+							for (var projectSpec in gApp.mWorkspace.mProjectSpecs)
+							{
+								if (projectSpec.mProjectName == projectName)
+								{
+									if (projectSpec.mVerSpec case .Git(let url, let ver))
+									{
+										dependencyEntry.SetValue(1, url);
+										dependencyEntry.SetValue(2, ver.mVersion);
+									}
+								}
+							}
+							var propEntries = mPropPage.mPropEntries[listViewItem];
+							UpdatePropertyValue(propEntries);
+						}
+						else
+						{
+							DeleteAndNullify!(dependencyEntry.mURL);
+							DeleteAndNullify!(dependencyEntry.mVersion);
+							var propEntries = mPropPage.mPropEntries[listViewItem];
+							UpdatePropertyValue(propEntries);
+						}
+
+					});
+
+
+				subItem = (.)listViewItem.GetOrCreateSubItem(2);
+				if (dependencyEntry.mURL != null)
+					subItem.Label = dependencyEntry.mURL;
+				subItem.mOnMouseDown.Add(new => DepPropValueClicked);
+
+				subItem = (.)listViewItem.GetOrCreateSubItem(3);
+				if (dependencyEntry.mVersion != null)
+					subItem.Label = dependencyEntry.mVersion;
+				subItem.mOnMouseDown.Add(new => DepPropValueClicked);
+
+				propEntries[0] = propEntry;
+		        mPropPage.mPropEntries[listViewItem] = propEntries;
+		    }            
+		}
+
+		protected void DepPropValueClicked(MouseEvent theEvent)
+		{
+		    DarkListViewItem clickedItem = (DarkListViewItem)theEvent.mSender;
+			if (clickedItem.mColumnIdx == 0)
+			{
+				clickedItem.mListView.SetFocus();
+				clickedItem.mListView.GetRoot().SelectItemExclusively(clickedItem);
+				return;
+			}
+
+			if (theEvent.mX != -1)
+			{
+				clickedItem.mListView.GetRoot().SelectItemExclusively(null);
+			}
+
+		    DarkListViewItem item = (DarkListViewItem)clickedItem;
+			DarkListViewItem rootItem = (DarkListViewItem)clickedItem.GetSubItem(0);
+
+		    PropEntry[] propertyEntries = mPropPage.mPropEntries[rootItem];
+			if (propertyEntries[0].mDisabled)
+				return;
+		    EditValue(item, propertyEntries, clickedItem.mColumnIdx - 1);
+		}
+
 		void PopulateBeefGlobalOptions()
 		{
 		    var root = (DarkListViewItem)mPropPage.mPropertiesListView.GetRoot();
@@ -939,6 +1170,7 @@ namespace IDE.ui
 		{
 			base.Close();
 			SetWorkspaceData(false);
+			gApp.NotifyProjectVersionLocks(mUpdateProjectLocks);
 		}
 
         public override void CalcSize()

+ 326 - 0
IDE/src/util/GitManager.bf

@@ -0,0 +1,326 @@
+#pragma warning disable 168
+
+using System.Diagnostics;
+using System;
+using System.Threading;
+using System.IO;
+using System.Collections;
+
+namespace IDE.util;
+
+class GitManager
+{
+	public enum Error
+	{
+		Unknown
+	}
+
+	public class GitInstance : RefCounted
+	{
+		public class TagInfo
+		{
+			public String mHash ~ delete _;
+			public String mTag ~ delete _;
+		}
+
+		public GitManager mGitManager;
+		public bool mFailed;
+		public bool mDone;
+		public bool mStarted;
+		public bool mRemoved;
+
+		public String mArgs ~ delete _;
+		public String mPath ~ delete _;
+		public float mProgress;
+		public float mProgressRecv;
+		public float mProgressDeltas;
+		public float mProgressFiles;
+
+		public Stopwatch mStopwatch = new .()..Start() ~ delete _;
+
+		public SpawnedProcess mProcess ~ delete _;
+		public Monitor mMonitor = new .() ~ delete _;
+		public List<String> mDeferredOutput = new .() ~ DeleteContainerAndItems!(_);
+		public List<TagInfo> mTagInfos = new .() ~ DeleteContainerAndItems!(_);
+
+		public Thread mOutputThread ~ delete _;
+		public Thread mErrorThread ~ delete _;
+
+		public this(GitManager gitManager)
+		{
+			mGitManager = gitManager;
+		}
+
+		public ~this()
+		{
+			IDEUtils.SafeKill(mProcess);
+			mOutputThread?.Join();
+			mErrorThread?.Join();
+
+			if (!mRemoved)
+				mGitManager.mGitInstances.Remove(this);
+		}
+
+		public void Init(StringView args, StringView path)
+		{
+			mArgs = new .(args);
+			if (path != default)
+				mPath = new .(path);
+		}
+
+		public void Start()
+		{
+			if (mStarted)
+				return;
+			mStarted = true;
+
+			ProcessStartInfo psi = scope ProcessStartInfo();
+
+			String gitPath = scope .();
+#if BF_PLATFORM_WINDOWS
+			Path.GetAbsolutePath(gApp.mInstallDir, "git/cmd/git.exe", gitPath);
+			if (!File.Exists(gitPath))
+				gitPath.Clear();
+
+			if (gitPath.IsEmpty)
+			{
+				Path.GetAbsolutePath(gApp.mInstallDir, "../../bin/git/cmd/git.exe", gitPath);
+				if (!File.Exists(gitPath))
+					gitPath.Clear();
+			}
+
+			if (gitPath.IsEmpty)
+			{
+				Path.GetAbsolutePath(gApp.mInstallDir, "../../../bin/git/cmd/git.exe", gitPath);
+				if (!File.Exists(gitPath))
+					gitPath.Clear();
+			}
+#endif
+			if (gitPath.IsEmpty)
+				gitPath.Set("git");
+
+			psi.SetFileName(gitPath);
+			psi.SetArguments(mArgs);
+			if (mPath != null)
+				psi.SetWorkingDirectory(mPath);
+			psi.UseShellExecute = false;
+			psi.RedirectStandardError = true;
+			psi.RedirectStandardOutput = true;
+			psi.CreateNoWindow = true;
+
+			mProcess = new SpawnedProcess();
+			if (mProcess.Start(psi) case .Err)
+			{
+				gApp.OutputErrorLine("Failed to execute Git");
+				mFailed = true;
+				return;
+			}
+
+			mOutputThread = new Thread(new => ReadOutputThread);
+			mOutputThread.Start(false);
+
+			mErrorThread = new Thread(new => ReadErrorThread);
+			mErrorThread.Start(false);
+		}
+
+		public void ReadOutputThread()
+		{
+			FileStream fileStream = scope FileStream();
+			if (mProcess.AttachStandardOutput(fileStream) case .Err)
+				return;
+			StreamReader streamReader = scope StreamReader(fileStream, null, false, 4096);
+
+			int count = 0;
+			while (true)
+			{
+				count++;
+				var buffer = scope String();
+				if (streamReader.ReadLine(buffer) case .Err)
+					break;
+				using (mMonitor.Enter())
+				{
+					mDeferredOutput.Add(new .(buffer));
+				}
+			}
+		}
+
+		public void ReadErrorThread()
+		{
+			FileStream fileStream = scope FileStream();
+			if (mProcess.AttachStandardError(fileStream) case .Err)
+				return;
+			StreamReader streamReader = scope StreamReader(fileStream, null, false, 4096);
+
+			while (true)
+			{
+				var buffer = scope String();
+				if (streamReader.ReadLine(buffer) case .Err)
+					break;
+
+				using (mMonitor.Enter())
+				{
+					//mDeferredOutput.Add(new $"{mStopwatch.ElapsedMilliseconds / 1000.0:0.0}: {buffer}");
+					mDeferredOutput.Add(new .(buffer));
+				}
+			}
+		}
+
+		public void Update()
+		{
+			using (mMonitor.Enter())
+			{
+				while (!mDeferredOutput.IsEmpty)
+				{
+					var line = mDeferredOutput.PopFront();
+					defer delete line;
+					//Debug.WriteLine($"GIT: {line}");
+
+					if (line.StartsWith("Cloning into "))
+					{
+						// May be starting a submodule
+						mProgressRecv = 0;
+						mProgressDeltas = 0;
+						mProgressFiles = 0;
+					}
+
+					if (line.StartsWith("remote: Counting objects"))
+					{
+						mProgressRecv = 0.001f;
+					}
+
+					if (line.StartsWith("Receiving objects: "))
+					{
+						var pctStr = line.Substring("Receiving objects: ".Length, 3)..Trim();
+						mProgressRecv = float.Parse(pctStr).GetValueOrDefault() / 100.0f;
+					}
+
+					if (line.StartsWith("Resolving deltas: "))
+					{
+						var pctStr = line.Substring("Resolving deltas: ".Length, 3)..Trim();
+						mProgressDeltas = float.Parse(pctStr).GetValueOrDefault() / 100.0f;
+						mProgressRecv = 1.0f;
+					}
+
+					if (line.StartsWith("Updating files: "))
+					{
+						var pctStr = line.Substring("Updating files: ".Length, 3)..Trim();
+						mProgressFiles = float.Parse(pctStr).GetValueOrDefault() / 100.0f;
+						mProgressRecv = 1.0f;
+						mProgressDeltas = 1.0f;
+					}
+
+					StringView version = default;
+
+					int refTagIdx = line.IndexOf("\trefs/tags/");
+					if (refTagIdx == 40)
+						version = line.Substring(40 + "\trefs/tags/".Length);
+
+					if ((line.Length == 45) && (line.EndsWith("HEAD")))
+						version = "HEAD";
+
+					if (!version.IsEmpty)
+					{
+						TagInfo tagInfo = new .();
+						tagInfo.mHash = new .(line, 0, 40);
+						tagInfo.mTag = new .(version);
+						mTagInfos.Add(tagInfo);
+					}
+				}
+			}
+
+			float pct = 0;
+			if (mProgressRecv > 0)
+				pct = 0.1f + (mProgressRecv * 0.3f) + (mProgressDeltas * 0.4f) + (mProgressFiles * 0.2f);
+
+			if (pct > mProgress)
+			{
+				mProgress = pct;
+				//Debug.WriteLine($"Completed Pct: {pct}");
+			}
+
+			if (mProcess.WaitFor(0))
+			{
+				if (mProcess.ExitCode != 0)
+					mFailed = true;
+				mDone = true;
+			}
+		}
+
+		public void Cancel()
+		{
+			if (!mProcess.WaitFor(0))
+			{
+				//Debug.WriteLine($"GitManager Cancel {mProcess.ProcessId}");
+				IDEUtils.SafeKill(mProcess);
+			}
+		}
+	}
+
+	public const int sMaxActiveGitInstances = 4;
+
+	public List<GitInstance> mGitInstances = new .() ~
+		{
+			for (var gitInstance in _)
+				gitInstance.ReleaseRef();
+			delete _;
+		};
+
+	public void Init()
+	{
+		//StartGit("-v");
+
+		//Repository repository = Clone("https://github.com/llvm/llvm-project", "c:/temp/__LLVM");
+
+		//Repository repository = Clone("https://github.com/Starpelly/raylib-beef", "c:/temp/__RAYLIB");
+		/*while (true)
+		{
+			Thread.Sleep(500);
+			Debug.WriteLine($"Repository {repository.mStatus} {repository.GetCompletedPct()}");
+		}*/
+	}
+
+	public GitInstance StartGit(StringView cmd, StringView path = default)
+	{
+		//Debug.WriteLine($"GIT STARTING: {cmd} in {path}");
+
+		GitInstance gitInst = new .(this);
+		gitInst.Init(cmd, path);
+		mGitInstances.Add(gitInst);
+		return gitInst;
+	}
+
+	public GitInstance Clone(StringView url, StringView path)
+	{
+		return StartGit(scope $"clone -v --progress --recurse-submodules {url} \"{path}\"");
+	}
+
+	public GitInstance Checkout(StringView path, StringView hash)
+	{
+		return StartGit(scope $"checkout -b BeefManaged {hash}", path);
+	}
+
+	public GitInstance GetTags(StringView url)
+	{
+		return StartGit(scope $"ls-remote {url}");
+	}
+
+	public void Update()
+	{
+		for (var gitInstance in mGitInstances)
+		{
+			if (@gitInstance.Index >= sMaxActiveGitInstances)
+				break;
+
+			if (!gitInstance.mStarted)
+				gitInstance.Start();
+			gitInstance.Update();
+
+			if (gitInstance.mDone)
+			{
+				@gitInstance.Remove();
+				gitInstance.mRemoved = true;
+				gitInstance.ReleaseRef();
+			}
+		}
+	}
+}

+ 378 - 16
IDE/src/util/PackMan.bf

@@ -1,35 +1,397 @@
+#pragma warning disable 168
+
 using System;
 using IDE.Util;
-
-#if BF_PLATFORM_WINDOWS
-using static Git.GitApi;
-#define SUPPORT_GIT
-#endif
+using System.Collections;
+using System.Security.Cryptography;
+using System.IO;
+using Beefy.utils;
+using System.Threading;
 
 namespace IDE.util
 {
 	class PackMan
 	{
-		class GitHelper
+		public class WorkItem
 		{
-			static bool sInitialized;
+			public enum Kind
+			{
+				None,
+				FindVersion,
+				Clone,
+				Checkout
+			}
 
-			public this()
+			public Kind mKind;
+			public String mProjectName ~ delete _;
+			public String mURL ~ delete _;
+			public List<String> mConstraints ~ DeleteContainerAndItems!(_);
+			public String mTag ~ delete _;
+			public String mHash ~ delete _;
+			public String mPath ~ delete _;
+			public GitManager.GitInstance mGitInstance ~ _?.ReleaseRef();
+
+			public ~this()
 			{
-				if (!sInitialized)
-				{
-#if SUPPORT_GIT
-#unwarn
-					var result = git_libgit2_init();
-					sInitialized = true;
-#endif
-				}
+				mGitInstance?.Cancel();
 			}
 		}
 
+		public List<WorkItem> mWorkItems = new .() ~ DeleteContainerAndItems!(_);
+		public bool mInitialized;
+		public String mManagedPath ~ delete _;
+		public bool mFailed;
+
+		public void Fail(StringView error)
+		{
+			gApp.OutputErrorLine(error);
+
+			if (!mFailed)
+			{
+				mFailed = true;
+				gApp.[Friend]FlushDeferredLoadProjects();
+			}
+		}
+
+		public bool CheckInit()
+		{
+			if (mInitialized)
+				return true;
+
+			if (gApp.mBeefConfig.mManagedLibPath.IsEmpty)
+				return false;
+
+			mManagedPath = new .(gApp.mBeefConfig.mManagedLibPath);
+			mInitialized = true;
+			return true;
+		}
+
+		public void GetPath(StringView url, StringView hash, String outPath)
+		{
+			//var urlHash = SHA256.Hash(url.ToRawData()).ToString(.. scope .());
+			//outPath.AppendF($"{mManagedPath}/{urlHash}/{hash}");
+			outPath.AppendF($"{mManagedPath}/{hash}");
+		}
+
 		public bool CheckLock(StringView projectName, String outPath)
 		{
+			if (!CheckInit())
+				return false;
+
+			if (gApp.mWantUpdateVersionLocks != null)
+			{
+				if ((gApp.mWantUpdateVersionLocks.IsEmpty) || (gApp.mWantUpdateVersionLocks.ContainsAlt(projectName)))
+					return false;
+			}
+
+			if (!gApp.mWorkspace.mProjectLockMap.TryGetAlt(projectName, ?, var lock))
+				return false;
+
+			switch (lock)
+			{
+			case .Git(let url, let tag, let hash):
+				var path = GetPath(url, hash, .. scope .());
+				var managedFilePath = scope $"{path}/BeefManaged.toml";
+				if (File.Exists(managedFilePath))
+				{
+					outPath.Append(path);
+					outPath.Append("/BeefProj.toml");
+					return true;
+				}
+			default:
+			}
+
 			return false;
 		}
+
+		public void CloneCompleted(StringView projectName, StringView url, StringView tag, StringView hash, StringView path)
+		{
+			gApp.mWorkspace.SetLock(projectName, .Git(new .(url), new .(tag), new .(hash)));
+
+			StructuredData sd = scope .();
+			sd.CreateNew();
+			sd.Add("FileVersion", 1);
+			sd.Add("Version", tag);
+			sd.Add("GitURL", url);
+			sd.Add("GitTag", tag);
+			sd.Add("GitHash", hash);
+			var tomlText = sd.ToTOML(.. scope .());
+			var managedFilePath = scope $"{path}/BeefManaged.toml";
+			File.WriteAllText(managedFilePath, tomlText).IgnoreError();
+		}
+
+		public void GetWithHash(StringView projectName, StringView url, StringView tag, StringView hash)
+		{
+			if (!CheckInit())
+				return;
+
+			String destPath = GetPath(url, hash, .. scope .());
+			var urlPath = Path.GetDirectoryPath(destPath, .. scope .());
+			Directory.CreateDirectory(urlPath).IgnoreError();
+			if (Directory.Exists(destPath))
+			{
+				var managedFilePath = scope $"{destPath}/BeefManaged.toml";
+				if (File.Exists(managedFilePath))
+				{
+					if (gApp.mVerbosity >= .Normal)
+					{
+						if (tag.IsEmpty)
+							gApp.OutputLine($"Git selecting library '{projectName}' at {hash.Substring(0, 7)}");
+						else
+							gApp.OutputLine($"Git selecting library '{projectName}' tag '{tag}' at {hash.Substring(0, 7)}");
+					}
+
+					CloneCompleted(projectName, url, tag, hash, destPath);
+					ProjectReady(projectName, destPath);
+					return;
+				}
+
+				String tempDir = new $"{destPath}__{(int32)Internal.GetTickCountMicro():X}";
+
+				//if (Directory.DelTree(destPath) case .Err)
+				if (Directory.Move(destPath, tempDir) case .Err)
+				{
+					delete tempDir;
+					Fail(scope $"Failed to remove directory '{destPath}'");
+					return;
+				}
+
+				ThreadPool.QueueUserWorkItem(new () =>
+					{
+						Directory.DelTree(tempDir);
+					}
+					~
+					{
+						delete tempDir;
+					});
+			}
+
+			if (gApp.mVerbosity >= .Normal)
+			{
+				if (tag.IsEmpty)
+					gApp.OutputLine($"Git cloning library '{projectName}' at {hash.Substring(0, 7)}...");
+				else
+					gApp.OutputLine($"Git cloning library '{projectName}' tag '{tag}' at {hash.Substring(0, 7)}");
+			}
+
+			WorkItem workItem = new .();
+			workItem.mKind = .Clone;
+			workItem.mProjectName = new .(projectName);
+			workItem.mURL = new .(url);
+			workItem.mTag = new .(tag);
+			workItem.mHash = new .(hash);
+			workItem.mPath = new .(destPath);
+			mWorkItems.Add(workItem);
+		}
+
+		public void GetWithVersion(StringView projectName, StringView url, SemVer semVer)
+		{
+			if (!CheckInit())
+				return;
+
+			bool ignoreLock = false;
+			if (gApp.mWantUpdateVersionLocks != null)
+			{
+				if ((gApp.mWantUpdateVersionLocks.IsEmpty) || (gApp.mWantUpdateVersionLocks.ContainsAlt(projectName)))
+					ignoreLock = true;
+			}
+
+			if ((!ignoreLock) && (gApp.mWorkspace.mProjectLockMap.TryGetAlt(projectName, ?, var lock)))
+			{
+				switch (lock)
+				{
+				case .Git(let checkURL, let tag, let hash):
+					if (checkURL == url)
+						GetWithHash(projectName, url, tag, hash);
+					return;
+				default:
+				}
+			}
+
+			if (gApp.mVerbosity >= .Normal)
+				gApp.OutputLine($"Git retrieving version list for '{projectName}'");
+
+			WorkItem workItem = new .();
+			workItem.mKind = .FindVersion;
+			workItem.mProjectName = new .(projectName);
+			workItem.mURL = new .(url);
+			if (semVer != null)
+				workItem.mConstraints = new .() { new String(semVer.mVersion) };
+			mWorkItems.Add(workItem);
+		}
+
+		public void UpdateGitConstraint(StringView url, SemVer semVer)
+		{
+			for (var workItem in mWorkItems)
+			{
+				if ((workItem.mKind == .FindVersion) && (workItem.mURL == url))
+				{
+					if (workItem.mConstraints == null)
+						workItem.mConstraints = new .();
+					workItem.mConstraints.Add(new String(semVer.mVersion));
+				}
+			}
+		}
+
+		public void Checkout(StringView projectName, StringView url, StringView path, StringView tag, StringView hash)
+		{
+			if (!CheckInit())
+				return;
+
+			WorkItem workItem = new .();
+			workItem.mKind = .Checkout;
+			workItem.mProjectName = new .(projectName);
+			workItem.mURL = new .(url);
+			workItem.mTag = new .(tag);
+			workItem.mHash = new .(hash);
+			workItem.mPath = new .(path);
+			mWorkItems.Add(workItem);
+		}
+
+		public void ProjectReady(StringView projectName, StringView path)
+		{
+			if (var project = gApp.mWorkspace.FindProject(projectName))
+			{
+				String projectPath = scope $"{path}/BeefProj.toml";
+
+				project.mProjectPath.Set(projectPath);
+				gApp.RetryProjectLoad(project, false);
+			}
+		}
+
+		public void Update()
+		{
+			bool executingGit = false;
+
+			// First handle active git items
+			for (var workItem in mWorkItems)
+			{
+				if (workItem.mGitInstance == null)
+					continue;
+
+				if (!workItem.mGitInstance.mDone)
+				{
+					executingGit = true;
+					continue;
+				}
+
+				if (!workItem.mGitInstance.mFailed)
+				{
+					switch (workItem.mKind)
+					{
+					case .FindVersion:
+						gApp.CompilerLog("");
+
+						StringView bestTag = default;
+						StringView bestHash = default;
+
+						for (var tag in workItem.mGitInstance.mTagInfos)
+						{
+							if ((tag.mTag == "HEAD") && (workItem.mConstraints == null))
+								bestHash = tag.mHash;
+							else if (workItem.mConstraints != null)
+							{
+								bool hasMatch = false;
+								for (var constraint in workItem.mConstraints)
+								{
+									if (SemVer.IsVersionMatch(tag.mTag, constraint))
+									{
+										hasMatch = true;
+										break;
+									}
+								}
+
+								if (hasMatch)
+								{
+									if ((bestTag.IsEmpty) || (SemVer.Compare(tag.mTag, bestTag) > 0))
+									{
+										bestTag = tag.mTag;
+										bestHash = tag.mHash;
+									}
+								}
+							}
+						}
+
+						if (bestHash != default)
+						{
+							GetWithHash(workItem.mProjectName, workItem.mURL, bestTag, bestHash);
+						}
+						else
+						{
+							String constraints = scope .();
+							for (var constraint in workItem.mConstraints)
+							{
+								if (!constraints.IsEmpty)
+									constraints.Append(", ");
+								constraints.Append('\'');
+								constraints.Append(constraint);
+								constraints.Append('\'');
+							}
+
+							Fail(scope $"Failed to locate version for '{workItem.mProjectName}' with constraints '{constraints}'");
+						}
+					case .Clone:
+						Checkout(workItem.mProjectName, workItem.mURL, workItem.mPath, workItem.mTag, workItem.mHash);
+					case .Checkout:
+						CloneCompleted(workItem.mProjectName, workItem.mURL, workItem.mTag, workItem.mHash, workItem.mPath);
+						ProjectReady(workItem.mProjectName, workItem.mPath);
+
+						if (gApp.mVerbosity >= .Normal)
+							gApp.OutputLine($"Git cloning library '{workItem.mProjectName}' done.");
+					default:
+					}
+				}
+
+				@workItem.Remove();
+				delete workItem;
+			}
+
+			if (!executingGit)
+			{
+				// First handle active git items
+				for (var workItem in mWorkItems)
+				{
+					if (workItem.mGitInstance != null)
+						continue;
+
+					switch (workItem.mKind)
+					{
+					case .FindVersion:
+						workItem.mGitInstance = gApp.mGitManager.GetTags(workItem.mURL)..AddRef();
+					case .Checkout:
+						workItem.mGitInstance = gApp.mGitManager.Checkout(workItem.mPath, workItem.mHash)..AddRef();
+					case .Clone:
+						workItem.mGitInstance = gApp.mGitManager.Clone(workItem.mURL, workItem.mPath)..AddRef();
+					default:
+					}
+				}
+			}
+		}
+
+		public void GetHashFromFilePath(StringView filePath, String path)
+		{
+			if (mManagedPath == null)
+				return;
+
+			if (!filePath.StartsWith(mManagedPath))
+				return;
+
+			StringView hashPart = filePath.Substring(mManagedPath.Length);
+			if (hashPart.Length < 42)
+				return;
+
+			hashPart.RemoveFromStart(1);
+			hashPart.Length = 40;
+			path.Append(hashPart);
+		}
+
+		public void CancelAll()
+		{
+			if (mWorkItems.IsEmpty)
+				return;
+
+			Fail("Aborted project transfer");
+			mWorkItems.ClearAndDeleteItems();
+		}
 	}
 }

+ 276 - 0
IDE/src/util/SemVer.bf

@@ -2,10 +2,51 @@ using System;
 
 namespace IDE.Util
 {
+	[Reflect]
 	class SemVer
 	{
+		public struct Parts
+		{
+			public enum Kind
+			{
+				case Empty;
+				case Num(int32 val);
+				case Wild;
+
+				public int32 NumOrDefault
+				{
+					get
+					{
+						if (this case .Num(let val))
+							return val;
+						return 0;
+					}
+				}
+			}
+
+			public Kind[3] mPart;
+			public StringView mPreRelease;
+
+			public Kind Major => mPart[0];
+			public Kind Minor => mPart[1];
+			public Kind Patch => mPart[2];
+		}
+
+		enum CompareKind
+		{
+			Caret, // Default
+			Tilde,
+			Equal,
+			Gt,
+			Gte,
+			Lt,
+			Lte
+		}
+
 		public String mVersion ~ delete _;
 
+		public bool IsEmpty => String.IsNullOrEmpty(mVersion);
+
 		public this()
 		{
 
@@ -27,5 +68,240 @@ namespace IDE.Util
 			mVersion = new String(ver);
 			return .Ok;
 		}
+
+		public static Result<Parts> GetParts(StringView version)
+		{
+			int startIdx = 0;
+			int partIdx = 0;
+
+			if (version.IsEmpty)
+				return .Err;
+
+			if (version.StartsWith("V", .OrdinalIgnoreCase))
+				startIdx++;
+
+			Parts parts = .();
+
+			Result<void> SetPart(Parts.Kind kind)
+			{
+				if (partIdx >= 3)
+					return .Err;
+				parts.mPart[partIdx] = kind;
+				partIdx++;
+				return .Ok;
+			}
+
+			Result<void> FlushPart(int i)
+			{
+				StringView partStr = version.Substring(startIdx, i - startIdx);
+				if (!partStr.IsEmpty)
+				{
+					int32 partNum = Try!(int32.Parse(partStr));
+					Try!(SetPart(.Num(partNum)));
+				}
+				return .Ok;
+			}
+
+			for (int i in startIdx ..< version.Length)
+			{
+				char8 c = version[i];
+				if (c.IsWhiteSpace)
+					return .Err;
+
+				if (c == '.')
+				{
+					Try!(FlushPart(i));
+					startIdx = i + 1;
+					continue;
+				}
+				else if (c.IsNumber)
+				{
+					continue;
+				}
+				else if (c == '-')
+				{
+					if (partIdx == 0)
+						return .Err;
+					parts.mPreRelease = version.Substring(i);
+					return .Ok(parts);
+				}
+				else if (c == '*')
+				{
+					Try!(SetPart(.Wild));
+					continue;
+				}
+
+				return .Err;
+			}
+			Try!(FlushPart(version.Length));
+
+			return parts;
+		}
+
+		public Result<Parts> GetParts()
+		{
+			return GetParts(mVersion);
+		}
+
+		public static bool IsVersionMatch(StringView fullVersion, StringView wildcard)
+		{
+			int commaPos = wildcard.IndexOf(',');
+			if (commaPos != -1)
+				return IsVersionMatch(fullVersion, wildcard.Substring(0, commaPos)..Trim()) && IsVersionMatch(fullVersion, wildcard.Substring(commaPos + 1)..Trim());
+
+			var wildcard;
+
+			wildcard.Trim();
+			CompareKind compareKind = .Caret;
+			if (wildcard.StartsWith('^'))
+			{
+				compareKind = .Caret;
+				wildcard.RemoveFromStart(1);
+			}
+			else if (wildcard.StartsWith('~'))
+			{
+				compareKind = .Tilde;
+				wildcard.RemoveFromStart(1);
+			}
+			else if (wildcard.StartsWith('='))
+			{
+				compareKind = .Equal;
+				wildcard.RemoveFromStart(1);
+			}
+			else if (wildcard.StartsWith('>'))
+			{
+				compareKind = .Gt;
+				wildcard.RemoveFromStart(1);
+				if (wildcard.StartsWith('='))
+				{
+					compareKind = .Gte;
+					wildcard.RemoveFromStart(1);
+				}
+			}
+			else if (wildcard.StartsWith('<'))
+			{
+				compareKind = .Lt;
+				wildcard.RemoveFromStart(1);
+				if (wildcard.StartsWith('='))
+				{
+					compareKind = .Lte;
+					wildcard.RemoveFromStart(1);
+				}
+			}
+			wildcard.Trim();
+
+			// Does we include equality?
+			if ((compareKind != .Gt) && (compareKind != .Lt))
+			{
+				if (fullVersion == wildcard)
+					return true;
+			}
+
+			Parts full;
+			if (!(GetParts(fullVersion) case .Ok(out full)))
+				return false;
+			Parts wild;
+			if (!(GetParts(wildcard) case .Ok(out wild)))
+				return false;
+
+			// Don't allow a general wildcard to match a pre-prelease
+			if ((!full.mPreRelease.IsEmpty) && (full.mPreRelease != wild.mPreRelease))
+				return false;
+
+			for (int partIdx < 3)
+			{
+				if (wild.mPart[partIdx] case .Wild)
+					return true;
+				int comp = full.mPart[partIdx].NumOrDefault <=> wild.mPart[partIdx].NumOrDefault;
+				switch (compareKind)
+				{
+				case .Caret:
+					if ((full.mPart[partIdx].NumOrDefault > 0) || (wild.mPart[partIdx].NumOrDefault > 0))
+					{
+						if (comp != 0)
+							return false;
+						// First number matches, now make sure we are at least a high enough version on the other numbers
+						compareKind = .Gte;
+					}
+				case .Tilde:
+					if (wild.mPart[partIdx] case .Empty)
+						return true;
+					if (partIdx == 2)
+					{
+						if (comp < 0)
+							return false;
+					}
+					else if (comp != 0)
+						return false;
+				case .Equal:
+					if (wild.mPart[partIdx] case .Empty)
+						return true;
+					if (comp != 0)
+						return false;
+				case .Gt:
+					if (comp > 0)
+						return true;
+					if (partIdx == 2)
+						return false;
+					if (comp < 0)
+						return false;
+				case .Gte:
+					if (comp < 0)
+						return false;
+				case .Lt:
+					if (comp < 0)
+						return true;
+					if (partIdx == 2)
+						return false;
+					if (comp > 0)
+						return false;
+				case .Lte:
+					if (comp > 0)
+						return false;
+				default:
+				}
+			}
+
+			return true;
+		}
+
+		public static bool IsVersionMatch(SemVer fullVersion, SemVer wildcard) => IsVersionMatch(fullVersion.mVersion, wildcard.mVersion);
+
+		public static Result<int> Compare(StringView lhs, StringView rhs)
+		{
+			Parts lhsParts;
+			if (!(GetParts(lhs) case .Ok(out lhsParts)))
+				return .Err;
+			Parts rhsParts;
+			if (!(GetParts(rhs) case .Ok(out rhsParts)))
+				return .Err;
+
+			int comp = 0;
+			for (int partIdx < 3)
+			{
+				comp = lhsParts.mPart[partIdx].NumOrDefault <=> rhsParts.mPart[partIdx].NumOrDefault;
+				if (comp != 0)
+					return comp;
+			}
+
+			// Don't allow a general wildcard to match a pre-prelease
+			if ((!lhsParts.mPreRelease.IsEmpty) || (!rhsParts.mPreRelease.IsEmpty))
+			{
+				if (lhsParts.mPreRelease.IsEmpty)
+					return 1;
+				if (rhsParts.mPreRelease.IsEmpty)
+					return -1;
+				return lhsParts.mPreRelease <=> rhsParts.mPreRelease;
+			}
+
+			return comp;
+		}
+
+		public override void ToString(String strBuffer)
+		{
+			strBuffer.Append(mVersion);
+		}
+
+		public static int operator<=>(Self lhs, Self rhs) => (lhs?.mVersion ?? "") <=> (rhs?.mVersion ?? "");
 	}
 }

+ 3 - 1
IDE/src/util/VerSpec.bf

@@ -39,6 +39,8 @@ namespace IDE.Util
 			case .Path(let path):
 				return .Path(new String(path));
 			case .Git(let url, let ver):
+				if (ver == null)
+					return .Git(new String(url), null);
 				return .Git(new String(url), new SemVer(ver));
 			}
 		}
@@ -113,7 +115,7 @@ namespace IDE.Util
 				using (data.CreateObject(name))
 				{
 					data.Add("Git", path);
-					if (ver != null)
+					if ((ver != null) && (!ver.mVersion.IsEmpty))
 						data.Add("Version", ver.mVersion);
 				}
 			case .SemVer(var ver):