feat: implement tab indentation support in markdown editor and enhance file handling in app

This commit is contained in:
tusharmagar 2026-02-10 15:00:57 +05:30
parent 4e05a08bd0
commit 27c1142bb5
2 changed files with 134 additions and 63 deletions

View file

@ -537,6 +537,7 @@ function App() {
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileContent, setFileContent] = useState<string>('')
const [editorContent, setEditorContent] = useState<string>('')
const editorContentRef = useRef<string>('')
const [tree, setTree] = useState<TreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
@ -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) {

View file

@ -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