Skip to content

Commit 5ed8707

Browse files
committed
Show local revision diffs (#28)
1 parent 5cc25e8 commit 5ed8707

7 files changed

Lines changed: 226 additions & 16 deletions

File tree

src/PrompterOne.Shared/Contracts/UiTestIds.Editor.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ public static class Editor
188188

189189
public static string LocalHistoryItem(int index) => $"editor-local-history-item-{index}";
190190

191+
public static string LocalHistoryDiff(int index) => $"editor-local-history-diff-{index}";
192+
193+
public static string LocalHistoryDiffSummary(int index) => $"editor-local-history-diff-summary-{index}";
194+
191195
public static string LocalHistoryRestore(int index) => $"editor-local-history-restore-{index}";
192196

193197
public static string RenderedBlock(int segmentIndex, int blockIndex) => $"editor-rendered-block-{segmentIndex}-{blockIndex}";

src/PrompterOne.Shared/Editor/Components/EditorMetadataToolsPanel.razor

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,35 @@
5858
{
5959
var revision = LocalHistory[index];
6060
<div class="ed-local-history-item" data-test="@UiTestIds.Editor.LocalHistoryItem(index)">
61-
<div class="ed-local-history-copy">
62-
<span class="ed-local-history-time">@revision.SavedAtLabel</span>
63-
<span class="ed-local-history-file">@revision.DocumentName</span>
61+
<div class="ed-local-history-head">
62+
<div class="ed-local-history-copy">
63+
<span class="ed-local-history-time">@revision.SavedAtLabel</span>
64+
<span class="ed-local-history-file">@revision.DocumentName</span>
65+
</div>
66+
<div class="ed-local-history-actions">
67+
<span class="ed-local-history-diff-summary"
68+
data-test="@UiTestIds.Editor.LocalHistoryDiffSummary(index)">@revision.DiffSummary</span>
69+
<button type="button"
70+
class="ed-local-history-restore"
71+
data-test="@UiTestIds.Editor.LocalHistoryRestore(index)"
72+
@onclick="() => LocalHistoryRestoreRequested.InvokeAsync(revision.Id)">
73+
@Text(UiTextKey.CommonRestore)
74+
</button>
75+
</div>
6476
</div>
65-
<button type="button"
66-
class="ed-local-history-restore"
67-
data-test="@UiTestIds.Editor.LocalHistoryRestore(index)"
68-
@onclick="() => LocalHistoryRestoreRequested.InvokeAsync(revision.Id)">
69-
@Text(UiTextKey.CommonRestore)
70-
</button>
77+
@if (revision.DiffLines.Count > 0)
78+
{
79+
<div class="ed-local-history-diff" data-test="@UiTestIds.Editor.LocalHistoryDiff(index)">
80+
@foreach (var line in revision.DiffLines)
81+
{
82+
<div class="ed-local-history-diff-line"
83+
data-kind="@line.Kind">
84+
<span class="ed-local-history-diff-marker">@line.Marker</span>
85+
<span class="ed-local-history-diff-text">@line.Text</span>
86+
</div>
87+
}
88+
</div>
89+
}
7190
</div>
7291
}
7392
</div>

src/PrompterOne.Shared/Editor/Components/EditorViewModels.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,18 @@ public sealed record EditorLocalRevisionViewModel(
4444
string Id,
4545
string SavedAtLabel,
4646
string Title,
47-
string DocumentName);
47+
string DocumentName,
48+
int DiffAddedCount,
49+
int DiffRemovedCount,
50+
IReadOnlyList<EditorLocalRevisionDiffLineViewModel> DiffLines)
51+
{
52+
public string DiffSummary => $"+{DiffAddedCount} / -{DiffRemovedCount}";
53+
}
54+
55+
public sealed record EditorLocalRevisionDiffLineViewModel(
56+
string Kind,
57+
string Marker,
58+
string Text);
4859

