diff --git a/.changeset/settings-scope-badges-and-search.md b/.changeset/settings-scope-badges-and-search.md new file mode 100644 index 00000000..43c49def --- /dev/null +++ b/.changeset/settings-scope-badges-and-search.md @@ -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. diff --git a/packages/app/package.json b/packages/app/package.json index 3517ef55..c9eed66a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/settings/LintingSection.dom.test.tsx b/packages/app/src/components/settings/LintingSection.dom.test.tsx index fc19c22c..709d1a0d 100644 --- a/packages/app/src/components/settings/LintingSection.dom.test.tsx +++ b/packages/app/src/components/settings/LintingSection.dom.test.tsx @@ -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; @@ -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, @@ -73,7 +73,7 @@ function projectDataWithMarkdownlintRules( }, }; } -mock.module('@/editor/lint-config-client', () => ({ +vi.doMock('@/editor/lint-config-client', () => ({ emitLintConfigChanged: () => {}, subscribeToLintConfigChanged: () => () => {}, runLintAudit: async () => null, @@ -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', () => { diff --git a/packages/app/src/components/settings/LintingSection.tsx b/packages/app/src/components/settings/LintingSection.tsx index b4cfde86..040ddf39 100644 --- a/packages/app/src/components/settings/LintingSection.tsx +++ b/packages/app/src/components/settings/LintingSection.tsx @@ -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() { @@ -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 (
-

- {title} -

+
+

+ {title} +

+ {scope ? : null} +

{children}

); } /** 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 (
- + Flag common markdown issues in the editor. Powered by{' '} - +
); } diff --git a/packages/app/src/components/settings/ScopeBadge.dom.test.tsx b/packages/app/src/components/settings/ScopeBadge.dom.test.tsx new file mode 100644 index 00000000..442b76a3 --- /dev/null +++ b/packages/app/src/components/settings/ScopeBadge.dom.test.tsx @@ -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( + + + , + ); +} + +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(); + }); +}); diff --git a/packages/app/src/components/settings/ScopeBadge.tsx b/packages/app/src/components/settings/ScopeBadge.tsx new file mode 100644 index 00000000..d3758519 --- /dev/null +++ b/packages/app/src/components/settings/ScopeBadge.tsx @@ -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 ( + + + {/* 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). */} + + {scope === 'user' ? User : Project} + + + + {scope === 'user' ? ( + Personal to this device — stored in your user config, not shared. + ) : ( + Shared with everyone on this project — committed to config.yml via git. + )} + + + ); +} diff --git a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx index 516ef9c1..95453860 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx @@ -1,4 +1,3 @@ -import { afterEach, describe, expect, test } from 'bun:test'; import { CONFIG_DOC_NAME_PROJECT, CONFIG_DOC_NAME_USER, @@ -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'; @@ -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(); diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx index 89f76505..30bdd11f 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { createContext, type ReactNode, use, useState } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; type SyncStatus = { @@ -49,12 +49,12 @@ const actualCore = await import('@inkeep/open-knowledge-core'); import * as actualLinguiMacro from '@lingui/react/macro'; -mock.module('@inkeep/open-knowledge-core', () => ({ +vi.doMock('@inkeep/open-knowledge-core', () => ({ ...actualCore, SHOW_INSTALL_SKILL: true, })); -mock.module('@lingui/react/macro', () => ({ +vi.doMock('@lingui/react/macro', () => ({ ...actualLinguiMacro, Plural: ({ value, one, other }: { value: number; one: string; other: string }) => ( <>{(value === 1 ? one : other).replace('#', String(value))} @@ -63,7 +63,7 @@ mock.module('@lingui/react/macro', () => ({ useLingui: () => ({ t: renderLinguiTemplate }), })); -mock.module('@lingui/core/macro', () => ({ +vi.doMock('@lingui/core/macro', () => ({ ...actualLinguiMacro, msg: renderLinguiTemplate, plural: (value: number, options: { one: string; other: string }) => @@ -71,7 +71,7 @@ mock.module('@lingui/core/macro', () => ({ t: renderLinguiTemplate, })); -mock.module('@/components/ui/button', () => ({ +vi.doMock('@/components/ui/button', () => ({ Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (