diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 981a9b94..712d5e0a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -537,6 +537,7 @@ function App() { const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState('') const [editorContent, setEditorContent] = useState('') + const editorContentRef = useRef('') const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) @@ -635,6 +636,7 @@ function App() { if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) { editorPathRef.current = nextSelectedPath } + editorContentRef.current = markdown setEditorContent(markdown) }, []) @@ -685,6 +687,7 @@ function App() { if (selectedPathRef.current !== pathToReload) return setFileContent(result.data) setEditorContent(result.data) + editorContentRef.current = result.data editorPathRef.current = pathToReload initialContentByPathRef.current.set(pathToReload, result.data) initialContentRef.current = result.data @@ -700,6 +703,7 @@ function App() { if (!selectedPath) { setFileContent('') setEditorContent('') + editorContentRef.current = '' initialContentRef.current = '' setLastSaved(null) return @@ -715,14 +719,26 @@ function App() { const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad }) if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return setFileContent(result.data) - setEditorContent(result.data) - editorPathRef.current = pathToLoad - initialContentByPathRef.current.set(pathToLoad, result.data) - initialContentRef.current = result.data - setLastSaved(null) + const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim() + const isSameEditorFile = editorPathRef.current === pathToLoad + const wouldClobberActiveEdits = + isSameEditorFile + && normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data) + if (!wouldClobberActiveEdits) { + setEditorContent(result.data) + editorContentRef.current = result.data + editorPathRef.current = pathToLoad + initialContentByPathRef.current.set(pathToLoad, result.data) + initialContentRef.current = result.data + setLastSaved(null) + } else { + // Still update the editor's path so subsequent autosaves write to the correct file. + editorPathRef.current = pathToLoad + } } else { setFileContent('') setEditorContent('') + editorContentRef.current = '' initialContentRef.current = '' } } catch (err) { @@ -730,6 +746,7 @@ function App() { if (!cancelled && fileLoadRequestIdRef.current === requestId && selectedPathRef.current === pathToLoad) { setFileContent('') setEditorContent('') + editorContentRef.current = '' initialContentRef.current = '' } } @@ -758,15 +775,17 @@ function App() { if (debouncedContent === baseline) return if (!debouncedContent) return - const saveFile = async () => { - const wasActiveAtStart = selectedPathRef.current === pathAtStart - if (wasActiveAtStart) setIsSaving(true) - let pathToSave = pathAtStart - try { - // Only rename the currently active file (avoids renaming/jumping while user switches rapidly) - if ( - wasActiveAtStart && - selectedPathRef.current === pathAtStart && + const saveFile = async () => { + const wasActiveAtStart = selectedPathRef.current === pathAtStart + if (wasActiveAtStart) setIsSaving(true) + let pathToSave = pathAtStart + let renamedFrom: string | null = null + let renamedTo: string | null = null + try { + // Only rename the currently active file (avoids renaming/jumping while user switches rapidly) + if ( + wasActiveAtStart && + selectedPathRef.current === pathAtStart && !renameInProgressRef.current && pathAtStart.startsWith('knowledge/') ) { @@ -778,29 +797,45 @@ function App() { const targetPath = `${parentDir}/${desiredName}.md` if (targetPath !== pathAtStart) { const exists = await window.ipc.invoke('workspace:exists', { path: targetPath }) - if (!exists.exists) { - renameInProgressRef.current = true - await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) - pathToSave = targetPath - editorPathRef.current = targetPath - initialContentByPathRef.current.delete(pathAtStart) - if (selectedPathRef.current === pathAtStart) { - setSelectedPath(targetPath) - } - } - } - } - } - await window.ipc.invoke('workspace:writeFile', { - path: pathToSave, - data: debouncedContent, - opts: { encoding: 'utf8' } - }) - initialContentByPathRef.current.set(pathToSave, debouncedContent) + if (!exists.exists) { + renameInProgressRef.current = true + await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath }) + pathToSave = targetPath + renamedFrom = pathAtStart + renamedTo = targetPath + editorPathRef.current = targetPath + initialContentByPathRef.current.delete(pathAtStart) + } + } + } + } + await window.ipc.invoke('workspace:writeFile', { + path: pathToSave, + data: debouncedContent, + opts: { encoding: 'utf8' } + }) + initialContentByPathRef.current.set(pathToSave, debouncedContent) - // Only update "current file" UI state if we're still on this file - if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { - initialContentRef.current = debouncedContent + // If we renamed the active file, update state/history AFTER the write completes so the editor + // doesn't reload stale on-disk content mid-typing (which can drop the latest character). + if (renamedFrom && renamedTo) { + const fromPath = renamedFrom + const toPath = renamedTo + const replaceRenamedPath = (stack: ViewState[]) => + stack.map((v) => (v.type === 'file' && v.path === fromPath ? ({ type: 'file', path: toPath } satisfies ViewState) : v)) + setHistory({ + back: replaceRenamedPath(historyRef.current.back), + forward: replaceRenamedPath(historyRef.current.forward), + }) + + if (selectedPathRef.current === fromPath) { + setSelectedPath(toPath) + } + } + + // Only update "current file" UI state if we're still on this file + if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) { + initialContentRef.current = debouncedContent setLastSaved(new Date()) } } catch (err) { diff --git a/apps/x/apps/renderer/src/components/markdown-editor.tsx b/apps/x/apps/renderer/src/components/markdown-editor.tsx index ddeb46e0..f05fcdb5 100644 --- a/apps/x/apps/renderer/src/components/markdown-editor.tsx +++ b/apps/x/apps/renderer/src/components/markdown-editor.tsx @@ -225,6 +225,41 @@ const createSelectionHighlightExtension = (getRange: () => SelectionHighlightRan }) } +const TabIndentExtension = Extension.create({ + name: 'tabIndent', + addKeyboardShortcuts() { + const indentText = ' ' + return { + Tab: () => { + // Always handle Tab so focus never leaves the editor. + // First try list indentation; otherwise insert spaces. + if (this.editor.can().sinkListItem('taskItem')) { + void this.editor.commands.sinkListItem('taskItem') + return true + } + if (this.editor.can().sinkListItem('listItem')) { + void this.editor.commands.sinkListItem('listItem') + return true + } + void this.editor.commands.insertContent(indentText) + return true + }, + 'Shift-Tab': () => { + // Always handle Shift+Tab so focus never leaves the editor. + if (this.editor.can().liftListItem('taskItem')) { + void this.editor.commands.liftListItem('taskItem') + return true + } + if (this.editor.can().liftListItem('listItem')) { + void this.editor.commands.liftListItem('listItem') + return true + } + return true + }, + } + }, +}) + export function MarkdownEditor({ content, onChange, @@ -295,6 +330,7 @@ export function MarkdownEditor({ transformPastedText: true, }), selectionHighlightExtension, + TabIndentExtension, ], content: '', onUpdate: ({ editor }) => { @@ -310,35 +346,35 @@ export function MarkdownEditor({ }, handleKeyDown: (_view, event) => { const state = wikiKeyStateRef.current - if (!state.open) return false + if (state.open) { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setActiveWikiLink(null) + setAnchorPosition(null) + setWikiCommandValue('') + return true + } - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - setActiveWikiLink(null) - setAnchorPosition(null) - setWikiCommandValue('') - return true - } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (state.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const currentIndex = Math.max(0, state.options.indexOf(state.value)) + const delta = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (currentIndex + delta + state.options.length) % state.options.length + setWikiCommandValue(state.options[nextIndex]) + return true + } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - if (state.options.length === 0) return true - event.preventDefault() - event.stopPropagation() - const currentIndex = Math.max(0, state.options.indexOf(state.value)) - const delta = event.key === 'ArrowDown' ? 1 : -1 - const nextIndex = (currentIndex + delta + state.options.length) % state.options.length - setWikiCommandValue(state.options[nextIndex]) - return true - } - - if (event.key === 'Enter' || event.key === 'Tab') { - if (state.options.length === 0) return true - event.preventDefault() - event.stopPropagation() - const selected = state.options.includes(state.value) ? state.value : state.options[0] - handleSelectWikiLinkRef.current(selected) - return true + if (event.key === 'Enter' || event.key === 'Tab') { + if (state.options.length === 0) return true + event.preventDefault() + event.stopPropagation() + const selected = state.options.includes(state.value) ? state.value : state.options[0] + handleSelectWikiLinkRef.current(selected) + return true + } } return false