import React, { useState, useEffect, useMemo, useContext, useRef } from 'react' import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter' import { AuthContext } from '@/context/AuthContext' import { useEditor } from '@tldraw/tldraw' import { useAutomergeHandle } from '@/context/AutomergeHandleContext' interface ObsidianVaultBrowserProps { onObsNoteSelect: (obs_note: ObsidianObsNote) => void onObsNotesSelect: (obs_notes: ObsidianObsNote[]) => void onClose: () => void className?: string autoOpenFolderPicker?: boolean showVaultBrowser?: boolean shapeMode?: boolean // When true, renders without modal overlay for use in shape } export const ObsidianVaultBrowser: React.FC = ({ onObsNoteSelect, onObsNotesSelect, onClose, className = '', autoOpenFolderPicker = false, showVaultBrowser = true, shapeMode = false }) => { // Safely get auth context - use useContext directly to avoid throwing error // This allows the component to work even when used outside AuthProvider (e.g., during SVG export) const authContext = useContext(AuthContext) const fallbackSession = { username: '', authed: false, loading: false, backupCreated: null, obsidianVaultPath: undefined, obsidianVaultName: undefined } const session = authContext?.session || fallbackSession const updateSession = authContext?.updateSession || (() => {}) const [importer] = useState(() => new ObsidianImporter()) const [vault, setVault] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') const [isLoading, setIsLoading] = useState(() => { // Check if we have a vault configured and start loading immediately return !!(session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') || !!(session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) }) const [error, setError] = useState(null) const [selectedNotes, setSelectedNotes] = useState>(new Set()) const [showVaultInput, setShowVaultInput] = useState(false) const [vaultPath, setVaultPath] = useState('') const [inputMethod, setInputMethod] = useState<'folder' | 'url' | 'quartz'>('folder') const [showFolderReselect, setShowFolderReselect] = useState(false) const [isLoadingVault, setIsLoadingVault] = useState(false) const [hasLoadedOnce, setHasLoadedOnce] = useState(false) const [folderTree, setFolderTree] = useState(null) const [expandedFolders, setExpandedFolders] = useState>(new Set()) const [selectedFolder, setSelectedFolder] = useState(null) const [viewMode, setViewMode] = useState<'grid' | 'list' | 'tree'>('tree') // Track previous vault path/name to prevent unnecessary reloads const previousVaultPathRef = useRef(session.obsidianVaultPath) const previousVaultNameRef = useRef(session.obsidianVaultName) const editor = useEditor() const automergeHandle = useAutomergeHandle() // Initialize debounced search query to match search query useEffect(() => { setDebouncedSearchQuery(searchQuery) }, []) // Update folder tree when vault changes useEffect(() => { if (vault && vault.folderTree) { setFolderTree(vault.folderTree) // Expand root folder by default setExpandedFolders(new Set([''])) } }, [vault]) // Save vault to Automerge store const saveVaultToAutomerge = (vault: ObsidianVault) => { if (!automergeHandle) { console.warn('⚠️ Automerge handle not available, saving to localStorage only') try { const vaultRecord = importer.vaultToRecord(vault) localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ ...vaultRecord, lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported })) console.log('🔧 Saved vault to localStorage (Automerge handle not available):', vaultRecord.id) } catch (localStorageError) { console.warn('⚠️ Could not save vault to localStorage:', localStorageError) } return } try { const vaultRecord = importer.vaultToRecord(vault) // Save directly to Automerge, bypassing TLDraw store validation // This allows us to save custom record types like obsidian_vault automergeHandle.change((doc: any) => { // Ensure doc.store exists if (!doc.store) { doc.store = {} } // Save the vault record directly to Automerge store // Convert Date to ISO string for serialization const recordToSave = { ...vaultRecord, lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported } doc.store[vaultRecord.id] = recordToSave }) console.log('🔧 Saved vault to Automerge:', vaultRecord.id) // Also save to localStorage as a backup try { localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ ...vaultRecord, lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported })) console.log('🔧 Saved vault to localStorage as backup:', vaultRecord.id) } catch (localStorageError) { console.warn('⚠️ Could not save vault to localStorage:', localStorageError) } } catch (error) { console.error('❌ Error saving vault to Automerge:', error) // Don't throw - allow vault loading to continue even if saving fails // Try localStorage as fallback try { const vaultRecord = importer.vaultToRecord(vault) localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({ ...vaultRecord, lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported })) console.log('🔧 Saved vault to localStorage as fallback:', vaultRecord.id) } catch (localStorageError) { console.warn('⚠️ Could not save vault to localStorage:', localStorageError) } } } // Load vault from Automerge store const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => { // Try loading from Automerge first if (automergeHandle) { try { const doc = automergeHandle.doc() if (doc && doc.store) { const vaultId = `obsidian_vault:${vaultName}` const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') { console.log('🔧 Loaded vault from Automerge:', vaultId) // Convert date string back to Date object if needed const recordCopy = JSON.parse(JSON.stringify(vaultRecord)) if (typeof recordCopy.lastImported === 'string') { recordCopy.lastImported = new Date(recordCopy.lastImported) } return importer.recordToVault(recordCopy) } } } catch (error) { console.warn('⚠️ Could not load vault from Automerge:', error) } } // Try localStorage as fallback try { const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`) if (cached) { const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') { console.log('🔧 Loaded vault from localStorage cache:', vaultName) // Convert date string back to Date object if (typeof vaultRecord.lastImported === 'string') { vaultRecord.lastImported = new Date(vaultRecord.lastImported) } return importer.recordToVault(vaultRecord) } } } catch (e) { console.warn('⚠️ Could not load vault from localStorage:', e) } return null } // Load vault on component mount - prioritize user's configured vault from session useEffect(() => { // Prevent multiple loads if already loading or already loaded once if (isLoadingVault || hasLoadedOnce) { console.log('🔧 ObsidianVaultBrowser: Skipping load - already loading or loaded once') return } console.log('🔧 ObsidianVaultBrowser: Component mounted, checking user identity for vault...') console.log('🔧 Current session vault data:', { path: session.obsidianVaultPath, name: session.obsidianVaultName, authed: session.authed, username: session.username }) // FIRST PRIORITY: Try to load from user's configured vault in session (user identity) if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { console.log('✅ Found configured vault in user identity:', session.obsidianVaultPath) console.log('🔧 Loading vault from user identity...') // First try to load from Automerge cache for faster loading if (session.obsidianVaultName) { const cachedVault = loadVaultFromAutomerge(session.obsidianVaultName) if (cachedVault) { console.log('✅ Loaded vault from Automerge cache') setVault(cachedVault) setIsLoading(false) setHasLoadedOnce(true) return } } // If not in cache, load from source (Quartz URL or local path) console.log('🔧 Loading vault from source:', session.obsidianVaultPath) loadVault(session.obsidianVaultPath) } else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { console.log('🔧 Vault was previously selected via folder picker, showing reselect interface') // For folder-selected vaults, we can't reload them, so show a special reselect interface setVault(null) setShowFolderReselect(true) setIsLoading(false) setHasLoadedOnce(true) } else { console.log('⚠️ No vault configured in user identity, showing empty state...') setVault(null) setIsLoading(false) setHasLoadedOnce(true) } }, []) // Remove dependencies to ensure this only runs once on mount // Handle session changes only if we haven't loaded yet AND values actually changed useEffect(() => { // Check if values actually changed (not just object reference) const vaultPathChanged = previousVaultPathRef.current !== session.obsidianVaultPath const vaultNameChanged = previousVaultNameRef.current !== session.obsidianVaultName // If vault is already loaded and values haven't changed, don't do anything if (hasLoadedOnce && !vaultPathChanged && !vaultNameChanged) { return // Already loaded and nothing changed, no need to reload } // Update refs to current values previousVaultPathRef.current = session.obsidianVaultPath previousVaultNameRef.current = session.obsidianVaultName // Only proceed if values actually changed and we haven't loaded yet if (!vaultPathChanged && !vaultNameChanged) { return // Values haven't changed, no need to reload } if (hasLoadedOnce || isLoadingVault) { return // Don't reload if we've already loaded or are currently loading } if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { console.log('🔧 Session vault path changed, loading vault:', session.obsidianVaultPath) loadVault(session.obsidianVaultPath) } else if (session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { console.log('🔧 Session shows folder-selected vault, showing reselect interface') setVault(null) setShowFolderReselect(true) setIsLoading(false) setHasLoadedOnce(true) } }, [session.obsidianVaultPath, session.obsidianVaultName, hasLoadedOnce, isLoadingVault]) // Auto-open folder picker if requested useEffect(() => { if (autoOpenFolderPicker) { console.log('Auto-opening folder picker...') handleFolderPicker() } }, [autoOpenFolderPicker]) // Reset loading state when component is closed (but not in shape mode) useEffect(() => { if (!showVaultBrowser && !shapeMode) { // Reset states when component is closed (only in modal mode, not shape mode) setHasLoadedOnce(false) setIsLoadingVault(false) } }, [showVaultBrowser, shapeMode]) // Debounce search query for better performance useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchQuery(searchQuery) }, 150) // 150ms delay return () => clearTimeout(timer) }, [searchQuery]) // Handle ESC key to close the browser useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { console.log('🔧 ESC key pressed, closing vault browser') onClose() } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } }, [onClose]) const loadVault = async (path?: string) => { // Prevent concurrent loading operations if (isLoadingVault) { console.log('🔧 loadVault: Already loading, skipping concurrent request') return } setIsLoadingVault(true) setIsLoading(true) setError(null) try { if (path) { // Check if it's a Quartz URL if (path.startsWith('http') || path.includes('quartz') || path.includes('.xyz') || path.includes('.com')) { // Load from Quartz URL - always get latest data console.log('🔧 Loading Quartz vault from URL (getting latest data):', path) const loadedVault = await importer.importFromQuartzUrl(path) console.log('Loaded Quartz vault from URL:', loadedVault) setVault(loadedVault) setShowVaultInput(false) setShowFolderReselect(false) // Save the vault path and name to user session console.log('🔧 Saving Quartz vault to session:', { path, name: loadedVault.name }) updateSession({ obsidianVaultPath: path, obsidianVaultName: loadedVault.name }) console.log('🔧 Quartz vault saved to session successfully') // Save vault to Automerge for persistence saveVaultToAutomerge(loadedVault) } else { // Load from local directory console.log('🔧 Loading vault from local directory:', path) const loadedVault = await importer.importFromDirectory(path) console.log('Loaded vault from path:', loadedVault) setVault(loadedVault) setShowVaultInput(false) setShowFolderReselect(false) // Save the vault path and name to user session console.log('🔧 Saving vault to session:', { path, name: loadedVault.name }) updateSession({ obsidianVaultPath: path, obsidianVaultName: loadedVault.name }) console.log('🔧 Vault saved to session successfully') // Save vault to Automerge for persistence saveVaultToAutomerge(loadedVault) } } else { // No vault configured - show empty state console.log('No vault configured, showing empty state...') setVault(null) setShowVaultInput(false) } } catch (err) { console.error('Failed to load vault:', err) setError('Failed to load Obsidian vault. Please try again.') setVault(null) // Don't show vault input if user already has a vault configured // Only show vault input if this is a fresh attempt if (!session.obsidianVaultPath) { setShowVaultInput(true) } } finally { setIsLoading(false) setIsLoadingVault(false) setHasLoadedOnce(true) } } const handleVaultPathSubmit = async () => { if (!vaultPath.trim()) { setError('Please enter a vault path or URL') return } console.log('📝 Submitting vault path:', vaultPath.trim(), 'Method:', inputMethod) if (inputMethod === 'quartz') { // Handle Quartz URL try { setIsLoading(true) setError(null) const loadedVault = await importer.importFromQuartzUrl(vaultPath.trim()) setVault(loadedVault) setShowVaultInput(false) setShowFolderReselect(false) // Save Quartz vault to user identity (session) console.log('🔧 Saving Quartz vault to user identity:', { path: vaultPath.trim(), name: loadedVault.name }) updateSession({ obsidianVaultPath: vaultPath.trim(), obsidianVaultName: loadedVault.name }) } catch (error) { console.error('❌ Error loading Quartz vault:', error) setError(error instanceof Error ? error.message : 'Failed to load Quartz vault') } finally { setIsLoading(false) } } else { // Handle regular vault path (local folder or URL) loadVault(vaultPath.trim()) } } const handleFolderPicker = async () => { console.log('📁 Folder picker button clicked') if (!('showDirectoryPicker' in window)) { setError('File System Access API is not supported in this browser. Please use "Enter Path" instead.') setShowVaultInput(true) return } try { setIsLoading(true) setError(null) console.log('📁 Opening directory picker...') const loadedVault = await importer.importFromFileSystem() console.log('✅ Vault loaded from folder picker:', loadedVault.name) setVault(loadedVault) setShowVaultInput(false) setShowFolderReselect(false) // Note: We can't get the actual path from importFromFileSystem, // but we can save a flag that a folder was selected console.log('🔧 Saving folder-selected vault to user identity:', { path: 'folder-selected', name: loadedVault.name }) updateSession({ obsidianVaultPath: 'folder-selected', obsidianVaultName: loadedVault.name }) console.log('✅ Folder-selected vault saved to user identity successfully') // Save vault to Automerge for persistence saveVaultToAutomerge(loadedVault) } catch (err) { console.error('❌ Failed to load vault from folder picker:', err) if ((err as any).name === 'AbortError') { // User cancelled the folder picker console.log('📁 User cancelled folder picker') setError(null) // Don't show error for cancellation } else { setError('Failed to load Obsidian vault. Please try again.') } } finally { setIsLoading(false) } } // Filter obs_notes based on search query and folder selection const filteredObsNotes = useMemo(() => { if (!vault) return [] let obs_notes = vault.obs_notes // Filter out any undefined or null notes first obs_notes = obs_notes.filter(obs_note => obs_note != null) // Filter by search query - use debounced query for better performance // When no search query, show all notes if (debouncedSearchQuery && debouncedSearchQuery.trim()) { const lowercaseQuery = debouncedSearchQuery.toLowerCase().trim() obs_notes = obs_notes.filter(obs_note => obs_note && ( (obs_note.title && obs_note.title.toLowerCase().includes(lowercaseQuery)) || (obs_note.content && obs_note.content.toLowerCase().includes(lowercaseQuery)) || (obs_note.tags && obs_note.tags.some(tag => tag.toLowerCase().includes(lowercaseQuery))) || (obs_note.filePath && obs_note.filePath.toLowerCase().includes(lowercaseQuery)) ) ) } // Filter by selected folder if in tree view if (viewMode === 'tree' && selectedFolder !== null && folderTree) { const folder = importer.findFolderByPath(folderTree, selectedFolder) if (folder) { const folderNotes = importer.getAllNotesFromTree(folder) obs_notes = obs_notes.filter(note => folderNotes.some(folderNote => folderNote.id === note.id)) } } else if (viewMode === 'tree' && selectedFolder === null) { // In tree view but no folder selected, show all notes // This allows users to see all notes when no specific folder is selected } // Debug logging console.log('Search query:', debouncedSearchQuery) console.log('View mode:', viewMode) console.log('Selected folder:', selectedFolder) console.log('Total notes:', vault.obs_notes.length) console.log('Filtered notes:', obs_notes.length) return obs_notes }, [vault, debouncedSearchQuery, viewMode, selectedFolder, folderTree, importer]) // Listen for trigger-obsnote-creation event from CustomToolbar useEffect(() => { const handleTriggerCreation = () => { console.log('🎯 ObsidianVaultBrowser: Received trigger-obsnote-creation event') if (selectedNotes.size > 0) { // Create shapes from currently selected notes const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id)) console.log('🎯 Creating shapes from selected notes:', selectedObsNotes.length) onObsNotesSelect(selectedObsNotes) } else { // If no notes are selected, select all visible notes const allVisibleNotes = filteredObsNotes if (allVisibleNotes.length > 0) { console.log('🎯 No notes selected, creating shapes from all visible notes:', allVisibleNotes.length) onObsNotesSelect(allVisibleNotes) } else { console.log('🎯 No notes available to create shapes from') } } } window.addEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener) return () => { window.removeEventListener('trigger-obsnote-creation', handleTriggerCreation as EventListener) } }, [selectedNotes, filteredObsNotes, onObsNotesSelect]) // Helper function to get a better title for display const getDisplayTitle = (obs_note: ObsidianObsNote): string => { // Safety check for undefined obs_note if (!obs_note) { return 'Untitled' } // Use frontmatter title if available, otherwise use filename without extension if (obs_note.frontmatter && obs_note.frontmatter.title) { return obs_note.frontmatter.title } // For Quartz URLs, use the title property which should be clean if (obs_note.filePath && obs_note.filePath.startsWith('http')) { return obs_note.title || 'Untitled' } // Clean up filename for display return obs_note.filePath .replace(/\.md$/, '') .replace(/[-_]/g, ' ') .replace(/\b\w/g, l => l.toUpperCase()) } // Helper function to get content preview const getContentPreview = (obs_note: ObsidianObsNote, maxLength: number = 200): string => { // Safety check for undefined obs_note if (!obs_note) { return 'No content available' } let content = obs_note.content || '' // Remove frontmatter if present content = content.replace(/^---\n[\s\S]*?\n---\n/, '') // Remove markdown headers for cleaner preview content = content.replace(/^#+\s+/gm, '') // Clean up and truncate content = content .replace(/\n+/g, ' ') .replace(/\s+/g, ' ') .trim() if (content.length > maxLength) { content = content.substring(0, maxLength) + '...' } return content || 'No content preview available' } // Helper function to get file path, checking session for quartz link if blank const getFilePath = (obs_note: ObsidianObsNote): string => { // If filePath exists and is not blank, use it if (obs_note.filePath && obs_note.filePath.trim() !== '') { if (obs_note.filePath.startsWith('http')) { try { return new URL(obs_note.filePath).pathname.replace(/^\//, '') || 'Home' } catch (e) { return obs_note.filePath } } return obs_note.filePath } // If filePath is blank, check session for quartz link (user API) if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected' && (session.obsidianVaultPath.startsWith('http') || session.obsidianVaultPath.includes('quartz') || session.obsidianVaultPath.includes('.xyz') || session.obsidianVaultPath.includes('.com'))) { // Construct file path from quartz URL and note title/ID try { const baseUrl = new URL(session.obsidianVaultPath) // Use note title or ID to construct a path const notePath = obs_note.title || obs_note.id || 'Untitled' // Clean up the note path to make it URL-friendly const cleanPath = notePath.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() return `${baseUrl.hostname}${baseUrl.pathname}/${cleanPath}` } catch (e) { // If URL parsing fails, just return the vault path return session.obsidianVaultPath } } // If no quartz link found in session, return a fallback based on note info return obs_note.title || obs_note.id || 'Untitled' } // Helper function to highlight search matches const highlightSearchMatches = (text: string, query: string): string => { if (!query.trim()) return text try { const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') return text.replace(regex, '$1') } catch (error) { console.error('Error highlighting search matches:', error) return text } } const handleObsNoteClick = (obs_note: ObsidianObsNote) => { console.log('🎯 ObsidianVaultBrowser: handleObsNoteClick called with:', obs_note) onObsNoteSelect(obs_note) } const handleObsNoteToggle = (obs_note: ObsidianObsNote) => { const newSelected = new Set(selectedNotes) if (newSelected.has(obs_note.id)) { newSelected.delete(obs_note.id) } else { newSelected.add(obs_note.id) } setSelectedNotes(newSelected) } const handleBulkImport = () => { const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id)) console.log('🎯 ObsidianVaultBrowser: handleBulkImport called with:', selectedObsNotes.length, 'notes') onObsNotesSelect(selectedObsNotes) setSelectedNotes(new Set()) } const handleSelectAll = () => { if (selectedNotes.size === filteredObsNotes.length) { setSelectedNotes(new Set()) } else { setSelectedNotes(new Set(filteredObsNotes.map(obs_note => obs_note.id))) } } const clearFilters = () => { setSearchQuery('') setDebouncedSearchQuery('') setSelectedNotes(new Set()) } // Folder management functions const toggleFolderExpansion = (folderPath: string) => { const newExpanded = new Set(expandedFolders) if (newExpanded.has(folderPath)) { newExpanded.delete(folderPath) } else { newExpanded.add(folderPath) } setExpandedFolders(newExpanded) } const selectFolder = (folderPath: string) => { setSelectedFolder(folderPath) } const getNotesFromFolder = (folder: FolderNode): ObsidianObsNote[] => { if (!folder) return [] let notes = [...folder.notes] // If folder is expanded, include notes from subfolders if (expandedFolders.has(folder.path)) { folder.children.forEach(child => { notes.push(...getNotesFromFolder(child)) }) } return notes } const handleDisconnectVault = () => { // Clear the vault from session updateSession({ obsidianVaultPath: undefined, obsidianVaultName: undefined }) // Reset component state setVault(null) setSearchQuery('') setDebouncedSearchQuery('') setSelectedNotes(new Set()) setShowVaultInput(false) setShowFolderReselect(false) setError(null) setHasLoadedOnce(false) setIsLoadingVault(false) console.log('🔧 Vault disconnected successfully') } const handleBackdropClick = (e: React.MouseEvent) => { // Only close if clicking on the backdrop, not on the modal content if (e.target === e.currentTarget) { onClose() } } if (isLoading) { return (

Loading Obsidian vault...

) } if (error) { return (

Error Loading Vault

{error}

) } if (!vault && !showVaultInput && !isLoading) { // Check if user has a folder-selected vault that needs reselection if (showFolderReselect && session.obsidianVaultPath === 'folder-selected' && session.obsidianVaultName) { return (

Reselect Obsidian Vault

Your vault "{session.obsidianVaultName}" was previously selected via folder picker.

Due to browser security restrictions, we need you to reselect the folder to access your notes.

Select the same folder again to continue using your Obsidian vault, or enter the path manually.

) } // Check if user has a vault configured but it failed to load if (session.obsidianVaultPath && session.obsidianVaultPath !== 'folder-selected') { return (

