From a59c42e22bb47175077d2fbef06456a693ee7f5b Mon Sep 17 00:00:00 2001 From: gagan Date: Sun, 24 May 2026 23:48:20 +0530 Subject: [PATCH] =?UTF-8?q?fix:=20notes=20=E2=80=94=20in-note=20section=20?= =?UTF-8?q?links,=20deep-note=20wiki=20resolution,=20file=20links=20(#571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- apps/x/apps/renderer/src/App.tsx | 30 +++- .../renderer/src/components/email-view.tsx | 2 +- .../src/components/markdown-editor.tsx | 151 +++++++++++++++++- .../src/components/rich-markdown-viewer.tsx | 1 + .../src/components/settings-dialog.tsx | 15 +- .../apps/renderer/src/extensions/wiki-link.ts | 58 ++++--- apps/x/apps/renderer/src/lib/wiki-links.ts | 38 ++++- 7 files changed, 261 insertions(+), 34 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 59a16b26..33eda548 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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) diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 69f9b91e..d64c3fc1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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…', diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 3666f446..6146d2e4 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -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 { 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(() => { diff --git a/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx b/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx index da6342fe..6c3c79b5 100644 --- a/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx +++ b/apps/x/apps/renderer/src/components/rich-markdown-viewer.tsx @@ -42,6 +42,7 @@ export function RichMarkdownViewer({ content }: { content: string }) { heading: { levels: [1, 2, 3], }, + link: false, }), Link.configure({ openOnClick: true, diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 3a93e1c5..c7750d6c 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -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() {

Help & Support

Get help from our community

+