From 91377db28f294e796b42f255e6417becc71e8e84 Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Mon, 20 Jul 2026 16:04:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(open-knowledge):=20WYSIWYG=20=E2=87=84=20s?= =?UTF-8?q?ource=20toggle=20for=20JSON=20markdownlint=20config=20files=20(?= =?UTF-8?q?#2723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [US-001] Add core markdownlint JSON config-file predicate Core now owns the markdownlint native config filename list and a isMarkdownlintJsonConfig(basename) predicate so the browser editor can decide, without a server round-trip, when to offer the rule-editing toggle. Scope is JSON/JSONC only, matching the format-preserving writer. The server keeps its own discovery copy for now. * [US-002] Add lint-config view-mode persistence hook * [US-003] Add JSONC round-trip guard test for markdownlint writer * [US-004] Add LintConfigEditor Source/Rules toggle component * [US-005] Dispatch markdownlint config files to LintConfigEditor * [qa] Add lint-config toggle E2E coverage * fixup! local-review: address findings (pass 1) * [US-004] Fix lint-config Rules view: add padding, vertical scroll, max-width The rule browser has no scroll/padding of its own — in Settings it inherits both from the dialog body. Mounted standalone it rendered edge-to-edge and clipped vertically. Give the Rules view its own scroll container + content gutter; Source stays full-bleed (CodeMirror owns its scroll). * chore(open-knowledge): add changeset for lint-config WYSIWYG toggle * chore(open-knowledge): regenerate ng-anchors catalog for new core/markdown file The lint-config predicate adds a file under packages/core/src/markdown/**, which the md-audit ng-anchors catalog enumerates (productionFileCount 99 -> 100). Refresh the committed catalog so the freshness gate passes. * fix(open-knowledge): announce disabled lint-config toggle reason via aria-describedby aria-description is an ARIA 1.3 draft attribute with uneven screen-reader support. Point aria-describedby at a visually-hidden element carrying the reason instead, so the disabled Rules segment's unavailability is announced by mainstream screen readers. * fix(open-knowledge): carry NotInSidebarIndicator in the lint-config editor AssetPreview renders NotInSidebarIndicator on every asset surface so a file opened outside the sidebar (a hidden config dotfile is exactly this case) advertises the reveal affordance. LintConfigEditor replaced AssetPreview for configs and dropped it; add it back with the same placement. Self-gates to null when the file is visible. * test(open-knowledge): drop e2e waitForTimeout for a deterministic config check waitForTimeout is a Playwright anti-pattern. Replace the fixed 500ms 'stays disabled' guard with a deterministic GET /api/lint/config assertion (server reports the gating input), then re-assert disabled — the pattern the null-config case already used. * test(open-knowledge): pin the Source-segment tooltip sourceLabel override Symmetric with the existing Rules-tooltip test — guards against a refactor that drops the sourceLabel ternary and silently shows 'Markdown' for the config editor. * feat(open-knowledge): hide the rule-browser config source note in the lint-config editor When the rule browser is opened on the config file itself, the 'these rules come from ' note is redundant — the user is already looking at the file. Add an optional hideConfigSourceNote prop (default off, so Settings is unchanged) and set it from LintConfigEditor. The Modified-badge legend stays. * ci(open-knowledge): raise integration-shard budget to 25 min Main-added integration suites (CmdPalette/Timeline/crash-detection/ rendered-diff) pushed test:integration:shard1 past the shared 15-min cap — cancelled at ~15:17 on every attempt across branches (this PR and fix-pr-cla alike) while shard2 finishes ~9 min. Same remedy as the unit and DOM tiers: per-task matrix timeout of 25. * test(open-knowledge): RED pin for byte-preserving rename in timeline no-op filter The existing rename-history unit tests perform a byte-CHANGING rename (`# A v1` -> `# B v1`), so the rename commit's blob differs from its predecessor and never reaches the byte-identical no-op filter added in #2607. A real managed rename (`/api/rename-path`) preserves the doc's bytes and only changes the path, so the rename commit's blob is byte-identical to the pre-rename version — and `dropByteIdenticalRows` drops it, silently losing the rename event from the timeline. This adds a unit-tier RED pin using a byte-preserving rename, matching the invariant the two failing integration tests in rename-history.test.ts encode. * fix(open-knowledge): timeline no-op filter drops managed-rename commits Root cause: `dropByteIdenticalRows` in timeline-query.ts (added in #2607) drops any non-checkpoint timeline row whose document blob OID equals the adjacent-older visible version. A managed rename changes the doc's PATH, not its bytes, so a rename commit's blob is byte-identical to the pre-rename version — the filter dropped it, silently removing the rename event from the timeline. This broke the rename-history invariant (the timeline must span file renames): the destination doc's timeline lost the rename commit SHA, so `getDocumentHistory` no longer surfaced the name epoch boundary. Fix: treat managed-rename commits as name-epoch landmarks, exactly like checkpoints — never dropped, never a byte baseline. The rename commit SHAs are already available on the `chain` passed to the function (`chain[i].renameCommit`, set from the rename-log entry's commitSha in rename-log.ts). A shared `isLandmark` predicate replaces the two checkpoint-only skip checks. Fixes the two failing integration tests in rename-history.test.ts (`file rename round-trip` and `chained A->B->C`) and is pinned at the unit tier by the byte-preserving-rename test added in the prior commit. * test(open-knowledge): migrate PR-touched tests off the bun:test shim PR #2757 added a diff-scoped CI guard (scripts/check-no-new-bun-test-imports.mjs) that fails any file a change adds or modifies if it still imports the `bun:test` compat shim; the ~1,900 untouched files stay grandfathered. These 7 test files are touched by this PR, so they must import from `vitest` directly. Migration per the guard/shim guidance: - `from 'bun:test'` -> `from 'vitest'` (all 7 files). - `test.if(cond)` -> `test.runIf(cond)` (markdownlint-write). - `mock()` -> `vi.fn()`, `spyOn` -> `vi.spyOn` (useLintConfigViewMode). - `mock.module(path, factory)` -> `vi.doMock(path, factory)` in the three DOM tests, which already register mocks before a dynamic `import()` of the SUT — the mock-then-dynamic-import shape the shim emulates. Calling vi.doMock directly from the test keys the mock against the test module (correct resolver base), which is exactly what the shim's bare-specifier path works to reproduce. Behavior-preserving: no assertion, mock, or test-count changes. Biome organizeImports reordered the swapped import lines. Per-file test counts unchanged (core 3, markdownlint-write 25, timeline-query 33, EditorArea dispatch 5, LintConfigEditor 12, rule-browser 28, useLintConfigViewMode 22). * chore(open-knowledge): raise combined-chunks size budget 3.35 -> 3.36 MB An 18-commit catch-up merge of main pushed the merged combined-chunks total 461 B over the 3.35 MB budget (CI measured 3,350,461 B). The lint-config feature's chunks are unchanged; growth is concurrent main features (OpenClaw/Hermes targets, bundle checkpoint refs, FileTree refactor, crash-capture line, et al.). Same catch-up-merge bump convention as the prior raises documented in the package.json comment. --------- GitOrigin-RevId: 0950dbf8a8260c9c350bbef077645b4528102298 --- .changeset/lint-config-wysiwyg-toggle.md | 5 + .../advanced/content-rules/markdownlint.mdx | 2 + packages/app/package.json | 4 +- ...itorArea.lint-config-dispatch.dom.test.tsx | 172 +++++++++ packages/app/src/components/EditorArea.tsx | 18 +- .../app/src/components/EditorModeToggle.tsx | 81 +++- .../components/LintConfigEditor.dom.test.tsx | 244 ++++++++++++ .../app/src/components/LintConfigEditor.tsx | 101 +++++ .../markdownlint-rule-browser.dom.test.tsx | 27 +- .../settings/markdownlint-rule-browser.tsx | 105 +++--- .../src/editor/useLintConfigViewMode.test.ts | 254 +++++++++++++ .../app/src/editor/useLintConfigViewMode.ts | 90 +++++ packages/app/src/locales/en/messages.json | 2 + packages/app/src/locales/en/messages.po | 9 + packages/app/src/locales/pseudo/messages.json | 2 + packages/app/src/locales/pseudo/messages.po | 9 + .../tests/stress/lint-config-toggle.e2e.ts | 347 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/markdown/lint/config-files.ts | 8 + packages/core/src/markdown/lint/index.ts | 1 + .../src/lint/markdownlint-write.test.ts | 40 +- packages/server/src/timeline-query.test.ts | 40 +- packages/server/src/timeline-query.ts | 35 +- 23 files changed, 1516 insertions(+), 81 deletions(-) create mode 100644 .changeset/lint-config-wysiwyg-toggle.md create mode 100644 packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx create mode 100644 packages/app/src/components/LintConfigEditor.dom.test.tsx create mode 100644 packages/app/src/components/LintConfigEditor.tsx create mode 100644 packages/app/src/editor/useLintConfigViewMode.test.ts create mode 100644 packages/app/src/editor/useLintConfigViewMode.ts create mode 100644 packages/app/tests/stress/lint-config-toggle.e2e.ts create mode 100644 packages/core/src/markdown/lint/config-files.ts diff --git a/.changeset/lint-config-wysiwyg-toggle.md b/.changeset/lint-config-wysiwyg-toggle.md new file mode 100644 index 000000000..78a0a3c30 --- /dev/null +++ b/.changeset/lint-config-wysiwyg-toggle.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Opening a JSON markdownlint config file (`.markdownlint.json` / `.markdownlint.jsonc`) now offers a toggle between a **Source** view and a **Rules** view. Source is the raw, read-only file (comments, `extends`, and formatting intact); Rules is the same searchable rule browser as Settings — flip rules on or off and edit their options, with changes written back to the file through the format-preserving writer (comments, `extends`, and trailing commas are preserved). Rule editing targets the project's root config, so a nested or not-yet-created config opens in Source with the Rules option disabled and an explanation. Your Source/Rules choice is remembered per user, separately from the markdown editor's own mode. diff --git a/docs/content/advanced/content-rules/markdownlint.mdx b/docs/content/advanced/content-rules/markdownlint.mdx index c703a4ec7..24a2f88c4 100644 --- a/docs/content/advanced/content-rules/markdownlint.mdx +++ b/docs/content/advanced/content-rules/markdownlint.mdx @@ -29,6 +29,8 @@ The rules live in your project's own native markdownlint file — `.markdownlint **Settings ▸ Plugins ▸ markdownlint** (markdownlint appears there once the plugin is enabled) lists the full catalog — all 53 rules, generated from the installed engine's own config schema, so it always matches what actually runs. Search by id, alias, or name; browse by category (Headings, Lists, Whitespace, Code, Links & images, Style); or check **Only modified** to see just the rules your config changes. Expanding a rule reveals a link to its upstream documentation and typed editors for each of its options. +The same browser also opens **directly on the config file**. Open your project's root `.markdownlint.json` or `.markdownlint.jsonc` in the editor (reveal hidden files to find it) and use the **Source / Rules** toggle: **Source** — the default — shows the raw file read-only, and **Rules** is the same no-code browser. Because rule edits target the project's governing root config, **Rules** is enabled only for that root file; open a nested or otherwise non-governing `.markdownlint.*` and **Rules** is disabled with a tooltip while **Source** still shows the file. Your Source/Rules choice is remembered separately from the document editor's own Visual/Markdown mode. + Every edit writes back to your native `.markdownlint.*` file, preserving what you wrote: JSON and JSONC files get minimal text edits (comments and formatting survive), and a rule you configured under an alias stays under that alias. Severity strings are shown as a read-only badge. If the project has no file yet, the first change creates a `.markdownlint.json` seeded with the defaults — from then on that file is the whole story for OK and every other markdownlint tool. ## Auto-fix and severities diff --git a/packages/app/package.json b/packages/app/package.json index 10f4855e2..3517ef553 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/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", "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", @@ -52,7 +52,7 @@ { "name": "all JS chunks combined (gzipped)", "path": "dist/assets/*.js", - "limit": "3.35 MB", + "limit": "3.36 MB", "gzip": true, "running": false }, diff --git a/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx new file mode 100644 index 000000000..a6d657234 --- /dev/null +++ b/packages/app/src/components/EditorArea.lint-config-dispatch.dom.test.tsx @@ -0,0 +1,172 @@ +/** + * Dispatch test for the EditorArea asset branch: a markdownlint JSON config file + * routes to the LintConfigEditor (Source/Rules toggle), while every other asset + * — including a non-config `.json` like package.json — keeps the read-only + * AssetPreview. Both branch targets are stubbed to markers so this test observes + * only WHICH branch EditorArea selects; LintConfigEditor's own toggle behavior + * is covered by its dedicated DOM test. + * + * The heavy EditorArea dependency graph is mocked following the sibling + * `EditorArea.share-receive-miss.dom.test.tsx` harness. The config predicate + * (`isMarkdownlintJsonConfig`) runs for real — it is the seam under test. + */ + +import { cleanup, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ConfigProvider } from '@/lib/config-provider'; + +function assetCtx(assetPath: string) { + return { + activeDocName: null, + activeProvider: {} as never, + activeTarget: { + kind: 'asset' as const, + target: assetPath, + assetPath, + mediaKind: 'text' as string | null, + }, + recycleDocument: () => {}, + docPanelMode: 'timeline', + docPanelAgentId: null, + docPanelExpandSignal: 0, + }; +} +let docCtx: ReturnType = assetCtx('.markdownlint.json'); + +vi.doMock('@/lib/perf', () => ({ + mark: () => {}, + ProfilerBoundary: ({ children }: { children: ReactNode }) => children, +})); +vi.doMock('@/components/PropertyContext', () => ({ + PropertyProvider: ({ children }: { children: ReactNode }) => children, + useProperties: () => ({ requestAddProperty: () => {} }), +})); +vi.doMock('@/editor/DocumentContext', () => ({ + useDocumentContext: () => docCtx, + useDocumentTransition: () => ({ openDocumentTransition: null }), +})); +vi.doMock('@/hooks/use-document-stats', () => ({ useDocumentStats: () => null })); +vi.doMock('@/hooks/use-selection-stats', () => ({ useSelectionStats: () => null })); +vi.doMock('@/hooks/use-lifecycle-status', () => ({ useLifecycleStatus: () => 'ready' })); +vi.doMock('@/presence/use-sync-status', () => ({ useSyncStatus: () => 'synced' })); +vi.doMock('@/lib/use-settings-route', () => ({ + useSettingsRoute: () => ({ open: false, close: () => {} }), + SETTINGS_OPEN_HASH: '#settings', + isSettingsShortcut: () => false, +})); +vi.doMock('@/components/settings/SettingsDialogShell', () => ({ + SettingsDialogShell: () =>
, +})); +vi.doMock('@/components/EditorSkeleton', () => ({ + EditorSkeleton: () =>
, +})); +vi.doMock('@/components/EmptyEditorState', () => ({ + EmptyEditorState: () =>
, +})); +vi.doMock('./TerminalDock', () => ({ + TerminalDock: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.doMock('react-resizable-panels', () => ({ + usePanelRef: () => ({ current: { collapse: () => {}, expand: () => {} } }), + useGroupRef: () => ({ current: { getLayout: () => [], setLayout: () => {} } }), +})); +vi.doMock('@/components/ui/resizable', () => ({ + ResizablePanelGroup: ({ children }: { children: ReactNode }) =>
{children}
, + ResizablePanel: ({ children }: { children: ReactNode }) =>
{children}
, + ResizableHandle: () =>
, +})); +vi.doMock('./EditorActivityPool', () => ({ + EditorActivityPool: () =>
, +})); +vi.doMock('@/editor/find-replace/FindReplaceController', () => ({ + FindReplaceController: () => null, +})); +vi.doMock('./EditorToolbar', () => ({ + EditorToolbar: () =>
, +})); +vi.doMock('./EditorFooter', () => ({ EditorFooter: () =>
})); +vi.doMock('./BottomComposer', () => ({ + BottomComposer: () =>
, +})); +vi.doMock('./editor-area-overlay', () => ({ shouldPaintOverlay: () => false })); +vi.doMock('@/components/DocPanel', () => ({ DocPanel: () =>
})); + +// The two asset-branch targets, stubbed to markers so the test reads which +// branch EditorArea picked (not the components' own rendering). +vi.doMock('@/components/AssetPreview', () => ({ + AssetPreview: ({ assetPath }: { assetPath: string }) => ( +
+ ), +})); +vi.doMock('@/components/LintConfigEditor', () => ({ + LintConfigEditor: ({ assetPath }: { assetPath: string }) => ( +
+ ), +})); + +const { EditorArea } = await import('./EditorArea'); + +function renderEditorArea() { + return render( + + {}} + activeTab="timeline" + onActiveTabChange={() => {}} + /> + , + ); +} + +describe('EditorArea — markdownlint config dispatch', () => { + beforeEach(() => cleanup()); + afterEach(() => cleanup()); + + test('routes a root .markdownlint.json to the config editor, not the asset preview', () => { + docCtx = assetCtx('.markdownlint.json'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor').getAttribute('data-asset-path')).toBe( + '.markdownlint.json', + ); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('routes a .markdownlint.jsonc to the config editor', () => { + docCtx = assetCtx('.markdownlint.jsonc'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor')).toBeDefined(); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('routes a nested config to the config editor (dispatch is basename-driven)', () => { + docCtx = assetCtx('docs/.markdownlint.json'); + renderEditorArea(); + + expect(screen.getByTestId('lint-config-editor').getAttribute('data-asset-path')).toBe( + 'docs/.markdownlint.json', + ); + expect(screen.queryByTestId('asset-preview')).toBeNull(); + }); + + test('leaves a non-config JSON (package.json) on the asset preview', () => { + docCtx = assetCtx('package.json'); + renderEditorArea(); + + expect(screen.getByTestId('asset-preview').getAttribute('data-asset-path')).toBe( + 'package.json', + ); + expect(screen.queryByTestId('lint-config-editor')).toBeNull(); + }); + + test('leaves an ordinary .json data file on the asset preview', () => { + docCtx = assetCtx('data/settings.json'); + renderEditorArea(); + + expect(screen.getByTestId('asset-preview')).toBeDefined(); + expect(screen.queryByTestId('lint-config-editor')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 6c03748b8..c1a2cdaf8 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -1,4 +1,7 @@ -import { detectEmbeddedHostFromBrowser } from '@inkeep/open-knowledge-core'; +import { + detectEmbeddedHostFromBrowser, + isMarkdownlintJsonConfig, +} from '@inkeep/open-knowledge-core'; import { Trans, useLingui } from '@lingui/react/macro'; import { useTheme } from 'next-themes'; import { @@ -23,6 +26,7 @@ import { EditorSkeleton } from '@/components/EditorSkeleton'; import { EmptyEditorState } from '@/components/EmptyEditorState'; import { FolderOverview } from '@/components/FolderOverview'; import { LargeFileEditorState } from '@/components/LargeFileEditorState'; +import { LintConfigEditor } from '@/components/LintConfigEditor'; import { MountStalledAffordance } from '@/components/MountStalledAffordance'; import { PropertyProvider, useProperties } from '@/components/PropertyContext'; import { ShareReceiveMissPanel } from '@/components/ShareReceiveMissPanel'; @@ -879,6 +883,18 @@ function EditorAreaInner({ ); } + } else if ( + activeTarget?.kind === 'asset' && + isMarkdownlintJsonConfig(activeTarget.assetPath.split('/').pop() ?? activeTarget.assetPath) + ) { + // A markdownlint JSON config file opens in the dedicated config editor (a + // Source/Rules toggle) instead of the read-only asset preview. Like the + // skill-file branch below it's a plain REST-backed sibling — NOT + // DocumentBoundary-wrapped: the config is served over HTTP, not a pooled + // CRDT doc. Keyed by path so navigating between configs remounts + resets. + viewContent = ( + + ); } else if (activeTarget?.kind === 'asset') { // `key={assetPath}` forces a fresh `AssetPreview` instance on every asset // navigation so the in-pane `forceText` toggle (from the "View as text" diff --git a/packages/app/src/components/EditorModeToggle.tsx b/packages/app/src/components/EditorModeToggle.tsx index b613d5e5b..5b3cd4319 100644 --- a/packages/app/src/components/EditorModeToggle.tsx +++ b/packages/app/src/components/EditorModeToggle.tsx @@ -1,4 +1,5 @@ import { Trans, useLingui } from '@lingui/react/macro'; +import { useId } from 'react'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { EditorModeValue } from '@/editor/use-editor-mode'; @@ -10,19 +11,49 @@ interface EditorModeToggleProps { onModeChange: (mode: EditorModeValue) => void; /** Disables the Markdown (source) option (e.g. doc editor when offline). */ sourceDisabled?: boolean; + /** + * Disables the Visual (wysiwyg) option (e.g. the lint-config editor when the + * opened file is not the governing root config the rule writer can target). + */ + wysiwygDisabled?: boolean; + /** + * Overrides the default segment labels ("Visual" / "Markdown"), applied to + * both the tooltip and the accessible name. The lint-config editor passes + * "Rules" / "Source". Pass an already-translated string so message extraction + * stays at the call site. + */ + wysiwygLabel?: string; + sourceLabel?: string; + /** + * Tooltip shown on the Visual segment while `wysiwygDisabled` — explains why + * the option is unavailable so a pointer user learns the reason. + */ + wysiwygDisabledReason?: string; } /** * Visual ⇄ Markdown editor-mode toggle — the segmented control shared by the - * document editor toolbar (`EditorToolbar`) and the skill editor, so both read - * identically. + * document editor toolbar (`EditorToolbar`), the skill editor, and the + * lint-config editor, so all three read identically. Labels default to + * Visual/Markdown; a caller may relabel the two segments and disable the visual + * one without affecting the others. */ export function EditorModeToggle({ isSourceMode, onModeChange, sourceDisabled = false, + wysiwygDisabled = false, + wysiwygLabel, + sourceLabel, + wysiwygDisabledReason, }: EditorModeToggleProps) { const { t } = useLingui(); + const wysiwygName = wysiwygLabel ?? t`Visual editor`; + const sourceName = sourceLabel ?? t`Markdown source`; + // Stable id so the disabled Visual segment can point `aria-describedby` at a + // visually-hidden element carrying its reason. + const wysiwygReasonId = useId(); + const describeWysiwyg = wysiwygDisabled && wysiwygDisabledReason ? wysiwygDisabledReason : null; return ( - - - - - + + {/* Disabled