import { Component, Inject, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatDividerModule } from '@angular/material/divider';
import { BreakpointObserver } from '@angular/cdk/layout';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { I18nService } from '../services/i18n.service';
import { SettingsService } from '../services/settings.service';
import { RedisStateService } from '../services/redis-state.service';
import { CommonService } from '../services/common.service';
import { SocketService } from '../services/socket.service';
import { MainCommandService } from '../services/main-command.service';
import { ConnectionDialogService } from '../dialogs/connection-dialog.service';
import { TreecontrolSettingsDialogService } from '../dialogs/treecontrol-settings-dialog.service';
import { AiSettingsDialogService } from '../dialogs/ai-settings-dialog.service';
import { P3xrAccordionComponent } from '../components/p3xr-accordion.component';
import { P3xrButtonComponent } from '../components/p3xr-button.component';
/**
* Settings page — Angular replacement for AngularJS p3xrSettings.
* First complete Angular page migration.
*
* Contains:
* - Connections list (add/edit/delete/connect/disconnect)
* - License info panel
* - Tree settings panel
*/
@Component({
selector: 'p3xr-ng-settings',
standalone: true,
imports: [
MatToolbarModule, MatButtonModule, MatIconModule, MatListModule, MatSlideToggleModule,
MatTooltipModule, MatDividerModule, DragDropModule,
P3xrAccordionComponent, P3xrButtonComponent,
],
template: `
@if (!readonlyConnections) {
}
@if (connectionsList.length === 0) {
{{ strings().intention?.noConnectionsInSettings || 'No connections' }}
}
@if (connectionsList.length > 0) {
@if (groupModeEnabled) {
@for (group of groupedConnections; track group.name) {
@if (!collapsedGroups.has(group.name)) {
@for (connection of group.connections; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) {
{{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
}
@if (currentConnectionId !== connection.id) {
@if (isXs) {
power
} @else {
power
{{ strings().intention?.connect || 'Connect' }}
}
}
@if (currentConnectionId === connection.id) {
@if (isXs) {
} @else {
{{ strings().intention?.disconnect || 'Disconnect' }}
}
}
@if (!readonlyConnections) {
@if (isXs) {
delete_forever
} @else {
delete_forever
{{ strings().intention?.delete || 'Delete' }}
}
@if (isXs) {
edit
} @else {
edit
{{ strings().intention?.edit || 'Edit' }}
}
}
@if (readonlyConnections) {
@if (isXs) {
mode_comment
} @else {
mode_comment
{{ strings().intention?.view || 'View' }}
}
}
@if (!last) {
}
}
}
}
}
@if (!groupModeEnabled) {
@for (connection of connectionsList; track connection.id; let last = $last) {
{{ connection.name }}
{{ connection.host }}:{{ connection.port }}
@for (entry of getConnectionClients(connection); track entry.key) {
{{ strings().page?.overview?.connectedCount?.({ length: entry.clients }) }}
}
@if (currentConnectionId !== connection.id) {
@if (isXs) {
power
} @else {
power
{{ strings().intention?.connect || 'Connect' }}
}
}
@if (currentConnectionId === connection.id) {
@if (isXs) {
} @else {
{{ strings().intention?.disconnect || 'Disconnect' }}
}
}
@if (!readonlyConnections) {
@if (isXs) {
delete_forever
} @else {
delete_forever
{{ strings().intention?.delete || 'Delete' }}
}
@if (isXs) {
edit
} @else {
edit
{{ strings().intention?.edit || 'Edit' }}
}
}
@if (readonlyConnections) {
@if (isXs) {
mode_comment
} @else {
mode_comment
{{ strings().intention?.view || 'View' }}
}
}
@if (!last) {
}
}
}
}
@if (!readonlyConnections && !isGroqApiKeyReadonly()) {
}
{{ strings().label?.aiEnabled || 'AI Enabled' }}
@if (isAiEnabled() && hasGroqApiKey()) {
{{ strings().label?.aiRouteViaNetwork || 'Route via network.corifeus.com' }}
{{ isUseOwnKey() ? (strings().label?.aiRoutingDirect || 'Queries go directly to Groq using your own API key, bypassing network.corifeus.com.') : (strings().label?.aiRoutingNetwork || 'AI queries are routed through network.corifeus.com. If you have your own free Groq API key, you can turn off this switch to route directly to Groq without network.corifeus.com.') }}
@if (!isUseOwnKey()) {
console.groq.com
}
{{ strings().label?.aiGroqApiKey || 'Groq API Key' }}
{{ getGroqApiKeyDisplay() }}
}
{{ strings().form?.treeSettings?.field?.treeSeparator }}
{{ settings.redisTreeDivider() || strings().label?.treeSeparatorEmptyNote }}
{{ strings().form?.treeSettings?.field?.page }} {{ settings.pageCount() }}
{{ strings().form?.treeSettings?.error?.page }}
{{ strings().form?.treeSettings?.field?.keyPageCount }} {{ settings.keyPageCount() }}
{{ strings().form?.treeSettings?.error?.keyPageCount }}
{{ strings().form?.treeSettings?.maxValueDisplay }} {{ settings.maxValueDisplay() }}
{{ strings().form?.treeSettings?.maxValueDisplayInfo }}
{{ strings().form?.treeSettings?.maxKeys }} {{ settings.maxKeys() }}
{{ strings().form?.treeSettings?.maxKeysInfo }}
{{ strings().form?.treeSettings?.field?.keysSort }}
{{ settings.keysSort() ? strings().label?.keysSort?.on : strings().label?.keysSort?.off }}
{{ strings().form?.treeSettings?.field?.searchMode }}
{{ settings.searchClientSide() ? strings().form?.treeSettings?.label?.searchModeClient : strings().form?.treeSettings?.label?.searchModeServer }}
{{ strings().form?.treeSettings?.field?.searchModeStartsWith }}
{{ settings.searchStartsWith() ? strings().form?.treeSettings?.label?.searchModeStartsWith : strings().form?.treeSettings?.label?.searchModeIncludes }}
{{ settings.jsonFormat() === 2 ? strings().form?.treeSettings?.label?.jsonFormatTwoSpace : strings().form?.treeSettings?.label?.jsonFormatFourSpace }}
{{ settings.animation() ? strings().form?.treeSettings?.label?.animation : strings().form?.treeSettings?.label?.noAnimation }}
`,
styles: [`
:host { display: block; color: var(--mat-app-text-color, inherit); }
.p3xr-settings-hint {
font-size: 12px;
color: var(--mat-app-text-color, rgba(0, 0, 0, 0.54));
opacity: 0.7;
}
/* GUI toggle */
.p3xr-gui-toggle {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--p3xr-border-color, rgba(0,0,0,0.12));
}
.p3xr-gui-toggle-active {
padding: 8px 24px;
font-weight: 700;
font-size: 14px;
user-select: none;
background-color: var(--p3xr-btn-primary-bg);
color: var(--p3xr-btn-primary-color);
}
.p3xr-gui-toggle-item {
padding: 8px 24px;
font-weight: 500;
font-size: 14px;
user-select: none;
cursor: pointer;
}
.p3xr-gui-toggle-item:hover {
background-color: var(--p3xr-hover-bg);
}
/* Wide screens: show button text, hide tooltip */
.hide-xs { display: inline; }
.show-xs-tooltip { display: none; }
/* Small screens: hide text, show icon-only square buttons */
@media (max-width: 599px) {
.hide-xs { display: none !important; }
/* Buttons become square icon buttons on mobile */
.p3xr-connection-item button {
min-width: 40px !important;
width: 40px !important;
height: 40px !important;
padding: 0 !important;
margin: 2px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.p3xr-connection-item button mat-icon,
.p3xr-connection-item button i {
margin: 0 !important;
}
}
/* Connection items: match production md-list-item */
.p3xr-connection-item {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 8px 8px 16px;
min-height: 56px;
box-sizing: border-box;
}
.p3xr-connection-info { flex: 1; min-width: 0; overflow: hidden; }
.p3xr-connection-item button { flex-shrink: 0; }
/* Drag and drop */
.p3xr-connection-item.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
background: var(--mat-app-background-color, #fff);
}
.p3xr-connection-item.cdk-drag-placeholder { opacity: 0.3; }
.cdk-drop-list-dragging .p3xr-connection-item:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.p3xr-connection-group-block.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
background: var(--mat-app-background-color, #fff);
}
.p3xr-connection-group-block.cdk-drag-placeholder { opacity: 0.3; }
.p3xr-group-drop-list.cdk-drop-list-dragging .p3xr-connection-group-block:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.p3xr-connection-group-header[cdkDragHandle] { cursor: grab; }
/* Only tree settings rows are clickable/hoverable. License rows stay static like AngularJS. */
.p3xr-tree-settings-list mat-list-item { cursor: pointer; }
.p3xr-tree-settings-list mat-list-item:hover { background-color: var(--p3xr-hover-bg); }
/* Settings list: bold label (left), normal value (right) */
::ng-deep .p3xr-tree-settings-list .mdc-list-item__primary-text {
width: 100%;
}
::ng-deep .p3xr-settings-label {
font-weight: 500;
}
::ng-deep .p3xr-settings-value {
font-weight: 400;
opacity: 0.8;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SettingsComponent implements OnInit, OnDestroy {
private static readonly UNGROUPED_GROUP_KEY = '';
strings;
connectionsList: any[] = [];
groupedConnections: Array<{ name: string; connections: any[] }> = [];
collapsedGroups: Set;
groupModeEnabled = false;
private static readonly COLLAPSED_GROUPS_KEY = 'p3xr-collapsed-connection-groups';
private static readonly GROUP_MODE_KEY = 'p3xr-connection-group-mode';
readonlyConnections = false;
currentConnectionId: string | undefined;
isXs = false;
private electronUiStorage: Record | null = null;
private readonly unsubs: Array<() => void> = [];
constructor(
@Inject(I18nService) private i18n: I18nService,
@Inject(SettingsService) public settings: SettingsService,
@Inject(RedisStateService) private state: RedisStateService,
@Inject(CommonService) private common: CommonService,
@Inject(SocketService) private socket: SocketService,
@Inject(MainCommandService) private cmd: MainCommandService,
@Inject(ConnectionDialogService) private connectionDialog: ConnectionDialogService,
@Inject(TreecontrolSettingsDialogService) private treeSettingsDialog: TreecontrolSettingsDialogService,
@Inject(AiSettingsDialogService) private aiSettingsDialog: AiSettingsDialogService,
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver,
@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef,
) {
this.strings = this.i18n.strings;
this.restoreGroupingState();
this.breakpointObserver.observe('(max-width: 599px)').subscribe(result => {
this.isXs = result.matches;
this.cdr.markForCheck();
});
}
ngOnInit(): void {
this.refreshState();
// Subscribe to socket events for reactive updates
const sub1 = this.socket.connections$.subscribe(() => this.refreshState());
const sub2 = this.socket.configuration$.subscribe(() => this.refreshState());
const sub3 = this.socket.stateChanged$.subscribe(() => this.refreshState());
const sub4 = this.socket.redisStatus$.subscribe(() => this.refreshState());
this.unsubs.push(() => { sub1.unsubscribe(); sub2.unsubscribe(); sub3.unsubscribe(); sub4.unsubscribe(); });
}
ngOnDestroy(): void {
this.unsubs.forEach(fn => fn());
}
private refreshState(): void {
this.connectionsList = this.state.connections()?.list || [];
this.readonlyConnections = this.state.cfg()?.readonlyConnections === true;
this.currentConnectionId = this.state.connection()?.id;
this.buildGroupedConnections();
this.cdr.detectChanges();
}
toggleGroupMode(): void {
this.groupModeEnabled = !this.groupModeEnabled;
this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled));
}
toggleGroup(name: string): void {
if (this.collapsedGroups.has(name)) {
this.collapsedGroups.delete(name);
} else {
this.collapsedGroups.add(name);
}
this.setPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY, JSON.stringify([...this.collapsedGroups]));
}
private restoreGroupingState(): void {
this.groupModeEnabled = this.getPersistentItem(SettingsComponent.GROUP_MODE_KEY) === 'true';
// Sync bootstrap value to localStorage so React can read it (shared origin in Electron)
this.setPersistentItem(SettingsComponent.GROUP_MODE_KEY, String(this.groupModeEnabled));
try {
const stored = this.getPersistentItem(SettingsComponent.COLLAPSED_GROUPS_KEY);
this.collapsedGroups = stored
? new Set(JSON.parse(stored).map((name: string) => this.normalizeCollapsedGroupName(name)))
: new Set();
} catch {
this.collapsedGroups = new Set();
}
}
private getPersistentItem(key: string): string | null {
const value = this.getElectronUiStorage()[key];
if (typeof value === 'string') {
return value;
}
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private setPersistentItem(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch { /* ignore */ }
const storage = this.getElectronUiStorage();
storage[key] = value;
this.electronUiStorage = storage;
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'p3x-ui-storage-set', key, value }, '*');
}
} catch { /* ignore */ }
}
private getElectronUiStorage(): Record {
if (this.electronUiStorage !== null) {
return this.electronUiStorage;
}
// Read from __p3xr_electron_bootstrap which was captured in main.js
// BEFORE Angular's router stripped the query params.
let storage: Record = {};
try {
const bootstrap = (globalThis as any).__p3xr_electron_bootstrap;
if (bootstrap && typeof bootstrap === 'object' && !Array.isArray(bootstrap)) {
storage = this.normalizeElectronUiStorage(bootstrap);
}
} catch {
storage = {};
}
this.electronUiStorage = storage;
return storage;
}
private normalizeElectronUiStorage(value: unknown): Record {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.entries(value).reduce((result: Record, [key, entryValue]) => {
if (typeof entryValue === 'string') {
result[key] = entryValue;
}
return result;
}, {});
}
getGroupDisplayName(name: string): string {
return name === SettingsComponent.UNGROUPED_GROUP_KEY
? this.getUngroupedLabel()
: name;
}
private getUngroupedLabel(): string {
return this.strings().label?.ungrouped || 'Ungrouped';
}
private normalizeCollapsedGroupName(name: unknown): string {
if (typeof name !== 'string') {
return '';
}
return this.isLegacyUngroupedGroupName(name)
? SettingsComponent.UNGROUPED_GROUP_KEY
: name;
}
private isLegacyUngroupedGroupName(name: string): boolean {
return name === 'Ungrouped' || name === this.getUngroupedLabel();
}
private buildGroupedConnections(): void {
// Use a Map to preserve the order groups first appear in the connections list.
// This respects the server-persisted order (including after drag reorder).
const groups = new Map();
for (const conn of this.connectionsList) {
const groupName = conn.group?.trim() || SettingsComponent.UNGROUPED_GROUP_KEY;
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 });
}
this.groupedConnections = result;
}
// Predicates prevent items from entering the wrong drop list level
groupDropPredicate = (drag: any) => drag.data && 'connections' in drag.data;
connectionDropPredicate = (drag: any) => drag.data && !('connections' in drag.data);
async dropGroup(event: CdkDragDrop): Promise {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.groupedConnections, event.previousIndex, event.currentIndex);
// Rebuild flat list in new group order and persist
const allIds: string[] = [];
for (const group of this.groupedConnections) {
for (const conn of group.connections) {
allIds.push(conn.id);
}
}
try {
await this.socket.request({
action: 'connections-reorder',
payload: { ids: allIds },
});
} catch (e) {
this.common.generalHandleError(e);
this.refreshState();
}
}
async dropConnection(event: CdkDragDrop, groupName: string): Promise {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
// Persist the new order to the server
try {
const reorderedIds = event.container.data.map((c: any) => c.id);
await this.socket.request({
action: 'connections-reorder',
payload: { group: groupName || undefined, ids: reorderedIds },
});
} catch (e) {
this.common.generalHandleError(e);
this.refreshState();
}
}
// --- Connections ---
connectionForm(type: string, model?: any): void {
this.connectionDialog.show({ type: type as any, model, $event: undefined });
}
async connect(connection: any): Promise {
this.cmd.connectRequest$.next({ connection, disableState: true });
}
async disconnect(): Promise {
await this.cmd.disconnect();
this.refreshState();
}
async deleteConnection(connection: any, $event: any): Promise {
try {
await this.common.confirm({
event: $event,
message: this.strings().confirm?.deleteConnectionText || 'Delete this connection?',
});
await this.socket.request({
action: 'connection-delete',
payload: { id: connection.id },
});
this.common.toast(this.strings().status?.deleted || 'Deleted');
} catch (e) {
if (e !== undefined) {
this.common.generalHandleError(e);
}
}
}
getConnectionClients(connection: any): { key: string; clients: number }[] {
const redisConnections = this.state.redisConnections() || {};
const results: { key: string; clients: number }[] = [];
for (const key of Object.keys(redisConnections)) {
if (redisConnections[key].connection?.name === connection.name) {
results.push({ key, clients: redisConnections[key].clients?.length || 0 });
}
}
return results;
}
// --- AI Settings ---
isAiEnabled(): boolean {
return this.state.cfg()?.aiEnabled !== false;
}
async toggleAiEnabled(enabled: boolean): Promise {
try {
await this.socket.request({
action: 'set-groq-api-key',
payload: {
apiKey: this.state.cfg()?.groqApiKey || '',
aiEnabled: enabled,
},
});
const cfg = { ...this.state.cfg(), aiEnabled: enabled };
this.state.cfg.set(cfg);
this.cdr.markForCheck();
} catch (e) {
this.common.generalHandleError(e);
}
}
hasGroqApiKey(): boolean {
const key = this.state.cfg()?.groqApiKey || '';
return key.startsWith('gsk_') && key.length > 20;
}
isUseOwnKey(): boolean {
// Can only use own key if one is actually set
return this.state.cfg()?.aiUseOwnKey === true && this.hasGroqApiKey();
}
async toggleUseOwnKey(useOwn: boolean): Promise {
// Can't use own key if no key is set
if (useOwn && !this.hasGroqApiKey()) {
return;
}
try {
await this.socket.request({
action: 'set-groq-api-key',
payload: {
apiKey: this.state.cfg()?.groqApiKey || '',
aiEnabled: this.state.cfg()?.aiEnabled !== false,
aiUseOwnKey: useOwn,
},
});
const cfg = { ...this.state.cfg(), aiUseOwnKey: useOwn };
this.state.cfg.set(cfg);
this.cdr.markForCheck();
} catch (e) {
this.common.generalHandleError(e);
}
}
isAiReadonly(): boolean {
return this.readonlyConnections || this.state.cfg()?.groqApiKeyReadonly === true;
}
isGroqApiKeyReadonly(): boolean {
return this.state.cfg()?.groqApiKeyReadonly === true;
}
async openAiSettings($event: any): Promise {
await this.aiSettingsDialog.show();
this.cdr.markForCheck();
}
getGroqApiKeyDisplay(): string {
const key = this.state.cfg()?.groqApiKey || '';
if (!key) return this.strings().label?.aiGroqApiKeyNotSet || 'Not set';
if (key.length <= 8) return '****';
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}
// --- Tree Settings ---
openTreeSettings($event: any): void {
this.treeSettingsDialog.show({ $event });
}
// --- GUI Framework Switch ---
switchToReact(): void {
try { localStorage.setItem('p3xr-frontend', 'react'); } catch {}
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'p3x-ui-storage-set', key: 'p3xr-frontend', value: 'react' }, '*');
}
} catch {}
location.href = '/react/settings';
}
}