fix: stabilize knowledge note loading and untitled title behavior

This commit is contained in:
tusharmagar 2026-03-05 12:19:30 +05:30
parent c76d08953d
commit e8a666499a
2 changed files with 109 additions and 4 deletions

View file

@ -492,6 +492,7 @@ function App() {
const fileLoadRequestIdRef = useRef(0) const fileLoadRequestIdRef = useRef(0)
const initialContentByPathRef = useRef<Map<string, string>>(new Map()) const initialContentByPathRef = useRef<Map<string, string>>(new Map())
const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map()) const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map())
const untitledRenameReadyPathsRef = useRef<Set<string>>(new Set())
// Global navigation history (back/forward) across views (chat/file/graph/task) // Global navigation history (back/forward) across views (chat/file/graph/task)
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] }) const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
@ -739,6 +740,7 @@ function App() {
const removeEditorCacheForPath = useCallback((path: string) => { const removeEditorCacheForPath = useCallback((path: string) => {
editorContentByPathRef.current.delete(path) editorContentByPathRef.current.delete(path)
untitledRenameReadyPathsRef.current.delete(path)
setEditorContentByPath((prev) => { setEditorContentByPath((prev) => {
if (!(path in prev)) return prev if (!(path in prev)) return prev
const next = { ...prev } const next = { ...prev }
@ -1019,7 +1021,8 @@ function App() {
if (isUntitledPlaceholderName(currentBase)) { if (isUntitledPlaceholderName(currentBase)) {
const headingTitle = getHeadingTitle(debouncedContent) const headingTitle = getHeadingTitle(debouncedContent)
const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null const desiredName = headingTitle ? sanitizeHeadingForFilename(headingTitle) : null
if (desiredName && desiredName !== currentBase) { const shouldAutoRename = untitledRenameReadyPathsRef.current.has(pathAtStart)
if (shouldAutoRename && desiredName && desiredName !== currentBase) {
const parentDir = pathAtStart.split('/').slice(0, -1).join('/') const parentDir = pathAtStart.split('/').slice(0, -1).join('/')
let targetPath = `${parentDir}/${desiredName}.md` let targetPath = `${parentDir}/${desiredName}.md`
if (targetPath !== pathAtStart) { if (targetPath !== pathAtStart) {
@ -1041,6 +1044,7 @@ function App() {
renamedFrom = pathAtStart renamedFrom = pathAtStart
renamedTo = targetPath renamedTo = targetPath
editorPathRef.current = targetPath editorPathRef.current = targetPath
untitledRenameReadyPathsRef.current.delete(pathAtStart)
setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab))) setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
initialContentByPathRef.current.delete(pathAtStart) initialContentByPathRef.current.delete(pathAtStart)
const cachedContent = editorContentByPathRef.current.get(pathAtStart) const cachedContent = editorContentByPathRef.current.get(pathAtStart)
@ -2157,6 +2161,7 @@ function App() {
if (closingTab && !isGraphTabPath(closingTab.path)) { if (closingTab && !isGraphTabPath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
untitledRenameReadyPathsRef.current.delete(closingTab.path)
if (editorPathRef.current === closingTab.path) { if (editorPathRef.current === closingTab.path) {
editorPathRef.current = null editorPathRef.current = null
} }
@ -2756,6 +2761,7 @@ function App() {
parts[parts.length - 1] = finalName parts[parts.length - 1] = finalName
const newPath = parts.join('/') const newPath = parts.join('/')
await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath }) await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath })
untitledRenameReadyPathsRef.current.delete(oldPath)
const rewriteForRename = (content: string) => const rewriteForRename = (content: string) =>
isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath) isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath)
setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab))) setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab)))
@ -2798,6 +2804,7 @@ function App() {
if (path.endsWith('.md')) { if (path.endsWith('.md')) {
removeEditorCacheForPath(path) removeEditorCacheForPath(path)
initialContentByPathRef.current.delete(path) initialContentByPathRef.current.delete(path)
untitledRenameReadyPathsRef.current.delete(path)
} }
// Close any file tab showing the deleted file // Close any file tab showing the deleted file
const tabForFile = fileTabs.find(t => t.path === path) const tabForFile = fileTabs.find(t => t.path === path)
@ -3376,6 +3383,10 @@ function App() {
<MarkdownEditor <MarkdownEditor
content={tabContent} content={tabContent}
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }} onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
onPrimaryHeadingCommit={() => {
untitledRenameReadyPathsRef.current.add(tab.path)
}}
preserveUntitledTitleHeading={isUntitledPlaceholderName(getBaseName(tab.path))}
placeholder="Start writing..." placeholder="Start writing..."
wikiLinks={wikiLinkConfig} wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}

View file

@ -1,6 +1,6 @@
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react' import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey } from '@tiptap/pm/state' import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view' import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
@ -192,6 +192,8 @@ type WikiLinkConfig = {
interface MarkdownEditorProps { interface MarkdownEditorProps {
content: string content: string
onChange: (markdown: string) => void onChange: (markdown: string) => void
onPrimaryHeadingCommit?: () => void
preserveUntitledTitleHeading?: boolean
placeholder?: string placeholder?: string
wikiLinks?: WikiLinkConfig wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null> onImageUpload?: (file: File) => Promise<string | null>
@ -278,6 +280,8 @@ const TabIndentExtension = Extension.create({
export function MarkdownEditor({ export function MarkdownEditor({
content, content,
onChange, onChange,
onPrimaryHeadingCommit,
preserveUntitledTitleHeading = false,
placeholder = 'Start writing...', placeholder = 'Start writing...',
wikiLinks, wikiLinks,
onImageUpload, onImageUpload,
@ -292,6 +296,7 @@ export function MarkdownEditor({
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null) const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
const selectionHighlightRef = useRef<SelectionHighlightRange>(null) const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
const [wikiCommandValue, setWikiCommandValue] = useState<string>('') const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' }) const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {}) const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
@ -304,6 +309,68 @@ export function MarkdownEditor({
[] []
) )
useEffect(() => {
onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit
}, [onPrimaryHeadingCommit])
const maybeCommitPrimaryHeading = useCallback((view: EditorView) => {
const onCommit = onPrimaryHeadingCommitRef.current
if (!onCommit) return
const { selection, doc } = view.state
if (!selection.empty) return
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return
if (!['heading', 'paragraph'].includes($from.parent.type.name)) return
const firstNode = doc.firstChild
if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return
onCommit()
}, [])
const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => {
if (!preserveUntitledTitleHeading) return false
if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false
const { selection } = view.state
if (!selection.empty) return false
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return false
if ($from.parent.type.name !== 'heading') return false
const headingLevel = ((
$from.parent.attrs as { level?: number } | null | undefined
)?.level) ?? 0
if (headingLevel !== 1) return false
if ($from.parentOffset !== 0) return false
if ($from.parent.textContent.length > 0) return false
event.preventDefault()
return true
}, [preserveUntitledTitleHeading])
const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => {
if (!preserveUntitledTitleHeading) return
const { state, dispatch } = view
const { selection } = state
if (!selection.empty) return
const { $from } = selection
if ($from.depth < 1 || $from.index(0) !== 0) return
if ($from.parent.type.name !== 'paragraph') return
if ($from.parentOffset !== 0) return
if ($from.parent.textContent.length > 0) return
const headingType = state.schema.nodes.heading
if (!headingType) return
const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 })
dispatch(tr)
}, [preserveUntitledTitleHeading])
const editor = useEditor({ const editor = useEditor({
editable, editable,
extensions: [ extensions: [
@ -359,11 +426,14 @@ export function MarkdownEditor({
markdown = postprocessMarkdown(markdown) markdown = postprocessMarkdown(markdown)
onChange(markdown) onChange(markdown)
}, },
onBlur: ({ editor }) => {
maybeCommitPrimaryHeading(editor.view)
},
editorProps: { editorProps: {
attributes: { attributes: {
class: 'prose prose-sm max-w-none focus:outline-none', class: 'prose prose-sm max-w-none focus:outline-none',
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (view, event) => {
const state = wikiKeyStateRef.current const state = wikiKeyStateRef.current
if (state.open) { if (state.open) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -396,6 +466,25 @@ export function MarkdownEditor({
} }
} }
if (preventTitleHeadingDemotion(view, event)) {
return true
}
const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey
if (isPrintableKey) {
promoteFirstParagraphToTitleHeading(view)
}
if (
event.key === 'Enter'
&& !event.shiftKey
&& !event.ctrlKey
&& !event.metaKey
&& !event.altKey
) {
maybeCommitPrimaryHeading(view)
}
return false return false
}, },
handleClickOn: (_view, _pos, node, _nodePos, event) => { handleClickOn: (_view, _pos, node, _nodePos, event) => {
@ -407,7 +496,12 @@ export function MarkdownEditor({
return false return false
}, },
}, },
}, [editorSessionKey]) }, [
editorSessionKey,
maybeCommitPrimaryHeading,
preventTitleHeadingDemotion,
promoteFirstParagraphToTitleHeading,
])
const orderedFiles = useMemo(() => { const orderedFiles = useMemo(() => {
if (!wikiLinks) return [] if (!wikiLinks) return []