diff --git a/Source/LoreSourceControl/LoreSourceControl.Build.cs b/Source/LoreSourceControl/LoreSourceControl.Build.cs index 29181f7..e49040a 100644 --- a/Source/LoreSourceControl/LoreSourceControl.Build.cs +++ b/Source/LoreSourceControl/LoreSourceControl.Build.cs @@ -22,6 +22,7 @@ public LoreSourceControl(ReadOnlyTargetRules Target) : base(Target) "DesktopWidgets", "DeveloperSettings", "Engine", + "InputCore", "Json", "MainFrame", "SourceControl", diff --git a/Source/LoreSourceControl/Private/LoreSourceControlModule.cpp b/Source/LoreSourceControl/Private/LoreSourceControlModule.cpp index ddbbb50..04cecae 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlModule.cpp +++ b/Source/LoreSourceControl/Private/LoreSourceControlModule.cpp @@ -4,6 +4,7 @@ #include "LoreSourceControlOperations.h" #include "LoreSourceControlProvider.h" #include "LoreSourceControlUtils.h" +#include "SLoreBranchHistory.h" #include "Misc/App.h" #include "Modules/ModuleManager.h" #include "Features/IModularFeatures.h" @@ -20,7 +21,9 @@ #include "Logging/MessageLog.h" #include "Framework/Notifications/NotificationManager.h" #include "Framework/Application/SlateApplication.h" +#include "Framework/Docking/TabManager.h" #include "Widgets/Notifications/SNotificationList.h" +#include "Widgets/Docking/SDockTab.h" #include "Widgets/SWindow.h" #include "Widgets/Input/SComboButton.h" #include "Widgets/Layout/SScrollBox.h" @@ -43,6 +46,7 @@ #define LOCTEXT_NAMESPACE "LoreSourceControl" #if SOURCE_CONTROL_WITH_SLATE +static const FName LoreBranchHistoryTabName(TEXT("LoreSourceControl.BranchHistory")); static TWeakPtr ShowLoreProgressNotification(const FText& Text) { FNotificationInfo Info(Text); @@ -108,6 +112,7 @@ void FLoreSourceControlModule::StartupModule() LoreSourceControlProvider.RegisterWorker("Unlock", FLoreGetSourceControlWorker::CreateStatic(&CreateLoreWorker)); LoreSourceControlProvider.RegisterWorker("LorePrintStatus", FLoreGetSourceControlWorker::CreateStatic(&CreateLoreWorker)); LoreSourceControlProvider.RegisterWorker("LoreRefreshBranches", FLoreGetSourceControlWorker::CreateStatic(&CreateLoreWorker)); + LoreSourceControlProvider.RegisterWorker("LoreRefreshBranchHistory", FLoreGetSourceControlWorker::CreateStatic(&CreateLoreWorker)); LoreSourceControlProvider.RegisterWorker("LoreSwitchBranch", FLoreGetSourceControlWorker::CreateStatic(&CreateLoreWorker)); // Load settings (binary path, etc.) @@ -159,8 +164,7 @@ void FLoreSourceControlModule::StartupModule() #if SOURCE_CONTROL_WITH_SLATE if (ISourceControlModule::Get().GetProvider().GetName() == LoreSourceControlProvider.GetName()) { - // Same dialog as the toolbar's "Submit Content" - drives our CheckIn worker and pushes when a remote is configured. - FSourceControlWindows::ChoosePackagesToCheckIn(); + OpenLoreCommitDialog(); } #endif }), @@ -173,6 +177,19 @@ void FLoreSourceControlModule::StartupModule() if (FSlateApplication::IsInitialized()) { FLoreSourceControlCommands::Register(); + FGlobalTabmanager::Get()->RegisterNomadTabSpawner( + LoreBranchHistoryTabName, + FOnSpawnTab::CreateRaw(this, &FLoreSourceControlModule::SpawnBranchHistoryTab)) + .SetDisplayName(LOCTEXT("LoreBranchHistoryTabTitle", "Branch History")) + .SetTooltipText(LOCTEXT("LoreBranchHistoryTabTooltip", "View revisions on the current Lore branch")) + .SetIcon(FSlateIcon(FRevisionControlStyleManager::GetStyleSetName(), "RevisionControl.ChangelistsTab")) + .SetMenuType(ETabSpawnerMenuType::Hidden); + + LoreBranchHistoryCommand = IConsoleManager::Get().RegisterConsoleCommand( + TEXT("LoreSourceControl.FocusBranchHistory"), + TEXT("Open the Lore branch history tab."), + FConsoleCommandDelegate::CreateRaw(this, &FLoreSourceControlModule::ShowBranchHistoryTab), + ECVF_Default); // The engine's own Revision Control widget registers inside SStatusBar::Construct(), which only runs once the level editor's main tab is built - well after this module loads. // Registering via UToolMenus::RegisterStartupCallback (module load time) would be too early for that widget to exist yet, so wait for the main frame instead. @@ -220,6 +237,12 @@ void FLoreSourceControlModule::ShutdownModule() LoreCommitCommand = nullptr; } + if (LoreBranchHistoryCommand) + { + ConsoleManager.UnregisterConsoleObject(LoreBranchHistoryCommand, false); + LoreBranchHistoryCommand = nullptr; + } + #if SOURCE_CONTROL_WITH_SLATE if (IMainFrameModule* MainFrameModule = FModuleManager::GetModulePtr("MainFrame")) { @@ -231,6 +254,7 @@ void FLoreSourceControlModule::ShutdownModule() if (FSlateApplication::IsInitialized()) { + FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(LoreBranchHistoryTabName); if (const TSharedPtr Notification = SyncNotification.Pin()) { Notification->ExpireAndFadeout(); @@ -307,6 +331,7 @@ void FLoreSourceControlModule::RefreshToolbarExtension() const { if (UToolMenus* ToolMenus = UToolMenus::TryGet()) { + ToolMenus->RefreshMenuWidget(TEXT("StatusBar.ToolBar.SourceControl")); for (const FName& MenuName : RegisteredToolbarMenus) { ToolMenus->RefreshMenuWidget(MenuName); @@ -316,6 +341,7 @@ void FLoreSourceControlModule::RefreshToolbarExtension() const void FLoreSourceControlModule::RegisterToolbarExtension() { + RegisterRevisionControlMenuExtension(); RegisterToolbarExtensionForMenu(TEXT("LevelEditor.StatusBar.ToolBar")); // Asset editors (Blueprint, Material, etc.) don't share the level editor's status bar and have no fixed menu name to register against ahead of time. @@ -329,6 +355,70 @@ void FLoreSourceControlModule::RegisterToolbarExtension() } } +void FLoreSourceControlModule::RegisterRevisionControlMenuExtension() +{ + if (bRevisionControlMenuRegistered) + { + return; + } + + FToolMenuOwnerScoped OwnerScoped(this); + UToolMenu* Menu = UToolMenus::Get()->ExtendMenu(TEXT("StatusBar.ToolBar.SourceControl")); + if (!Menu) + { + return; + } + + bRevisionControlMenuRegistered = true; + FToolMenuSection& Section = Menu->FindOrAddSection(TEXT("SourceControlActions")); + Section.AddDynamicEntry(TEXT("LoreCommitChanges"), FNewToolMenuSectionDelegate::CreateLambda([this](FToolMenuSection& InSection) + { + const bool bLoreIsActiveProvider = ISourceControlModule::Get().GetProvider().GetName() == LoreSourceControlProvider.GetName(); + if (!bLoreIsActiveProvider || !LoreSourceControlProvider.IsAvailable()) + { + return; + } + + InSection.AddMenuEntry( + TEXT("LoreCommitChanges"), + LOCTEXT("LoreCommitChanges", "View / Commit Changes..."), + LOCTEXT("LoreCommitChangesTooltip", "View pending Lore changes and optionally commit and push selected files"), + FSlateIcon(FRevisionControlStyleManager::GetStyleSetName(), TEXT("RevisionControl.Actions.Submit")), + FUIAction( + FExecuteAction::CreateRaw(this, &FLoreSourceControlModule::OpenLoreCommitDialog), + FCanExecuteAction::CreateRaw(this, &FLoreSourceControlModule::CanOpenLoreCommitDialog))); + })); +} + +void FLoreSourceControlModule::OpenLoreCommitDialog() const +{ + if (ISourceControlModule::Get().GetProvider().GetName() == LoreSourceControlProvider.GetName()) + { + FSourceControlWindows::ChoosePackagesToCheckIn(); + } +} + +bool FLoreSourceControlModule::CanOpenLoreCommitDialog() const +{ + return ISourceControlModule::Get().GetProvider().GetName() == LoreSourceControlProvider.GetName() + && LoreSourceControlProvider.IsAvailable() + && FSourceControlWindows::CanChoosePackagesToCheckIn(); +} + +TSharedRef FLoreSourceControlModule::SpawnBranchHistoryTab(const FSpawnTabArgs& InArgs) +{ + return SNew(SDockTab) + .TabRole(ETabRole::NomadTab) + [ + SNew(SLoreBranchHistory, &LoreSourceControlProvider) + ]; +} + +void FLoreSourceControlModule::ShowBranchHistoryTab() const +{ + FGlobalTabmanager::Get()->TryInvokeTab(FTabId(LoreBranchHistoryTabName)); +} + void FLoreSourceControlModule::OnAssetEditorOpened(UObject* InAsset, IAssetEditorInstance* InInstance) { if (!InInstance) diff --git a/Source/LoreSourceControl/Private/LoreSourceControlModule.h b/Source/LoreSourceControl/Private/LoreSourceControlModule.h index d79808c..6580546 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlModule.h +++ b/Source/LoreSourceControl/Private/LoreSourceControlModule.h @@ -7,8 +7,10 @@ #include "LoreSourceControlProvider.h" class SWindow; +class SDockTab; class SNotificationItem; class FSlateStyleSet; +class FSpawnTabArgs; struct IConsoleCommand; class FLoreSourceControlModule : public IModuleInterface @@ -24,6 +26,8 @@ class FLoreSourceControlModule : public IModuleInterface #if SOURCE_CONTROL_WITH_SLATE /** Adds the branch switcher combo button to the level editor's Source Control toolbar, and hooks up asset editors to get the same treatment as they open */ void RegisterToolbarExtension(); + /** Adds Lore's View / Commit Changes action to Unreal's bottom Revision Control dropdown. */ + void RegisterRevisionControlMenuExtension(); /** Extends a single status-bar toolbar menu with the branch switcher combo button; no-ops if that menu was already extended */ void RegisterToolbarExtensionForMenu(FName InMenuName); @@ -33,6 +37,12 @@ class FLoreSourceControlModule : public IModuleInterface /** Builds the dropdown menu content listing all local branches (called when the combo button is opened) */ TSharedRef GenerateBranchMenu(); + /** Open the standard submit dialog through Lore. */ + void OpenLoreCommitDialog() const; + bool CanOpenLoreCommitDialog() const; + /** Create and focus the dockable branch history tab used by UE6's built-in Lore action. */ + TSharedRef SpawnBranchHistoryTab(const FSpawnTabArgs& InArgs); + void ShowBranchHistoryTab() const; /** Prompts for confirmation, then switches to the given branch (hot-reloading Content, or asking for a restart if Source/Config was touched) */ void OnBranchSelected(FString InBranchName); @@ -76,11 +86,13 @@ class FLoreSourceControlModule : public IModuleInterface TWeakPtr SyncNotification; TWeakPtr BranchSwitchNotification; TSharedPtr BranchMenuStyle; + bool bRevisionControlMenuRegistered = false; #endif IConsoleCommand* LoreSyncCommand = nullptr; IConsoleCommand* LoreStatusCommand = nullptr; IConsoleCommand* LoreCommitCommand = nullptr; + IConsoleCommand* LoreBranchHistoryCommand = nullptr; /** The one and only source control provider */ FLoreSourceControlProvider LoreSourceControlProvider; diff --git a/Source/LoreSourceControl/Private/LoreSourceControlOperations.cpp b/Source/LoreSourceControl/Private/LoreSourceControlOperations.cpp index 09eb406..cdd55c0 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlOperations.cpp +++ b/Source/LoreSourceControl/Private/LoreSourceControlOperations.cpp @@ -263,6 +263,23 @@ bool FLoreCheckInWorker::UpdateStates() const { return FLoreSourceControlUtils::UpdateCachedStates(Provider, States, StateScanPaths, bApplyStateResults); } +//----------------------------------------------------------------------------- +// Branch history +//----------------------------------------------------------------------------- +bool FLoreRefreshBranchHistoryWorker::Execute(FLoreSourceControlCommand& InCommand) +{ + const TSharedRef Operation = StaticCastSharedRef(InCommand.Operation); + + TArray History; + InCommand.bCommandSuccessful = FLoreSourceControlUtils::RunGetBranchHistory( + InCommand.PathToLoreBinary, + InCommand.PathToRepositoryRoot, + Operation->GetBranchName(), + History, + InCommand.ErrorMessages); + Operation->SetHistory(MoveTemp(History)); + return InCommand.bCommandSuccessful; +} //----------------------------------------------------------------------------- // Sync diff --git a/Source/LoreSourceControl/Private/LoreSourceControlOperations.h b/Source/LoreSourceControl/Private/LoreSourceControlOperations.h index 032e8f9..ba65e1b 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlOperations.h +++ b/Source/LoreSourceControl/Private/LoreSourceControlOperations.h @@ -4,6 +4,7 @@ #include "CoreMinimal.h" #include "ILoreSourceControlWorker.h" +#include "LoreSourceControlUtils.h" #include "SourceControlOperationBase.h" class FLoreSourceControlState; @@ -26,6 +27,27 @@ class FLoreRefreshBranchesOperation : public FSourceControlOperationBase virtual FText GetInProgressString() const override { return NSLOCTEXT("LoreSourceControl", "RefreshingBranches", "Refreshing Lore branches..."); } }; +/** Internal operation used by the branch history tab. */ +class FLoreRefreshBranchHistoryOperation : public FSourceControlOperationBase +{ +public: + explicit FLoreRefreshBranchHistoryOperation(FString InBranchName) + : BranchName(MoveTemp(InBranchName)) + { + } + + virtual FName GetName() const override { return "LoreRefreshBranchHistory"; } + virtual FText GetInProgressString() const override { return NSLOCTEXT("LoreSourceControl", "RefreshingBranchHistory", "Refreshing Lore branch history..."); } + + const FString& GetBranchName() const { return BranchName; } + const TArray& GetHistory() const { return History; } + void SetHistory(TArray&& InHistory) { History = MoveTemp(InHistory); } + +private: + FString BranchName; + TArray History; +}; + /** Internal operation for a non-blocking branch switch. */ class FLoreSwitchBranchOperation : public FSourceControlOperationBase { @@ -181,6 +203,14 @@ class FLoreRefreshBranchesWorker : public ILoreSourceControlWorker bool bRefreshSucceeded = false; }; +class FLoreRefreshBranchHistoryWorker : public ILoreSourceControlWorker +{ +public: + virtual FName GetName() const override { return "LoreRefreshBranchHistory"; } + virtual bool Execute(FLoreSourceControlCommand& InCommand) override; + virtual bool UpdateStates() const override { return false; } +}; + class FLoreSwitchBranchWorker : public ILoreSourceControlWorker { public: diff --git a/Source/LoreSourceControl/Private/LoreSourceControlRevision.cpp b/Source/LoreSourceControl/Private/LoreSourceControlRevision.cpp index 967dad1..a4a470e 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlRevision.cpp +++ b/Source/LoreSourceControl/Private/LoreSourceControlRevision.cpp @@ -4,13 +4,14 @@ #include "LoreSourceControlUtils.h" #include "ISourceControlModule.h" #include "HAL/FileManager.h" +#include "Logging/StructuredLog.h" #include "Misc/Paths.h" bool FLoreSourceControlRevision::Get(FString& InOutFilename, EConcurrency::Type InConcurrency) const { if (InConcurrency != EConcurrency::Synchronous) { - UE_LOG(LogSourceControl, Warning, TEXT("FLoreSourceControlRevision::Get only supports EConcurrency::Synchronous.")); + UE_LOGFMT(LogSourceControl, Warning, "FLoreSourceControlRevision::Get only supports EConcurrency::Synchronous."); } if (InOutFilename.IsEmpty()) diff --git a/Source/LoreSourceControl/Private/LoreSourceControlUtils.cpp b/Source/LoreSourceControl/Private/LoreSourceControlUtils.cpp index 2ecdec6..1eb13e6 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlUtils.cpp +++ b/Source/LoreSourceControl/Private/LoreSourceControlUtils.cpp @@ -9,6 +9,7 @@ #include "Misc/ConfigCacheIni.h" #include "Misc/FileHelper.h" #include "HAL/PlatformProcess.h" +#include "Logging/StructuredLog.h" #include "UObject/UObjectGlobals.h" #include "Dom/JsonObject.h" #include "Serialization/JsonReader.h" @@ -450,7 +451,7 @@ namespace FLoreSourceControlUtils FullCommand += TEXT("\""); } - UE_LOG(LogSourceControl, Verbose, TEXT("[Lore] %s %s (cwd=%s)"), *InLoreBinary, *FullCommand, *WorkingDir); + UE_LOGFMT(LogSourceControl, Verbose, "[Lore] {Binary} {Command} (cwd={WorkingDirectory})", InLoreBinary, FullCommand, WorkingDir); // Pass the correct working directory. // Lore discovers the repository by walking up for a .lore folder, but running from the correct root makes status/stage/commit/sync more reliable across platforms. @@ -464,10 +465,10 @@ namespace FLoreSourceControlUtils ParseCommandErrors(OutResults, OutErrorMessages); } - UE_LOG(LogSourceControl, Verbose, TEXT("[Lore] ReturnCode=%d, Stdout:\n%s"), ReturnCode, *Results); + UE_LOGFMT(LogSourceControl, Verbose, "[Lore] ReturnCode={ReturnCode}, Stdout:\n{Stdout}", ReturnCode, Results); if (!Errors.IsEmpty()) { - UE_LOG(LogSourceControl, Warning, TEXT("[Lore] Stderr:\n%s"), *Errors); + UE_LOGFMT(LogSourceControl, Warning, "[Lore] Stderr:\n{Stderr}", Errors); } return ReturnCode == 0; @@ -918,6 +919,108 @@ namespace FLoreSourceControlUtils return bOk; } + void ParseBranchHistoryResults(const TArray& Results, TArray& OutHistory) + { + TOptional Current; + + auto FlushCurrent = [&Current, &OutHistory]() + { + if (Current.IsSet()) + { + OutHistory.Add(MoveTemp(Current.GetValue())); + Current.Reset(); + } + }; + + for (const FString& Line : Results) + { + TSharedPtr JsonObj; + if (!ParseJsonLine(Line.TrimStartAndEnd(), JsonObj)) + { + continue; + } + + FString TagName; + if (!JsonObj->TryGetStringField(TEXT("tagName"), TagName)) + { + continue; + } + + const FJsonObject* Data = GetObjectField(*JsonObj, TEXT("data")); + if (!Data) + { + continue; + } + + if (TagName == TEXT("revisionHistoryEntry")) + { + FlushCurrent(); + Current.Emplace(); + Data->TryGetStringField(TEXT("revision"), Current->RevisionHash); + + double RevisionNumber = 0.0; + if (Data->TryGetNumberField(TEXT("revisionNumber"), RevisionNumber)) + { + Current->RevisionNumber = static_cast(RevisionNumber); + } + } + else if (TagName == TEXT("metadata") && Current.IsSet()) + { + FString Key; + Data->TryGetStringField(TEXT("key"), Key); + + const FJsonObject* Value = GetObjectField(*Data, TEXT("value")); + if (!Value) + { + continue; + } + + if (Key == TEXT("message")) + { + Value->TryGetStringField(TEXT("data"), Current->Description); + } + else if (Key == TEXT("committed-by") || (Key == TEXT("created-by") && Current->Author.IsEmpty())) + { + Value->TryGetStringField(TEXT("data"), Current->Author); + } + else if (Key == TEXT("timestamp")) + { + double TimestampMs = 0.0; + if (Value->TryGetNumberField(TEXT("data"), TimestampMs) && TimestampMs > 0.0) + { + Current->Date = FDateTime::FromUnixTimestamp(static_cast(TimestampMs / 1000.0)); + } + } + } + } + + FlushCurrent(); + } + + bool RunGetBranchHistory(const FString& InLoreBinary, const FString& InRepositoryRoot, const FString& InBranchName, TArray& OutHistory, TArray& OutErrorMessages) + { + TArray Params; + Params.Add(TEXT("100")); + if (!InBranchName.IsEmpty()) + { + Params.Add(FString::Printf(TEXT("--branch=%s"), *QuoteCommandLineArgument(InBranchName))); + Params.Add(TEXT("--only-branch")); + } + Params.Add(TEXT("--local")); + + TArray Results; + const bool bOk = RunLoreCommand(TEXT("revision history"), InLoreBinary, InRepositoryRoot, Params, TArray(), Results, OutErrorMessages); + ParseBranchHistoryResults(Results, OutHistory); + + // Lore 0.8.x may append an auth-resolution error after a successful local history result + // when the server has no auth endpoint. The history itself is complete and usable. + if (bOk) + { + OutErrorMessages.RemoveAll([](const FString& Error) { return Error.Contains(TEXT("authentication requires a configured auth endpoint")); }); + } + return bOk; + } + bool RunSwitchBranch(const FString& InLoreBinary, const FString& InRepositoryRoot, const FString& InBranchName, TArray& OutErrorMessages, TArray* OutChangedPaths) { TArray Params; diff --git a/Source/LoreSourceControl/Private/LoreSourceControlUtils.h b/Source/LoreSourceControl/Private/LoreSourceControlUtils.h index 1a50ba5..553dc7b 100644 --- a/Source/LoreSourceControl/Private/LoreSourceControlUtils.h +++ b/Source/LoreSourceControl/Private/LoreSourceControlUtils.h @@ -15,6 +15,16 @@ struct LORESOURCECONTROL_API FLoreBranchInfo bool bIsCurrent = false; }; +/** One repository revision displayed in the branch history tab. */ +struct LORESOURCECONTROL_API FLoreBranchHistoryEntry +{ + int32 RevisionNumber = 0; + FString RevisionHash; + FString Description; + FString Author; + FDateTime Date; +}; + /** Repository-level facts extracted from one "lore status" run, alongside the per-file states. */ struct LORESOURCECONTROL_API FLoreStatusSummary { @@ -107,6 +117,9 @@ namespace FLoreSourceControlUtils /** Parse and deduplicate Lore branch-list entries. */ LORESOURCECONTROL_API void ParseBranchResults(const TArray& InResults, TArray& OutBranches); + /** Parse revision-history entries and their following metadata events. */ + LORESOURCECONTROL_API void ParseBranchHistoryResults(const TArray& InResults, TArray& OutHistory); + /** Parse lock-query results and optional owner display names. */ LORESOURCECONTROL_API void ParseLockResults(const TArray& InResults, const FString& InRepositoryRoot, TMap& OutLockedBy); @@ -122,6 +135,9 @@ namespace FLoreSourceControlUtils */ bool RunGetBranches(const FString& InLoreBinary, const FString& InRepositoryRoot, TArray& OutBranches, TArray* OutErrorMessages = nullptr); + /** Run `lore revision history` for a branch using local repository data. */ + bool RunGetBranchHistory(const FString& InLoreBinary, const FString& InRepositoryRoot, const FString& InBranchName, TArray& OutHistory, TArray& OutErrorMessages); + /** * Run `lore branch switch ` to switch the working copy to a different branch. * Caller is responsible for warning the user beforehand - this changes files on disk. diff --git a/Source/LoreSourceControl/Private/SLoreBranchHistory.cpp b/Source/LoreSourceControl/Private/SLoreBranchHistory.cpp new file mode 100644 index 0000000..797e7bc --- /dev/null +++ b/Source/LoreSourceControl/Private/SLoreBranchHistory.cpp @@ -0,0 +1,257 @@ +// Copyright Solessfir 2026. All Rights Reserved. + +#include "SLoreBranchHistory.h" +#include "LoreSourceControlOperations.h" +#include "LoreSourceControlProvider.h" +#include "LoreSourceControlUtils.h" +#include "Input/Reply.h" +#include "Styling/AppStyle.h" +#include "Widgets/Images/SThrobber.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Layout/SBorder.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/SOverlay.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/Text/STextBlock.h" +#include "Widgets/Views/SHeaderRow.h" +#include "Widgets/Views/SListView.h" + +#define LOCTEXT_NAMESPACE "LoreSourceControl" + +namespace +{ + const FName RevisionColumn(TEXT("Revision")); + const FName DescriptionColumn(TEXT("Description")); + const FName AuthorColumn(TEXT("Author")); + const FName DateColumn(TEXT("Date")); + const FName HashColumn(TEXT("Hash")); + + class SLoreBranchHistoryRow final : public SMultiColumnTableRow + { + public: + SLATE_BEGIN_ARGS(SLoreBranchHistoryRow) {} + SLATE_ARGUMENT(FLoreBranchHistoryItem, Item) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) + { + Item = InArgs._Item; + SMultiColumnTableRow::Construct( + FSuperRowType::FArguments().Padding(FMargin(4.f, 2.f)), + InOwnerTable); + } + + virtual TSharedRef GenerateWidgetForColumn(const FName& InColumnName) override + { + FString Value; + if (InColumnName == RevisionColumn) + { + Value = FString::Printf(TEXT("%d"), Item->RevisionNumber); + } + else if (InColumnName == DescriptionColumn) + { + Value = Item->Description; + } + else if (InColumnName == AuthorColumn) + { + Value = Item->Author; + } + else if (InColumnName == DateColumn) + { + Value = Item->Date.GetTicks() > 0 + ? Item->Date.ToString(TEXT("%Y-%m-%d %H:%M:%S UTC")) + : FString(); + } + else if (InColumnName == HashColumn) + { + Value = Item->RevisionHash.Left(12); + } + + return SNew(STextBlock) + .Text(FText::FromString(Value)) + .ToolTipText(FText::FromString(InColumnName == HashColumn ? Item->RevisionHash : Value)); + } + + private: + FLoreBranchHistoryItem Item; + }; +} + +void SLoreBranchHistory::Construct(const FArguments& InArgs, FLoreSourceControlProvider* InProvider) +{ + Provider = InProvider; + + ChildSlot + [ + SNew(SBorder) + .BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder")) + .Padding(8.f) + [ + SNew(SVerticalBox) + + SVerticalBox::Slot() + .AutoHeight() + .Padding(0.f, 0.f, 0.f, 8.f) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .FillWidth(1.f) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(this, &SLoreBranchHistory::GetHeadingText) + .TextStyle(FAppStyle::Get(), "HeadingExtraSmall") + ] + + SHorizontalBox::Slot() + .AutoWidth() + [ + SNew(SButton) + .Text(LOCTEXT("RefreshBranchHistory", "Refresh")) + .ToolTipText(LOCTEXT("RefreshBranchHistoryTooltip", "Reload the current Lore branch history")) + .IsEnabled_Lambda([this]() { return !bLoading; }) + .OnClicked(this, &SLoreBranchHistory::OnRefreshClicked) + ] + ] + + SVerticalBox::Slot() + .FillHeight(1.f) + [ + SNew(SOverlay) + + SOverlay::Slot() + [ + SAssignNew(HistoryList, SListView) + .ListItemsSource(&HistoryItems) + .OnGenerateRow(this, &SLoreBranchHistory::OnGenerateRow) + .SelectionMode(ESelectionMode::Single) + .HeaderRow + ( + SNew(SHeaderRow) + + SHeaderRow::Column(RevisionColumn) + .DefaultLabel(LOCTEXT("RevisionColumn", "Revision")) + .FixedWidth(80.f) + + SHeaderRow::Column(DescriptionColumn) + .DefaultLabel(LOCTEXT("DescriptionColumn", "Description")) + .FillWidth(0.45f) + + SHeaderRow::Column(AuthorColumn) + .DefaultLabel(LOCTEXT("AuthorColumn", "Author")) + .FillWidth(0.2f) + + SHeaderRow::Column(DateColumn) + .DefaultLabel(LOCTEXT("DateColumn", "Date")) + .FillWidth(0.2f) + + SHeaderRow::Column(HashColumn) + .DefaultLabel(LOCTEXT("HashColumn", "Hash")) + .FillWidth(0.15f) + ) + ] + + SOverlay::Slot() + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + [ + SNew(SThrobber) + .Visibility(this, &SLoreBranchHistory::GetLoadingVisibility) + ] + + SOverlay::Slot() + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + [ + SNew(STextBlock) + .Text(this, &SLoreBranchHistory::GetStatusText) + .Visibility(this, &SLoreBranchHistory::GetStatusVisibility) + ] + ] + ] + ]; + + Refresh(); +} + +FReply SLoreBranchHistory::OnRefreshClicked() +{ + Refresh(); + return FReply::Handled(); +} + +void SLoreBranchHistory::Refresh() +{ + if (bLoading) + { + return; + } + + ErrorText = FText::GetEmpty(); + if (!Provider || !Provider->IsAvailable()) + { + ErrorText = LOCTEXT("BranchHistoryUnavailable", "Lore is not available."); + return; + } + + BranchName = Provider->GetBranchName(); + bLoading = true; + + const TSharedRef Operation = ISourceControlOperation::Create(BranchName); + const ECommandResult::Type StartResult = Provider->Execute( + Operation, + TArray(), + EConcurrency::Asynchronous, + FSourceControlOperationComplete::CreateSP(this, &SLoreBranchHistory::OnRefreshComplete)); + + if (StartResult == ECommandResult::Failed) + { + bLoading = false; + ErrorText = LOCTEXT("BranchHistoryStartFailed", "Could not start the Lore branch history query."); + } +} + +void SLoreBranchHistory::OnRefreshComplete(const FSourceControlOperationRef& InOperation, ECommandResult::Type InResult) +{ + bLoading = false; + HistoryItems.Empty(); + + if (InResult == ECommandResult::Succeeded) + { + const TSharedRef Operation = StaticCastSharedRef(InOperation); + for (const FLoreBranchHistoryEntry& Entry : Operation->GetHistory()) + { + HistoryItems.Add(MakeShared(Entry)); + } + } + else + { + ErrorText = LOCTEXT("BranchHistoryQueryFailed", "Lore branch history could not be loaded. See the Source Control log for details."); + } + + if (HistoryList) + { + HistoryList->RequestListRefresh(); + } +} + +TSharedRef SLoreBranchHistory::OnGenerateRow(FLoreBranchHistoryItem InItem, const TSharedRef& InOwnerTable) +{ + return SNew(SLoreBranchHistoryRow, InOwnerTable) + .Item(MoveTemp(InItem)); +} + +FText SLoreBranchHistory::GetHeadingText() const +{ + return FText::Format( + LOCTEXT("BranchHistoryHeading", "Branch History — {0}"), + FText::FromString(BranchName.IsEmpty() ? TEXT("unknown") : BranchName)); +} + +FText SLoreBranchHistory::GetStatusText() const +{ + return ErrorText.IsEmpty() + ? LOCTEXT("BranchHistoryEmpty", "No revisions found on this branch.") + : ErrorText; +} + +EVisibility SLoreBranchHistory::GetLoadingVisibility() const +{ + return bLoading ? EVisibility::Visible : EVisibility::Collapsed; +} + +EVisibility SLoreBranchHistory::GetStatusVisibility() const +{ + return !bLoading && HistoryItems.IsEmpty() ? EVisibility::Visible : EVisibility::Collapsed; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/LoreSourceControl/Private/SLoreBranchHistory.h b/Source/LoreSourceControl/Private/SLoreBranchHistory.h new file mode 100644 index 0000000..9af7e36 --- /dev/null +++ b/Source/LoreSourceControl/Private/SLoreBranchHistory.h @@ -0,0 +1,42 @@ +// Copyright Solessfir 2026. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "ISourceControlOperation.h" +#include "ISourceControlProvider.h" +#include "Widgets/SCompoundWidget.h" + +class FLoreSourceControlProvider; +class ITableRow; +class STableViewBase; +template class SListView; +struct FLoreBranchHistoryEntry; + +using FLoreBranchHistoryItem = TSharedPtr; + +class SLoreBranchHistory : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS(SLoreBranchHistory) {} + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, FLoreSourceControlProvider* InProvider); + +private: + FReply OnRefreshClicked(); + void Refresh(); + void OnRefreshComplete(const FSourceControlOperationRef& InOperation, ECommandResult::Type InResult); + TSharedRef OnGenerateRow(FLoreBranchHistoryItem InItem, const TSharedRef& InOwnerTable); + FText GetHeadingText() const; + FText GetStatusText() const; + EVisibility GetLoadingVisibility() const; + EVisibility GetStatusVisibility() const; + + FLoreSourceControlProvider* Provider = nullptr; + FString BranchName; + TArray HistoryItems; + TSharedPtr> HistoryList; + FText ErrorText; + bool bLoading = false; +}; diff --git a/Source/LoreSourceControlTests/Private/LoreSourceControlParsingTests.cpp b/Source/LoreSourceControlTests/Private/LoreSourceControlParsingTests.cpp index 08d2687..5927de5 100644 --- a/Source/LoreSourceControlTests/Private/LoreSourceControlParsingTests.cpp +++ b/Source/LoreSourceControlTests/Private/LoreSourceControlParsingTests.cpp @@ -119,6 +119,34 @@ bool FLoreBranchParserTest::RunTest(const FString& Parameters) return true; } +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FLoreBranchHistoryParserTest, "LoreSourceControl.BranchHistory.Parser", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) + +bool FLoreBranchHistoryParserTest::RunTest(const FString& Parameters) +{ + const TArray Results{ + TEXT(R"({"tagName":"revisionHistoryEntry","data":{"revision":"abc123","revisionNumber":7}})"), + TEXT(R"({"tagName":"metadata","data":{"key":"message","value":{"data":"First change"}}})"), + TEXT(R"({"tagName":"metadata","data":{"key":"created-by","value":{"data":"Creator"}}})"), + TEXT(R"({"tagName":"metadata","data":{"key":"committed-by","value":{"data":"Committer"}}})"), + TEXT(R"({"tagName":"metadata","data":{"key":"timestamp","value":{"data":1700000000000}}})"), + TEXT(R"({"tagName":"revisionHistoryEntry","data":{"revision":"def456","revisionNumber":8}})"), + TEXT(R"({"tagName":"metadata","data":{"key":"message","value":{"data":"Second change"}}})"), + TEXT("not json") + }; + + TArray History; + FLoreSourceControlUtils::ParseBranchHistoryResults(Results, History); + + TestEqual(TEXT("Branch history count"), History.Num(), 2); + TestEqual(TEXT("First revision hash"), History[0].RevisionHash, FString(TEXT("abc123"))); + TestEqual(TEXT("First revision number"), History[0].RevisionNumber, 7); + TestEqual(TEXT("First description"), History[0].Description, FString(TEXT("First change"))); + TestEqual(TEXT("Committed-by takes precedence"), History[0].Author, FString(TEXT("Committer"))); + TestEqual(TEXT("First timestamp"), History[0].Date.ToUnixTimestamp(), static_cast(1700000000)); + TestEqual(TEXT("Second description"), History[1].Description, FString(TEXT("Second change"))); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST(FLoreLockParserTest, "LoreSourceControl.Locks.Parser", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ProductFilter) bool FLoreLockParserTest::RunTest(const FString& Parameters)