fix: notes — in-note section links, deep-note wiki resolution, file links (#571)

* fix discord users feedback

* fix: drop duplicate Link extension and scroll headings to viewport top

* fix: collapse ../ segments in note-relative file links

---------

Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
gagan 2026-05-24 23:48:20 +05:30 committed by GitHub
parent 566f4553b4
commit a59c42e22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 34 deletions

View file

@ -69,7 +69,7 @@ import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BillingErrorDialog } from "@/components/billing-error-dialog"
import { matchBillingError, type BillingErrorMatch } from "@/lib/billing-error"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
import { extractConferenceLink } from '@/lib/calendar-event'
import { OnboardingModal } from '@/components/onboarding'
@ -4776,8 +4776,30 @@ function App() {
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
}, [])
const resolveWikiFilePath = useCallback((wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath) return null
const targetPath = ensureMarkdownExtension(basePath)
const targetKey = targetPath.toLowerCase()
const exactMatch = knowledgeFiles.find((filePath) => normalizeWikiPath(filePath).toLowerCase() === targetKey)
if (exactMatch) return toKnowledgePath(exactMatch)
if (!basePath.includes('/')) {
const targetBaseName = targetPath.split('/').pop()?.toLowerCase()
const basenameMatches = knowledgeFiles.filter((filePath) => {
const normalizedFile = normalizeWikiPath(filePath)
return normalizedFile.split('/').pop()?.toLowerCase() === targetBaseName
})
if (basenameMatches.length === 1) return toKnowledgePath(basenameMatches[0])
}
return toKnowledgePath(basePath)
}, [knowledgeFiles])
const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath)
const resolvedPath = resolveWikiFilePath(wikiPath)
if (!resolvedPath) return null
try {
const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath })
@ -4794,9 +4816,11 @@ function App() {
console.error('Failed to ensure wiki link target:', err)
return null
}
}, [])
}, [resolveWikiFilePath])
const openWikiLink = useCallback(async (wikiPath: string) => {
const { path: basePath } = splitWikiFragment(normalizeWikiPath(wikiPath))
if (!basePath) return
const resolvedPath = await ensureWikiFile(wikiPath)
if (resolvedPath) {
navigateToFile(resolvedPath)

View file

@ -500,7 +500,7 @@ function ComposeBox({
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',

View file

@ -1,5 +1,5 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -83,7 +83,8 @@ function nodeToText(node: JsonNode): string {
return text
} else if (child.type === 'wikiLink') {
const path = (child.attrs?.path as string) || ''
return path ? `[[${path}]]` : ''
const label = (child.attrs?.label as string | null | undefined) || ''
return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
} else if (child.type === 'hardBreak') {
return '\n'
}
@ -189,7 +190,8 @@ function blockToMarkdown(node: JsonNode): string {
return '---'
case 'wikiLink': {
const path = (node.attrs?.path as string) || ''
return `[[${path}]]`
const label = (node.attrs?.label as string | null | undefined) || ''
return `[[${path}${label ? `|${label}` : ''}]]`
}
case 'image': {
const src = (node.attrs?.src as string) || ''
@ -297,7 +299,7 @@ import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css'
@ -523,6 +525,106 @@ const TabIndentExtension = Extension.create({
},
})
const slugifyHeading = (text: string) =>
text
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const decodeLinkTarget = (target: string) => {
try {
return decodeURIComponent(target)
} catch {
return target
}
}
const scrollToHeading = (view: EditorView, rawTarget: string) => {
const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
if (!target) return false
const targetSlug = slugifyHeading(target)
let foundPos: number | null = null
view.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return true
const headingText = node.textContent.trim()
if (
headingText.toLowerCase() === target.toLowerCase()
|| slugifyHeading(headingText) === targetSlug
) {
foundPos = pos
return false
}
return true
})
if (foundPos === null) return false
const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
view.dispatch(
view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
)
view.focus()
const domAtPos = view.domAtPos(foundPos + 1)
const node = domAtPos.node
const headingEl = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement
headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
return true
}
const stripMarkdownExtension = (path: string) =>
path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path
const isSameNotePath = (linkPath: string, notePath?: string) => {
if (!notePath) return false
const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
return normalizedLink === normalizedNote
}
const isExternalHref = (href: string) =>
/^(https?:|mailto:|tel:)/i.test(href)
const collapseRelativeSegments = (relPath: string) => {
const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
const stack: string[] = []
for (const part of parts) {
if (part === '..') {
if (stack.length === 0) return null
stack.pop()
} else {
stack.push(part)
}
}
return stack.join('/')
}
const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
const withoutHash = href.split('#')[0]
const withoutQuery = withoutHash.split('?')[0]
const decoded = decodeLinkTarget(withoutQuery)
if (!decoded) return null
if (/^file:\/\//i.test(decoded)) {
try {
return decodeURIComponent(new URL(decoded).pathname)
} catch {
return decoded
}
}
if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))
const noteDir = notePath.split('/').slice(0, -1).join('/')
return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
}
export interface MarkdownEditorHandle {
/** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
getCursorContext: () => { path: string; lineNumber: number } | null
@ -644,6 +746,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
heading: {
levels: [1, 2, 3],
},
link: false,
}),
Link.configure({
openOnClick: false,
@ -804,17 +907,57 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
handleClickOn: (_view, _pos, node, _nodePos, event) => {
if (node.type.name === 'wikiLink') {
event.preventDefault()
const wikiPath = String(node.attrs.path ?? '')
const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
return scrollToHeading(_view, heading)
}
wikiLinks?.onOpen?.(node.attrs.path)
return true
}
return false
},
handleDOMEvents: {
click: (view, event) => {
const target = event.target as Element | null
const link = target?.closest('a[href]') as HTMLAnchorElement | null
if (!link) return false
if (link.dataset.type === 'wiki-link') return false
const href = link.getAttribute('href') ?? ''
if (!href) return false
if (href.startsWith('#')) {
event.preventDefault()
return scrollToHeading(view, href)
}
if (isExternalHref(href)) {
event.preventDefault()
window.open(href, '_blank')
return true
}
const workspacePath = resolveWorkspaceLinkPath(href, notePath)
if (!workspacePath) return false
event.preventDefault()
void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
if (result.error) console.error('Failed to open linked file:', result.error)
}).catch((err) => {
console.error('Failed to open linked file:', err)
})
return true
},
},
},
}, [
editorSessionKey,
maybeCommitPrimaryHeading,
notePath,
preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading,
wikiLinks,
])
const orderedFiles = useMemo(() => {

View file

@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) {
heading: {
levels: [1, 2, 3],
},
link: false,
}),
Link.configure({
openOnClick: true,

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import {
Dialog,
@ -110,6 +110,19 @@ function HelpSettings() {
<h4 className="text-sm font-medium">Help &amp; Support</h4>
<p className="text-xs text-muted-foreground mt-0.5">Get help from our community</p>
</div>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"
onClick={() => window.open("https://github.com/rowboatlabs/rowboat/issues/new", "_blank")}
>
<div className="flex size-8 items-center justify-center rounded-md bg-destructive/10">
<Bug className="size-4 text-destructive" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium">Report a bug</span>
<span className="text-xs text-muted-foreground">Send feedback to the Rowboat team</span>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-3"

View file

@ -1,5 +1,5 @@
import { InputRule, Node, mergeAttributes } from '@tiptap/core'
import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiAlias, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/
const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g
@ -25,9 +25,12 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
for (const match of matches) {
const matchIndex = match.index ?? 0
const matchText = match[0] ?? ''
const rawPath = match[1]?.trim() ?? ''
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
const isValidPath = normalizedPath && !normalizedPath.endsWith('/') && !normalizedPath.includes('..')
const rawLink = match[1]?.trim() ?? ''
const { label } = splitWikiAlias(rawLink)
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
const isValidPath = isHeadingOnlyLink || (normalizedPath && !basePath.endsWith('/') && !basePath.includes('..'))
if (matchIndex > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex)))
@ -35,7 +38,8 @@ const replaceWikiLinksInTextNode = (textNode: Text) => {
if (isValidPath) {
const el = document.createElement('wiki-link')
el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath))
el.setAttribute('data-path', isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath))
if (label) el.setAttribute('data-label', label)
fragment.appendChild(el)
} else {
fragment.appendChild(document.createTextNode(matchText))
@ -80,6 +84,9 @@ export const WikiLink = Node.create<WikiLinkOptions>({
path: {
default: '',
},
label: {
default: null,
},
}
},
@ -89,28 +96,34 @@ export const WikiLink = Node.create<WikiLinkOptions>({
tag: 'wiki-link[data-path]',
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
{
tag: 'a[data-type="wiki-link"]',
getAttrs: (element: Element) => ({
path: (element as HTMLElement).getAttribute('data-path') ?? '',
label: (element as HTMLElement).getAttribute('data-label'),
}),
},
]
},
renderHTML({ node, HTMLAttributes }) {
const label = wikiLabel(node.attrs.path) || node.attrs.path
const label = node.attrs.label || wikiLabel(node.attrs.path) || node.attrs.path
return [
'a',
mergeAttributes(HTMLAttributes, {
'data-type': 'wiki-link',
'data-path': node.attrs.path,
'href': '#',
'class': 'wiki-link',
'aria-label': node.attrs.path,
}),
mergeAttributes(
HTMLAttributes,
{
'data-type': 'wiki-link',
'data-path': node.attrs.path,
'href': '#',
'class': 'wiki-link',
'aria-label': node.attrs.path,
},
node.attrs.label ? { 'data-label': node.attrs.label } : {}
),
label,
]
},
@ -120,7 +133,8 @@ export const WikiLink = Node.create<WikiLinkOptions>({
markdown: {
serialize(state: { write: (text: string) => void }, node: { attrs: { path?: string } }) {
const path = node.attrs.path ?? ''
state.write(`[[${path}]]`)
const label = (node.attrs as { label?: string }).label
state.write(`[[${path}${label ? `|${label}` : ''}]]`)
},
parse: {
updateDOM(element: HTMLElement) {
@ -137,14 +151,20 @@ export const WikiLink = Node.create<WikiLinkOptions>({
new InputRule({
find: wikiLinkInputRegex,
handler: ({ state, range, match }) => {
const rawPath = match[1]?.trim()
const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : ''
if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.includes('..')) return null
const rawLink = match[1]?.trim()
const { label } = splitWikiAlias(rawLink ?? '')
const normalizedPath = rawLink ? normalizeWikiPath(rawLink) : ''
const { path: basePath, heading } = splitWikiFragment(normalizedPath)
const isHeadingOnlyLink = !basePath && Boolean(heading)
if (
!normalizedPath
|| (!isHeadingOnlyLink && (basePath.endsWith('/') || basePath.includes('..')))
) return null
if (state.selection.$from.parent.type.spec.code) return null
if (state.selection.$from.marks().some((mark) => mark.type.spec.code)) return null
const finalPath = ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath }))
const finalPath = isHeadingOnlyLink ? normalizedPath : ensureMarkdownExtension(normalizedPath)
state.tr.replaceWith(range.from, range.to, this.type.create({ path: finalPath, label }))
onCreate?.(finalPath)
},
}),

View file

@ -3,24 +3,50 @@ const KNOWLEDGE_PREFIX = 'knowledge/'
export const stripKnowledgePrefix = (path: string) =>
path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path
export const splitWikiAlias = (input: string) => {
const separatorIndex = input.indexOf('|')
if (separatorIndex === -1) return { target: input, label: undefined }
const target = input.slice(0, separatorIndex)
const label = input.slice(separatorIndex + 1).trim()
return { target, label: label || undefined }
}
export const splitWikiFragment = (path: string) => {
const hashIndex = path.indexOf('#')
if (hashIndex === -1) return { path: path, heading: undefined }
const basePath = path.slice(0, hashIndex)
const heading = path.slice(hashIndex + 1).trim()
return { path: basePath, heading: heading || undefined }
}
export const normalizeWikiPath = (input: string) => {
const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '')
const { target } = splitWikiAlias(input)
const trimmed = target.trim().replace(/^\/+/, '').replace(/^\.\//, '')
return stripKnowledgePrefix(trimmed)
}
export const ensureMarkdownExtension = (path: string) => {
if (path.toLowerCase().endsWith('.md')) return path
return `${path}.md`
const { path: basePath, heading } = splitWikiFragment(path)
if (!basePath) return heading ? `#${heading}` : path
const filePath = basePath.toLowerCase().endsWith('.md') ? basePath : `${basePath}.md`
return heading ? `${filePath}#${heading}` : filePath
}
export const toKnowledgePath = (wikiPath: string) => {
const normalized = normalizeWikiPath(wikiPath)
if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}`
const { path: basePath } = splitWikiFragment(normalized)
if (!basePath || basePath.includes('..') || basePath.endsWith('/')) return null
return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(basePath)}`
}
export const wikiLabel = (wikiPath: string) => {
const { label } = splitWikiAlias(wikiPath)
if (label) return label
const normalized = normalizeWikiPath(wikiPath)
const name = normalized.split('/').pop() || normalized
const { path: basePath, heading } = splitWikiFragment(normalized)
if (!basePath && heading) return heading
const name = (basePath || normalized).split('/').pop() || normalized
return name.replace(/\.md$/i, '')
}