mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(settings): search settings from the header and the command palette (#1714)
* feat(settings): search settings from the header and the command palette The header search on the Settings page was shown but disabled. It now searches a shared index of all 56 settings rows by translated title, description and English synonyms, replaces the section page with ranked results, and opens a result by scrolling to, focusing and briefly highlighting its row. Enter opens the best match, and the section navigation shows per-section match counts. The command palette gains a "Settings" group that lists the best six matches for a non-empty query, so any setting is one Ctrl/Cmd+K away. Rows hidden by the current form state fall back to the control that reveals them; rows the runtime cannot render are never returned. The index ships through a new @iptvnator/workspace/shell/util/settings-search sub-entrypoint so it stays out of the eager bundle, and a registry spec keeps it in step with the section templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): let search reveals win over pending input and gate embedded MPV rows - A reveal (result click, command palette, Enter) now cancels a search keystroke still waiting for its debounce, so its q navigation can no longer supersede the reveal and leave the results open. - Embedded MPV extra options and auto-reconnect require a lazily probed embedded MPV capability; frame copy also needs frameCopyAvailable, so search never offers a row the settings page cannot render. - Keyboard users keep a focus-visible ring on the revealed row after the highlight fades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): wait for palette probes without Promise.allSettled The web tsconfig lib predates Promise.allSettled; use Promise.all over rejection-safe probes instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
77 files changed
+3304
-316
No files matched your search
+146
@@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceCommandPaletteComponent - settings group', () => {
|
||||
function settingsCommand(
|
||||
id: string,
|
||||
label: string
|
||||
): WorkspaceResolvedCommandItem {
|
||||
return {
|
||||
id: `settings:${id}`,
|
||||
label,
|
||||
description: 'Playback',
|
||||
group: 'settings',
|
||||
icon: 'play_circle',
|
||||
keywords: [],
|
||||
priority: 100,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const commands: WorkspaceResolvedCommandItem[] = [
|
||||
{
|
||||
id: 'open-settings',
|
||||
label: 'Open settings',
|
||||
description: '',
|
||||
group: 'global',
|
||||
icon: 'settings',
|
||||
keywords: ['settings'],
|
||||
priority: 50,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
},
|
||||
settingsCommand('video-player', 'Video player'),
|
||||
settingsCommand('theme', 'Theme'),
|
||||
];
|
||||
|
||||
function setup(options: {
|
||||
query: string;
|
||||
recentIds?: readonly string[];
|
||||
searchSettings?: jest.Mock;
|
||||
}) {
|
||||
const searchSettings =
|
||||
options.searchSettings ??
|
||||
jest.fn((query: string) =>
|
||||
query.includes('player') ? ['settings:video-player'] : []
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WorkspaceCommandPaletteComponent],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
query: options.query,
|
||||
commands,
|
||||
recentIds: options.recentIds ?? [],
|
||||
searchSettings,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(
|
||||
WorkspaceCommandPaletteComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return {
|
||||
component: fixture.componentInstance,
|
||||
fixture,
|
||||
searchSettings,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps settings out of the list while the query is empty', () => {
|
||||
const { component, searchSettings } = setup({ query: '' });
|
||||
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'open-settings',
|
||||
]);
|
||||
expect(searchSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists settings matches after the command groups, in search order', () => {
|
||||
const searchSettings = jest.fn(() => [
|
||||
'settings:theme',
|
||||
'settings:video-player',
|
||||
]);
|
||||
const { component, fixture } = setup({
|
||||
query: 'settings',
|
||||
searchSettings,
|
||||
});
|
||||
|
||||
const groups = component.commandGroups();
|
||||
expect(groups.map((group) => group.group)).toEqual([
|
||||
'global',
|
||||
'settings',
|
||||
]);
|
||||
expect(groups[1].items.map((item) => item.id)).toEqual([
|
||||
'settings:theme',
|
||||
'settings:video-player',
|
||||
]);
|
||||
expect(searchSettings).toHaveBeenLastCalledWith('settings');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'
|
||||
);
|
||||
});
|
||||
|
||||
it('ranks settings only through the search callback, never by substring', () => {
|
||||
const { component } = setup({ query: 'theme' });
|
||||
|
||||
// "Theme" contains the query, but the search callback decides.
|
||||
expect(component.flatCommands()).toEqual([]);
|
||||
|
||||
component.query.set('player');
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'settings:video-player',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a recently opened setting in the recent section', () => {
|
||||
const { component } = setup({
|
||||
query: '',
|
||||
recentIds: ['settings:theme'],
|
||||
});
|
||||
|
||||
const [recent] = component.commandGroups();
|
||||
expect(recent.group).toBe('recent');
|
||||
expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']);
|
||||
});
|
||||
});
|
||||
+40
-2
@@ -23,10 +23,15 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { foldSearchText } from '@iptvnator/shared/interfaces';
|
||||
|
||||
interface WorkspaceCommandPaletteData {
|
||||
export interface WorkspaceCommandPaletteData {
|
||||
commands: WorkspaceResolvedCommandItem[];
|
||||
query?: string;
|
||||
recentIds?: readonly string[];
|
||||
/**
|
||||
* Ranked ids of the `settings` group commands matching a query. Settings
|
||||
* commands are listed only through this, never for an empty query.
|
||||
*/
|
||||
searchSettings?: (query: string) => readonly string[];
|
||||
}
|
||||
|
||||
type PaletteSectionGroup = WorkspaceCommandGroup | 'recent';
|
||||
@@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
|
||||
readonly filteredCommands = computed(() => {
|
||||
const term = foldSearchText(this.query().trim());
|
||||
const commands = this.visibleCommands();
|
||||
const commands = this.visibleCommands().filter(
|
||||
(command) => command.group !== 'settings'
|
||||
);
|
||||
|
||||
if (!term) {
|
||||
return commands;
|
||||
@@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
}
|
||||
);
|
||||
|
||||
readonly settingsSection = computed<WorkspaceCommandGroupSection | null>(
|
||||
() => {
|
||||
const query = this.query().trim();
|
||||
const searchSettings = this.data?.searchSettings;
|
||||
if (!query || !searchSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byId = new Map(
|
||||
this.visibleCommands().map((command) => [command.id, command])
|
||||
);
|
||||
const items = searchSettings(query)
|
||||
.map((id) => byId.get(id))
|
||||
.filter(
|
||||
(command): command is WorkspaceResolvedCommandItem =>
|
||||
command?.group === 'settings'
|
||||
);
|
||||
|
||||
return items.length === 0 ? null : { group: 'settings', items };
|
||||
}
|
||||
);
|
||||
|
||||
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
|
||||
const commands = this.filteredCommands();
|
||||
const recent = this.recentSection();
|
||||
@@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
);
|
||||
|
||||
sections.push(...groups);
|
||||
|
||||
const settings = this.settingsSection();
|
||||
if (settings) {
|
||||
sections.push(settings);
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
@@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
if (group === 'playlist') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
|
||||
}
|
||||
if (group === 'settings') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS';
|
||||
}
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -45,6 +45,11 @@
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
// Sections without settings-search matches stay reachable but recede.
|
||||
.settings-section-item.has-no-matches:not(.active) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-section-item:hover:not(.active):not(.selected),
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
|
||||
|
||||
describe('WorkspaceSettingsContextPanelComponent', () => {
|
||||
function setup() {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WorkspaceSettingsContextPanelComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = TestBed.inject(SettingsContextService);
|
||||
ctx.setSections([
|
||||
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
},
|
||||
]);
|
||||
const fixture = TestBed.createComponent(
|
||||
WorkspaceSettingsContextPanelComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const link = (id: string) =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
`[data-test-id="settings-section-${id}"]`
|
||||
) as HTMLElement;
|
||||
return { ctx, fixture, link };
|
||||
}
|
||||
|
||||
it('shows no counts while settings search is idle', () => {
|
||||
const { link } = setup();
|
||||
|
||||
expect(link('general').querySelector('.nav-item-meta')).toBeNull();
|
||||
expect(link('general').classList).not.toContain('has-no-matches');
|
||||
});
|
||||
|
||||
it('shows per-section match counts and mutes sections without matches', () => {
|
||||
const { ctx, fixture, link } = setup();
|
||||
|
||||
ctx.setMatchCounts({ general: 2 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
link('general').querySelector('.nav-item-meta')?.textContent
|
||||
).toBe('2');
|
||||
expect(
|
||||
link('playback').querySelector('.nav-item-meta')?.textContent
|
||||
).toBe('0');
|
||||
expect(link('general').classList).not.toContain('has-no-matches');
|
||||
expect(link('playback').classList).toContain('has-no-matches');
|
||||
|
||||
ctx.reset();
|
||||
fixture.detectChanges();
|
||||
expect(ctx.matchCounts()).toBeNull();
|
||||
});
|
||||
});
|
||||
+18
-1
@@ -28,10 +28,27 @@ import {
|
||||
[routerLink]="['/workspace/settings', section.id]"
|
||||
[replaceUrl]="true"
|
||||
[attr.data-test-id]="'settings-section-' + section.id"
|
||||
[class.has-no-matches]="
|
||||
ctx.matchCounts() !== null &&
|
||||
!ctx.matchCounts()?.[section.id]
|
||||
"
|
||||
(click)="onSectionClicked()"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
<span class="nav-item-label">{{
|
||||
section.label | translate
|
||||
}}</span>
|
||||
<!-- While a settings search is active, each section
|
||||
shows how many of its settings match. -->
|
||||
@if (ctx.matchCounts(); as counts) {
|
||||
<span
|
||||
class="nav-item-meta"
|
||||
[attr.data-test-id]="
|
||||
'settings-section-matches-' + section.id
|
||||
"
|
||||
>{{ counts[section.id] ?? 0 }}</span
|
||||
>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -42,7 +42,9 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!hasNoPlaylists()) {
|
||||
<!-- Settings search works without any playlist, so a fresh user can
|
||||
find settings before importing a first source. -->
|
||||
@if (!hasNoPlaylists() || isSettingsRoute()) {
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
@if (searchScopeLabel()) {
|
||||
|
||||
+15
@@ -229,6 +229,21 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('hides search without playlists except on the settings route', () => {
|
||||
const searchInput = () =>
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
|
||||
fixture.componentRef.setInput('hasNoPlaylists', true);
|
||||
fixture.detectChanges();
|
||||
expect(searchInput()).toBeNull();
|
||||
|
||||
// Settings search needs no playlist: a fresh user can still search.
|
||||
fixture.componentRef.setInput('isSettingsRoute', true);
|
||||
fixture.detectChanges();
|
||||
expect(searchInput()).not.toBeNull();
|
||||
expect(searchInput().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('renders scope and status chips when search metadata is provided', () => {
|
||||
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
|
||||
fixture.componentRef.setInput(
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
SettingsSearchEntry,
|
||||
SettingsSearchResult,
|
||||
SettingsSearchService,
|
||||
SETTINGS_SECTION_DEFINITIONS,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import {
|
||||
buildSettingsPaletteCommands,
|
||||
SETTINGS_PALETTE_RESULT_LIMIT,
|
||||
} from './workspace-settings-commands';
|
||||
|
||||
const THEME: SettingsSearchEntry = {
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
labelKey: 'SETTINGS.THEME',
|
||||
keywords: ['dark'],
|
||||
};
|
||||
const PLAYER: SettingsSearchEntry = {
|
||||
id: 'video-player',
|
||||
section: 'playback',
|
||||
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
|
||||
};
|
||||
|
||||
function createSettingsSearch() {
|
||||
return {
|
||||
visibleEntries: jest.fn(() => [THEME, PLAYER]),
|
||||
search: jest.fn((): SettingsSearchResult[] => [
|
||||
{
|
||||
entry: PLAYER,
|
||||
section: SETTINGS_SECTION_DEFINITIONS[1],
|
||||
label: 'Video player',
|
||||
description: '',
|
||||
sectionLabel: 'Playback',
|
||||
score: 10,
|
||||
},
|
||||
]),
|
||||
reveal: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSettingsPaletteCommands', () => {
|
||||
const translate = (key: string) => `t(${key})`;
|
||||
|
||||
it('resolves one settings-group command per visible row', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
|
||||
const { commands } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(commands).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'settings:theme',
|
||||
group: 'settings',
|
||||
icon: 'tune',
|
||||
label: 't(SETTINGS.THEME)',
|
||||
description: 't(SETTINGS.NAV_GENERAL)',
|
||||
keywords: ['dark'],
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'settings:video-player',
|
||||
icon: 'play_circle',
|
||||
description: 't(SETTINGS.NAV_PLAYBACK)',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reveals the row when a command runs', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
const { commands } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
commands[1].run({ query: 'player' });
|
||||
|
||||
expect(settingsSearch.reveal).toHaveBeenCalledWith(PLAYER);
|
||||
});
|
||||
|
||||
it('delegates ranking to the settings search with the palette cap', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
const { search } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(search('player')).toEqual(['settings:video-player']);
|
||||
expect(settingsSearch.search).toHaveBeenCalledWith(
|
||||
'player',
|
||||
SETTINGS_PALETTE_RESULT_LIMIT
|
||||
);
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
SETTINGS_SECTION_DEFINITIONS,
|
||||
SettingsSearchService,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { TranslateFn } from './workspace-shell-search-labels';
|
||||
|
||||
/** Palette rows reserved for settings matches, so commands stay visible. */
|
||||
export const SETTINGS_PALETTE_RESULT_LIMIT = 6;
|
||||
|
||||
const SETTINGS_COMMAND_PREFIX = 'settings:';
|
||||
|
||||
export interface SettingsPaletteCommands {
|
||||
/** One command per visible settings row, for recents and selection. */
|
||||
readonly commands: WorkspaceResolvedCommandItem[];
|
||||
/** Ids of the best settings commands for `query`, best first. */
|
||||
readonly search: (query: string) => readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings rows as command palette entries. Ranking is delegated to
|
||||
* `SettingsSearchService` so the palette and the settings page search agree
|
||||
* on which settings match and in which order.
|
||||
*/
|
||||
export function buildSettingsPaletteCommands(
|
||||
settingsSearch: SettingsSearchService,
|
||||
translate: TranslateFn
|
||||
): SettingsPaletteCommands {
|
||||
const sections = new Map(
|
||||
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
|
||||
);
|
||||
|
||||
const commands = settingsSearch
|
||||
.visibleEntries()
|
||||
.map((entry): WorkspaceResolvedCommandItem => {
|
||||
const section = sections.get(entry.section);
|
||||
return {
|
||||
id: `${SETTINGS_COMMAND_PREFIX}${entry.id}`,
|
||||
group: 'settings',
|
||||
icon: section?.icon ?? 'settings',
|
||||
label: translate(entry.labelKey),
|
||||
description: section ? translate(section.navLabelKey) : '',
|
||||
keywords: entry.keywords ?? [],
|
||||
priority: 100,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => settingsSearch.reveal(entry),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
commands,
|
||||
search: (query) =>
|
||||
settingsSearch
|
||||
.search(query, SETTINGS_PALETTE_RESULT_LIMIT)
|
||||
.map(({ entry }) => `${SETTINGS_COMMAND_PREFIX}${entry.id}`),
|
||||
};
|
||||
}
|
||||
+2
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
|
||||
return 0;
|
||||
case 'playlist':
|
||||
return 1;
|
||||
case 'settings':
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ export const FILTER_SECTION_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
export const SEARCH_SOURCES_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
|
||||
export const SEARCH_SETTINGS_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.SEARCH_SETTINGS_PLACEHOLDER';
|
||||
export const SEARCH_LOADED_ONLY_STATUS =
|
||||
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
|
||||
|
||||
|
||||
+28
-11
@@ -6,9 +6,14 @@ import {
|
||||
WorkspaceResolvedCommandItem,
|
||||
WorkspaceViewCommandService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
|
||||
import {
|
||||
WorkspaceCommandPaletteComponent,
|
||||
WorkspaceCommandPaletteData,
|
||||
} from '../../workspace-command-palette/workspace-command-palette.component';
|
||||
import { RecentCommandsService } from '../../recent-commands';
|
||||
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { buildSettingsPaletteCommands } from './helpers/workspace-settings-commands';
|
||||
import {
|
||||
buildCommandPaletteItems,
|
||||
CommandBuilderContext,
|
||||
@@ -23,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
|
||||
private readonly playerCommands = inject(
|
||||
WorkspacePlayerCommandsContributor
|
||||
);
|
||||
private readonly settingsSearch = inject(SettingsSearchService);
|
||||
|
||||
private commandPaletteRef: MatDialogRef<
|
||||
WorkspaceCommandPaletteComponent,
|
||||
@@ -46,11 +52,18 @@ export class WorkspaceShellCommandPaletteService {
|
||||
return;
|
||||
}
|
||||
|
||||
const embeddedMpvSupportLoad =
|
||||
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
|
||||
if (embeddedMpvSupportLoad) {
|
||||
const embeddedMpvSupportLoads = [
|
||||
this.playerCommands.ensureEmbeddedMpvSupportLoaded(),
|
||||
this.settingsSearch.ensureEmbeddedMpvSupportLoaded(),
|
||||
].filter((load): load is Promise<void> => load !== undefined);
|
||||
if (embeddedMpvSupportLoads.length > 0) {
|
||||
this.commandPaletteOpening = true;
|
||||
void embeddedMpvSupportLoad.finally(() => {
|
||||
// A failed probe only hides its commands; it must not block opening.
|
||||
void Promise.all(
|
||||
embeddedMpvSupportLoads.map((load) =>
|
||||
load.catch(() => undefined)
|
||||
)
|
||||
).finally(() => {
|
||||
this.commandPaletteOpening = false;
|
||||
this.openResolvedCommandPalette(ctx, initialQuery);
|
||||
});
|
||||
@@ -68,17 +81,20 @@ export class WorkspaceShellCommandPaletteService {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = this.buildPaletteCommands(ctx);
|
||||
const settings = buildSettingsPaletteCommands(
|
||||
this.settingsSearch,
|
||||
ctx.translate
|
||||
);
|
||||
const commands = [
|
||||
...this.buildPaletteCommands(ctx),
|
||||
...settings.commands,
|
||||
];
|
||||
const recentIds = this.recentCommands
|
||||
.entries()
|
||||
.map((entry) => entry.id);
|
||||
const dialogRef = this.dialog.open<
|
||||
WorkspaceCommandPaletteComponent,
|
||||
{
|
||||
commands: WorkspaceResolvedCommandItem[];
|
||||
query: string;
|
||||
recentIds: readonly string[];
|
||||
},
|
||||
WorkspaceCommandPaletteData,
|
||||
WorkspaceCommandSelection | undefined
|
||||
>(WorkspaceCommandPaletteComponent, {
|
||||
width: 'min(760px, 92vw)',
|
||||
@@ -89,6 +105,7 @@ export class WorkspaceShellCommandPaletteService {
|
||||
commands,
|
||||
query: initialQuery,
|
||||
recentIds,
|
||||
searchSettings: settings.search,
|
||||
},
|
||||
});
|
||||
this.commandPaletteRef = dialogRef;
|
||||
|
||||
+16
@@ -9,6 +9,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
|
||||
@@ -133,6 +134,21 @@ describe('WorkspaceShellSearchSyncService', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('drops a still-debouncing keystroke when a settings result is opened', () => {
|
||||
// Typing one more letter and clicking a visible settings result
|
||||
// within the debounce window: applying that keystroke afterwards
|
||||
// would start a `q` navigation that supersedes the reveal.
|
||||
service.onSearchInput('them');
|
||||
TestBed.inject(SettingsSearchService).reveal({
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
labelKey: 'SETTINGS.THEME',
|
||||
});
|
||||
jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS);
|
||||
|
||||
expect(service.appliedSearchQuery()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps in-flight typing when the page writes an unrelated query param', () => {
|
||||
// The downloads filter chips write `?filter=…` with replaceUrl. Under
|
||||
// load that navigation can land after the first keystroke — it must
|
||||
|
||||
+8
@@ -5,6 +5,7 @@ import { filter } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
|
||||
import {
|
||||
getRoutePath,
|
||||
@@ -30,6 +31,13 @@ export class WorkspaceShellSearchSyncService {
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.cancelPendingSearchApply());
|
||||
// Opening a settings search result navigates away from the typed
|
||||
// term; a keystroke still debouncing must not apply afterwards.
|
||||
this.destroyRef.onDestroy(
|
||||
inject(SettingsSearchService).onReveal(() =>
|
||||
this.cancelPendingSearchApply()
|
||||
)
|
||||
);
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
|
||||
+25
-5
@@ -7,9 +7,11 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import {
|
||||
SEARCH_LOADED_ONLY_STATUS,
|
||||
SEARCH_PLAYLIST_PLACEHOLDER,
|
||||
SEARCH_SETTINGS_PLACEHOLDER,
|
||||
} from './helpers/workspace-shell-constants';
|
||||
import {
|
||||
resolveSearchPlaceholderKey,
|
||||
@@ -28,6 +30,7 @@ export class WorkspaceShellSearchService {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly routeState = inject(WorkspaceShellRouteStateService);
|
||||
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
|
||||
private readonly settingsSearch = inject(SettingsSearchService);
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
@@ -44,17 +47,19 @@ export class WorkspaceShellSearchService {
|
||||
const section = route.section;
|
||||
const appliedQuery = this.appliedSearchQuery().trim();
|
||||
|
||||
// Settings search filters an in-memory index of setting rows (see
|
||||
// SettingsSearchService); the settings page reads the term from `q`.
|
||||
if (route.kind === 'settings') {
|
||||
return {
|
||||
enabled: false,
|
||||
behavior: 'disabled',
|
||||
enabled: true,
|
||||
behavior: 'local-filter',
|
||||
context: null,
|
||||
section: null,
|
||||
searchMode: 'none',
|
||||
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
|
||||
searchMode: 'local-filter',
|
||||
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
|
||||
scopeLabel: '',
|
||||
statusLabel: '',
|
||||
minLength: 0,
|
||||
minLength: 1,
|
||||
advancedRouteTarget: null,
|
||||
};
|
||||
}
|
||||
@@ -181,6 +186,21 @@ export class WorkspaceShellSearchService {
|
||||
const trimmedValue = value.trim();
|
||||
this.searchQuery.set(trimmedValue);
|
||||
|
||||
// Enter on settings jumps to the best match, like picking the first
|
||||
// result. The reveal navigation drops `q`, which clears the box. The
|
||||
// term is deliberately not applied: applying would make the `q` sync
|
||||
// start its own navigation, superseding the reveal navigation (the
|
||||
// reveal also cancels a keystroke still waiting for its debounce).
|
||||
if (this.routeState.currentRoute().kind === 'settings') {
|
||||
const [bestMatch] = this.settingsSearch.search(trimmedValue, 1);
|
||||
if (bestMatch) {
|
||||
this.settingsSearch.reveal(bestMatch.entry);
|
||||
} else {
|
||||
this.searchSync.applySearchQuery(trimmedValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.searchCapability().behavior === 'advanced-only') {
|
||||
const advancedRouteTarget =
|
||||
this.searchCapability().advancedRouteTarget;
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
SettingsSearchEntry,
|
||||
SettingsSearchService,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SEARCH_SETTINGS_PLACEHOLDER } from './helpers/workspace-shell-constants';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
|
||||
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
|
||||
|
||||
const THEME: SettingsSearchEntry = {
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
labelKey: 'SETTINGS.THEME',
|
||||
};
|
||||
|
||||
describe('WorkspaceShellSearchService on settings routes', () => {
|
||||
let service: WorkspaceShellSearchService;
|
||||
let currentRoute: ReturnType<
|
||||
typeof signal<ReturnType<typeof parseWorkspaceShellRoute>>
|
||||
>;
|
||||
let searchSync: {
|
||||
searchQuery: ReturnType<typeof signal<string>>;
|
||||
appliedSearchQuery: ReturnType<typeof signal<string>>;
|
||||
applySearchQuery: jest.Mock;
|
||||
};
|
||||
let settingsSearch: { search: jest.Mock; reveal: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
currentRoute = signal(
|
||||
parseWorkspaceShellRoute('/workspace/settings/general')
|
||||
);
|
||||
searchSync = {
|
||||
searchQuery: signal(''),
|
||||
appliedSearchQuery: signal(''),
|
||||
applySearchQuery: jest.fn(),
|
||||
};
|
||||
settingsSearch = {
|
||||
search: jest.fn((query: string) =>
|
||||
query === 'theme' ? [{ entry: THEME, score: 1 }] : []
|
||||
),
|
||||
reveal: jest.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
WorkspaceShellSearchService,
|
||||
{ provide: Router, useValue: { navigate: jest.fn() } },
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: { getSelectedCategory: () => null },
|
||||
},
|
||||
{
|
||||
provide: StalkerStore,
|
||||
useValue: { getSelectedCategoryName: () => '' },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: of(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellRouteStateService,
|
||||
useValue: {
|
||||
currentRoute,
|
||||
playlists: signal([]),
|
||||
dashboardXtreamContext: signal(null),
|
||||
currentContext: signal(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceShellSearchSyncService,
|
||||
useValue: searchSync,
|
||||
},
|
||||
{ provide: SettingsSearchService, useValue: settingsSearch },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(WorkspaceShellSearchService);
|
||||
});
|
||||
|
||||
it('enables a local settings filter with the settings placeholder', () => {
|
||||
expect(service.searchCapability()).toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
behavior: 'local-filter',
|
||||
searchMode: 'local-filter',
|
||||
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
|
||||
scopeLabel: '',
|
||||
})
|
||||
);
|
||||
expect(service.canUseSearch()).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the best match on Enter without applying the term', () => {
|
||||
service.onSearchEnter(' theme ');
|
||||
|
||||
expect(settingsSearch.search).toHaveBeenCalledWith('theme', 1);
|
||||
expect(settingsSearch.reveal).toHaveBeenCalledWith(THEME);
|
||||
// Applying would start the `q` sync navigation and supersede the
|
||||
// reveal navigation.
|
||||
expect(searchSync.applySearchQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the term on Enter when nothing matches', () => {
|
||||
service.onSearchEnter('zzz ');
|
||||
|
||||
expect(settingsSearch.reveal).not.toHaveBeenCalled();
|
||||
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('zzz');
|
||||
});
|
||||
|
||||
it('leaves Enter on other routes to their own search', () => {
|
||||
currentRoute.set(parseWorkspaceShellRoute('/workspace/sources'));
|
||||
|
||||
service.onSearchEnter('theme');
|
||||
|
||||
expect(settingsSearch.search).not.toHaveBeenCalled();
|
||||
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('theme');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1152,7 +1152,7 @@ describe('WorkspaceShellFacade', () => {
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
|
||||
resolveSupport();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
+19
@@ -546,6 +546,25 @@ describe('WorkspaceShellComponent', () => {
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
|
||||
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
|
||||
|
||||
// On settings the header search is the settings search, so find
|
||||
// focuses it in place instead of leaving for global search.
|
||||
facade.openGlobalSearch.mockClear();
|
||||
header.focusSearchInput.mockClear();
|
||||
facade.isSettingsRoute.set(true);
|
||||
const settingsEvent = new KeyboardEvent('keydown', {
|
||||
key: 'f',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
document.dispatchEvent(settingsEvent);
|
||||
jest.runOnlyPendingTimers();
|
||||
|
||||
expect(settingsEvent.defaultPrevented).toBe(true);
|
||||
expect(facade.openGlobalSearch).not.toHaveBeenCalled();
|
||||
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -163,7 +163,11 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.facade.openGlobalSearch(this.facade.searchQuery());
|
||||
// On settings the header search already searches the settings, so
|
||||
// find stays on the page instead of leaving for global search.
|
||||
if (!this.facade.isSettingsRoute()) {
|
||||
this.facade.openGlobalSearch(this.facade.searchQuery());
|
||||
}
|
||||
setTimeout(() => header?.focusSearchInput({ select: true }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user