Skip to content

Commit cd38b0e

Browse files
committed
feat: add diff review dialog, undo support, and monitoring panels
- Add diff dialog with inline and side-by-side views for reviewing changes before saving (Angular + React), gated by new setting - Add undo support for string and JSON key saves via toast action with 5-second window to revert to previous value - Add server info, persistence, replication, keyspace, CPU, and loaded modules panels to the monitoring/pulse page - Add "undo enabled" and "show diff before save" toggle settings to tree settings dialog and settings page - Add `diff` (v8.0.4) and `@types/diff` dependencies - Add diff, undo, and monitoring i18n strings across all 54 languages - Bump version to v2026.4.422
1 parent 739f6d8 commit cd38b0e

87 files changed

Lines changed: 2484 additions & 107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ https://corifeus.com/redis-ui
1212

1313

1414
---
15-
# 💿 P3X Redis UI dual frontend — Angular + React/MUI with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity v2026.4.421
15+
# 💿 P3X Redis UI dual frontend — Angular + React/MUI with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity v2026.4.422
1616

1717

1818

@@ -265,7 +265,7 @@ All my domains, including [patrikx3.com](https://patrikx3.com), [corifeus.eu](ht
265265
---
266266

267267

268-
[**P3X-REDIS-UI-MATERIAL**](https://corifeus.com/redis-ui-material) Build v2026.4.421
268+
[**P3X-REDIS-UI-MATERIAL**](https://corifeus.com/redis-ui-material) Build v2026.4.422
269269

270270
[![NPM](https://img.shields.io/npm/v/p3x-redis-ui-material.svg)](https://www.npmjs.com/package/p3x-redis-ui-material) [![Donate for PatrikX3 / P3X](https://img.shields.io/badge/Donate-PatrikX3-003087.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=QZVM4V6HVZJW6) [![Contact Corifeus / P3X](https://img.shields.io/badge/Contact-P3X-ff9900.svg)](https://www.patrikx3.com/en/front/contact) [![Like Corifeus @ Facebook](https://img.shields.io/badge/LIKE-Corifeus-3b5998.svg)](https://www.facebook.com/corifeus.software)
271271

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "p3x-redis-ui-material",
3-
"version": "2026.4.421",
3+
"version": "2026.4.422",
44
"description": "💿 P3X Redis UI dual frontend — Angular + React/MUI with 54 languages, 7 themes, Socket.IO, desktop notifications, and full feature parity",
55
"corifeus": {
66
"icon": "fas fa-database",
@@ -62,6 +62,7 @@
6262
"@fontsource/roboto-mono": "^5.2.8",
6363
"@fortawesome/fontawesome-free": "^7.2.0",
6464
"@playwright/test": "^1.59.1",
65+
"@types/diff": "^8.0.0",
6566
"@types/lodash-es": "^4.17.12",
6667
"@types/react": "^19.2.14",
6768
"@types/react-dom": "^19.2.3",
@@ -105,6 +106,7 @@
105106
"@tanstack/react-virtual": "^3.13.23",
106107
"@uiw/codemirror-theme-github": "^4.25.9",
107108
"codemirror": "^6.0.2",
109+
"diff": "^8.0.4",
108110
"jspdf": "^4.2.1",
109111
"jszip": "^3.10.1",
110112
"react": "^19.2.5",
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { Component, Inject, ChangeDetectionStrategy, ViewEncapsulation, signal, computed, OnInit } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
4+
import { MatButtonModule } from '@angular/material/button';
5+
import { MatIconModule } from '@angular/material/icon';
6+
import { MatToolbarModule } from '@angular/material/toolbar';
7+
import { MatTooltipModule } from '@angular/material/tooltip';
8+
import { MatButtonToggleModule } from '@angular/material/button-toggle';
9+
import { BreakpointObserver } from '@angular/cdk/layout';
10+
import { diffLines, Change } from 'diff';
11+
import { I18nService } from '../services/i18n.service';
12+
import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component';
13+
14+
export interface DiffDialogData {
15+
keyName: string;
16+
fieldName?: string;
17+
oldValue: string;
18+
newValue: string;
19+
}
20+
21+
interface DiffBlock {
22+
type: 'added' | 'removed' | 'unchanged' | 'collapse';
23+
lines: string[];
24+
collapsedCount?: number;
25+
expanded?: boolean;
26+
}
27+
28+
const CONTEXT_LINES = 3;
29+
30+
@Component({
31+
selector: 'p3xr-diff-dialog',
32+
standalone: true,
33+
imports: [
34+
CommonModule, MatDialogModule, MatButtonModule, MatIconModule,
35+
MatToolbarModule, MatTooltipModule, MatButtonToggleModule,
36+
DialogCancelButtonComponent,
37+
],
38+
changeDetection: ChangeDetectionStrategy.OnPush,
39+
encapsulation: ViewEncapsulation.None,
40+
template: `
41+
<mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong">
42+
<span mat-dialog-title class="p3xr-dialog-title p3xr-dialog-title-with-icon" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
43+
<mat-icon>difference</mat-icon>
44+
<span>{{ diffStrings().reviewChanges || 'Review changes' }}</span>
45+
</span>
46+
<span style="flex: 1;"></span>
47+
<mat-button-toggle-group [value]="mode()" (change)="mode.set($event.value)" class="p3xr-diff-toggle" [hideSingleSelectionIndicator]="true">
48+
<mat-button-toggle value="inline">{{ diffStrings().inline || 'Inline' }}</mat-button-toggle>
49+
<mat-button-toggle value="side-by-side">{{ diffStrings().sideBySide || 'Side by side' }}</mat-button-toggle>
50+
</mat-button-toggle-group>
51+
<span class="p3xr-diff-summary-header">
52+
<span class="p3xr-diff-count-add">+{{ additions() }}</span> {{ diffStrings().additions || 'additions' }},
53+
<span class="p3xr-diff-count-del">-{{ deletions() }}</span> {{ diffStrings().deletions || 'deletions' }}
54+
</span>
55+
<button mat-icon-button (click)="dialogRef.close(false)"><mat-icon>close</mat-icon></button>
56+
</mat-toolbar>
57+
58+
<mat-dialog-content class="p3xr-dialog-content p3xr-diff-content" [class.p3xr-diff-sbs]="mode() === 'side-by-side'">
59+
@if (mode() === 'inline') {
60+
@for (block of blocks(); track $index) {
61+
@if (block.type === 'collapse' && !block.expanded) {
62+
<div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div>
63+
} @else {
64+
@for (line of block.lines; track $index) {
65+
<div class="p3xr-diff-line" [class.p3xr-diff-added]="block.type === 'added'" [class.p3xr-diff-removed]="block.type === 'removed'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'">
66+
<span class="p3xr-diff-prefix">{{ block.type === 'added' ? '+' : block.type === 'removed' ? '-' : ' ' }}</span>{{ line }}
67+
</div>
68+
}
69+
}
70+
}
71+
} @else {
72+
<div class="p3xr-diff-side">
73+
<div class="p3xr-diff-side-header">{{ diffStrings().before || 'Before' }}</div>
74+
@for (block of blocks(); track $index) {
75+
@if (block.type === 'collapse' && !block.expanded) {
76+
<div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div>
77+
} @else if (block.type !== 'added') {
78+
@for (line of block.lines; track $index) {
79+
<div class="p3xr-diff-line" [class.p3xr-diff-removed]="block.type === 'removed'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'">{{ line }}</div>
80+
}
81+
}
82+
}
83+
</div>
84+
<div class="p3xr-diff-side">
85+
<div class="p3xr-diff-side-header">{{ diffStrings().after || 'After' }}</div>
86+
@for (block of blocks(); track $index) {
87+
@if (block.type === 'collapse' && !block.expanded) {
88+
<div class="p3xr-diff-collapse" (click)="expandBlock($index)">... {{ block.collapsedCount }} {{ diffStrings().unchangedLines || 'unchanged lines' }} ...</div>
89+
} @else if (block.type !== 'removed') {
90+
@for (line of block.lines; track $index) {
91+
<div class="p3xr-diff-line" [class.p3xr-diff-added]="block.type === 'added'" [class.p3xr-diff-unchanged]="block.type === 'unchanged' || block.type === 'collapse'">{{ line }}</div>
92+
}
93+
}
94+
}
95+
</div>
96+
}
97+
</mat-dialog-content>
98+
99+
<mat-dialog-actions class="p3xr-dialog-actions">
100+
<p3xr-dialog-cancel (cancel)="dialogRef.close(false)"></p3xr-dialog-cancel>
101+
<button mat-raised-button class="btn-primary" (click)="dialogRef.close(true)"
102+
[matTooltip]="strings().intention?.save || 'Save'" [matTooltipDisabled]="isWide">
103+
<mat-icon>save</mat-icon>
104+
@if (isWide) { <span>{{ strings().intention?.save || 'Save' }}</span> }
105+
</button>
106+
</mat-dialog-actions>
107+
`,
108+
styles: [`
109+
.p3xr-diff-content {
110+
font-family: 'Roboto Mono', monospace;
111+
font-size: 13px;
112+
padding: 0 !important;
113+
min-height: 200px;
114+
max-height: 60vh;
115+
overflow: auto;
116+
}
117+
.p3xr-diff-sbs {
118+
display: grid;
119+
grid-template-columns: 1fr 1fr;
120+
}
121+
.p3xr-diff-side {
122+
overflow: auto;
123+
&:first-child { border-right: 1px solid rgba(128,128,128,0.2); }
124+
}
125+
.p3xr-diff-side-header {
126+
padding: 4px 8px;
127+
font-weight: 500;
128+
position: sticky;
129+
top: 0;
130+
z-index: 1;
131+
border-bottom: 1px solid rgba(128,128,128,0.2);
132+
background: var(--p3xr-content-bg, inherit);
133+
}
134+
.p3xr-diff-line {
135+
padding: 1px 8px;
136+
white-space: pre-wrap;
137+
word-break: break-all;
138+
}
139+
.p3xr-diff-prefix {
140+
display: inline-block;
141+
width: 16px;
142+
font-weight: 700;
143+
user-select: none;
144+
}
145+
.p3xr-diff-added { background: rgba(76,175,80,0.12); }
146+
.p3xr-diff-removed { background: rgba(244,67,54,0.12); }
147+
.p3xr-diff-unchanged { opacity: 0.6; }
148+
.p3xr-diff-collapse {
149+
padding: 4px 8px;
150+
opacity: 0.4;
151+
font-style: italic;
152+
cursor: pointer;
153+
&:hover { opacity: 0.7; }
154+
}
155+
.p3xr-diff-toggle {
156+
height: 28px;
157+
margin-right: 4px;
158+
border-radius: 4px !important;
159+
overflow: hidden;
160+
border: 1px solid rgba(255,255,255,0.3) !important;
161+
162+
.mat-button-toggle {
163+
height: 28px;
164+
font-size: 12px;
165+
border: none !important;
166+
border-left: 1px solid rgba(255,255,255,0.3) !important;
167+
border-radius: 0 !important;
168+
background: transparent;
169+
color: rgba(255,255,255,0.7);
170+
}
171+
.mat-button-toggle:first-child {
172+
border-left: none !important;
173+
}
174+
.mat-button-toggle-checked {
175+
background: rgba(255,255,255,0.15) !important;
176+
color: rgba(255,255,255,0.95) !important;
177+
}
178+
.mat-button-toggle-button {
179+
height: 28px;
180+
}
181+
.mat-button-toggle-label-content {
182+
line-height: 28px !important;
183+
padding: 0 10px !important;
184+
}
185+
.mat-pseudo-checkbox,
186+
.mdc-button__icon {
187+
display: none !important;
188+
}
189+
.mat-button-toggle-button {
190+
padding: 0 !important;
191+
}
192+
}
193+
.p3xr-diff-summary-header {
194+
font-size: 12px; opacity: 0.8; white-space: nowrap; margin-left: 8px; margin-right: 4px;
195+
}
196+
.p3xr-diff-count-add { color: #81c784; font-weight: 700; }
197+
.p3xr-diff-count-del { color: #ef9a9a; font-weight: 700; }
198+
`],
199+
})
200+
export class DiffDialogComponent implements OnInit {
201+
readonly strings;
202+
readonly diffStrings;
203+
readonly mode = signal<'inline' | 'side-by-side'>('inline');
204+
readonly blocks = signal<DiffBlock[]>([]);
205+
isWide = true;
206+
207+
private rawChanges: Change[];
208+
readonly additions;
209+
readonly deletions;
210+
211+
constructor(
212+
@Inject(MAT_DIALOG_DATA) public data: DiffDialogData,
213+
@Inject(MatDialogRef) public dialogRef: MatDialogRef<DiffDialogComponent>,
214+
@Inject(I18nService) private i18n: I18nService,
215+
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
216+
) {
217+
this.strings = this.i18n.strings;
218+
this.diffStrings = computed(() => this.strings()?.diff || {});
219+
this.rawChanges = diffLines(data.oldValue, data.newValue);
220+
this.additions = computed(() => this.rawChanges.filter(c => c.added).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0));
221+
this.deletions = computed(() => this.rawChanges.filter(c => c.removed).reduce((n, c) => n + (c.value.split('\n').length - 1 || 1), 0));
222+
}
223+
224+
ngOnInit(): void {
225+
this.blocks.set(this.buildBlocks());
226+
this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => {
227+
this.isWide = r.matches;
228+
});
229+
}
230+
231+
expandBlock(index: number): void {
232+
const updated = [...this.blocks()];
233+
const block = { ...updated[index] };
234+
block.expanded = true;
235+
block.type = 'unchanged';
236+
updated[index] = block;
237+
this.blocks.set(updated);
238+
}
239+
240+
private buildBlocks(): DiffBlock[] {
241+
const blocks: DiffBlock[] = [];
242+
for (const change of this.rawChanges) {
243+
const lines = change.value.replace(/\n$/, '').split('\n');
244+
if (change.added) {
245+
blocks.push({ type: 'added', lines });
246+
} else if (change.removed) {
247+
blocks.push({ type: 'removed', lines });
248+
} else {
249+
if (lines.length <= CONTEXT_LINES * 2 + 1) {
250+
blocks.push({ type: 'unchanged', lines });
251+
} else {
252+
blocks.push({ type: 'unchanged', lines: lines.slice(0, CONTEXT_LINES) });
253+
const collapsed = lines.slice(CONTEXT_LINES, -CONTEXT_LINES);
254+
blocks.push({ type: 'collapse', lines: collapsed, collapsedCount: collapsed.length });
255+
blocks.push({ type: 'unchanged', lines: lines.slice(-CONTEXT_LINES) });
256+
}
257+
}
258+
}
259+
return blocks;
260+
}
261+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Injectable, Inject } from '@angular/core';
2+
import { MatDialog } from '@angular/material/dialog';
3+
import { createDialogPopupSettings } from './dialog-popup';
4+
import { SettingsService } from '../services/settings.service';
5+
6+
export interface DiffDialogOptions {
7+
keyName: string;
8+
fieldName?: string;
9+
oldValue: string;
10+
newValue: string;
11+
}
12+
13+
@Injectable({ providedIn: 'root' })
14+
export class DiffDialogService {
15+
16+
constructor(
17+
@Inject(MatDialog) private dialog: MatDialog,
18+
@Inject(SettingsService) private settings: SettingsService,
19+
) {}
20+
21+
async show(options: DiffDialogOptions): Promise<boolean> {
22+
if (!this.settings.showDiffBeforeSave()) return true;
23+
options.oldValue = String(options.oldValue ?? '');
24+
options.newValue = String(options.newValue ?? '');
25+
if (options.oldValue === options.newValue) return true;
26+
27+
const { DiffDialogComponent } = await import(
28+
/* webpackChunkName: "dialog-diff" */
29+
'./diff-dialog.component'
30+
);
31+
32+
const dialogRef = this.dialog.open(DiffDialogComponent, createDialogPopupSettings({
33+
data: options,
34+
width: '800px',
35+
maxHeight: '90vh',
36+
}));
37+
38+
return new Promise<boolean>((resolve) => {
39+
dialogRef.afterClosed().subscribe((result) => resolve(result === true));
40+
});
41+
}
42+
}

src/ng/dialogs/json-editor-dialog.component.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CommonService } from '../services/common.service';
1212
import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component';
1313
import { RedisStateService } from '../services/redis-state.service';
1414
import { SettingsService } from '../services/settings.service';
15+
import { DiffDialogService } from './diff-dialog.service';
1516

1617
export interface JsonEditorDialogData {
1718
value: string;
@@ -111,6 +112,7 @@ export class JsonEditorDialogComponent implements OnInit, AfterViewInit, OnDestr
111112
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
112113
@Inject(RedisStateService) private state: RedisStateService,
113114
@Inject(SettingsService) private settings: SettingsService,
115+
@Inject(DiffDialogService) private diffDialog: DiffDialogService,
114116
) {
115117
this.strings = this.i18n.strings;
116118
}
@@ -270,11 +272,14 @@ export class JsonEditorDialogComponent implements OnInit, AfterViewInit, OnDestr
270272
}
271273
}
272274

273-
save(format: boolean): void {
275+
async save(format: boolean): Promise<void> {
274276
try {
275277
const text = this.editorView.state.doc.toString();
276278
const parsed = JSON.parse(text);
277279
const result = JSON.stringify(parsed, null, format ? (this.settings.jsonFormat() ?? 2) : 0);
280+
const keyName = this.state.connection()?.name || 'key';
281+
const confirmed = await this.diffDialog.show({ keyName, oldValue: this.data.value, newValue: result });
282+
if (!confirmed) return;
278283
this.dialogRef.close({ obj: result });
279284
} catch (e) {
280285
this.common.generalHandleError(e);

src/ng/dialogs/json-editor-dialog.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export class JsonEditorDialogService {
2727

2828
const dialogRef = this.dialog.open(JsonEditorDialogComponent, createDialogPopupSettings({
2929
data: { value: options.value, hideFormatSave: options.hideFormatSave },
30-
disableClose: true,
30+
disableClose: false,
3131
width: '90vw',
3232
height: '90vh',
3333
}));

src/ng/dialogs/key-import-dialog.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class KeyImportDialogService {
1414

1515
const dialogRef = this.dialog.open(KeyImportDialogComponent, createDialogPopupSettings({
1616
data: options.data,
17-
disableClose: true,
17+
disableClose: false,
1818
width: '700px',
1919
maxWidth: '95vw',
2020
maxHeight: '90vh',

0 commit comments

Comments
 (0)