feat: rename note by double-clicking its tab — inline edit, updates filename + wiki-links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,10 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleRenameTab = useCallback((path: string, newTitle: string) => {
|
||||
notes.handleRenameNote(path, newTitle, vaultPath, vault.replaceEntry)
|
||||
}, [notes, vaultPath, vault])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: () => setShowQuickOpen(true),
|
||||
onCreateNote: handleCreateNoteImmediate,
|
||||
@@ -173,6 +177,7 @@ function App() {
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ interface EditorProps {
|
||||
onRestoreNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
// --- Custom Inline Content: WikiLink ---
|
||||
@@ -199,6 +200,7 @@ export const Editor = memo(function Editor({
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote,
|
||||
onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab,
|
||||
}: EditorProps) {
|
||||
const [diffMode, setDiffMode] = useState(false)
|
||||
const [diffContent, setDiffContent] = useState<string | null>(null)
|
||||
@@ -419,6 +421,7 @@ export const Editor = memo(function Editor({
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onRenameTab={onRenameTab}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState, useRef, useCallback } from 'react'
|
||||
import { memo, useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
@@ -16,10 +16,73 @@ interface TabBarProps {
|
||||
onCloseTab: (path: string) => void
|
||||
onCreateNote?: () => void
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
// --- Inline edit ---
|
||||
|
||||
/** Inline edit input shown when user double-clicks a tab title. */
|
||||
function InlineTabEdit({ initialValue, onSave, onCancel }: {
|
||||
initialValue: string
|
||||
onSave: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// Guard against double-fire: Enter calls handleSave, then React unmounts
|
||||
// the input (editingPath → null), which triggers blur → handleSave again.
|
||||
const committedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (committedRef.current) return
|
||||
committedRef.current = true
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && trimmed !== initialValue) {
|
||||
onSave(trimmed)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}, [value, initialValue, onSave, onCancel])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
if (e.key === 'Escape') onCancel()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onBlur={handleSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 40,
|
||||
maxWidth: 150,
|
||||
background: 'var(--background)',
|
||||
border: '1px solid var(--ring)',
|
||||
borderRadius: 3,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--foreground)',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Drag-and-drop helpers ---
|
||||
|
||||
function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null {
|
||||
@@ -88,19 +151,23 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: {
|
||||
function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
onDoubleClick: () => void
|
||||
onRenameSave: (newTitle: string) => void
|
||||
onRenameCancel: () => void
|
||||
dragProps: React.HTMLAttributes<HTMLDivElement>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
|
||||
@@ -112,13 +179,19 @@ function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onS
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
padding: '0 12px', fontSize: 12,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
cursor: isEditing ? 'default' : isDragging ? 'grabbing' : 'grab',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
onClick={onSwitch}
|
||||
onClick={() => !isEditing && onSwitch()}
|
||||
>
|
||||
{showDropBefore && <DropIndicator side="left" />}
|
||||
<span className="truncate">{tab.entry.title}</span>
|
||||
{isEditing ? (
|
||||
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
|
||||
) : (
|
||||
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
@@ -160,9 +233,10 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
|
||||
// --- Main TabBar ---
|
||||
|
||||
export const TabBar = memo(function TabBar({
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -176,11 +250,15 @@ export const TabBar = memo(function TabBar({
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
@@ -14,6 +14,33 @@ interface NewEntryParams {
|
||||
status: string | null
|
||||
}
|
||||
|
||||
interface RenameResult {
|
||||
new_path: string
|
||||
updated_files: number
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
|
||||
}
|
||||
|
||||
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle }
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
return isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
}
|
||||
|
||||
function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return {
|
||||
@@ -116,7 +143,7 @@ export function useNoteActions(
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const tabMgmt = useTabManagement()
|
||||
const { setTabs, handleSelectNote } = tabMgmt
|
||||
const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
@@ -153,6 +180,30 @@ export function useNoteActions(
|
||||
}
|
||||
}, [updateTabContent, setToastMessage])
|
||||
|
||||
const handleRenameNote = useCallback(async (
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performRename(path, newTitle, vaultPath)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
||||
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
onEntryRenamed(path, newEntry, newContent)
|
||||
|
||||
const n = result.updated_files
|
||||
setToastMessage(n > 0 ? `Renamed — updated ${n} wiki link${n > 1 ? 's' : ''}` : 'Renamed')
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note:', err)
|
||||
setToastMessage('Failed to rename note')
|
||||
}
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage])
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
handleNavigateWikilink,
|
||||
@@ -161,5 +212,6 @@ export function useNoteActions(
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ export function useVaultLoader(vaultPath: string) {
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e))
|
||||
}, [])
|
||||
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
|
||||
setAllContent((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[oldPath]
|
||||
next[patch.path] = newContent
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
||||
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
|
||||
catch (err) { console.warn('Failed to load git history:', err); return [] }
|
||||
@@ -93,6 +103,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
modifiedFiles,
|
||||
addEntry,
|
||||
updateEntry,
|
||||
replaceEntry,
|
||||
updateContent,
|
||||
loadModifiedFiles,
|
||||
loadGitHistory,
|
||||
|
||||
@@ -1679,6 +1679,42 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const timestamp = Date.now()
|
||||
return `${vault}/attachments/${timestamp}-${args.filename}`
|
||||
},
|
||||
rename_note: (args: { vault_path: string; old_path: string; new_title: string }) => {
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
const newPath = `${parentDir}/${slug}.md`
|
||||
|
||||
// Update H1 heading in content
|
||||
const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`)
|
||||
|
||||
// Move content to new path
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = newContent
|
||||
|
||||
// Update wikilinks in other notes
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
let updatedFiles = 0
|
||||
if (oldTitle) {
|
||||
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
|
||||
if (path === newPath) continue
|
||||
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]`
|
||||
)
|
||||
if (replaced !== content) {
|
||||
MOCK_CONTENT[path] = replaced
|
||||
updatedFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
}
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
},
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
|
||||
Reference in New Issue
Block a user