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
5 changes: 5 additions & 0 deletions .changeset/editable-text-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": minor
---

Code and plain-text files (`.ts`, `.json`, `.css`, `.py`, `.yaml`, `.txt`, and friends) now open as real editors instead of read-only previews — line numbers, syntax highlighting, and live collaborative editing, with every keystroke saved straight back to the file on disk. HTML, SVG, and lockfiles open the same way, straight from a click in the file tree. Markdown keeps its rich editor and Mermaid files keep their diagram editor.
20 changes: 18 additions & 2 deletions packages/app/src/components/EditorActivityPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
* (invalidate + nav), or (c) Activity eviction from the MRU mount list.
*/

import { isManagedArtifactDocName, isMermaidDocFile } from '@inkeep/open-knowledge-core';
import {
isEditableTextDocFile,
isManagedArtifactDocName,
isMermaidDocFile,
} from '@inkeep/open-knowledge-core';
import { t } from '@lingui/core/macro';
import { Loader2, RefreshCw } from 'lucide-react';
import {
Expand Down Expand Up @@ -97,6 +101,9 @@ const ManagedArtifactProperties = lazy(async () => ({
const MermaidDocEditor = lazy(async () => ({
default: (await import('./MermaidDocEditor')).MermaidDocEditor,
}));
const TextDocEditor = lazy(async () => ({
default: (await import('./TextDocEditor')).TextDocEditor,
}));

/**
* Large-doc threshold in Y.Text characters. Above this, the non-active editor
Expand Down Expand Up @@ -476,7 +483,8 @@ function EditorActivityPoolInner({
!loading &&
!pages.has(entry.docName) &&
!isManagedArtifactDocName(entry.docName) &&
!isMermaidDocFile(entry.docName)
!isMermaidDocFile(entry.docName) &&
!isEditableTextDocFile(entry.docName)
}
previousDocName={previousDocName}
onNavigateBack={onNavigateBack}
Expand Down Expand Up @@ -1038,6 +1046,9 @@ function ActivityEntry({
// Standalone Mermaid docs (`.mmd`/`.mermaid`) render a dedicated diagram+source
// editor instead of the markdown dual-editor (they are Y.Text-only, no bridge).
const isMermaid = isMermaidDocFile(entry.docName);
// Editable text docs (`.ts` / `.json` / `.txt` / …) — verbatim Y.Text docs
// rendered by a dedicated CodeMirror editor (no markdown dual-editor).
const isTextDoc = !isMermaid && isEditableTextDocFile(entry.docName);

// Per-Activity portal target for <EditorContent>. Stable DOM element
// exclusively owned by THIS ActivityEntry — `useState` with a lazy
Expand Down Expand Up @@ -1320,6 +1331,11 @@ function ActivityEntry({
provider={entry.provider}
isSourceMode={effectiveIsSourceMode}
/>
) : isTextDoc ? (
/* Editable text doc: single CodeMirror surface bound to this
doc's Y.Text('source') — same doc-class plumbing as the
Mermaid branch above, minus the diagram pane. */
<TextDocEditor docName={entry.docName} provider={entry.provider} />
) : (
/* Dual-editor mount with size-gated defer for large docs. Small
docs render both (pre-mount-both default — mode swap stays
Expand Down
65 changes: 44 additions & 21 deletions packages/app/src/components/FileTree.showall-lazy.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,11 @@ vi.doMock('@/editor/DocumentContext', () => ({
}),
}));
vi.doMock('@/components/PageListContext', () => ({
usePageList: () => ({ addPage: vi.fn(() => {}), pages: new Set<string>() }),
usePageList: () => ({
addPage: vi.fn(() => {}),
pages: new Set<string>(),
pageMeta: new Map<string, { size: number }>(),
}),
}));
vi.doMock('./ui/sidebar', () => ({
useSidebar: () => ({ notifySidebarFileSelected: vi.fn(() => {}) }),
Expand Down Expand Up @@ -443,7 +447,35 @@ describe('FileTree showAll lazy root seed', () => {
expect(model.getItem('README.md')?.isDirectory()).toBe(false);
});

test('first click on non-document file rows opens the asset tab', async () => {
test('first click on a bare-name file row opens the asset tab', async () => {
showAllResponseFactory = () =>
jsonResponse({
documents: [assetEntry('LICENSE'), assetEntry('package.json')],
truncated: false,
});
const view = render(<FileTree />);

await waitFor(() => expect(model.items.has('LICENSE')).toBe(true));
view.rerender(<FileTree />);
// Bare names have no extension-full docName, so they stay on the
// read-only asset viewer.
fireEvent.click(screen.getByRole('treeitem', { name: 'LICENSE' }));
await waitFor(() =>
expect(openTargetMock).toHaveBeenCalledWith(
{
kind: 'asset',
target: 'LICENSE',
assetPath: 'LICENSE',
mediaKind: null,
},
{ tabBehavior: 'replace-active' },
),
);
expect(window.location.hash).toBe('#/__asset__/LICENSE');
window.location.hash = '';
});

test('first click on an editable text file row opens the collaborative editor', async () => {
showAllResponseFactory = () =>
jsonResponse({
documents: [assetEntry('LICENSE'), assetEntry('package.json')],
Expand All @@ -453,25 +485,16 @@ describe('FileTree showAll lazy root seed', () => {

await waitFor(() => expect(model.items.has('package.json')).toBe(true));
view.rerender(<FileTree />);
for (const path of ['LICENSE', 'package.json']) {
const row = screen.getByRole('treeitem', { name: path });
fireEvent.click(row);

await waitFor(() =>
expect(openTargetMock).toHaveBeenCalledWith(
{
kind: 'asset',
target: path,
assetPath: path,
mediaKind: null,
},
{ tabBehavior: 'replace-active' },
),
);
expect(window.location.hash).toBe(`#/__asset__/${path}`);
openTargetMock.mockClear();
window.location.hash = '';
}
// Editable text files (extension-full docName) open the collaborative
// editor, not the asset viewer.
fireEvent.click(screen.getByRole('treeitem', { name: 'package.json' }));
await waitFor(() =>
expect(openTargetMock).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'doc', docName: 'package.json' }),
expect.anything(),
),
);
window.location.hash = '';
});

test('first click from one document row to README opens README', async () => {
Expand Down
122 changes: 122 additions & 0 deletions packages/app/src/components/TextDocEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Editor for an editable text doc (`.ts` / `.json` / `.css` / `.txt` / … —
* see `EDITABLE_TEXT_FILE_EXTENSIONS`). These are verbatim
* Y.Text('source')-only CRDT docs, the same doc class as standalone Mermaid
* docs (markdown bridge gated off server-side; bytes stored verbatim), so a
* plain-text file edits like a normal IDE buffer: CodeMirror bound to the
* shared `Y.Text` via `yCollab`, collaborative cursors included.
*
* Language highlighting resolves lazily from the file extension through the
* same `text-viewer-languages` loader the read-only asset `TextViewer` uses —
* grammars stay out of the main bundle and unknown extensions fall back to
* plain text.
*
* There is no wysiwyg mode for a code file, so the editor renders the same
* CodeMirror surface regardless of the global source-mode toggle (mirrors how
* a diagram doc treats "wysiwyg" as its rendered form; here source IS the
* only form).
*
* Mounted by `EditorActivityPool` inside the doc's `DocumentBoundary` (peer
* to the MermaidDocEditor branch), so `provider` is sync-gated and the
* precedent #18(b) hybrid render tree is preserved.
*/

import { type Language, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import type { HocuspocusProvider } from '@hocuspocus/provider';
import {
codeLanguageForExtension,
EDITABLE_TEXT_EXTRA_LANGUAGE,
extensionOf,
} from '@inkeep/open-knowledge-core';
import { useLingui } from '@lingui/react/macro';
import { basicDarkInit, basicLightInit } from '@uiw/codemirror-theme-basic';
import { basicSetup } from 'codemirror';
import { useTheme } from 'next-themes';
import { useEffect, useRef, useState } from 'react';
import { yCollab } from 'y-codemirror.next';
import * as Y from 'yjs';
import { propEditorHighlight } from '@/editor/components/CodeMirrorPropInput';
import { loadCodeMirrorLanguageForExtension } from './text-viewer-languages';

const darkTheme = basicDarkInit({
settings: { background: 'var(--background)', gutterBackground: 'var(--muted)' },
});
const lightTheme = basicLightInit({
settings: { background: 'var(--background)', gutterBackground: 'var(--muted)' },
});

export function TextDocEditor({
docName,
provider,
}: {
docName: string;
provider: HocuspocusProvider;
}) {
const { t } = useLingui();
const basename = docName.slice(docName.lastIndexOf('/') + 1);
const containerRef = useRef<HTMLDivElement>(null);
const { resolvedTheme } = useTheme();
const ytext = provider.document.getText('source');
// Stable across theme-driven view rebuilds so undo history survives them
// (yCollab would otherwise mint a fresh UndoManager per rebuild). Not
// destroyed in cleanup — same StrictMode rationale as MermaidDocEditor.
const [undoManager] = useState(() => new Y.UndoManager(ytext));

useEffect(() => {
const el = containerRef.current;
if (!el) return;
const theme = resolvedTheme === 'dark' ? darkTheme : lightTheme;
// Language pack loads async; a Compartment lets it slot in without
// rebuilding the view (which would drop cursor/scroll state).
const languageSlot = new Compartment();
const wrapSlot = new Compartment();
const hasGrammar =
(EDITABLE_TEXT_EXTRA_LANGUAGE[extensionOf(docName)] ??
codeLanguageForExtension(extensionOf(docName))) !== null && extensionOf(docName) !== '';
const view = new EditorView({
state: EditorState.create({
doc: ytext.toString(),
extensions: [
basicSetup,
yCollab(ytext, provider.awareness, { undoManager }),
languageSlot.of([]),
syntaxHighlighting(propEditorHighlight),
// Plain-text formats (txt / log / csv — no grammar) read better
// wrapped; code keeps horizontal scroll like an IDE.
wrapSlot.of(hasGrammar ? [] : EditorView.lineWrapping),
EditorView.theme({ '&': { height: '100%' } }),
theme,
],
}),
parent: el,
});
let disposed = false;
const extension = extensionOf(docName) || null;
if (extension) {
void loadCodeMirrorLanguageForExtension(
extension,
EDITABLE_TEXT_EXTRA_LANGUAGE[extension] ?? codeLanguageForExtension(extension),
).then((language: Language | null) => {
if (disposed || !language) return;
view.dispatch({ effects: languageSlot.reconfigure(language) });
});
}
return () => {
disposed = true;
view.destroy();
};
// Rebuild only on doc identity / theme change — yCollab keeps content synced.
}, [ytext, provider, resolvedTheme, docName, undoManager]);

return (
<main
className="flex h-full min-h-0 flex-col bg-background"
aria-label={t`${basename} — text editor`}
data-text-doc-editor=""
>
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto" />
</main>
);
}
7 changes: 7 additions & 0 deletions packages/app/src/components/file-tree-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,3 +587,10 @@ describe('file-tree-adapter', () => {
expect(target.path).toBe('docs/photo');
});
});

describe('docNameToTreePath — editable text docs', () => {
test('maps a text docName to the tree path verbatim (no .md appended)', () => {
expect(docNameToTreePath('src/util.ts')).toBe('src/util.ts');
expect(docNameToTreePath('config.json')).toBe('config.json');
});
});
9 changes: 8 additions & 1 deletion packages/app/src/components/file-tree-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isEditableTextDocFile,
isMermaidDocFile,
mediaKindForSidebarAssetExtension,
type UploadAssetSuccess,
Expand Down Expand Up @@ -31,7 +32,13 @@ export function docNameToTreePath(
// A Mermaid docName already carries its `.mmd`/`.mermaid` extension (it IS the
// filename), so it maps to the tree path verbatim — appending `.md` would
// point at a nonexistent file and break tree-highlight matching.
if (TREE_EXTENSION_PATTERN.test(docName) || isMermaidDocFile(docName)) return docName;
if (
TREE_EXTENSION_PATTERN.test(docName) ||
isMermaidDocFile(docName) ||
isEditableTextDocFile(docName)
) {
return docName;
}
return `${docName}${docExt}`;
}

Expand Down
32 changes: 32 additions & 0 deletions packages/app/src/components/file-tree-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,35 @@ describe('resolveFileTreeSelectionAction — Mermaid assets', () => {
).toEqual({ kind: 'document', path: 'assets/flow.mmd' });
});
});

