Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/settings-scope-badges-and-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@inkeep/open-knowledge": minor
---

Settings now tells you where a plugin's changes are stored and lets you search across the whole dialog.

Plugin panels carry a scope badge: markdownlint shows a "Project" badge (its rules are committed to config.yml and shared with your team) and Themes shows a "User" badge (personal to your device). Hover either badge for a one-line explanation of where the change is saved.

A search box is pinned at the top of the settings sidebar. Type to find any section, any Preferences/Theme setting, or any markdownlint rule by name, id, or alias, then jump straight to it: a section result switches panels, a setting result scrolls to and briefly highlights the field, and a markdownlint rule result opens the markdownlint panel already filtered to that rule. markdownlint rules only appear in search while the plugin is enabled. The search box stays put while the section list scrolls beneath it.
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"measure:fuzz": "bash scripts/measure-fuzz.sh",
"measure:stress": "bash scripts/measure-stress.sh",
"perf:compare": "bash scripts/perf-compare.sh",
"test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts",
"test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts tests/stress/settings-search.e2e.ts",
"test:e2e:install-browsers": "playwright install chromium webkit firefox",
"test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
* editor's write calls + gated visibility of controls.
*/

import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Config, ConfigBinding } from '@inkeep/open-knowledge-core';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';

// Radix primitives reach for DOM globals the jsdom preload doesn't expose;
Expand Down Expand Up @@ -36,7 +36,7 @@ let mockProjectConfig: Config | null = null;
let mockProjectSynced = true;
let mockProjectBinding: ConfigBinding | null = null;

