From 9b547e3f088c20ed5167fffd40642678e7775137 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 22 Aug 2026 16:56:53 -0700 Subject: [PATCH] fix(files): navigate sim file mentions --- .../mention/mention-chip.test.tsx | 107 ++++++++++++++---- .../mention/mention-chip.tsx | 14 ++- .../rich-markdown-editor/mention/mention.ts | 4 +- .../mention/sim-link.test.ts | 10 +- .../rich-markdown-editor/mention/sim-link.ts | 15 +-- .../mention/use-editor-mentions.ts | 2 +- .../rich-markdown-editor.tsx | 6 +- 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx index eafe06203bd..6d0192ef9c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx @@ -12,9 +12,14 @@ import type { Editor } from '@tiptap/react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +const navigation = vi.hoisted(() => ({ + push: vi.fn(), + params: {} as Record, +})) + vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn() }), - useParams: () => ({}), + useRouter: () => ({ push: navigation.push }), + useParams: () => navigation.params, })) // Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array. @@ -25,38 +30,59 @@ function fakeNode(attrs: Record) { return { attrs } as unknown as Parameters[0]['node'] } -function fakeEditor(): Editor { - return { storage: { mentionMenu: { navigable: false } } } as unknown as Editor +function fakeEditor(navigable = false): Editor { + return { storage: { mentionMenu: { navigable } } } as unknown as Editor } let container: HTMLDivElement | null = null let root: Root | null = null +async function renderChip({ + kind = 'file', + id = 'f1', + label = 'notes.md', + navigable = false, + workspaceId, +}: { + kind?: string + id?: string + label?: string + navigable?: boolean + workspaceId?: string +} = {}): Promise { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + navigation.params = workspaceId ? { workspaceId } : {} + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render( + MentionChipView({ + node: fakeNode({ kind, id, label }), + editor: fakeEditor(navigable), + } as Parameters[0]) + ) + }) + + const chip = container.querySelector('.mention-chip') as HTMLElement + expect(chip).not.toBeNull() + return chip +} + afterEach(() => { if (root) act(() => root?.unmount()) container?.remove() container = null root = null + navigation.params = {} + navigation.push.mockReset() + vi.restoreAllMocks() }) describe('MentionChipView', () => { it('renders its wrapper with no explicit text-color utility class', async () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - await act(async () => { - root?.render( - MentionChipView({ - node: fakeNode({ kind: 'file', id: 'f1', label: 'notes.md' }), - editor: fakeEditor(), - } as Parameters[0]) - ) - }) - - const chip = container.querySelector('.mention-chip') as HTMLElement - expect(chip).not.toBeNull() + const chip = await renderChip() // Any `text-*` utility targeting the wrapper itself — bare, or Tailwind's self-targeting // `[&]:text-*` arbitrary variant (as opposed to a descendant variant like `[&>svg]:text-*`, @@ -79,4 +105,45 @@ describe('MentionChipView', () => { 'text-[var(--text-icon)]' ) }) + + it('routes an ordinary click to the canonical resource path', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const chip = await renderChip({ navigable: true, workspaceId: 'ws1' }) + + act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(navigation.push).toHaveBeenCalledOnce() + expect(navigation.push).toHaveBeenCalledWith('/workspace/ws1/files/f1') + expect(open).not.toHaveBeenCalled() + }) + + it.each([ + ['Cmd', { metaKey: true }], + ['Ctrl', { ctrlKey: true }], + ])('opens a %s-click in a new tab without routing the current tab', async (_name, modifier) => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const chip = await renderChip({ navigable: true, workspaceId: 'ws1' }) + + act(() => + chip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...modifier })) + ) + + expect(open).toHaveBeenCalledOnce() + expect(open).toHaveBeenCalledWith('/workspace/ws1/files/f1', '_blank', 'noopener,noreferrer') + expect(navigation.push).not.toHaveBeenCalled() + }) + + it.each([ + ['navigation is disabled', false, 'ws1', 'file'], + ['the workspace route is absent', true, undefined, 'file'], + ['the resource kind is unsupported', true, 'ws1', 'integration'], + ])('stays inert when %s', async (_case, navigable, workspaceId, kind) => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const chip = await renderChip({ navigable, workspaceId, kind }) + + act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(navigation.push).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx index 5b737844816..5a3f88c83f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx @@ -28,9 +28,10 @@ const CHIP_CLASS = /** * Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted - * into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays - * inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for - * integration brand icons), so it's kept out of the headless {@link MarkdownMention} module. + * into navigation (the file viewer), a click routes to the resource and Cmd/Ctrl-click opens it in a + * new tab; in a modal field it stays inert so a click can't navigate away from an unsaved edit. This + * view pulls the block registry (for integration brand icons), so it's kept out of the headless + * {@link MarkdownMention} module. */ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const router = useRouter() @@ -42,8 +43,13 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) { const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null const handleClick = (event: MouseEvent) => { - if (!path || !(event.metaKey || event.ctrlKey)) return + if (!path) return event.preventDefault() + event.stopPropagation() + if (event.metaKey || event.ctrlKey) { + window.open(path, '_blank', 'noopener,noreferrer') + return + } router.push(path) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts index 1ab92bd938a..9da2ff2ad64 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts @@ -13,8 +13,8 @@ export const MENTION_PLUGIN_KEY = new PluginKey('mention') * Per-editor storage for the `@` mention extension. The host component populates {@link store} with * the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the * first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no - * workspace scope) so `@` stays literal text. {@link navigable} lets a chip Cmd/Ctrl-click to its - * resource — on for the file viewer, off inside a modal field so it can't route away from an edit. + * workspace scope) so `@` stays literal text. {@link navigable} lets a chip route to its resource — on + * for the file viewer, off inside a modal field so it can't route away from an edit. */ export interface MentionStorage { store: MentionStore diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts index 5efe8b66bd6..0b3cd911054 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts @@ -6,7 +6,7 @@ describe('simLinkPath', () => { // Each destination must match a real route — skills/folders deep-link via query params (no [id] route). it('resolves every kind to its real in-app route', () => { - expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view') + expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1') expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1') expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1') expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1') @@ -14,6 +14,14 @@ describe('simLinkPath', () => { expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1') }) + it('encodes ids as a single route or query component', () => { + expect(simLinkPath(ws, 'file', 'f/1?tab=raw')).toBe('/workspace/ws1/files/f%2F1%3Ftab%3Draw') + expect(simLinkPath('ws/1', 'file', 'f1')).toBe('/workspace/ws%2F1/files/f1') + expect(simLinkPath(ws, 'folder', 'd/1&archived=true')).toBe( + '/workspace/ws1/files?folderId=d%2F1%26archived%3Dtrue' + ) + }) + it('returns null for kinds with no navigable resource (integration) and unknown kinds', () => { // An integration mention's id is a block type, not a routable resource. expect(simLinkPath(ws, 'integration', 'slack')).toBeNull() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts index 5af0b6065e8..85dc1a6d773 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts @@ -18,20 +18,21 @@ export function toSimHref(kind: string, id: string): string { * credentials), so the chip stays display-only. */ export function simLinkPath(workspaceId: string, kind: string, id: string): string | null { - const base = `/workspace/${workspaceId}` + const base = `/workspace/${encodeURIComponent(workspaceId)}` + const encodedId = encodeURIComponent(id) switch (kind) { case 'file': - return `${base}/files/${id}/view` + return `${base}/files/${encodedId}` case 'folder': - return `${base}/files?folderId=${id}` + return `${base}/files?folderId=${encodedId}` case 'table': - return `${base}/tables/${id}` + return `${base}/tables/${encodedId}` case 'knowledge': - return `${base}/knowledge/${id}` + return `${base}/knowledge/${encodedId}` case 'workflow': - return `${base}/w/${id}` + return `${base}/w/${encodedId}` case 'skill': - return `${base}/skills?skillId=${id}` + return `${base}/skills?skillId=${encodedId}` default: return null } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts index 72ebc583a18..4a52948452c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts @@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/react' import { useMarkdownMentions } from './use-markdown-mentions' interface UseEditorMentionsOptions { - /** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */ + /** Whether a chip can navigate to its resource. On for the file viewer, off in modal fields. */ navigable?: boolean /** Force the `@` insertion menu off even with a workspace; existing tags still render. */ disableTagging?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 0a53b21cce5..bf83234e4b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -99,9 +99,10 @@ const EDITOR_SURFACE_CLASS = */ interface ReadOnlyPlaceholderProps { content: JSONContent + workspaceId: string } -function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { +function ReadOnlyPlaceholder({ content, workspaceId }: ReadOnlyPlaceholderProps) { const editor = useEditor({ extensions: EXTENSIONS, editable: false, @@ -113,6 +114,7 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { content, editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } }, }) + useEditorMentions(editor, workspaceId, { navigable: true, disableTagging: true }) return } @@ -1221,7 +1223,7 @@ export function LoadedRichMarkdownEditor({ }} /> {showPlaceholder && placeholderContent && ( - + )}