mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 09:56:23 +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 initialContentByPathRef = useRef<Map<string, string>>(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)
|
||||
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
|
||||
|
|
@ -739,6 +740,7 @@ function App() {
|
|||
|
||||
const removeEditorCacheForPath = useCallback((path: string) => {
|
||||
editorContentByPathRef.current.delete(path)
|
||||
untitledRenameReadyPathsRef.current.delete(path)
|
||||
setEditorContentByPath((prev) => {
|
||||
if (!(path in prev)) return prev
|
||||
const next = { ...prev }
|
||||
|
|
@ -1019,7 +1021,8 @@ function App() {
|
|||
if (isUntitledPlaceholderName(currentBase)) {
|
||||
const headingTitle = getHeadingTitle(debouncedContent)
|
||||
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('/')
|
||||
let targetPath = `${parentDir}/${desiredName}.md`
|
||||
if (targetPath !== pathAtStart) {
|
||||
|
|
@ -1041,6 +1044,7 @@ function App() {
|
|||
renamedFrom = pathAtStart
|
||||
renamedTo = targetPath
|
||||
editorPathRef.current = targetPath
|
||||
untitledRenameReadyPathsRef.current.delete(pathAtStart)
|
||||
setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
|
||||
initialContentByPathRef.current.delete(pathAtStart)
|
||||
const cachedContent = editorContentByPathRef.current.get(pathAtStart)
|
||||
|
|
@ -2157,6 +2161,7 @@ function App() {
|
|||
if (closingTab && !isGraphTabPath(closingTab.path)) {
|
||||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
if (editorPathRef.current === closingTab.path) {
|
||||
editorPathRef.current = null
|
||||
}
|
||||
|
|
@ -2756,6 +2761,7 @@ function App() {
|
|||
parts[parts.length - 1] = finalName
|
||||
const newPath = parts.join('/')
|
||||
await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath })
|
||||
untitledRenameReadyPathsRef.current.delete(oldPath)
|
||||
const rewriteForRename = (content: string) =>
|
||||
isDir ? content : rewriteWikiLinksForRenamedFileInMarkdown(content, oldPath, newPath)
|
||||
setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab)))
|
||||
|
|
@ -2798,6 +2804,7 @@ function App() {
|
|||
if (path.endsWith('.md')) {
|
||||
removeEditorCacheForPath(path)
|
||||
initialContentByPathRef.current.delete(path)
|
||||
untitledRenameReadyPathsRef.current.delete(path)
|
||||
}
|
||||
// Close any file tab showing the deleted file
|
||||
const tabForFile = fileTabs.find(t => t.path === path)
|
||||
|
|
@ -3376,6 +3383,10 @@ function App() {
|
|||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
onPrimaryHeadingCommit={() => {
|
||||
untitledRenameReadyPathsRef.current.add(tab.path)
|
||||
}}
|
||||
preserveUntitledTitleHeading={isUntitledPlaceholderName(getBaseName(tab.path))}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
|
||||
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 Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
|
|
@ -192,6 +192,8 @@ type WikiLinkConfig = {
|
|||
interface MarkdownEditorProps {
|
||||
content: string
|
||||
onChange: (markdown: string) => void
|
||||
onPrimaryHeadingCommit?: () => void
|
||||
preserveUntitledTitleHeading?: boolean
|
||||
placeholder?: string
|
||||
wikiLinks?: WikiLinkConfig
|
||||
onImageUpload?: (file: File) => Promise<string | null>
|
||||
|
|
@ -278,6 +280,8 @@ const TabIndentExtension = Extension.create({
|
|||
export function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
onPrimaryHeadingCommit,
|
||||
preserveUntitledTitleHeading = false,
|
||||
placeholder = 'Start writing...',
|
||||
wikiLinks,
|
||||
onImageUpload,
|
||||
|
|
@ -292,6 +296,7 @@ export function MarkdownEditor({
|
|||
const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
|
||||
const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
|
||||
const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
|
||||
const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
|
||||
const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
|
||||
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({
|
||||
editable,
|
||||
extensions: [
|
||||
|
|
@ -359,11 +426,14 @@ export function MarkdownEditor({
|
|||
markdown = postprocessMarkdown(markdown)
|
||||
onChange(markdown)
|
||||
},
|
||||
onBlur: ({ editor }) => {
|
||||
maybeCommitPrimaryHeading(editor.view)
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none focus:outline-none',
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
handleKeyDown: (view, event) => {
|
||||
const state = wikiKeyStateRef.current
|
||||
if (state.open) {
|
||||
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
|
||||
},
|
||||
handleClickOn: (_view, _pos, node, _nodePos, event) => {
|
||||
|
|
@ -407,7 +496,12 @@ export function MarkdownEditor({
|
|||
return false
|
||||
},
|
||||
},
|
||||
}, [editorSessionKey])
|
||||
}, [
|
||||
editorSessionKey,
|
||||
maybeCommitPrimaryHeading,
|
||||
preventTitleHeadingDemotion,
|
||||
promoteFirstParagraphToTitleHeading,
|
||||
])
|
||||
|
||||
const orderedFiles = useMemo(() => {
|
||||
if (!wikiLinks) return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue