diff --git a/.gitignore b/.gitignore index a297cf23..0bd4a0a8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ **/Release */* **/Release_*/* **/build/* +**/recovery/* **/.vs/* BeefSpace_User.toml lld-link.exe diff --git a/IDE/src/FileEditData.bf b/IDE/src/FileEditData.bf index 7298a678..9e710624 100644 --- a/IDE/src/FileEditData.bf +++ b/IDE/src/FileEditData.bf @@ -41,6 +41,7 @@ namespace IDE public bool mHadRefusedFileChange; public bool mFileDeleted; + public MD5Hash mRecoveryHash; public MD5Hash mMD5Hash; public SHA256Hash mSHA256Hash; diff --git a/IDE/src/FileRecovery.bf b/IDE/src/FileRecovery.bf new file mode 100644 index 00000000..9863e80a --- /dev/null +++ b/IDE/src/FileRecovery.bf @@ -0,0 +1,270 @@ +using System; +using System.Collections; +using System.Threading; +using System.Security.Cryptography; +using System.IO; +using Beefy; + +namespace IDE +{ + class FileRecovery + { + public class Entry + { + public FileRecovery mFileRecovery; + public String mPath ~ delete _; + public String mContents ~ delete _; + public String mRecoveryFileName ~ delete _; + public MD5Hash mLastSavedHash; + public MD5Hash mContentHash; + public int mCursorPos; + + public this() + { + + } + + public this(FileRecovery fileRecovery, StringView path, MD5Hash lastSavedHash, StringView contents, int cursorPos) + { + mFileRecovery = fileRecovery; + mPath = new String(path); + mContents = new String(contents); + mLastSavedHash = lastSavedHash; + mCursorPos = cursorPos; + using (mFileRecovery.mMonitor.Enter()) + { + mFileRecovery.mFileSet.Add(this); + mFileRecovery.mDirty = true; + } + } + + public ~this() + { + if (mFileRecovery != null) + { + using (mFileRecovery.mMonitor.Enter()) + { + mFileRecovery.mFileSet.Remove(this); + } + } + } + } + + Monitor mMonitor = new .() ~ delete _; + HashSet mFileSet = new .() ~ DeleteContainerAndItems!(_); + bool mDirty = false; + WaitEvent mProcessingEvent ~ delete _; + String mWorkspaceDir = new String() ~ delete _; + bool mWantWorkspaceCleanup; + + public ~this() + { + if (mProcessingEvent != null) + mProcessingEvent.WaitFor(); + } + + public void GetRecoveryFileName(StringView filePath, String recoveryFileName) + { + Path.GetFileNameWithoutExtension(filePath, recoveryFileName); + recoveryFileName.Append("_"); + var hash = MD5.Hash(.((.)filePath.Ptr, filePath.Length)); + hash.ToString(recoveryFileName); + Path.GetExtension(filePath, recoveryFileName); + } + + public void Process() + { + defer mProcessingEvent.Set(true); + + HashSet serializedFileNames = scope .(); + List indexEntries = scope .(); + defer ClearAndDeleteItems(indexEntries); + + bool wantWorkspaceCleanup = false; + using (mMonitor.Enter()) + { + for (var entry in mFileSet) + { + if (entry.mRecoveryFileName == null) + { + entry.mRecoveryFileName = new String(); + GetRecoveryFileName(entry.mPath, entry.mRecoveryFileName); + } + + if (entry.mContents != null) + { + entry.mContentHash = MD5.Hash(.((.)entry.mContents.Ptr, entry.mContents.Length)); + } + + if (entry.mRecoveryFileName != null) + serializedFileNames.Add(entry.mRecoveryFileName); + + Entry indexEntry = new Entry(); + indexEntry.mPath = new String(entry.mPath); + indexEntry.mRecoveryFileName = new String(entry.mRecoveryFileName); + indexEntry.mLastSavedHash = entry.mLastSavedHash; + indexEntry.mContents = entry.mContents; + indexEntry.mContentHash = entry.mContentHash; + indexEntry.mCursorPos = entry.mCursorPos; + entry.mContents = null; + indexEntries.Add(indexEntry); + } + + wantWorkspaceCleanup = mWantWorkspaceCleanup; + mDirty = false; + mWantWorkspaceCleanup = false; + } + + String recoverPath = scope String(); + recoverPath.Append(mWorkspaceDir); + recoverPath.Append("/recovery"); + + if (wantWorkspaceCleanup) + { + DateTime timeNow = DateTime.Now; + for (var entry in Directory.EnumerateFiles(recoverPath)) + { + String filePath = scope .(); + entry.GetFilePath(filePath); + // Just being paranoid + if ((!filePath.Contains('_')) || (filePath.Length < 33)) + continue; + DateTime lastWriteTime = entry.GetLastWriteTime(); + if ((timeNow - lastWriteTime).TotalDays > 7) + File.Delete(filePath).IgnoreError(); + } + } + + if (mFileSet.IsEmpty) + return; + + + if (Directory.CreateDirectory(recoverPath) case .Err) + return; + + indexEntries.Sort(scope (lhs, rhs) => lhs.mRecoveryFileName <=> rhs.mRecoveryFileName); + + String index = scope .(); + + for (var indexEntry in indexEntries) + { + if (indexEntry.mContents != null) + { + String outPath = scope String()..AppendF("{}/{}", recoverPath, indexEntry.mRecoveryFileName); + File.WriteAllText(outPath, indexEntry.mContents).IgnoreError(); + } + + index.AppendF("{}\t{}\t{}\t{}\n", indexEntry.mPath, indexEntry.mLastSavedHash, indexEntry.mContentHash, indexEntry.mCursorPos); + } + + String indexPath = scope .(); + indexPath.AppendF("{}/index.txt", recoverPath); + File.WriteAllText(indexPath, index).IgnoreError(); + } + + public void WorkspaceClosed() + { + if (!gApp.mWorkspace.IsInitialized) + return; + + String indexPath = scope String(); + indexPath.Append(gApp.mWorkspace.mDir); + indexPath.Append("/recovery/index.txt"); + File.Delete(indexPath).IgnoreError(); + } + + public void CheckWorkspace() + { + mWantWorkspaceCleanup = true; + + String recoverPath = scope String(); + recoverPath.Append(gApp.mWorkspace.mDir); + recoverPath.Append("/recovery"); + + String indexPath = scope .(); + indexPath.AppendF("{}/index.txt", recoverPath); + + String index = scope .(); + File.ReadAllText(indexPath, index).IgnoreError(); + + bool ReadFile(StringView path, String outText, out MD5Hash hash) + { + if (Utils.LoadTextFile(path, outText) case .Err) + { + hash = default; + return false; + } + hash = MD5.Hash(.((.)outText.Ptr, outText.Length)); + return true; + } + + for (var line in index.Split('\n')) + { + var lineEnum = line.Split('\t'); + StringView savedFilePath; + StringView indexSavedHashStr; + StringView indexRecoveryHashStr; + StringView cursorPosStr; + if (!(lineEnum.GetNext() case .Ok(out savedFilePath))) continue; + if (!(lineEnum.GetNext() case .Ok(out indexSavedHashStr))) continue; + if (!(lineEnum.GetNext() case .Ok(out indexRecoveryHashStr))) continue; + if (!(lineEnum.GetNext() case .Ok(out cursorPosStr))) continue; + + MD5Hash indexSavedHash = MD5Hash.Parse(indexSavedHashStr).GetValueOrDefault(); + if (indexSavedHash.IsZero) + continue; + MD5Hash indexRecoveryHash = MD5Hash.Parse(indexRecoveryHashStr).GetValueOrDefault(); + if (indexRecoveryHash.IsZero) + continue; + + MD5Hash savedHash; + String savedFileContents = scope String(); + ReadFile(savedFilePath, savedFileContents, out savedHash); + if (savedHash != indexSavedHash) + continue; + + String recoveryFilePath = scope String()..AppendF("{}/", recoverPath); + GetRecoveryFileName(savedFilePath, recoveryFilePath); + MD5Hash recoveryFileHash; + String recoveryFileContents = scope String(); + ReadFile(recoveryFilePath, recoveryFileContents, out recoveryFileHash); + if (recoveryFileHash != indexRecoveryHash) + continue; + + var sourceViewPanel = gApp.ShowSourceFile(scope String(savedFilePath)); + if (sourceViewPanel == null) + continue; + + sourceViewPanel.mEditData.mRecoveryHash = savedHash; + sourceViewPanel.mEditWidget.SetText(recoveryFileContents); + sourceViewPanel.mEditWidget.mEditWidgetContent.CursorTextPos = int.Parse(cursorPosStr).GetValueOrDefault(); + sourceViewPanel.mEditWidget.mEditWidgetContent.EnsureCursorVisible(); + + gApp.OutputLine("File recovered: {}", savedFilePath); + } + } + + public void Update() + { + if (mProcessingEvent != null) + { + if (!mProcessingEvent.WaitFor(0)) + return; + DeleteAndNullify!(mProcessingEvent); + } + + using (mMonitor.Enter()) + { + if ((!mDirty) && (!mWantWorkspaceCleanup)) + return; + } + + if (!gApp.mWorkspace.IsInitialized) + return; + + mWorkspaceDir.Set(gApp.mWorkspace.mDir); + mProcessingEvent = new WaitEvent(); + ThreadPool.QueueUserWorkItem(new => Process); + } + } +} diff --git a/IDE/src/IDEApp.bf b/IDE/src/IDEApp.bf index 4a73b3cc..f421320b 100644 --- a/IDE/src/IDEApp.bf +++ b/IDE/src/IDEApp.bf @@ -217,6 +217,9 @@ namespace IDE public Settings mSettings = new Settings() ~ delete _; public Workspace mWorkspace = new Workspace() ~ delete _; public FileWatcher mFileWatcher = new FileWatcher() ~ delete _; +#if !CLI + public FileRecovery mFileRecovery = new FileRecovery() ~ delete _; +#endif public int mLastFileChangeId; public bool mHaveSourcesChangedInternallySinceLastCompile; public bool mHaveSourcesChangedExternallySinceLastCompile; @@ -630,6 +633,7 @@ namespace IDE mSettings.Save(); SaveDefaultLayoutData(); } + mFileRecovery.WorkspaceClosed(); #endif /*WithTabs(scope (tabButton) => @@ -2245,6 +2249,10 @@ namespace IDE mDockingFrame = mMainFrame.mDockingFrame; //mMainFrame.AddedToParent; +#if !CLI + mFileRecovery.WorkspaceClosed(); +#endif + delete mWorkspace; mWorkspace = new Workspace(); @@ -2594,6 +2602,9 @@ namespace IDE #if !CLI if (mBfResolveCompiler != null) mBfResolveCompiler.QueueSetWorkspaceOptions(null, 0); + + if (mMainWindow != null) + mFileRecovery.CheckWorkspace(); #endif String relaunchCmd = scope .(); @@ -6184,7 +6195,7 @@ namespace IDE tabbedView.AddTab(newTabButton); newTabButton.mCloseClickedEvent.Add(new () => DocumentCloseClicked(sourceViewPanel)); newTabButton.Activate(setFocus); - if (setFocus) + if ((setFocus) && (sourceViewPanel.mWidgetWindow != null)) sourceViewPanel.FocusEdit(); return sourceViewPanel; @@ -11198,6 +11209,9 @@ namespace IDE ShowPanel(mOutputPanel, false); UpdateRecentFileMenuItems(); ShowStartupFile(); +#if !CLI + mFileRecovery.CheckWorkspace(); +#endif if ((mIsFirstRun) && (!mWorkspace.IsInitialized)) ShowWelcome(); @@ -12587,6 +12601,9 @@ namespace IDE void UpdateWorkspace() { mFileWatcher.Update(); +#if !CLI + mFileRecovery.Update(); +#endif bool appHasFocus = false; for (var window in mWindows) diff --git a/IDE/src/ui/SourceEditWidgetContent.bf b/IDE/src/ui/SourceEditWidgetContent.bf index 4c9c4b35..88af7db7 100644 --- a/IDE/src/ui/SourceEditWidgetContent.bf +++ b/IDE/src/ui/SourceEditWidgetContent.bf @@ -515,6 +515,12 @@ namespace IDE.ui mSourceViewPanel.mLockFlashPct = 0.00001f; return true; } + + if (mSourceViewPanel != null) + { + mSourceViewPanel.CheckSavedContents(); + } + return false; } if (mSourceViewPanel != null) diff --git a/IDE/src/ui/SourceViewPanel.bf b/IDE/src/ui/SourceViewPanel.bf index 5a3ca2e6..69758307 100644 --- a/IDE/src/ui/SourceViewPanel.bf +++ b/IDE/src/ui/SourceViewPanel.bf @@ -300,6 +300,7 @@ namespace IDE.ui public SourceEditWidget mEditWidget; public ProjectSource mProjectSource; public FileEditData mEditData; + public FileRecovery.Entry mFileRecoveryEntry ~ delete _; public List mTrackedTextElementViewList ~ DeleteContainerAndItems!(_); public List mErrorList = new List() ~ DeleteContainerAndItems!(_); public List mDeferredResolveResults = new .() ~ DeleteContainerAndItems!(_); @@ -314,6 +315,7 @@ namespace IDE.ui public int32 mLastTextVersionId; public int32 mAutocompleteTextVersionId; public int32 mClassifiedTextVersionId; + public int32 mLastRecoveryTextVersionId; public bool mLoadFailed; String mOldVerLoadCmd ~ delete _; HTTPRequest mOldVerHTTPRequest ~ delete _; @@ -857,7 +859,20 @@ namespace IDE.ui return !char8IdData.IsRangeEqual(mEditWidget.mEditWidgetContent.mData.mTextIdData.GetPrepared(), char8IdStart, char8IdEnd); } - + + public void CheckSavedContents() + { + if (mEditData.mLastFileTextVersion == mEditWidget.Content.mData.mCurTextVersionId) + { + if ((mEditData != null) && (mEditData.mRecoveryHash.IsZero)) + { + String text = scope .(); + mEditWidget.GetText(text); + mEditData.mRecoveryHash = MD5.Hash(.((uint8*)text.Ptr, text.Length)); + } + } + } + public void FileSaved() { ClearLoadFailed(); @@ -880,6 +895,9 @@ namespace IDE.ui } gApp.FileChanged(mFilePath); + DeleteAndNullify!(mFileRecoveryEntry); + mLastRecoveryTextVersionId = mEditWidget.Content.mData.mCurTextVersionId; + mEditData.mRecoveryHash = default; } void ClassifyThreadDone() @@ -2936,6 +2954,7 @@ namespace IDE.ui if ((mEditData != null) && (mFilePath != null)) Debug.Assert(Path.Equals(mFilePath, mEditData.mFilePath)); + mLastRecoveryTextVersionId = mEditWidget.Content.mData.mCurTextVersionId; return true; } @@ -6130,6 +6149,21 @@ namespace IDE.ui mTicksSinceTextChanged++; } + if ((mLastRecoveryTextVersionId != mEditWidget.Content.mData.mCurTextVersionId) && (mTicksSinceTextChanged >= 16)) + { +#if !CLI + DeleteAndNullify!(mFileRecoveryEntry); + if ((mFilePath != null) && (mEditData != null) && (!mEditData.mRecoveryHash.IsZero)) + { + String contents = scope .(); + mEditWidget.GetText(contents); + mFileRecoveryEntry = new .(gApp.mFileRecovery, mFilePath, mEditData.mRecoveryHash, contents, mEditWidget.mEditWidgetContent.CursorTextPos); + } +#endif + + mLastRecoveryTextVersionId = mEditWidget.Content.mData.mCurTextVersionId; + } + //TODO: This is just a test! /*if (Rand.Float()