diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 869cf880..a8e3db70 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -53,6 +53,7 @@ import { } from "@/components/ui/sidebar" import { TooltipProvider } from "@/components/ui/tooltip" import { Separator } from "@/components/ui/separator" +import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' type DirEntry = z.infer type RunEventType = z.infer @@ -200,6 +201,12 @@ function buildTree(entries: DirEntry[]): TreeNode[] { return sortNodes(roots) } +const collectDirPaths = (nodes: TreeNode[]): string[] => + nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : []) + +const collectFilePaths = (nodes: TreeNode[]): string[] => + nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : [])) + // Sample chat history (will be replaced with real data later) const chatHistory = [ { @@ -229,6 +236,7 @@ function App() { const [editorContent, setEditorContent] = useState('') const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) + const [recentWikiFiles, setRecentWikiFiles] = useState([]) // Auto-save state const [isSaving, setIsSaving] = useState(false) @@ -305,6 +313,16 @@ function App() { })() }, [selectedPath]) + // Track recently opened markdown files for wiki links + useEffect(() => { + if (!selectedPath || !selectedPath.endsWith('.md')) return + const wikiPath = stripKnowledgePrefix(selectedPath) + setRecentWikiFiles((prev) => { + const next = [wikiPath, ...prev.filter((path) => path !== wikiPath)] + return next.slice(0, 50) + }) + }, [selectedPath]) + // Auto-save when content changes useEffect(() => { if (!selectedPath || !selectedPath.endsWith('.md')) return @@ -546,8 +564,10 @@ function App() { }, []) // Knowledge quick actions - const collectDirPaths = (nodes: TreeNode[]): string[] => - nodes.flatMap(n => n.kind === 'dir' ? [n.path, ...(n.children ? collectDirPaths(n.children) : [])] : []) + const knowledgeFiles = React.useMemo(() => { + const files = collectFilePaths(tree).filter((path) => path.endsWith('.md')) + return Array.from(new Set(files.map(stripKnowledgePrefix))) + }, [tree]) // Get workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') @@ -615,6 +635,42 @@ function App() { }, }), [tree, selectedPath, workspaceRoot, collectDirPaths]) + const ensureWikiFile = useCallback(async (wikiPath: string) => { + const resolvedPath = toKnowledgePath(wikiPath) + if (!resolvedPath) return null + try { + const exists = await window.ipc.invoke('workspace:exists', { path: resolvedPath }) + if (!exists.exists) { + const title = wikiLabel(wikiPath) || 'New Note' + await window.ipc.invoke('workspace:writeFile', { + path: resolvedPath, + data: `# ${title}\n\n`, + opts: { encoding: 'utf8', mkdirp: true }, + }) + } + return resolvedPath + } catch (err) { + console.error('Failed to ensure wiki link target:', err) + return null + } + }, []) + + const openWikiLink = useCallback(async (wikiPath: string) => { + const resolvedPath = await ensureWikiFile(wikiPath) + if (resolvedPath) { + setSelectedPath(resolvedPath) + } + }, [ensureWikiFile, setSelectedPath]) + + const wikiLinkConfig = React.useMemo(() => ({ + files: knowledgeFiles, + recent: recentWikiFiles, + onOpen: (path: string) => { + void openWikiLink(path) + }, + onCreate: (path: string) => ensureWikiFile(path), + }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) + const renderConversationItem = (item: ConversationItem) => { if (isChatMessage(item)) { return ( @@ -771,6 +827,7 @@ function App() { content={editorContent} onChange={setEditorContent} placeholder="Start writing..." + wikiLinks={wikiLinkConfig} /> ) : ( diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index 7dbf730f..8ddb5492 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -3,18 +3,43 @@ import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Placeholder from '@tiptap/extension-placeholder' import { Markdown } from 'tiptap-markdown' -import { useEffect, useCallback, useRef } from 'react' +import { useEffect, useCallback, useMemo, useRef, useState } from 'react' import { EditorToolbar } from './editor-toolbar' +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 '@/styles/editor.css' +type WikiLinkConfig = { + files: string[] + recent: string[] + onOpen: (path: string) => void + onCreate: (path: string) => void | Promise +} + interface MarkdownEditorProps { content: string onChange: (markdown: string) => void placeholder?: string + wikiLinks?: WikiLinkConfig } -export function MarkdownEditor({ content, onChange, placeholder = 'Start writing...' }: MarkdownEditorProps) { +type WikiLinkMatch = { + range: { from: number; to: number } + query: string +} + +export function MarkdownEditor({ + content, + onChange, + placeholder = 'Start writing...', + wikiLinks, +}: MarkdownEditorProps) { const isInternalUpdate = useRef(false) + const wrapperRef = useRef(null) + const [activeWikiLink, setActiveWikiLink] = useState(null) + const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null) const editor = useEditor({ extensions: [ @@ -30,6 +55,13 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing target: '_blank', }, }), + WikiLink.configure({ + onCreate: wikiLinks?.onCreate + ? (path) => { + void wikiLinks.onCreate(path) + } + : undefined, + }), Placeholder.configure({ placeholder, }), @@ -50,9 +82,94 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing attributes: { class: 'prose prose-sm max-w-none focus:outline-none', }, + handleClickOn: (_view, _pos, node, _nodePos, event) => { + if (node.type.name === 'wikiLink') { + event.preventDefault() + wikiLinks?.onOpen?.(node.attrs.path) + return true + } + return false + }, }, }) + const orderedFiles = useMemo(() => { + if (!wikiLinks) return [] + const seen = new Set() + const ordered: string[] = [] + + const addPath = (path: string) => { + const normalized = normalizeWikiPath(path) + if (!normalized || seen.has(normalized)) return + seen.add(normalized) + ordered.push(normalized) + } + + wikiLinks.recent.forEach(addPath) + wikiLinks.files.forEach(addPath) + + return ordered + }, [wikiLinks]) + + const updateWikiLinkState = useCallback(() => { + if (!editor || !wikiLinks) return + const { selection } = editor.state + if (!selection.empty) { + setActiveWikiLink(null) + setAnchorPosition(null) + return + } + + const { $from } = selection + if ($from.parent.type.spec.code) { + setActiveWikiLink(null) + setAnchorPosition(null) + return + } + if ($from.marks().some((mark) => mark.type.spec.code)) { + setActiveWikiLink(null) + setAnchorPosition(null) + return + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n') + const textBefore = text.slice(0, $from.parentOffset) + const triggerIndex = textBefore.lastIndexOf('[[') + if (triggerIndex === -1 || textBefore.indexOf(']]', triggerIndex) !== -1) { + setActiveWikiLink(null) + setAnchorPosition(null) + return + } + + const matchText = textBefore.slice(triggerIndex) + const query = matchText.slice(2) + const range = { from: selection.from - matchText.length, to: selection.from } + setActiveWikiLink({ range, query }) + + const wrapper = wrapperRef.current + if (!wrapper) { + setAnchorPosition(null) + return + } + + const coords = editor.view.coordsAtPos(selection.from) + const wrapperRect = wrapper.getBoundingClientRect() + setAnchorPosition({ + left: coords.left - wrapperRect.left, + top: coords.bottom - wrapperRect.top, + }) + }, [editor, wikiLinks]) + + useEffect(() => { + if (!editor || !wikiLinks) return + editor.on('update', updateWikiLinkState) + editor.on('selectionUpdate', updateWikiLinkState) + return () => { + editor.off('update', updateWikiLinkState) + editor.off('selectionUpdate', updateWikiLinkState) + } + }, [editor, wikiLinks, updateWikiLinkState]) + // Update editor content when prop changes (e.g., file selection changes) useEffect(() => { if (editor && content !== undefined) { @@ -65,6 +182,47 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing } }, [editor, content]) + const normalizedQuery = normalizeWikiPath(activeWikiLink?.query ?? '').toLowerCase() + const filteredFiles = useMemo(() => { + if (!activeWikiLink) return [] + if (!normalizedQuery) return orderedFiles + return orderedFiles.filter((path) => path.toLowerCase().includes(normalizedQuery)) + }, [activeWikiLink, normalizedQuery, orderedFiles]) + + const visibleFiles = filteredFiles.slice(0, 12) + const rawCreateCandidate = activeWikiLink ? normalizeWikiPath(activeWikiLink.query) : '' + const createCandidate = rawCreateCandidate && !rawCreateCandidate.endsWith('/') + ? ensureMarkdownExtension(rawCreateCandidate) + : '' + const canCreate = Boolean( + createCandidate + && !orderedFiles.some((path) => path.toLowerCase() === createCandidate.toLowerCase()) + ) + + const handleSelectWikiLink = useCallback((path: string) => { + if (!editor || !activeWikiLink) return + const normalized = normalizeWikiPath(path) + if (!normalized) return + const finalPath = ensureMarkdownExtension(normalized) + void wikiLinks?.onCreate?.(finalPath) + + editor + .chain() + .focus() + .insertContentAt( + { from: activeWikiLink.range.from, to: activeWikiLink.range.to }, + { type: 'wikiLink', attrs: { path: finalPath } } + ) + .run() + + setActiveWikiLink(null) + setAnchorPosition(null) + }, [editor, activeWikiLink, wikiLinks]) + + const handleScroll = useCallback(() => { + updateWikiLinkState() + }, [updateWikiLinkState]) + // Handle keyboard shortcuts const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 's' && (event.metaKey || event.ctrlKey)) { @@ -73,11 +231,66 @@ export function MarkdownEditor({ content, onChange, placeholder = 'Start writing } }, []) + const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition) + return (
-
+
+ {wikiLinks ? ( + { + if (!open) { + setActiveWikiLink(null) + setAnchorPosition(null) + } + }} + > + + + + event.preventDefault()} + > + + + {canCreate ? ( + handleSelectWikiLink(createCandidate)} + > + Create "{wikiLabel(createCandidate) || createCandidate}" + + ) : null} + {visibleFiles.map((path) => ( + handleSelectWikiLink(path)} + > + {wikiLabel(path)} + + ))} + {visibleFiles.length === 0 && !canCreate ? ( + No matches found. + ) : null} + + + + + ) : null}
) diff --git a/apps/x/apps/renderer/src/extensions/wiki-link.ts b/apps/x/apps/renderer/src/extensions/wiki-link.ts new file mode 100644 index 00000000..5b7c5c4d --- /dev/null +++ b/apps/x/apps/renderer/src/extensions/wiki-link.ts @@ -0,0 +1,154 @@ +import { Node, mergeAttributes } from '@tiptap/react' +import { InputRule, inputRules } from '@tiptap/pm/inputrules' +import { ensureMarkdownExtension, normalizeWikiPath, wikiLabel } from '@/lib/wiki-links' + +const wikiLinkInputRegex = /\[\[([^[\]]+)\]\]$/ +const wikiLinkTokenRegex = /\[\[([^[\]]+)\]\]/g + +type WikiLinkOptions = { + onCreate?: (path: string) => void +} + +const isInsideCode = (textNode: Text) => + Boolean(textNode.parentElement?.closest('code, pre, a, wiki-link')) + +const replaceWikiLinksInTextNode = (textNode: Text) => { + const text = textNode.nodeValue + if (!text || !text.includes('[[')) return + if (isInsideCode(textNode)) return + + const matches = [...text.matchAll(wikiLinkTokenRegex)] + if (!matches.length) return + + const fragment = document.createDocumentFragment() + let lastIndex = 0 + + 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('..') + + if (matchIndex > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, matchIndex))) + } + + if (isValidPath) { + const el = document.createElement('wiki-link') + el.setAttribute('data-path', ensureMarkdownExtension(normalizedPath)) + fragment.appendChild(el) + } else { + fragment.appendChild(document.createTextNode(matchText)) + } + + lastIndex = matchIndex + matchText.length + } + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))) + } + + textNode.parentNode?.replaceChild(fragment, textNode) +} + +const replaceWikiLinksInTextNodes = (root: HTMLElement) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const textNodes: Text[] = [] + + while (walker.nextNode()) { + textNodes.push(walker.currentNode as Text) + } + + textNodes.forEach(replaceWikiLinksInTextNode) +} + +export const WikiLink = Node.create({ + name: 'wikiLink', + group: 'inline', + inline: true, + atom: true, + selectable: false, + + addOptions() { + return { + onCreate: undefined, + } + }, + + addAttributes() { + return { + path: { + default: '', + }, + } + }, + + parseHTML() { + return [ + { + tag: 'wiki-link[data-path]', + getAttrs: (element) => ({ + path: (element as HTMLElement).getAttribute('data-path') ?? '', + }), + }, + { + tag: 'a[data-type="wiki-link"]', + getAttrs: (element) => ({ + path: (element as HTMLElement).getAttribute('data-path') ?? '', + }), + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + const 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, + }), + label, + ] + }, + + addStorage() { + return { + markdown: { + serialize(state, node) { + const path = node.attrs.path ?? '' + state.write(`[[${path}]]`) + }, + parse: { + updateDOM(element) { + replaceWikiLinksInTextNodes(element) + }, + }, + }, + } + }, + + addProseMirrorPlugins() { + const onCreate = this.options.onCreate + const rules = [ + new InputRule(wikiLinkInputRegex, (state, match, start, end) => { + const rawPath = match[1]?.trim() + const normalizedPath = rawPath ? normalizeWikiPath(rawPath) : '' + if (!normalizedPath || normalizedPath.endsWith('/') || normalizedPath.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) + const tr = state.tr.replaceWith(start, end, this.type.create({ path: finalPath })) + onCreate?.(finalPath) + return tr + }), + ] + + return [inputRules({ rules })] + }, +}) diff --git a/apps/x/apps/renderer/src/lib/wiki-links.ts b/apps/x/apps/renderer/src/lib/wiki-links.ts new file mode 100644 index 00000000..4b429a05 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/wiki-links.ts @@ -0,0 +1,27 @@ +const KNOWLEDGE_PREFIX = 'knowledge/' + +export const stripKnowledgePrefix = (path: string) => + path.startsWith(KNOWLEDGE_PREFIX) ? path.slice(KNOWLEDGE_PREFIX.length) : path + +export const normalizeWikiPath = (input: string) => { + const trimmed = input.trim().replace(/^\/+/, '').replace(/^\.\//, '') + return stripKnowledgePrefix(trimmed) +} + +export const ensureMarkdownExtension = (path: string) => { + const lastSegment = path.split('/').pop() ?? path + if (lastSegment.includes('.')) return path + return `${path}.md` +} + +export const toKnowledgePath = (wikiPath: string) => { + const normalized = normalizeWikiPath(wikiPath) + if (!normalized || normalized.includes('..') || normalized.endsWith('/')) return null + return `${KNOWLEDGE_PREFIX}${ensureMarkdownExtension(normalized)}` +} + +export const wikiLabel = (wikiPath: string) => { + const normalized = normalizeWikiPath(wikiPath) + const name = normalized.split('/').pop() || normalized + return name.replace(/\.md$/i, '') +} diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index 4c3ae1f9..39df500a 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -13,6 +13,7 @@ flex: 1; min-height: 0; overflow-y: auto; + position: relative; } .tiptap-editor .ProseMirror { @@ -220,3 +221,10 @@ padding-left: 1rem; padding-right: 1rem; } + +.wiki-link-anchor { + position: absolute; + height: 0; + width: 0; + pointer-events: none; +}