Vault Loading Failed

Failed to load your configured Obsidian vault at: {session.obsidianVaultPath}

This might be because the path has changed or the vault is no longer accessible.

) } // No vault configured at all return (

Load Obsidian Vault

Choose how you'd like to load your Obsidian vault:

Select a folder containing your Obsidian vault, or enter the path manually.

) } if (showVaultInput) { return (

Enter Vault Path

setVaultPath(e.target.value)} className="path-input" onKeyPress={(e) => e.key === 'Enter' && handleVaultPathSubmit()} />
{inputMethod === 'folder' ? (

Enter the full path to your Obsidian vault folder on your computer.

) : inputMethod === 'quartz' ? (

Enter a Quartz site URL to import content as Obsidian notes (e.g., https://quartz.jzhao.xyz).

) : (

Enter a URL or path to your Obsidian vault (if accessible via web).

)}
) } // Helper function to check if a folder has content (notes or subfolders with content) const hasContent = (folder: FolderNode): boolean => { if (folder.notes.length > 0) return true return folder.children.some(child => hasContent(child)) } // Folder tree component - skips Root and content folders, shows only files from content const renderFolderTree = (folder: FolderNode, level: number = 0) => { if (!folder) return null // Skip Root folder - look for content folder inside it if (folder.name === 'Root') { // Find the "content" folder const contentFolder = folder.children.find(child => child.name === 'content' || child.name.toLowerCase() === 'content') if (contentFolder) { // Skip both Root and content folders, render content folder's children and notes directly return (
{contentFolder.children .filter(child => hasContent(child)) .map(child => renderFolderTree(child, level))} {contentFolder.notes.map(note => (
{ e.stopPropagation() handleObsNoteToggle(note) }} > 📄 {getDisplayTitle(note)}
))}
) } else { // No content folder found, render root's children (excluding root itself) return (
{folder.children .filter(child => hasContent(child) && child.name !== 'content') .map(child => renderFolderTree(child, level))} {folder.notes.map(note => (
{ e.stopPropagation() handleObsNoteToggle(note) }} > 📄 {getDisplayTitle(note)}
))}
) } } // Skip "content" folder - render its children and notes directly if (folder.name === 'content' || folder.name.toLowerCase() === 'content') { return (
{folder.children .filter(child => hasContent(child)) .map(child => renderFolderTree(child, level))} {folder.notes.map(note => (
{ e.stopPropagation() handleObsNoteToggle(note) }} > 📄 {getDisplayTitle(note)}
))}
) } // Render normal folders (not Root or content) const isExpanded = expandedFolders.has(folder.path) const isSelected = selectedFolder === folder.path const hasChildren = folder.children.length > 0 || folder.notes.length > 0 return (
selectFolder(folder.path)} > {hasChildren && ( )} 📁 {folder.name} ({folder.notes.length + folder.children.reduce((acc, child) => acc + child.notes.length, 0)})
{isExpanded && (
{folder.children .filter(child => hasContent(child) && child.name !== 'content') .map(child => renderFolderTree(child, level + 1))} {folder.notes.map(note => (
{ e.stopPropagation() handleObsNoteToggle(note) }} > 📄 {getDisplayTitle(note)}
))}
)}
) } // Shape mode: render without modal overlay if (shapeMode) { return (
{ // Only stop propagation for interactive elements (buttons, inputs, note items, etc.) const target = e.target as HTMLElement const isInteractive = target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.closest('button') || target.closest('input') || target.closest('textarea') || target.closest('select') || target.closest('[role="button"]') || target.closest('a') || target.closest('.note-item') || // Obsidian note items in list view target.closest('.note-card') // Obsidian note cards in grid/list view if (isInteractive) { e.stopPropagation() } // Don't stop propagation for white space - let tldraw handle dragging }} onPointerDown={(e) => { // Only stop propagation for interactive elements to allow tldraw to handle dragging on white space const target = e.target as HTMLElement const isInteractive = target.tagName === 'BUTTON' || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.closest('button') || target.closest('input') || target.closest('textarea') || target.closest('select') || target.closest('[role="button"]') || target.closest('a') || target.closest('.note-item') || // Obsidian note items in list view target.closest('.note-card') // Obsidian note cards in grid/list view if (isInteractive) { e.stopPropagation() } // Don't stop propagation for white space - let tldraw handle dragging }} style={{ width: '100%', height: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column', pointerEvents: 'auto' }} >
{/* Close button removed - using StandardizedToolWrapper header instead */}

{vault ? `Obsidian Vault: ${vault.name}` : 'No Obsidian Vault Connected'}

{!vault && (

Connect your Obsidian vault to browse and add notes to the canvas.

)}
{vault && (
setSearchQuery(e.target.value)} className="search-input" /> {searchQuery && ( )}
{searchQuery ? ( searchQuery !== debouncedSearchQuery ? ( Searching... ) : ( `${filteredObsNotes.length} result${filteredObsNotes.length !== 1 ? 's' : ''} found` ) ) : ( `Showing all ${filteredObsNotes.length} notes` )}
{selectedNotes.size > 0 && ( )}
)} {vault && (
{debouncedSearchQuery && debouncedSearchQuery.trim() ? `${filteredObsNotes.length} notes found for "${debouncedSearchQuery}"` : `All ${filteredObsNotes.length} notes` } {vault && ( (Total: {vault.obs_notes.length}, Search: "{debouncedSearchQuery}") )} {vault && vault.lastImported && ( Last imported: {vault.lastImported.toLocaleString()} )}
{viewMode === 'tree' ? (
{folderTree ? (
{renderFolderTree(folderTree)}
) : (

No folder structure available

)}
) : filteredObsNotes.length === 0 ? (

No notes found. {vault ? `Vault has ${vault.obs_notes.length} notes.` : 'Vault not loaded.'}

Search query: "{debouncedSearchQuery}"

) : ( filteredObsNotes.map(obs_note => { // Safety check for undefined obs_note if (!obs_note) { return null } const isSelected = selectedNotes.has(obs_note.id) const displayTitle = getDisplayTitle(obs_note) const contentPreview = getContentPreview(obs_note, viewMode === 'grid' ? 120 : 200) return (
handleObsNoteToggle(obs_note)} >
handleObsNoteToggle(obs_note)} onClick={(e) => e.stopPropagation()} />

{obs_note.modified ? (obs_note.modified instanceof Date ? obs_note.modified.toLocaleDateString() : new Date(obs_note.modified).toLocaleDateString() ) : 'Unknown date'}

{obs_note.tags.length > 0 && (
{obs_note.tags.slice(0, viewMode === 'grid' ? 2 : 4).map(tag => ( {tag.replace('#', '')} ))} {obs_note.tags.length > (viewMode === 'grid' ? 2 : 4) && ( +{obs_note.tags.length - (viewMode === 'grid' ? 2 : 4)} )}
)}
{getFilePath(obs_note)} {obs_note.links.length > 0 && ( {obs_note.links.length} links )}
) }) )}
)}
) } // Modal mode: render with overlay return (

