import { Component, Inject, OnInit, OnDestroy, HostListener, NgZone, ChangeDetectorRef, ChangeDetectionStrategy, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, ViewChild, ElementRef, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Router, NavigationEnd } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatDividerModule } from '@angular/material/divider';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BreakpointObserver } from '@angular/cdk/layout';
import { filter } from 'rxjs/operators';
import { ThemeService } from '../services/theme.service';
import { I18nService } from '../services/i18n.service';
import { RedisStateService } from '../services/redis-state.service';
import { SocketService } from '../services/socket.service';
import { CommonService } from '../services/common.service';
import { NavigationService } from '../services/navigation.service';
import { AskAuthorizationDialogService } from '../dialogs/ask-authorization-dialog.service';
import { MainCommandService } from '../services/main-command.service';
import { ShortcutsService } from '../services/shortcuts.service';
import { OverlayService } from '../services/overlay.service';
import { SettingsService } from '../services/settings.service';
import { AuthService } from '../services/auth.service';
import { IconRegistryService } from '../services/icon-registry.service';
import { LoginComponent } from '../components/login.component';
import { ConsoleDrawerComponent } from './console-drawer.component';
import { installOverlayScrolls } from '../../core/overlay-scroll';
/**
* Angular layout component — replaces the AngularJS p3xrLayout component.
*
* Renders the fixed header toolbar (app name, home, settings) and fixed footer
* toolbar (connection menu, disconnect, donate, language, theme, github).
*
* Electron bridge:
* global.p3xrSetLanguage(key) — called by webview inject script to set language
* global.p3xrSetMenu(route) — called by webview inject script to navigate
*
* Both globals are preserved exactly as they were in the AngularJS controller
* so existing Electron integration continues to work without any changes.
*/
@Component({
selector: 'p3xr-layout',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatToolbarModule,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatDividerModule,
MatTooltipModule,
LoginComponent,
ConsoleDrawerComponent,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './layout.component.html',
styleUrls: ['./layout.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutComponent implements OnInit, OnDestroy {
// Header buttons: text hidden below 720px (matches AngularJS p3xr-button component)
isWide = true;
// Footer buttons: different AngularJS breakpoints per button
isGtXs = true; // >600px — Theme button text (AngularJS: hide-xs)
isGtSm = true; // >960px — Disconnect/Language/GitHub text (AngularJS: hide-xs hide-sm)
isElectron = false;
isElectronInitialized = false;
private readonly unsubFns: Array<() => void> = [];
constructor(
@Inject(NgZone) private readonly ngZone: NgZone,
@Inject(BreakpointObserver) private readonly breakpointObserver: BreakpointObserver,
@Inject(ThemeService) readonly theme: ThemeService,
@Inject(I18nService) readonly i18n: I18nService,
@Inject(RedisStateService) readonly state: RedisStateService,
@Inject(SocketService) private readonly socket: SocketService,
@Inject(CommonService) private readonly common: CommonService,
@Inject(AskAuthorizationDialogService) private readonly authDialog: AskAuthorizationDialogService,
@Inject(NavigationService) private readonly nav: NavigationService,
@Inject(Router) private readonly router: Router,
@Inject(MainCommandService) private readonly cmd: MainCommandService,
@Inject(ChangeDetectorRef) private readonly cdr: ChangeDetectorRef,
@Inject(ShortcutsService) readonly shortcuts: ShortcutsService,
@Inject(OverlayService) private readonly overlay: OverlayService,
@Inject(SettingsService) private readonly settings: SettingsService,
@Inject(AuthService) readonly auth: AuthService,
@Inject(IconRegistryService) iconRegistry: IconRegistryService,
) {
iconRegistry.registerAll();
// Reflect the console-drawer open state on <html> so any page can layout
// around it via CSS custom properties (see console-drawer.component.scss).
// The drawer is always mounted (so loadSavedHeight runs at app start),
// but it only opens visually when a connection is live.
effect(() => {
const open = this.state.consoleDrawerOpen();
const connected = this.state.connectionState() === 'connected';
if (open && connected) {
document.documentElement.classList.add('p3xr-console-drawer-open');
} else {
document.documentElement.classList.remove('p3xr-console-drawer-open');
}
// Note: a ResizeObserver inside ConsoleDrawerComponent fires window.resize
// on every frame of the height transition, so pages re-layout live.
});
}
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
// Ctrl+` (or Cmd+` on Mac) toggles the bottom console drawer globally.
if (event.key === '`' && (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey) {
event.preventDefault();
this.toggleConsoleDrawer();
return;
}
this.shortcuts.handleKeydown(event);
}
toggleConsoleDrawer(): void {
this.state.toggleConsoleDrawer();
this.cdr.markForCheck();
}
ngOnInit(): void {
// Check auth status — only proceed with app init when authenticated
this.auth.checkAuthStatus().then(() => {
this.cdr.markForCheck();
if (this.auth.isAuthenticated()) {
// Auto-connect from localStorage on startup
const savedConnection = this.readConnectionFromStorage();
if (savedConnection) {
this.connect(savedConnection);
}
}
});
// Initialize filtered languages list
this.filterLanguages();
// Prefetch other GUI frameworks — fetch HTML, parse script/style tags, cache all assets
setTimeout(() => {
for (const gui of ['/react/', '/vue/']) {
fetch(gui).then(r => r.text()).then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script[src], link[rel="stylesheet"]').forEach((el: Element) => {
const url = (el as any).src || (el as any).href;
if (url) fetch(url).catch(() => {});
});
}).catch(() => {});
}
}, 3000);
// Header: 720px (matches AngularJS p3xr-button component threshold)
const sub720 = this.breakpointObserver.observe('(min-width: 720px)').subscribe(r => {
this.isWide = r.matches;
this.cdr.markForCheck();
});
// Footer: 600px (AngularJS hide-xs — Theme button)
const sub600 = this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => {
this.isGtXs = r.matches;
this.cdr.markForCheck();
});
// Footer: 960px (AngularJS hide-xs hide-sm — Disconnect/Language/GitHub)
const sub960 = this.breakpointObserver.observe('(min-width: 960px)').subscribe(r => {
this.isGtSm = r.matches;
this.cdr.markForCheck();
});
this.unsubFns.push(() => { sub720.unsubscribe(); sub600.unsubscribe(); sub960.unsubscribe(); });
this.isElectron = /electron/i.test(navigator.userAgent);
// Subscribe to socket events
this.subscribeSocketEvents();
// Google Analytics route tracking
this.setupRouteTracking();
// Subscribe to connect/disconnect requests from other components
const subConnect = this.cmd.connectRequest$.subscribe((req) => {
this.connect(req.connection);
});
const subDisconnect = this.cmd.disconnectRequest$.subscribe(() => {
this.disconnect();
});
this.unsubFns.push(() => { subConnect.unsubscribe(); subDisconnect.unsubscribe(); });
// Expose Electron bridge globals with a delay so the app is fully ready.
setTimeout(() => this.setupElectronBridge(), 3000);
// Promo toast — demo site only, once per session
if (window.location.hostname === 'p3x.redis.patrikx3.com' && !sessionStorage.getItem('p3xr-promo-shown')) {
setTimeout(() => {
const promo = this.i18n.strings()?.promo;
if (promo?.toastMessage) {
sessionStorage.setItem('p3xr-promo-shown', '1');
const msg = promo.toastMessage + (promo.disclaimer ? ' · ' + promo.disclaimer : '');
this.common.toast({ message: msg, hideDelay: 30000 });
}
}, 5000);
}
// Custom overlay scrollbar — macOS-style thin thumb, applied app-wide to
// every scrollable element. CodeMirror / xterm / Monaco are excluded
// inside the helper so they keep their own native scrollbars.
this.uninstallOverlayScrolls = installOverlayScrolls();
}
private uninstallOverlayScrolls: (() => void) | null = null;
ngOnDestroy(): void {
this.unsubFns.forEach(fn => fn());
this.uninstallOverlayScrolls?.();
}
// --- Computed properties (read by template) ---
get connectionName(): string {
const conn = this.state.connection();
const strings = this.i18n.strings();
if (conn) {
const fn = strings?.label?.connected;
return typeof fn === 'function' ? fn({ name: conn.name }) : (conn.name ?? '');
}
return strings?.intention?.connect ?? 'Connect';
}
readonly sortedThemeKeys = [
'light',
'enterprise',
'dark',
'darkNeu',
'darkoBluo',
'matrix',
'redis',
];
get themeSelectedKey(): string {
const theme = this.theme.currentTheme();
if (!theme.startsWith('p3xrTheme')) return '';
const raw = theme.slice('p3xrTheme'.length);
return raw.charAt(0).toLowerCase() + raw.slice(1);
}
get showLogin(): boolean {
return this.auth.authChecked() && this.auth.authRequired() && !this.auth.isAuthenticated();
}
get hasRediSearch(): boolean {
return !!this.state.hasRediSearch();
}
get reducedFunctions(): boolean {
return !!this.state.reducedFunctions();
}
get currentVersion(): string | undefined {
return this.state.version();
}
get connectionsList(): any[] {
return this.state.connections()?.list ?? [];
}
get groupedConnectionsList(): Array<{ name: string; connections: any[] }> {
const list = this.connectionsList;
let groupMode = false;
try {
groupMode = localStorage.getItem('p3xr-connection-group-mode') === 'true';
} catch { /* ignore */ }
if (!groupMode) {
return [{ name: '', connections: list }];
}
const groups = new Map<string, any[]>();
for (const conn of list) {
const groupName = conn.group?.trim() || '';
if (!groups.has(groupName)) {
groups.set(groupName, []);
}
groups.get(groupName)!.push(conn);
}
const result: Array<{ name: string; connections: any[] }> = [];
for (const [name, connections] of groups) {
result.push({ name, connections });
}
return result;
}
get currentConnection(): any {
return this.state.connection();
}
@ViewChild('languageSearchInput') languageSearchInput!: ElementRef<HTMLInputElement>;
@ViewChild('languageMenuTrigger') languageMenuTrigger!: MatMenuTrigger;
languageSearch = '';
filteredLanguages: string[] = [];
highlightedLanguageIndex = 0;
get availableLanguages(): string[] {
return Object.keys(this.i18n.strings()?.language ?? {});
}
onLanguageSearchInput(value: string): void {
this.languageSearch = value;
this.filterLanguages();
this.highlightedLanguageIndex = this.findCurrentLanguageIndex();
this.cdr.markForCheck();
}
onLanguageMenuOpened(): void {
this.highlightedLanguageIndex = this.findCurrentLanguageIndex();
setTimeout(() => {
this.languageSearchInput?.nativeElement?.focus();
this.scrollHighlightedLanguageIntoView();
});
}
private findCurrentLanguageIndex(): number {
const idx = this.filteredLanguages.indexOf(this.i18n.currentLang());
return idx >= 0 ? idx : 0;
}
onLanguageMenuClosed(): void {
this.languageSearch = '';
this.filterLanguages();
}
onLanguageSearchKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.languageMenuTrigger.closeMenu();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.onLanguageSearchEnter();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const len = this.filteredLanguages.length;
if (len === 0) return;
if (event.key === 'ArrowDown') {
this.highlightedLanguageIndex = (this.highlightedLanguageIndex + 1) % len;
} else {
this.highlightedLanguageIndex = (this.highlightedLanguageIndex - 1 + len) % len;
}
this.scrollHighlightedLanguageIntoView();
this.cdr.markForCheck();
return;
}
event.stopPropagation();
}
onLanguageSearchEnter(): void {
if (this.filteredLanguages.length > 0) {
this.setLanguage(this.filteredLanguages[this.highlightedLanguageIndex]);
this.languageMenuTrigger.closeMenu();
}
}
private scrollHighlightedLanguageIntoView(): void {
setTimeout(() => {
const menu = document.querySelector('.p3xr-language-menu .mat-mdc-menu-content');
if (!menu) return;
const items = menu.querySelectorAll('.mat-mdc-menu-item');
const target = items[this.highlightedLanguageIndex];
target?.scrollIntoView({ block: 'nearest' });
});
}
private filterLanguages(): void {
const all = this.availableLanguages;
const search = this.languageSearch.trim().toLowerCase();
if (!search) {
this.filteredLanguages = all;
return;
}
this.filteredLanguages = all.filter(key => {
const label = this.languageLabel(key).toLowerCase();
return label.includes(search) || key.toLowerCase().includes(search);
});
}
themeLabel(key: string): string {
return this.i18n.strings()?.label?.theme?.[key] ?? key;
}
languageLabel(key: string): string {
return this.i18n.strings()?.language?.[key] ?? key;
}
// --- Actions ---
isActivePage(page: string): boolean {
const url = this.nav.currentUrl;
switch (page) {
case 'database': return url.startsWith('/database');
case 'search': return url === '/search';
case 'monitoring': return url.startsWith('/monitoring');
case 'info': return url === '/info';
case 'settings': return url === '/settings';
default: return false;
}
}
navigateTo(stateName: string, params?: any): void {
this.nav.navigateTo(stateName, params);
}
reloadPage(): void {
location.href = '/ng/';
}
async logout(): Promise<void> {
try {
await this.common.confirm({
message: this.i18n.strings()?.intention?.logout,
});
this.auth.logout();
} catch {
// cancelled
}
}
setTheme(key: string): void {
this.theme.setTheme(this.theme.generateThemeName(key));
}
setThemeAuto(): void {
this.theme.setTheme('auto');
}
async setLanguage(key: string): Promise<void> {
try {
this.i18n.setLanguage(key);
if (this.isElectron) {
await this.socket.request({ action: 'settings/language', payload: { key } });
this.isElectronInitialized = true;
}
this.filterLanguages();
this.cdr.markForCheck();
} catch (e) {
this.common.generalHandleError(e);
}
}
async connect(connection: any): Promise<void> {
console.time('connect');
connection = this.cloneConnection(connection);
try {
const dbStorageKey = this.settings.getStorageKeyCurrentDatabase(connection.id);
const db = this.getStorageString(dbStorageKey);
if (connection.askAuth === true) {
const auth = await this.authDialog.show();
connection.username = auth.username || undefined;
connection.password = auth.password || undefined;
}
const strings = this.i18n.strings();
this.overlay.show({
message: strings?.title?.connectingRedis ?? 'Connecting...',
});
this.state.connectionState.set('connecting');
const response = await this.socket.request({
action: 'connection/connect',
payload: { connection, db },
});
// Update state signals directly
this.state.page.set(1);
this.state.monitor.set(false);
this.state.dbsize.set(response.dbsize);
const databaseIndexes: number[] = [];
let i = 0;
while (i < response.databases) databaseIndexes.push(i++);
this.state.databaseIndexes.set(databaseIndexes);
this.state.connection.set(connection);
const commands: string[] = [];
Object.keys(response.commands ?? {}).forEach(k => {
commands.push(response.commands[k][0]);
});
commands.sort();
this.state.commands.set(commands);
this.state.commandsMeta.set(response.commandsMeta ?? {});
// Detect loaded Redis modules
const modules = Array.isArray(response.modules) ? response.modules : [];
this.state.modules.set(modules);
this.state.hasReJSON.set(modules.some((m: any) => m.name === 'ReJSON'));
this.state.hasRediSearch.set(modules.some((m: any) => m.name === 'search'));
this.state.hasTimeSeries.set(modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries'));
this.state.hasBloom.set(modules.some((m: any) => m.name === 'bf'));
await this.common.loadRedisInfoResponse({ response });
this.socket.stateChanged$.next();
this.setStorageObject(
this.settings.connectInfoStorageKey,
connection,
);
this.state.connectionState.set('connected');
// No navigation — just refresh the current view in place
} catch (error) {
this.removeStorageItem(this.settings.connectInfoStorageKey);
this.state.connection.set(undefined);
this.state.connectionState.set('none');
this.common.generalHandleError(error);
} finally {
this.overlay.hide();
this.cdr.markForCheck();
}
console.timeEnd('connect');
}
async disconnect(): Promise<void> {
await this.cmd.disconnect();
this.cdr.markForCheck();
}
reducedFunctionality(): void {
const strings = this.i18n.strings();
const fn = strings?.label?.tooManyKeys;
const message = typeof fn === 'function'
? fn({
count: this.state.keysRaw()?.length ?? 0,
maxLightKeysCount: this.settings.maxLightKeysCount,
})
: '';
this.common.confirm({ disableCancel: true, message }).catch(() => {});
}
openLink(target: 'github' | 'githubRelease' | 'githubChangelog' | 'donate'): void {
const urls: Record<string, string> = {
github: 'https://github.com/patrikx3/redis-ui',
githubRelease: 'https://github.com/patrikx3/redis-ui/releases',
githubChangelog: 'https://github.com/patrikx3/redis-ui/blob/master/change-log.md#change-log',
donate: 'https://www.paypal.me/patrikx3',
};
window.open(urls[target], '_blank');
}
// --- Private helpers ---
private cloneConnection(connection: any): any {
return structuredClone(connection);
}
private readConnectionFromStorage(): any {
return this.getStorageObject(
this.settings.connectInfoStorageKey,
);
}
private getStorageString(name: string | undefined): string | undefined {
if (!name) return undefined;
try { return localStorage.getItem(name) ?? undefined; } catch { return undefined; }
}
private getStorageObject(name: string | undefined): any {
const raw = this.getStorageString(name);
if (!raw) return undefined;
try { return JSON.parse(raw); } catch { return undefined; }
}
private setStorageObject(name: string | undefined, value: any): void {
if (!name) return;
try { localStorage.setItem(name, JSON.stringify(value)); } catch {}
}
private removeStorageItem(name: string | undefined): void {
if (!name) return;
try { localStorage.removeItem(name); } catch {}
}
private subscribeSocketEvents(): void {
const sub1 = this.socket.redisDisconnected$.subscribe(() => {
this.state.connection.set(undefined);
this.state.connectionState.set('none');
this.nav.navigateTo('settings');
this.cdr.markForCheck();
});
const sub2 = this.socket.socketError$.subscribe(() => {
this.cdr.markForCheck();
});
const sub3 = this.socket.connections$.subscribe(() => {
this.cdr.markForCheck();
});
const sub4 = this.socket.configuration$.subscribe(() => {
this.cdr.markForCheck();
});
this.unsubFns.push(
() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); }
);
}
private setupRouteTracking(): void {
const sub = this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
).subscribe((event) => {
// Update currentPage signal — used by the console drawer + AI context
this.state.currentPage.set(this.urlToPage(event.urlAfterRedirects));
// Google Analytics page tracking
if (/spider|bot|yahoo|bing|google|yandex|crawl|slurp|curl/i.test(navigator.userAgent)) return;
try {
const path = event.urlAfterRedirects.toLowerCase().startsWith('/database/key/')
? '/database/key'
: event.urlAfterRedirects;
(globalThis as any).gtag?.('config',
this.settings.googleAnalytics,
{ page_path: path },
);
} catch { /* noop */ }
});
this.unsubFns.push(() => sub.unsubscribe());
}
private urlToPage(url: string): 'connections' | 'database' | 'pulse' | 'profiler' | 'pubsub' | 'analysis' |
'search' | 'timeseries' | 'info' | 'settings' | 'unknown' {
const u = url.toLowerCase();
if (u.startsWith('/database')) return 'database';
if (u.startsWith('/monitoring/profiler')) return 'profiler';
if (u.startsWith('/monitoring/pubsub')) return 'pubsub';
if (u.startsWith('/monitoring/memory-analysis') || u.startsWith('/monitoring/analysis')) return 'analysis';
if (u.startsWith('/monitoring')) return 'pulse';
if (u.startsWith('/search')) return 'search';
if (u.startsWith('/timeseries')) return 'timeseries';
if (u.startsWith('/info')) return 'info';
if (u.startsWith('/settings')) return 'settings';
return 'unknown';
}
/**
* Expose the Electron bridge globals.
*
* Electron injects a script into the webview that calls:
* global.p3xrSetLanguage(key) — sets the UI language
* global.p3xrSetMenu(route) — navigates to a route
*
* These are the SAME globals as the AngularJS controller exposed.
* Keeping them with the same names and behaviour ensures no changes are
* needed in the Electron host application.
*/
private setupElectronBridge(): void {
if (!this.isElectron) return;
// Listen for postMessage from the Electron shell (iframe parent).
window.addEventListener('message', (event: MessageEvent) => {
const data = event.data;
if (!data || typeof data.type !== 'string') return;
if (data.type === 'p3x-set-language' && typeof data.translation === 'string') {
this.ngZone.run(async () => {
try {
await this.setLanguage(data.translation);
} catch (e) {
console.warn('[LayoutComponent] p3x-set-language failed', e);
}
});
} else if (data.type === 'p3x-menu' && typeof data.action === 'string') {
this.ngZone.run(() => {
try {
this.nav.navigateTo(data.action);
} catch (e) {
console.warn('[LayoutComponent] p3x-menu failed', e);
}
});
}
});
}
}