describe('resolveFileTreeSelectionAction — editable text assets', () => {
test('routes a code-file asset row to document navigation (not the asset hash)', () => {
expect(
resolveFileTreeSelectionAction('src/util.ts', [
{
kind: 'asset',
path: 'src/util.ts',
assetExt: '.ts',
mediaKind: 'text',
size: 0,
modified: '',
},
]),
).toEqual({ kind: 'document', path: 'src/util.ts' });
});

test('an oversized text asset stays on the read-only asset viewer', () => {
expect(
resolveFileTreeSelectionAction('big/data.json', [
{
kind: 'asset',
path: 'big/data.json',
assetExt: '.json',
mediaKind: 'text',
size: 5 * 1024 * 1024,
modified: '',
},
]),
).toMatchObject({ kind: 'asset', path: 'big/data.json' });
});
});
14 changes: 11 additions & 3 deletions packages/app/src/components/file-tree-selection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { InlineAssetMediaKind } from '@inkeep/open-knowledge-core';
import { isDocumentOverOpenByteLimit, isEditableTextDocFile } from '@inkeep/open-knowledge-core';
import { hashFromAssetPath } from '@/lib/doc-hash';
import {
fileEntryToTreePath,
Expand Down Expand Up @@ -98,10 +99,17 @@ export function resolveFileTreeSelectionAction(
return { kind: 'document', path: documentDocName };
}
if (entry && isAssetEntry(entry)) {
// Mermaid files (`.mmd`/`.mermaid`) are served as assets but open as editable
// CRDT docs, not the read-only asset viewer. Their docName IS the asset path
// Mermaid files (`.mmd`/`.mermaid`) and editable text files (`.ts` /
// `.json` / `.html` / …) are served as assets but open as editable CRDT
// docs, not the read-only asset viewer. Their docName IS the asset path
// (extension retained), so route the selection as a document.
if (entry.mediaKind === 'mermaid') {
if (
entry.mediaKind === 'mermaid' ||
(isEditableTextDocFile(entry.path) && !isDocumentOverOpenByteLimit(entry.size))
) {
// Oversized text files stay on the read-only asset viewer — the
// verbatim load path has no large-doc defer, so seeding a multi-MB
// file into Y.Text from a tree click would stall the doc open.
return { kind: 'document', path: entry.path };
}
return {
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/components/navigation-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,3 +722,15 @@ describe('resolveNavigationTarget — Mermaid docs', () => {
expect(resolveNavigationTarget('assets/flow.mmd/', { pages: new Set() }).kind).not.toBe('doc');
});
});

describe('editable text docs resolve as doc targets', () => {
test('a .ts target opens the editable doc, not the asset viewer', () => {
const target = resolveNavigationTarget('src/util.ts', {
pages: new Set<string>(),
pageMeta: new Map(),
folderPaths: new Set<string>(),
assetPaths: new Set<string>(['src/util.ts']),
} as never);
expect(target).toMatchObject({ kind: 'doc', docName: 'src/util.ts' });
});
});
Loading