Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
a21704a
Rework the activity view: readable change summaries and a before/afte…
myieye Jun 29, 2026
d74c664
Reframe reorder summaries and name the type on vocab field edits
myieye Jun 29, 2026
8990c43
Collapse single-entry create commits in the activity panel
myieye Jun 29, 2026
21d1d4e
Bound the activity list, and guard summary/diff coverage with tests
myieye Jun 29, 2026
9f7e728
Tidy after review: barrel exports, test/type tightening, comment trims
myieye Jun 29, 2026
9130fbb
Fix viewer lint on the activity-summary changes
myieye Jun 29, 2026
6d113b4
Drop a dead change handler and add reverse coverage
myieye Jun 29, 2026
ff63fe6
Polish activity summaries per review: readability, headwords, layout fix
myieye Jun 30, 2026
4018a28
Merge remote-tracking branch 'origin/develop' into claude/brave-allen…
myieye Jun 30, 2026
3e511aa
Address review round 2 + CodeRabbit: new change types, perf, vocab pr…
myieye Jun 30, 2026
9dbbe57
Merge remote-tracking branch 'origin/develop' into claude/brave-allen…
myieye Jun 30, 2026
64823ca
Fix History button no-op on delete-change cards
myieye Jun 30, 2026
e1bfd37
Iterate activity view: CFC preview fixes, sense-create format, exampl…
myieye Jul 1, 2026
4677ab8
Strengthen diff palette: bolder strikethrough, visible kept-outline i…
myieye Jul 1, 2026
fcc8596
Give activity summaries a clear hierarchy: bold subject, muted verb, …
myieye Jul 1, 2026
498ae5e
Make Detailed activity mode a change-inspector: group changes by subject
myieye Jul 1, 2026
bc3f266
Fix three activity-preview data-fidelity issues
myieye Jul 2, 2026
2e1438f
Handle audio field values generically in activity summaries
myieye Jul 2, 2026
6914184
Show the historical entry in collapsed create-entry previews
myieye Jul 2, 2026
8c6fde6
Render collapsed create-entry preview with diff components, not the r…
myieye Jul 2, 2026
4cf3f88
Summarize media resources by kind + play them in the preview
myieye Jul 2, 2026
72f408e
Show and play audio writing systems in diff previews
myieye Jul 2, 2026
45a674f
Make media-resource commits show as audio and play in the preview
myieye Jul 2, 2026
4e871db
Polish audio diffs: editor 'No audio' placeholder + gutter-marked add…
myieye Jul 2, 2026
f174e32
Name endpoints on removed-component summaries
myieye Jul 2, 2026
a435604
Detailed mode: change-kind gutter glyphs + a more generous change cap
myieye Jul 2, 2026
eb87d3e
Diff custom views, and guard vocab field coverage
myieye Jul 2, 2026
3806751
Use an mdi arrow for the single-select before→after transition
myieye Jul 2, 2026
a2e7f70
Show readable names for custom-view base and writing-system type enums
myieye Jul 2, 2026
2a4d25f
Number senses positionally like FieldWorks, not by gloss duplication
myieye Jul 2, 2026
eafa809
Drop the change-type-names stripe from the preview
myieye Jul 2, 2026
4940b90
Collapse a create-sense commit into a single sense + examples view
myieye Jul 2, 2026
87aba1c
Add a per-author coloured person icon to activity rows
myieye Jul 2, 2026
f8fa0d5
Merge remote-tracking branch 'origin/develop' into claude/brave-allen…
myieye Jul 2, 2026
fe4bdad
Extract EntryHeadwordButton for reuse in collapsed previews
myieye Jul 2, 2026
93573cc
Add headword button and Preview/Details tabs to collapsed previews
myieye Jul 2, 2026
a3728b1
Extract i18n strings for custom-view diff labels
myieye Jul 2, 2026
994a113
Fix viewer ESLint errors surfaced by lint:report
myieye Jul 2, 2026
6272319
Trail the author icon after the name
myieye Jul 2, 2026
cc2343d
Only hang-indent Detailed changes inside a subject group
myieye Jul 2, 2026
bbf87c7
Flatten the audio diff to full width with just a surface tint
myieye Jul 2, 2026
2e7ac25
Center change-type glyphs and breathe above the row footer
myieye Jul 2, 2026
20a12a1
Center the ungrouped change-type glyph via flex
myieye Jul 2, 2026
6867a74
Mark single-fact rows with a coloured corner cut
myieye Jul 2, 2026
558bf80
Resolve root-entry headwords and deleted-object labels for activity info
myieye Jul 6, 2026
06df785
Style writing-system codes as chips, opting out in the dictionary pre…
myieye Jul 6, 2026
e0d362a
Label custom-view fields in the vocab diff and tolerate null lists
myieye Jul 6, 2026
4022f29
Assign distinct author colours across the project's author set
myieye Jul 6, 2026
355efd0
Group activity rows by entry tree with bold names for created and del…
myieye Jul 6, 2026
542ec9d
Merge remote-tracking branch 'origin/develop' into claude/brave-allen…
myieye Jul 6, 2026
aa27c51
Summarize comment changes from develop in the activity feed
myieye Jul 6, 2026
91da2ea
Keep the group indent rail visible on selected rows
myieye Jul 6, 2026
638fae5
Merge remote-tracking branch 'origin/develop' into claude/brave-allen…
myieye Jul 7, 2026
0874e55
Bold the entity name in grouped sub-item summaries
myieye Jul 7, 2026
726626c
Darken writing-system code text to clear WCAG AA contrast
myieye Jul 7, 2026
c929da0
Guard entity-config coverage test against a silent empty parse
myieye Jul 7, 2026
c0bf70d
Merge branch 'develop' into claude/brave-allen-91bf15
myieye Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Reflection;
using LcmCrdt;
using Reinforced.Typings;
using Reinforced.Typings.Ast;
using Reinforced.Typings.Generators;

