Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9ecb00e
feat(files): inline rich markdown editor
waleedlatif1 Jun 19, 2026
89eadf0
fix(files): chain autosave unmount flush after in-flight save
waleedlatif1 Jun 19, 2026
ae9e331
fix(files): read pasted images from clipboard items, not just files
waleedlatif1 Jun 19, 2026
959a560
fix(files): destroy round-trip probe editor on serialization error
waleedlatif1 Jun 19, 2026
5df2666
fix(resource): hold breadcrumb nav latch across the route swap
waleedlatif1 Jun 19, 2026
4022c9e
chore(files): drop platform references and non-essential inline comments
waleedlatif1 Jun 19, 2026
24641a3
fix(files): scope inline markdown editor to the files view
waleedlatif1 Jun 19, 2026
0776152
fix(mothership): use the inline markdown editor in the chat resource …
waleedlatif1 Jun 19, 2026
312b5ee
refactor(files): collapse the duplicate raw-editor fallback branch in…
waleedlatif1 Jun 19, 2026
59eedeb
fix(mothership): swap to the inline editor once a file preview finish…
waleedlatif1 Jun 19, 2026
dc48ea8
Revert "fix(mothership): swap to the inline editor once a file previe…
waleedlatif1 Jun 19, 2026
ce582c0
Revert "fix(mothership): use the inline markdown editor in the chat r…
waleedlatif1 Jun 19, 2026
f0bd78b
feat(files): rich markdown editor across files + chat, read-only for …
waleedlatif1 Jun 19, 2026
511fc6b
chore(files): remove dead code (unused FileViewer logger + EmbeddedWo…
waleedlatif1 Jun 19, 2026
462cd81
fix(files): derive markdown round-trip verdict from live content, not…
waleedlatif1 Jun 19, 2026
c308124
test(files): guard the rich editor dirty signal — open is never dirty…
waleedlatif1 Jun 19, 2026
f844a6a
fix(files): lock the markdown round-trip verdict on opened content, n…
waleedlatif1 Jun 19, 2026
89a269e
improvement(file-viewer): reuse shared copy hook, lazy frontmatter split
waleedlatif1 Jun 19, 2026
b7d87c8
feat(file-viewer): linked images, typed-link input rule, drag-to-reor…
waleedlatif1 Jun 19, 2026
55860f6
improvement(file-viewer): Backspace at start of a heading reverts it …
waleedlatif1 Jun 19, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps {
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
}

/**
* Grace period before a hover-out dismisses the path popover. Covers the gap
* the pointer crosses between the trigger and the popover content (and brief
* jitter at their edges); re-entering either within this window cancels the
* close. Standard hover-intent close delay — not tied to any navigation timing.
*/
const POPOVER_CLOSE_DELAY_MS = 120

function BreadcrumbLocationPopover({
icon: Icon,
breadcrumbs,
Expand All @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootBreadcrumb = breadcrumbs[0]

const openPopover = () => {
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
}

/**
* Hover-intent open. Driven only by pointer-/keyboard-enter — never by
* pointer movement. This is what makes the popover dismiss cleanly on a
* click-to-navigate: a stationary click fires no enter event, so once
* {@link navigateAndClose} sets `open` false nothing re-opens it before the
* route swaps. (A move-driven open would re-fire under the resting cursor and
* flash the popover/veil back in mid-navigation.)
*/
const openPopover = () => {
cancelScheduledClose()
setOpen(true)
}

const scheduleClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
}
cancelScheduledClose()
closeTimeoutRef.current = setTimeout(() => {
setOpen(false)
closeTimeoutRef.current = null
}, 120)
}, POPOVER_CLOSE_DELAY_MS)
}

/**
* Closes the popover up front, then runs the crumb's handler. Closing first
* lets the veil fade and the popover play its exit animation instead of
* snapping away when navigation unmounts the header.
*/
const navigateAndClose = (onClick?: () => void) => {
if (!onClick) return
cancelScheduledClose()
setOpen(false)
onClick()
}