4960
public sealed record EditorRenderedSegmentViewModel(
5061
int Index,

src/PrompterOne.Shared/Editor/Pages/EditorPage.LocalHistory.cs

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ namespace PrompterOne.Shared.Pages;
88
public partial class EditorPage
99
{
1010
private const string LocalRevisionTimestampFormat = "yyyy-MM-dd HH:mm:ss";
11+
private const int LocalRevisionDiffPreviewLineLimit = 8;
1112

1213
private async Task LoadEditorFileWorkflowAsync(CancellationToken cancellationToken = default)
1314
{
@@ -69,10 +70,107 @@ private void ApplyLocalHistory(IReadOnlyList<EditorLocalRevisionRecord> revision
6970
: null;
7071
}
7172

72-
private static EditorLocalRevisionViewModel CreateLocalRevisionViewModel(EditorLocalRevisionRecord revision) =>
73-
new(
73+
private EditorLocalRevisionViewModel CreateLocalRevisionViewModel(EditorLocalRevisionRecord revision)
74+
{
75+
var diff = BuildLocalRevisionDiff(_sourceText, ReadRevisionBody(revision.PersistedText));
76+
return new(
7477
revision.Id,
7578
revision.SavedAt.LocalDateTime.ToString(LocalRevisionTimestampFormat, CultureInfo.InvariantCulture),
7679
revision.Title,
77-
revision.DocumentName);
80+
revision.DocumentName,
81+
diff.AddedCount,
82+
diff.RemovedCount,
83+
diff.Lines);
84+
}
85+
86+
private string ReadRevisionBody(string persistedText) =>
87+
_frontMatterService.Parse(persistedText).Body;
88+
89+
private static LocalRevisionDiff BuildLocalRevisionDiff(string currentText, string revisionText)
90+
{
91+
var currentLines = SplitDiffLines(currentText);
92+
var revisionLines = SplitDiffLines(revisionText);
93+
var table = BuildLongestCommonSubsequenceTable(currentLines, revisionLines);
94+
var lines = new List<EditorLocalRevisionDiffLineViewModel>();
95+
var addedCount = 0;
96+
var removedCount = 0;
97+
var currentIndex = 0;
98+
var revisionIndex = 0;
99+
100+
while (currentIndex < currentLines.Length && revisionIndex < revisionLines.Length)
101+
{
102+
if (string.Equals(currentLines[currentIndex], revisionLines[revisionIndex], StringComparison.Ordinal))
103+
{
104+
currentIndex++;
105+
revisionIndex++;
106+
continue;
107+
}
108+
109+
if (table[currentIndex + 1, revisionIndex] >= table[currentIndex, revisionIndex + 1])
110+
{
111+
removedCount++;
112+
AddDiffPreviewLine(lines, new("removed", "-", currentLines[currentIndex]));
113+
currentIndex++;
114+
}
115+
else
116+
{
117+
addedCount++;
118+
AddDiffPreviewLine(lines, new("added", "+", revisionLines[revisionIndex]));
119+
revisionIndex++;
120+
}
121+
}
122+
123+
while (currentIndex < currentLines.Length)
124+
{
125+
removedCount++;
126+
AddDiffPreviewLine(lines, new("removed", "-", currentLines[currentIndex]));
127+
currentIndex++;
128+
}
129+
130+
while (revisionIndex < revisionLines.Length)
131+
{
132+
addedCount++;
133+
AddDiffPreviewLine(lines, new("added", "+", revisionLines[revisionIndex]));
134+
revisionIndex++;
135+
}
136+
137+
return new(addedCount, removedCount, lines);
138+
}
139+
140+
private static void AddDiffPreviewLine(
141+
ICollection<EditorLocalRevisionDiffLineViewModel> lines,
142+
EditorLocalRevisionDiffLineViewModel line)
143+
{
144+
if (lines.Count < LocalRevisionDiffPreviewLineLimit)
145+
{
146+
lines.Add(line);
147+
}
148+
}
149+
150+
private static string[] SplitDiffLines(string text) =>
151+
(text ?? string.Empty)
152+
.Replace("\r\n", "\n", StringComparison.Ordinal)
153+
.Replace('\r', '\n')
154+
.Split('\n');
155+
156+
private static int[,] BuildLongestCommonSubsequenceTable(string[] currentLines, string[] revisionLines)
157+
{
158+
var table = new int[currentLines.Length + 1, revisionLines.Length + 1];
159+
for (var currentIndex = currentLines.Length - 1; currentIndex >= 0; currentIndex--)
160+
{
161+
for (var revisionIndex = revisionLines.Length - 1; revisionIndex >= 0; revisionIndex--)
162+
{
163+
table[currentIndex, revisionIndex] = string.Equals(currentLines[currentIndex], revisionLines[revisionIndex], StringComparison.Ordinal)
164+
? table[currentIndex + 1, revisionIndex + 1] + 1
165+
: Math.Max(table[currentIndex + 1, revisionIndex], table[currentIndex, revisionIndex + 1]);
166+
}
167+
}
168+
169+
return table;
170+
}
171+
172+
private sealed record LocalRevisionDiff(
173+
int AddedCount,
174+
int RemovedCount,
175+
IReadOnlyList<EditorLocalRevisionDiffLineViewModel> Lines);
78176
}

src/PrompterOne.Shared/wwwroot/design/modules/editor/10-metadata-toolbar.css

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,15 +353,22 @@ body.theme-light .ed-meta-toggle:hover {
353353

354354
.ed-local-history-item {
355355
display: flex;
356-
align-items: center;
357-
justify-content: space-between;
358-
gap: 10px;
356+
flex-direction: column;
357+
align-items: stretch;
358+
gap: 8px;
359359
padding: 8px 10px;
360360
border-radius: 10px;
361361
border: 1px solid var(--gold-10);
362362
background: var(--gold-03);
363363
}
364364

365+
.ed-local-history-head {
366+
display: flex;
367+
align-items: center;
368+
justify-content: space-between;
369+
gap: 10px;
370+
}
371+
365372
.ed-local-history-copy {
366373
display: flex;
367374
flex-direction: column;
@@ -388,6 +395,25 @@ body.theme-light .ed-meta-toggle:hover {
388395
color: #8A9E98;
389396
}
390397

398+
.ed-local-history-actions {
399+
display: inline-flex;
400+
align-items: center;
401+
gap: 6px;
402+
flex-shrink: 0;
403+
}
404+
405+
.ed-local-history-diff-summary {
406+
min-width: 46px;
407+
padding: 4px 6px;
408+
border-radius: 7px;
409+
border: 1px solid var(--gold-07);
410+
color: var(--t3);
411+
background: rgba(255,255,255,.025);
412+
font-family: var(--mono);
413+
font-size: 11px;
414+
text-align: center;
415+
}
416+
391417
.ed-local-history-restore {
392418
flex-shrink: 0;
393419
padding: 6px 10px;
@@ -408,6 +434,46 @@ body.theme-light .ed-meta-toggle:hover {
408434
color: var(--t1);
409435
}
410436

437+
.ed-local-history-diff {
438+
display: flex;
439+
flex-direction: column;
440+
gap: 3px;
441+
padding: 6px;
442+
border-radius: 8px;
443+
background: rgba(0,0,0,.12);
444+
border: 1px solid rgba(245,200,80,.06);
445+
}
446+
447+
.ed-local-history-diff-line {
448+
display: grid;
449+
grid-template-columns: 14px minmax(0, 1fr);
450+
align-items: start;
451+
gap: 4px;
452+
min-width: 0;
453+
font-family: var(--mono);
454+
font-size: 11px;
455+
line-height: 1.35;
456+
}
457+
458+
.ed-local-history-diff-line[data-kind="added"] {
459+
color: #A9DDB6;
460+
}
461+
462+
.ed-local-history-diff-line[data-kind="removed"] {
463+
color: #E0A1A1;
464+
}
465+
466+
.ed-local-history-diff-marker {
467+
font-weight: 800;
468+
text-align: center;
469+
}
470+
471+
.ed-local-history-diff-text {
472+
overflow: hidden;
473+
text-overflow: ellipsis;
474+
white-space: nowrap;
475+
}
476+
411477
.ed-tool-group { display: flex; gap: 3px; }
412478
.ed-tool-sep { width: 1px; height: 20px; background: var(--gold-07); margin: 0 10px; }
413479

tests/PrompterOne.Web.Tests/Editor/EditorLocalHistoryInteractionTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ public async Task EditorPage_LocalHistoryRestore_ReappliesOlderAutosavedRevision
103103
{
104104
Assert.Equal(2, LoadLocalHistoryEntries().Count);
105105
Assert.Contains(UiTestIds.Editor.LocalHistoryPanel, cut.Markup, StringComparison.Ordinal);
106+
Assert.Contains(UiTestIds.Editor.LocalHistoryDiff(1), cut.Markup, StringComparison.Ordinal);
106107
Assert.Contains(UiTestIds.Editor.LocalHistoryRestore(1), cut.Markup, StringComparison.Ordinal);
108+
Assert.Equal(
109+
"+0 / -1",
110+
cut.FindByTestId(UiTestIds.Editor.LocalHistoryDiffSummary(1)).TextContent.Trim());
111+
Assert.Contains(
112+
EditorLocalHistoryInteractionTestSource.SecondRevisionLine,
113+
cut.FindByTestId(UiTestIds.Editor.LocalHistoryDiff(1)).TextContent,
114+
StringComparison.Ordinal);
107115
Assert.Contains(
108116
EditorLocalHistoryInteractionTestSource.SecondRevisionLine,
109117
_harness.Session.State.Text,

tests/PrompterOne.Web.UITests.Editor/Editor/EditorLocalHistoryFlowTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ await Expect(page.GetByTestId(UiTestIds.Editor.LocalHistoryItem(BrowserTestConst
3333
await EditorMonacoDriver.SetTextAsync(page, secondRevisionText);
3434
await Expect(page.GetByTestId(UiTestIds.Editor.LocalHistoryItem(BrowserTestConstants.Editor.LocalHistoryOriginalRevisionIndex)))
3535
.ToBeVisibleAsync();
36+
await Expect(page.GetByTestId(UiTestIds.Editor.LocalHistoryDiff(BrowserTestConstants.Editor.LocalHistoryPreviousRevisionIndex)))
37+
.ToContainTextAsync(BrowserTestConstants.Editor.LocalHistorySecondLine);
38+
await Expect(page.GetByTestId(UiTestIds.Editor.LocalHistoryDiffSummary(BrowserTestConstants.Editor.LocalHistoryPreviousRevisionIndex)))
39+
.ToHaveTextAsync("+0 / -1");
3640
await UiScenarioArtifacts.CapturePageAsync(
3741
page,
3842
BrowserTestConstants.EditorFlow.LocalHistoryScenario,

0 commit comments

Comments
 (0)