mock.module('@/lib/config-provider', () => ({
vi.doMock('@/lib/config-provider', () => ({
useConfigContext: () => ({
userBinding: null,
userSynced: false,
Expand Down Expand Up @@ -73,7 +73,7 @@ function projectDataWithMarkdownlintRules(
},
};
}
mock.module('@/editor/lint-config-client', () => ({
vi.doMock('@/editor/lint-config-client', () => ({
emitLintConfigChanged: () => {},
subscribeToLintConfigChanged: () => () => {},
runLintAudit: async () => null,
Expand Down Expand Up @@ -198,6 +198,9 @@ describe('MarkdownlintPluginSection', () => {
expect(screen.getByTestId('settings-linting-markdownlint-rules')).toBeDefined();
expect(screen.getByTestId('markdownlint-rule-search')).toBeDefined();
expect(screen.getByTestId('markdownlint-rule-row-MD001')).toBeDefined();
// markdownlint is a project-scope plugin — the header carries a Project badge.
expect(screen.getByTestId('settings-scope-badge-project')).toBeDefined();
expect(screen.queryByTestId('settings-scope-badge-user')).toBeNull();
});

test('names the project config file in the description when one is present', () => {
Expand Down
28 changes: 22 additions & 6 deletions packages/app/src/components/settings/LintingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useConfigContext } from '@/lib/config-provider';
import { dispatchExternalLinkClick } from '@/lib/external-link';
import { LINT_PLUGIN_META } from './lint-plugin-meta';
import { MarkdownlintRuleBrowser } from './markdownlint-rule-browser';
import { ScopeBadge } from './ScopeBadge';

/** Project-scope content-rules config + a `contentRules`-patch writer. Shared by the sections. */
function useLinterConfig() {
Expand Down Expand Up @@ -194,31 +195,46 @@ export function UserPluginsManageSection({ userBinding }: { userBinding: ConfigB
function PluginSectionHeader({
titleId,
title,
scope,
children,
}: {
titleId: string;
title: string;
/** When set, renders a User/Project scope badge beside the title. */
scope?: 'user' | 'project';
children: ReactNode;
}) {
return (
<div className="space-y-1">
<h3 id={titleId} className="text-base font-semibold">
{title}
</h3>
<div className="flex items-center gap-2">
<h3 id={titleId} className="text-base font-semibold">
{title}
</h3>
{scope ? <ScopeBadge scope={scope} /> : null}
</div>
<p className="text-sm text-muted-foreground">{children}</p>
</div>
);
}

/** markdownlint plugin: the full-catalog rule browser. */
export function MarkdownlintPluginSection() {
export function MarkdownlintPluginSection({
initialRuleQuery,
}: {
/** Seeds the rule browser's search when the settings search jumps to a rule. */
initialRuleQuery?: { query: string; nonce: number } | null;
} = {}) {
return (
<section
aria-labelledby="settings-plugin-markdownlint-title"
className="space-y-4"
data-testid="settings-plugin-markdownlint"
>
<PluginSectionHeader titleId="settings-plugin-markdownlint-title" title="markdownlint">
<PluginSectionHeader
titleId="settings-plugin-markdownlint-title"
title="markdownlint"
scope="project"
>
<Trans>
Flag common markdown issues in the editor. Powered by{' '}
<a
Expand All @@ -239,7 +255,7 @@ export function MarkdownlintPluginSection() {
.
</Trans>
</PluginSectionHeader>
<MarkdownlintRuleBrowser />
<MarkdownlintRuleBrowser initialRuleQuery={initialRuleQuery} />
</section>
);
}
70 changes: 70 additions & 0 deletions packages/app/src/components/settings/ScopeBadge.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* DOM tests for ScopeBadge — the User/Project indicator shown in plugin panel
* headers. Asserts the visible label and the scope-specific tooltip copy.
*/

import { cleanup, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ScopeBadge } from './ScopeBadge';

// Radix Tooltip reaches for globals jsdom's preload doesn't expose.
type GlobalWithShims = typeof globalThis & { ResizeObserver?: unknown };
const g = globalThis as GlobalWithShims;
if (g.ResizeObserver === undefined) {
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
g.ResizeObserver = NoopResizeObserver;
}

function renderBadge(scope: 'user' | 'project') {
return render(
<TooltipProvider>
<ScopeBadge scope={scope} />
</TooltipProvider>,
);
}

describe('ScopeBadge', () => {
afterEach(() => cleanup());

test('user scope renders a "User" badge', () => {
renderBadge('user');
const badge = screen.getByTestId('settings-scope-badge-user');
expect(badge.textContent).toBe('User');
expect(screen.queryByTestId('settings-scope-badge-project')).toBeNull();
});

test('project scope renders a "Project" badge', () => {
renderBadge('project');
const badge = screen.getByTestId('settings-scope-badge-project');
expect(badge.textContent).toBe('Project');
expect(screen.queryByTestId('settings-scope-badge-user')).toBeNull();
});

test('user tooltip explains it is stored in user config', async () => {
renderBadge('user');
await userEvent.hover(screen.getByTestId('settings-scope-badge-user'));
const tooltip = await screen.findAllByRole('tooltip');
expect(within(tooltip[0]).getByText(/user config/i)).toBeDefined();
});

test('project tooltip explains it is shared via git', async () => {
renderBadge('project');
await userEvent.hover(screen.getByTestId('settings-scope-badge-project'));
const tooltip = await screen.findAllByRole('tooltip');
expect(within(tooltip[0]).getByText(/config\.yml/i)).toBeDefined();
});

test('badge is keyboard-focusable and focus opens the tooltip', async () => {
renderBadge('user');
await userEvent.tab();
expect(document.activeElement).toBe(screen.getByTestId('settings-scope-badge-user'));
const tooltip = await screen.findAllByRole('tooltip');
expect(within(tooltip[0]).getByText(/user config/i)).toBeDefined();
});
});
39 changes: 39 additions & 0 deletions packages/app/src/components/settings/ScopeBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* User/Project scope indicator for a plugin settings panel header. The
* "Plugins" sidebar group mixes scopes — markdownlint is project-scope (shared
* via config.yml + the native `.markdownlint.*`), Themes is user-scope
* (personal, device-local) — so the panel header alone can't tell you where a
* change lands. This badge makes the scope (and, via its tooltip, where the
* change is stored) explicit. Used ONLY in plugin panel headers; the other
* settings sections are already categorized by their labeled sidebar group.
*/
import { Trans } from '@lingui/react/macro';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';

export function ScopeBadge({ scope }: { scope: 'user' | 'project' }) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* tabIndex makes the badge keyboard-focusable so the tooltip's
storage/sharing explanation is reachable without a pointer (Radix
opens tooltips on focus and wires aria-describedby). */}
<Badge
variant="gray"
tabIndex={0}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`settings-scope-badge-${scope}`}
>
{scope === 'user' ? <Trans>User</Trans> : <Trans>Project</Trans>}
</Badge>
</TooltipTrigger>
<TooltipContent>
{scope === 'user' ? (
<Trans>Personal to this device — stored in your user config, not shared.</Trans>
) : (
<Trans>Shared with everyone on this project — committed to config.yml via git.</Trans>
)}
</TooltipContent>
</Tooltip>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { afterEach, describe, expect, test } from 'bun:test';
import {
CONFIG_DOC_NAME_PROJECT,
CONFIG_DOC_NAME_USER,
Expand All @@ -11,6 +10,7 @@ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, test } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { ConfigContext, type ConfigContextValue } from '@/lib/config-context';
import { emitConfigValidationRejected } from '@/lib/config-validation-events';
Expand Down Expand Up @@ -366,6 +366,13 @@ describe('SettingsDialogBody color-palette picker — optimistic mode flip', ()
cleanup();
});

test('the Themes plugin header shows a User scope badge (user-scope plugin)', () => {
const { binding } = makeBinding();
renderThemePluginWithTheme(binding);
expect(screen.getByTestId('settings-scope-badge-user')).toBeDefined();
expect(screen.queryByTestId('settings-scope-badge-project')).toBeNull();
});

test('selecting a light palette (Catppuccin Latte) flips next-themes to light', async () => {
const user = userEvent.setup();
const { binding } = makeBinding();
Expand Down
Loading