{vault ? `Obsidian Vault: ${vault.name}` : 'No Obsidian Vault Connected'}

{!vault && (

Connect your Obsidian vault to browse and add notes to the canvas.

)}
{vault && (
setSearchQuery(e.target.value)} className="search-input" /> {searchQuery && ( )}
{searchQuery ? ( searchQuery !== debouncedSearchQuery ? ( Searching... ) : ( `${filteredObsNotes.length} result${filteredObsNotes.length !== 1 ? 's' : ''} found` ) ) : ( `Showing all ${filteredObsNotes.length} notes` )}
{selectedNotes.size > 0 && ( )}
)} {vault && (
{debouncedSearchQuery && debouncedSearchQuery.trim() ? `${filteredObsNotes.length} notes found for "${debouncedSearchQuery}"` : `All ${filteredObsNotes.length} notes` } {vault && ( (Total: {vault.obs_notes.length}, Search: "{debouncedSearchQuery}") )} {vault && vault.lastImported && ( Last imported: {vault.lastImported.toLocaleString()} )}
{viewMode === 'tree' ? (
{folderTree ? (
{renderFolderTree(folderTree)}
) : (

No folder structure available

)}
) : filteredObsNotes.length === 0 ? (

No notes found. {vault ? `Vault has ${vault.obs_notes.length} notes.` : 'Vault not loaded.'}

Search query: "{debouncedSearchQuery}"

) : ( filteredObsNotes.map(obs_note => { // Safety check for undefined obs_note if (!obs_note) { return null } const isSelected = selectedNotes.has(obs_note.id) const displayTitle = getDisplayTitle(obs_note) const contentPreview = getContentPreview(obs_note, viewMode === 'grid' ? 120 : 200) return (
handleObsNoteToggle(obs_note)} >
handleObsNoteToggle(obs_note)} onClick={(e) => e.stopPropagation()} />

{obs_note.modified ? (obs_note.modified instanceof Date ? obs_note.modified.toLocaleDateString() : new Date(obs_note.modified).toLocaleDateString() ) : 'Unknown date'}

{obs_note.tags.length > 0 && (
{obs_note.tags.slice(0, viewMode === 'grid' ? 2 : 4).map(tag => ( {tag.replace('#', '')} ))} {obs_note.tags.length > (viewMode === 'grid' ? 2 : 4) && ( +{obs_note.tags.length - (viewMode === 'grid' ? 2 : 4)} )}
)}
{getFilePath(obs_note)} {obs_note.links.length > 0 && ( {obs_note.links.length} links )}
) }) )}
)}
) } export default ObsidianVaultBrowser