mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-13 17:22:37 +02:00
fix: stabilize knowledge note loading and untitled title behavior
This commit is contained in:
parent
c76d08953d
commit
e8a666499a
2 changed files with 109 additions and 4 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue