Skip to content

Commit 1c25741

Browse files
committed
refactor(ui): ♻️ streamline ReferenceReplacement dialog and menu integration
1 parent a445ffc commit 1c25741

8 files changed

Lines changed: 256 additions & 136 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
This file is **authoritative and persistent** for everything under `/ReferenceReplacementMod`. Keep it in version control and treat its directives as mandatory.
44

55
## Directives
6+
67
1. **Golden rule:** Relentlessly minimize complexity, maximize changeability, and enforce strict static checks to suppress runtime bugs. Names, code, and directory structures must be self-explanatory—avoid relying on comments/docs for basic comprehension.
78
2. **Testing cadence:** Hands-on runtime testing happens later in a live Resonite session; until then, document assumptions and leave TODOs rather than speculative fixes.
89
3. **Localization:** UI remains English-only until Resonite exposes extensible localization hooks. Do not ship partial translations; revisit only when upstream enables custom locale registration.
@@ -13,11 +14,14 @@ This file is **authoritative and persistent** for everything under `/ReferenceRe
1314
6. **Data model constraints:** This mod must not introduce new FrooxEngine data-model types or `SyncDelegate` definitions. All functionality must be built on existing data-model constructs to avoid sync registration overhead.
1415

1516
## Scope / Status
17+
1618
- Repository initialized 2025-11-12 with Reference Replacement mod for ResoniteModLoader.
17-
- Current deliverable: Dev Tool UI for bulk `ISyncRef` replacement plus creation-menu hook.
19+
- Current deliverable: Userspace dialog for bulk `ISyncRef` replacement, launched exclusively from the `Create New > Editor` menu entry.
1820

1921
## Localization Policy (Public Summary)
22+
2023
- English-only interface. Reevaluate once Resonite core supports custom localized strings for Dev Tool menus and UIX builders.
2124

2225
## History Tracking Rule
26+
2327
- Detailed work history lives in the git commit log; do not maintain manual work logs in docs. Summaries belong in commit messages and PR descriptions only.

README.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,7 @@
22

33
A [ResoniteModLoader](https://github.com/resonite-modding-group/ResoniteModLoader) mod for [Resonite](https://resonite.com/) that mirrors the Asset Optimization workflow to bulk swap every matching `ISyncRef` inside a single undo batch.
44

5-
## Features
6-
7-
- Dev Tool context-menu entry (**Reference Replacement…**) to open the dialog from any inspected slot.
8-
- Userspace dialog with pickers for process root, source reference, and replacement target.
9-
- Analyze button that previews compatible vs incompatible hits before committing.
10-
- Replace button that writes every compatible reference atomically for clean undo support.
11-
- Automatic skip counters so incompatible targets are reported instead of silently failing.
5+
Launch the tool via `Create New > Editor > Reference Replacement (Mod)` in the Dev Create menu.
126

137
## Installation
148

src/ReferenceReplacement/Logic/ReferenceScanner.cs

Lines changed: 77 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -135,55 +135,103 @@ private void VisitMember(ISyncMember member, TraversalPath path)
135135
VisitEnumerable(enumerable, path);
136136
}
137137

138-
foreach (PropertyInfo enumerableProperty in EnumerableInspector.GetEnumerableProperties(member.GetType()))
138+
VisitKnownCollections(member, path);
139+
}
140+
141+
private void VisitKnownCollections(ISyncMember member, TraversalPath path)
142+
{
143+
switch (member)
139144
{
140-
if (!TryGetEnumerableProperty(member, enumerableProperty, out IEnumerable? nested))
141-
{
142-
continue;
143-
}
145+
case ISyncList syncList:
146+
VisitEnumerableProperty(() => syncList.Elements, path, nameof(ISyncList.Elements));
147+
break;
148+
case ISyncBag syncBag:
149+
VisitEnumerableProperty(() => syncBag.Elements, path, nameof(ISyncBag.Elements));
150+
VisitEnumerableProperty(() => syncBag.Values, path, nameof(ISyncBag.Values));
151+
break;
152+
case ISyncDictionary syncDictionary:
153+
VisitEnumerableProperty(() => syncDictionary.BoxedEntries, path, nameof(ISyncDictionary.BoxedEntries));
154+
VisitEnumerableProperty(() => syncDictionary.Values, path, nameof(ISyncDictionary.Values));
155+
break;
156+
case ISyncArray syncArray:
157+
VisitSyncArray(syncArray, path);
158+
break;
159+
}
160+
}
144161

145-
if (nested == null)
146-
{
147-
continue;
148-
}
162+
private void VisitEnumerableProperty(Func<IEnumerable?> accessor, TraversalPath parentPath, string propertyName)
163+
{
164+
IEnumerable? enumerable;
165+
try
166+
{
167+
enumerable = accessor();
168+
}
169+
catch (Exception ex) when (IsSafeToIgnore(ex))
170+
{
171+
return;
172+
}
149173

150-
if (_accumulator.ShouldVisitEnumerable(nested))
151-
{
152-
VisitEnumerable(nested, path.NextProperty(enumerableProperty.Name));
153-
}
174+
if (enumerable == null)
175+
{
176+
return;
177+
}
178+
179+
if (_accumulator.ShouldVisitEnumerable(enumerable))
180+
{
181+
VisitEnumerable(enumerable, parentPath.NextProperty(propertyName));
154182
}
155183
}
156184

157-
private static bool TryGetEnumerableProperty(ISyncMember member, PropertyInfo property, out IEnumerable? result)
185+
private void VisitSyncArray(ISyncArray array, TraversalPath parentPath)
158186
{
159-
result = null;
160-
object? value;
161-
187+
int count;
162188
try
163189
{
164-
value = property.GetValue(member);
190+
count = array.Count;
165191
}
166-
catch (TargetInvocationException ex) when (IsSafeToIgnore(ex.InnerException))
192+
catch (Exception ex) when (IsSafeToIgnore(ex))
167193
{
168-
return false;
194+
return;
169195
}
170-
catch (NotSupportedException)
196+
197+
TraversalPath itemsPath = parentPath.NextProperty("Items");
198+
for (int index = 0; index < count; index++)
171199
{
172-
return false;
200+
object? element;
201+
try
202+
{
203+
element = array.GetElement(index);
204+
}
205+
catch (Exception ex) when (IsSafeToIgnore(ex))
206+
{
207+
continue;
208+
}
209+
210+
VisitNestedItem(element, itemsPath.NextIndex(index));
173211
}
212+
}
174213

175-
if (value is not IEnumerable enumerable)
214+
private void VisitNestedItem(object? item, TraversalPath path)
215+
{
216+
if (item == null)
176217
{
177-
return false;
218+
return;
178219
}
179220

180-
result = enumerable;
181-
return true;
221+
switch (item)
222+
{
223+
case ISyncMember member:
224+
VisitMember(member, path);
225+
break;
226+
case IEnumerable nested when _accumulator.ShouldVisitEnumerable(nested):
227+
VisitEnumerable(nested, path);
228+
break;
229+
}
182230
}
183231

184232
private static bool IsSafeToIgnore(Exception? exception)
185233
{
186-
return exception is NotSupportedException;
234+
return exception is NotSupportedException or InvalidOperationException;
187235
}
188236

189237
private void VisitEnumerable(IEnumerable enumerable, TraversalPath path)
@@ -198,15 +246,7 @@ private void VisitEnumerable(IEnumerable enumerable, TraversalPath path)
198246
continue;
199247
}
200248

201-
switch (item)
202-
{
203-
case ISyncMember member:
204-
VisitMember(member, itemPath);
205-
break;
206-
case IEnumerable nested when _accumulator.ShouldVisitEnumerable(nested):
207-
VisitEnumerable(nested, itemPath);
208-
break;
209-
}
249+
VisitNestedItem(item, itemPath);
210250

211251
index++;
212252
}
@@ -354,25 +394,6 @@ private bool SupportsTarget(ISyncRef syncRef)
354394

355395
private static class EnumerableInspector
356396
{
357-
private static readonly ConditionalWeakTable<Type, PropertyInfo[]> Cache = new();
358-
359-
internal static PropertyInfo[] GetEnumerableProperties(Type type)
360-
{
361-
if (Cache.TryGetValue(type, out var cached))
362-
{
363-
return cached;
364-
}
365-
366-
PropertyInfo[] discovered = Array.FindAll(
367-
type.GetProperties(BindingFlags.Instance | BindingFlags.Public),
368-
prop => prop.GetIndexParameters().Length == 0 &&
369-
typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) &&
370-
prop.PropertyType != typeof(string));
371-
372-
Cache.Add(type, discovered);
373-
return discovered;
374-
}
375-
376397
internal static ISyncRef? TryExtractSyncRef(object candidate)
377398
{
378399
Type type = candidate.GetType();
@@ -382,7 +403,7 @@ internal static PropertyInfo[] GetEnumerableProperties(Type type)
382403
}
383404

384405
PropertyInfo? valueProperty = type.GetProperty("Value");
385-
if (valueProperty == null || !typeof(ISyncRef).IsAssignableFrom(valueProperty.PropertyType))
406+
if (valueProperty == null)
386407
{
387408
return null;
388409
}

src/ReferenceReplacement/Patching/DevToolMenuPatch.cs

Lines changed: 0 additions & 38 deletions
This file was deleted.

src/ReferenceReplacement/UI/ReferenceReplacementDialog.cs

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public sealed class ReferenceReplacementDialog
2121
private Text? _detailText;
2222
private bool _disposed;
2323

24-
private ReferenceReplacementDialog(User owner, Slot? dialogSlot, Slot? suggestedRoot)
24+
private ReferenceReplacementDialog(User owner)
2525
{
2626
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
2727
Slot? userSpace = owner.LocalUserSpace ?? throw new InvalidOperationException("User space is unavailable.");
@@ -34,15 +34,14 @@ private ReferenceReplacementDialog(User owner, Slot? dialogSlot, Slot? suggested
3434

3535
ConfigureRootSlot();
3636
BuildUI();
37-
InitializeInputs(suggestedRoot);
3837
UpdateStatus("Select inputs to begin.");
3938
Focus();
4039
RepositionFor(owner);
4140
}
4241

43-
public static ReferenceReplacementDialog Create(User owner, Slot? dialogSlot, Slot? suggestedRoot)
42+
public static ReferenceReplacementDialog Create(User owner)
4443
{
45-
return new ReferenceReplacementDialog(owner, dialogSlot, suggestedRoot);
44+
return new ReferenceReplacementDialog(owner);
4645
}
4746

4847
public bool HasProcessRoot => GetProcessRootSlot() != null;
@@ -101,11 +100,6 @@ public void Close()
101100
ReferenceReplacementDialogManager.Unregister(this);
102101
}
103102

104-
private void InitializeInputs(Slot? suggestedRoot)
105-
{
106-
_processRootRef.Target = suggestedRoot ?? null!;
107-
}
108-
109103
private (ISyncRef processRoot, ISyncRef source, ISyncRef target) CreateReferenceFields()
110104
{
111105
return (CreateReferenceProxy(), CreateReferenceProxy(), CreateReferenceProxy());
@@ -229,20 +223,20 @@ private void Analyze(bool applyChanges)
229223
return;
230224
}
231225

232-
ReferenceScanResult scanResult = ReferenceScanner.Scan(root, source, target);
233-
if (scanResult.Matches.Count == 0)
234-
{
235-
UpdateStatus("No references found in the selected root.");
236-
return;
237-
}
238-
239226
if (!applyChanges)
240227
{
228+
ReferenceScanResult scanResult = ReferenceScanner.Scan(root, source, target);
229+
if (scanResult.Matches.Count == 0)
230+
{
231+
UpdateStatus("No references found in the selected root.");
232+
return;
233+
}
234+
241235
UpdateStatus($"Found {scanResult.Matches.Count} references (skipped {scanResult.IncompatibleCount}).", scanResult);
242236
return;
243237
}
244238

245-
ApplyReplacement(scanResult, root, target);
239+
ApplyReplacement(root, source, target);
246240
}
247241

248242
private bool TryResolveInputs(out Slot root, out IWorldElement source, out IWorldElement target, out string message)
@@ -304,8 +298,15 @@ private bool TryResolveInputs(out Slot root, out IWorldElement source, out IWorl
304298
return _processRootRef.Target as Slot;
305299
}
306300

307-
private void ApplyReplacement(ReferenceScanResult scanResult, Slot root, IWorldElement target)
301+
private void ApplyReplacement(Slot root, IWorldElement source, IWorldElement target)
308302
{
303+
ReferenceScanResult scanResult = ReferenceScanner.Scan(root, source, target);
304+
if (scanResult.Matches.Count == 0)
305+
{
306+
UpdateStatus("No references found in the selected root.");
307+
return;
308+
}
309+
309310
World? world = root.World;
310311
if (world == null)
311312
{

src/ReferenceReplacement/UI/ReferenceReplacementDialogManager.cs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using Elements.Core;
41
using FrooxEngine;
52

63
namespace ReferenceReplacement.UI;
74

85
internal static class ReferenceReplacementDialogManager
96
{
10-
public static void Show(Slot? dialogSlot, Slot? suggestedRoot)
7+
public static void Show(User? localUser)
118
{
12-
User? localUser = dialogSlot?.World?.LocalUser ?? suggestedRoot?.World?.LocalUser;
139
if (localUser == null)
1410
{
15-
dialogSlot?.Destroy();
1611
return;
1712
}
1813

19-
dialogSlot?.Destroy();
20-
21-
ReferenceReplacementDialog.Create(localUser, null, suggestedRoot);
14+
ReferenceReplacementDialog.Create(localUser);
2215
}
2316

2417
public static void Unregister(ReferenceReplacementDialog dialog)

0 commit comments

Comments
 (0)