namespace FwLiteShared.TypeGen;

/// <summary>
/// Emits a `ChangeType` string-literal union and a `knownChangeTypes` array built from the registered CRDT
/// change types (<see cref="LcmCrdtKernel.AllChangeTypes"/>) and each type's <c>IPolyType.TypeName</c>
/// (the serialized <c>$type</c> discriminator). Attached to the <c>ChangeTypes</c> marker; suppresses the
/// marker's own output.
/// </summary>
public class ChangeTypesCodeGenerator : ClassCodeGenerator
{
public override RtClass GenerateNode(Type element, RtClass result, TypeResolver resolver)
{
var typeNames = LcmCrdtKernel.AllChangeTypes()
.Select(GetChangeTypeName)
.Distinct()
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();

var union = string.Concat(typeNames.Select(name => $"\n | '{name}'"));
var array = string.Concat(typeNames.Select(name => $"\n '{name}',"));

Context.Location.CurrentNamespace.CompilationUnits.Add(new RtRaw(
$"export type ChangeType ={union};\n\nexport const knownChangeTypes = [{array}\n] as const satisfies readonly ChangeType[];\n"));

// Suppress the marker class; only the raw union/array above is emitted.
return null!;
}

private static string GetChangeTypeName(Type changeType)
{
var typeNameProperty = changeType.GetProperty("TypeName",
BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy);
// Mirrors Harmony's own discriminator logic, so the generated list stays in sync with the runtime $type:
// generic/shared changes (jsonPatch:, delete:, SetOrderChange:) define a custom static TypeName, while a
// dedicated change class (e.g. SetPartOfSpeechChange) has none and serializes under its CLR type name.
// The fallback to changeType.Name is therefore intentional, not a silent mismatch.
return typeNameProperty?.GetValue(null) as string ?? changeType.Name;
Comment thread
myieye marked this conversation as resolved.
}
}
10 changes: 10 additions & 0 deletions backend/FwLite/FwLiteShared/TypeGen/ChangeTypesMarker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Namespace is LcmCrdt (not FwLiteShared.TypeGen) on purpose: Reinforced.Typings derives the generated
// file's path from the type's namespace, so this keeps the output at generated-types/LcmCrdt/ChangeTypes.ts.
namespace LcmCrdt;