useEffect(() => {
Expand All @@ -413,15 +443,11 @@ function BreadcrumbLocationPopover({
<button
type='button'
aria-label={rootBreadcrumb?.label ?? 'Path'}
onClick={rootBreadcrumb?.onClick}
Comment thread
waleedlatif1 marked this conversation as resolved.
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
onFocus={openPopover}
onBlur={scheduleClose}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
onMouseMove={openPopover}
onPointerEnter={openPopover}
onPointerLeave={scheduleClose}
onPointerMove={openPopover}
className={cn(
chipVariants({ flush: true }),
'max-w-none gap-1.5 px-2 transition-colors',
Expand Down Expand Up @@ -457,10 +483,6 @@ function BreadcrumbLocationPopover({
)}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
onMouseMove={openPopover}
onPointerEnter={openPopover}
onPointerLeave={scheduleClose}
onPointerMove={openPopover}
>
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
<span className='inline-flex items-center gap-1'>
Expand All @@ -474,7 +496,7 @@ function BreadcrumbLocationPopover({
key={`${crumb.label}-${index}`}
icon={crumb.icon || (index === 0 ? Icon : undefined)}
label={crumb.label}
onClick={crumb.onClick}
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
active={index === breadcrumbs.length - 1}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client'

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
Expand Down Expand Up @@ -33,7 +32,10 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
ssr: false,
})

const logger = createLogger('FileViewer')
const RichMarkdownEditor = dynamic(
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
)

/**
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
Expand All @@ -50,6 +52,15 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
}

/**
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
* which don't apply to the single-surface editor.
*/
export function isMarkdownFile(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) === 'markdown'
}

/**
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
Expand Down Expand Up @@ -114,6 +125,14 @@ export function FileViewer({
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
}
// Markdown renders through the inline rich editor (non-editable) so the public share
// surface matches the in-app reading experience; canEdit={false} disables autosave,
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
Expand All @@ -122,6 +141,25 @@ export function FileViewer({
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}

if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
Comment on lines 141 to +153

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Round-trip-unsafe files are read-only in the rich editor, not falling back to Monaco

The PR description states "falls back to the raw Monaco editor for any file that can't be edited losslessly," but the code routes all markdown files through RichMarkdownEditor. When isRoundTripSafe returns false (files with footnotes, HTML entities, HTML comments, raw HTML blocks, etc.), LoadedRichMarkdownEditor sets isEditable = false — opening a read-only rich-editor view instead of the previously working Monaco editor.

This regresses editability for any user whose markdown contains constructs matched by STABLE_LOSS_PATTERNS. Before this PR they had a fully editable Monaco surface; now they get a read-only rich editor with no edit path. A check of the round-trip verdict (or the isMarkdownFile decision) should dispatch to TextEditor (Monaco) for the unsafe case.

saveRef={saveRef}
streamingContent={streamingContent}
streamingMode={streamingMode}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Comment thread
waleedlatif1 marked this conversation as resolved.

return (
<TextEditor
file={file}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
export {
FileViewer,
isCsvStreamOnly,
isMarkdownFile,
isPreviewable,
isTextEditable,
} from './file-viewer'
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useState } from 'react'
import type { JSONContent } from '@tiptap/core'
import { CodeBlock } from '@tiptap/extension-code-block'
import type { ReactNodeViewProps } from '@tiptap/react'
import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { Check, ChevronDown, Copy, WrapText } from 'lucide-react'
import {
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { detectLanguage } from './detect-language'

const PLAIN = 'plain'

/** Languages the Prism highlighter has registered (see {@link CodeBlockHighlight}). */
const LANGUAGE_OPTIONS = [
{ value: PLAIN, label: 'Plain text' },
{ value: 'bash', label: 'Bash' },
{ value: 'css', label: 'CSS' },
{ value: 'markup', label: 'HTML' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'json', label: 'JSON' },
{ value: 'python', label: 'Python' },
{ value: 'sql', label: 'SQL' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'yaml', label: 'YAML' },
] as const

const CONTROL_CLASS =
'flex size-[24px] items-center justify-center rounded-lg text-[var(--text-icon)] outline-none transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-body)] focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]'

function CodeBlockView({ node, updateAttributes }: ReactNodeViewProps) {
const [wrap, setWrap] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const explicitLanguage = node.attrs.language as string | null
const language = explicitLanguage ?? detectLanguage(node.textContent) ?? PLAIN
const label =
LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ??
explicitLanguage ??
'Plain text'

return (
<NodeViewWrapper className='group relative'>
<div
className={cn(
'absolute top-1.5 right-2 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100',
menuOpen && 'opacity-100'
)}
contentEditable={false}
>
<DropdownMenu onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Code language'
className={cn(
chipVariants({ variant: 'default', flush: true }),
'h-[24px] gap-1 px-1.5 text-[var(--text-muted)] data-[state=open]:bg-[var(--surface-active)] data-[state=open]:text-[var(--text-body)]'
)}
>
{label}
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{LANGUAGE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() =>
updateAttributes({ language: option.value === PLAIN ? null : option.value })
}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
type='button'
aria-label='Toggle line wrap'
aria-pressed={wrap}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setWrap((value) => !value)}
className={cn(
CONTROL_CLASS,
wrap && 'bg-[var(--surface-active)] text-[var(--text-body)]'
)}
>
<WrapText />
</button>
<button
type='button'
aria-label='Copy code'
onMouseDown={(event) => event.preventDefault()}
onClick={() => copy(node.textContent)}
className={CONTROL_CLASS}
>
{copied ? <Check /> : <Copy />}
</button>
</div>
<pre className='code-editor-theme pr-20' data-wrap={wrap}>
<NodeViewContent<'code'> as='code' />
</pre>
</NodeViewWrapper>
)
}

function codeBlockText(node: JSONContent): string {
return (node.content ?? []).map((child) => child.text ?? '').join('')
}

/** Fence sized to one backtick longer than the longest run inside the code (CommonMark rule). */
function fenceFor(text: string): string {
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
return '`'.repeat(Math.max(3, longestRun + 1))
}

/**
* Code block whose markdown serializer sizes the fence to the interior backtick runs, so a code
* block that itself contains a ``` line round-trips instead of shattering. Shared by the test
* (plain) and live ({@link CodeBlockWithLanguage}) paths.
*/
export const MarkdownCodeBlock = CodeBlock.extend({
renderMarkdown: (node: JSONContent) => {
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
const text = codeBlockText(node)
const fence = fenceFor(text)
return `${fence}${language}\n${text}\n${fence}`
},
})

/**
* Code block with hover-revealed controls (language picker, line-wrap toggle, copy). The
* `language` attribute drives {@link CodeBlockHighlight}'s Prism highlighting and serializes to
* the ```lang fence on save; wrap is a view-only preference.
*/
export const CodeBlockWithLanguage = MarkdownCodeBlock.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView)
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @vitest-environment jsdom
*/
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { buildDecorations } from './code-highlight'
import { createMarkdownContentExtensions } from './extensions'

let editor: Editor | null = null

function decorationClassesFor(markdown: string): string[] {
editor = new Editor({ extensions: createMarkdownContentExtensions() })
editor.commands.setContent(markdown, { contentType: 'markdown' })
const decorations = buildDecorations(editor.state.doc).find()
editor.destroy()
editor = null
return decorations.map(
(decoration) =>
(decoration as unknown as { type: { attrs: { class: string } } }).type.attrs.class
)
}

afterEach(() => {
editor?.destroy()
editor = null
})

describe('code block syntax highlighting', () => {
it('emits Prism token decorations for a known language', () => {
const classes = decorationClassesFor('```js\nconst x = 1\n```')
expect(classes.length).toBeGreaterThan(0)
expect(classes.every((c) => c.startsWith('token'))).toBe(true)
expect(classes.some((c) => c.includes('keyword'))).toBe(true)
})

it('does not decorate plain prose', () => {
expect(decorationClassesFor('just some text')).toHaveLength(0)
})

it('does not decorate an unregistered language', () => {
expect(decorationClassesFor('```unregistered-lang\n+++ foo\n```')).toHaveLength(0)
})
})
Loading
Loading