refactor: improve code health for vault/mod.rs and Editor.tsx — fix CodeScene hotspot score

Extract scan_vault helpers (is_md_file, try_parse_md, scan_root_md_files, scan_protected_folders)
to eliminate deep nesting and reduce cyclomatic complexity. Break up large assertion blocks in tests.
Extract useEditorSetup and useRawModeWithFlush hooks from Editor component to reduce complexity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-17 12:07:33 +01:00
parent db0589c99d
commit bb9fef9f77
2 changed files with 151 additions and 89 deletions

View File

@@ -569,6 +569,47 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
/// All other subfolders are ignored — notes and type definitions live flat at the vault root.
const PROTECTED_FOLDERS: &[&str] = &["attachments", "_themes", "assets"];
fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
}
fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
match parse_md_file(path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
fn scan_root_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
let dir_entries = match fs::read_dir(vault_path) {
Ok(d) => d,
Err(_) => return,
};
for dir_entry in dir_entries.flatten() {
let path = dir_entry.path();
if is_md_file(&path) {
try_parse_md(&path, entries);
}
}
}
fn scan_protected_folders(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
for folder in PROTECTED_FOLDERS {
let folder_path = vault_path.join(folder);
if !folder_path.is_dir() {
continue;
}
let md_files = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| is_md_file(e.path()));
for entry in md_files {
try_parse_md(entry.path(), entries);
}
}
}
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
if !vault_path.exists() {
return Err(format!(
@@ -584,44 +625,10 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
}
let mut entries = Vec::new();
scan_root_md_files(vault_path, &mut entries);
scan_protected_folders(vault_path, &mut entries);
// 1. Scan root-level .md files (non-recursive)
if let Ok(dir_entries) = fs::read_dir(vault_path) {
for dir_entry in dir_entries.flatten() {
let path = dir_entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
match parse_md_file(&path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
}
}
// 2. Scan protected folders recursively
for folder in PROTECTED_FOLDERS {
let folder_path = vault_path.join(folder);
if !folder_path.is_dir() {
continue;
}
for entry in WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file() && entry_path.extension().is_some_and(|ext| ext == "md") {
match parse_md_file(entry_path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => log::warn!("Skipping file: {}", e),
}
}
}
}
// Sort by modified date descending (newest first)
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(entries)
}
@@ -719,6 +726,7 @@ mod tests {
);
assert_eq!(entry.title, "Just a Title");
assert!(entry.aliases.is_empty());
assert!(entry.relationships.is_empty());
assert!(entry.properties.is_empty());
}
@@ -895,21 +903,24 @@ Status: Active
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
let has = entry.relationships.get("Has").unwrap();
assert_eq!(
entry.relationships.get("Has").unwrap(),
has,
&vec![
"[[essay/foo|Foo Essay]]".to_string(),
"[[essay/bar|Bar Essay]]".to_string()
]
);
let topics = entry.relationships.get("Topics").unwrap();
assert_eq!(
entry.relationships.get("Topics").unwrap(),
topics,
&vec!["[[topic/rust]]".to_string(), "[[topic/wasm]]".to_string()]
);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[responsibility]]".to_string()]
);
let rel_type = entry.relationships.get("Type").unwrap();
assert_eq!(rel_type, &vec!["[[responsibility]]".to_string()]);
}
#[test]
@@ -926,15 +937,18 @@ Belongs to:
create_test_file(dir.path(), "some-project.md", content);
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
// Owner is now a structural field (skipped from relationships)
assert!(entry.relationships.get("Owner").is_none());
assert_eq!(
entry.owner,
Some("[[person/luca-rossi|Luca Rossi]]".to_string())
);
// Belongs to is a wikilink array, should appear in relationships
let belongs = entry.relationships.get("Belongs to").unwrap();
assert_eq!(
entry.relationships.get("Belongs to").unwrap(),
belongs,
&vec!["[[responsibility/grow-newsletter]]".to_string()]
);
// Also parsed in the dedicated field
@@ -1753,6 +1767,7 @@ Company: Acme Corp
Some("Responsibility".to_string()),
"type must not be lost when other fields are arrays"
);
assert_eq!(entry.owner, Some("Luca".to_string()));
assert_eq!(entry.cadence, Some("Weekly".to_string()));
assert_eq!(entry.status, Some("Active".to_string()));