/// <summary>
/// Marker type only — no members. Reinforced.Typings (via <c>ChangeTypesCodeGenerator</c>) emits the
/// <c>ChangeType</c> string-literal union and <c>knownChangeTypes</c> array into ChangeTypes.ts from the
/// registered change types, so the frontend has a generated, exhaustive list of change <c>$type</c> values.
/// </summary>
internal sealed class ChangeTypes;
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(FwLiteConfig),
typeof(HistoryLineItem),
typeof(ProjectActivity),
typeof(ActivityChangeInfo),
typeof(ActivityAuthor),
typeof(ActivityChangeType),
typeof(ActivityQuery),
Expand All @@ -190,6 +191,8 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
typeof(AvailableUpdate),
], exportBuilder => exportBuilder.WithPublicProperties());

builder.ExportAsClass<ChangeTypes>().WithCodeGenerator<ChangeTypesCodeGenerator>();

builder.ExportAsEnum<ActivitySort>().UseString();
builder.ExportAsEnum<FwEventType>().UseString();
builder.ExportAsEnum<LogLevel>().UseString(false);
Expand Down
392 changes: 392 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/HistoryServiceActivityTests.cs

Large diffs are not rendered by default.

298 changes: 298 additions & 0 deletions backend/FwLite/LcmCrdt/ActivityChangeInfoResolver.cs

Large diffs are not rendered by default.

61 changes: 50 additions & 11 deletions backend/FwLite/LcmCrdt/HistoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ public static class ActivityFilterKeys
public const string AuthorNamePrefix = "name:";
}

/// <summary>
/// Resolved display info for one change, so the frontend can name what the change is about.
/// <paramref name="Subject"/> is the entity the change is on (entry headword, "headword › gloss" for a sense, or a vocab object's name);
/// <paramref name="Target"/> is a referenced item the change names only by id (e.g. the part of speech assigned, the semantic domain removed).
/// Both are null when unresolved — the frontend falls back to a type label.
/// </summary>
public record ActivityChangeInfo(string? Subject, Guid? RootEntryId, string? Target = null);