View File

@@ -116,24 +116,59 @@ function EditorEmptyState() {
)
}
export const Editor = memo(function Editor({
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
diffToggleRef,
onFileCreated,
onFileModified,
onVaultChanged,
}: EditorProps) {
interface EditorSetupParams {
tabs: Tab[]
activeTabPath: string | null
vaultPath?: string
onContentChange?: (path: string, content: string) => void
onTitleSync?: (path: string, newTitle: string) => void
onRenameTab?: (path: string, newTitle: string) => void
onLoadDiff?: (path: string) => Promise<string>
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
getNoteStatus?: (path: string) => NoteStatus
rawToggleRef?: React.MutableRefObject<() => void>
diffToggleRef?: React.MutableRefObject<() => void>
}
function useRawModeWithFlush(
activeTabPath: string | null,
onContentChange?: (path: string, content: string) => void,
) {
const rawLatestContentRef = useRef<string | null>(null)
const handleBeforeRawEnd = useCallback(() => {
if (rawLatestContentRef.current != null && activeTabPath) {
onContentChange?.(activeTabPath, rawLatestContentRef.current)
}
rawLatestContentRef.current = null
}, [activeTabPath, onContentChange])
const { rawMode, handleToggleRaw } = useRawMode({
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
})
// Flush raw editor content when switching tabs while raw mode stays active.
const prevTabPathRef = useRef(activeTabPath)
const onContentChangeRef = useRef(onContentChange)
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => {
const prev = prevTabPathRef.current
prevTabPathRef.current = activeTabPath
const hasUnflushedContent = prev && prev !== activeTabPath && rawMode && rawLatestContentRef.current != null
if (hasUnflushedContent) {
onContentChangeRef.current?.(prev, rawLatestContentRef.current!)
rawLatestContentRef.current = null
}
}, [activeTabPath, rawMode])
return { rawMode, handleToggleRaw, rawLatestContentRef }
}
function useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange, onTitleSync,
onRenameTab, onLoadDiff, onLoadDiffAtCommit, getNoteStatus,
rawToggleRef, diffToggleRef,
}: EditorSetupParams) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -150,34 +185,9 @@ export const Editor = memo(function Editor({
onTitleSync: onTitleSync ?? (() => {}),
})
// Ref updated by RawEditorView on every keystroke — used to flush
// debounced content synchronously before leaving raw mode.
const rawLatestContentRef = useRef<string | null>(null)
const handleBeforeRawEnd = useCallback(() => {
if (rawLatestContentRef.current != null && activeTabPath) {
onContentChange?.(activeTabPath, rawLatestContentRef.current)
}
rawLatestContentRef.current = null
}, [activeTabPath, onContentChange])
const { rawMode, handleToggleRaw } = useRawMode({
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
})
// Flush raw editor content when switching tabs while raw mode stays active.
// Must capture the previous tab path since handleBeforeRawEnd uses activeTabPath.
const prevTabPathRef = useRef(activeTabPath)
const onContentChangeRef = useRef(onContentChange)
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => {
const prev = prevTabPathRef.current
prevTabPathRef.current = activeTabPath
if (prev && prev !== activeTabPath && rawMode && rawLatestContentRef.current != null) {
onContentChangeRef.current?.(prev, rawLatestContentRef.current)
rawLatestContentRef.current = null
}
}, [activeTabPath, rawMode])
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
activeTabPath, onContentChange,
)
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs, activeTabPath, editor, onContentChange,
@@ -203,6 +213,43 @@ export const Editor = memo(function Editor({
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
return {
editor, activeTab, rawLatestContentRef,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleRenameTabWithSync, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
}
}
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
getNoteStatus, onCreateNote,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme, onFileCreated, onFileModified, onVaultChanged,
} = props
const {
editor, activeTab, rawLatestContentRef,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleRenameTabWithSync, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange, onTitleSync,
onRenameTab: props.onRenameTab, onLoadDiff: props.onLoadDiff,
onLoadDiffAtCommit: props.onLoadDiffAtCommit, getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<TabBar