public record ProjectActivity(
Guid CommitId,
DateTimeOffset Timestamp,
Expand All @@ -44,20 +52,23 @@ public record ProjectActivity(
{
public string ChangeName => HistoryService.ChangesNameHelper(Changes);
public string[] ChangeTypes { get; } = Changes.Select(c => HistoryService.GetChangeTypeKey(c.Change)).Distinct().ToArray();
/// <summary>Resolved display info per change, parallel to <see cref="Changes"/> by index. Set during enrichment.</summary>
public IReadOnlyList<ActivityChangeInfo> ChangeInfo { get; set; } = [];
}

public record ChangeContext(
Guid CommitId,
int ChangeIndex,
string ChangeName,
IObjectWithId? Snapshot,
IObjectWithId? PreviousSnapshot,
ICollection<Entry> AffectedEntries)
{
public ChangeContext(ChangeEntity<IChange> change, IObjectWithId? snapshot, ICollection<Entry> affectedEntries)
: this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, affectedEntries)
public ChangeContext(ChangeEntity<IChange> change, IObjectWithId? snapshot, IObjectWithId? previousSnapshot, ICollection<Entry> affectedEntries)
: this(change.CommitId, change.Index, HistoryService.ChangeNameHelper(change.Change), snapshot, previousSnapshot, affectedEntries)
{
}
public string EntityType => Snapshot?.GetType().Name ?? "Unknown";
public string EntityType => (Snapshot ?? PreviousSnapshot)?.GetType().Name ?? "Unknown";
}

public record HistoryLineItem(
Expand Down Expand Up @@ -143,7 +154,15 @@ from commit in commits.Skip(skip).Take(take)
NormalizeTimestamp(commit.HybridDateTime.DateTime),
commit.ChangeEntities.ToList(),
commit.Metadata);
// Materialize the whole page before resolving: ActivityChangeInfoResolver batch-loads labels across all
// changes in the page at once, so this enumerates the page rather than streaming row-by-row.
var activities = new List<ProjectActivity>();
await foreach (var projectActivity in queryable.ToLinqToDB().AsAsyncEnumerable())
{
activities.Add(projectActivity);
}
await ActivityChangeInfoResolver.ResolveAsync(dbContext, activities);
foreach (var projectActivity in activities)
{
yield return projectActivity;
}
Expand Down Expand Up @@ -301,21 +320,41 @@ public async Task<ChangeContext> LoadChangeContext(Guid commitId, int changeInde

// Use safe cast - some entity types like RemoteResource don't implement IObjectWithId
var snapshot = await dataModel.GetAtCommit<object>(commitId, change.EntityId) as IObjectWithId;
var previousSnapshot = await LoadPreviousSnapshot(crdtDbContext, commitId, change.EntityId);

await ResolveSensePartOfSpeech(snapshot);
await ResolveSensePartOfSpeech(previousSnapshot);
Comment thread
myieye marked this conversation as resolved.

var affectedEntries = await GetAffectedEntryIds(change)
.Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId))
.ToArrayAsync();

return new ChangeContext(change, snapshot, previousSnapshot, affectedEntries);
}

/// <summary>The entity's state just before <paramref name="commitId"/>, i.e. the snapshot at the most recent
/// commit that affected it before this one. Null if this commit created the entity.</summary>
private async Task<IObjectWithId?> LoadPreviousSnapshot(ICrdtDbContext dbContext, Guid commitId, Guid entityId)
{
var affectingCommitIds = await dbContext.Commits
.Where(c => c.ChangeEntities.Any(ce => ce.EntityId == entityId))
.DefaultOrderDescending()
.Select(c => c.Id)
.ToListAsyncLinqToDB();
var index = affectingCommitIds.IndexOf(commitId);
if (index < 0 || index + 1 >= affectingCommitIds.Count) return null;
return await dataModel.GetAtCommit<object>(affectingCommitIds[index + 1], entityId) as IObjectWithId;
}

// Older sense snapshots didn't store the part-of-speech object, only its id; resolve it so previews can show a label.
private async Task ResolveSensePartOfSpeech(IObjectWithId? snapshot)
{
if (snapshot is Sense sense && sense.PartOfSpeechId != sense.PartOfSpeech?.Id)
{
// previously we didn't update the part of speech object on sense snapshots
// we do now, so we patch old snapshots
sense.PartOfSpeech = sense.PartOfSpeechId.HasValue
? await dataModel.GetLatest<PartOfSpeech>(sense.PartOfSpeechId.Value)
: null;
}

var affectedEntries = await GetAffectedEntryIds(change)
.Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId))
.ToArrayAsync();

return new ChangeContext(change, snapshot, affectedEntries);
}

private async Task<Entry> GetCurrentOrLatestEntry(Guid entryId)
Expand Down
11 changes: 10 additions & 1 deletion frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"@lingui/core": "^5.6.1",
"@microsoft/dotnet-js-interop": "^10.0.0",
"@microsoft/signalr": "^10.0.0",
"@sanity/diff-match-patch": "^3.2.0",
"fast-json-patch": "^3.1.1",
"jsdom": "^26.1.0",
"just-throttle": "^4.2.0",
Expand Down
Loading
Loading