Overview
Glyph uses custom hooks to separate business logic from UI. Hooks handle:
- File tree operations (load, rename, delete)
- Editor state (save, auto-save, conflict detection)
- Search (full-text, advanced filters)
- AI chat (streaming, tool calls)
- View loading (folder, tag, database views)
Note
Hooks follow the “hooks as logic, components as UI” pattern. Components should be thin wrappers around hooks.
File Tree Hooks
useFileTree
Location: src/hooks/useFileTree.ts
Manages file tree operations (load, toggle, open).
interface UseFileTreeResult {
loadDir: (dirPath: string, force?: boolean) => Promise<void>;
toggleDir: (dirPath: string) => void;
openFile: (relPath: string) => Promise<void>;
openMarkdownFile: (relPath: string) => Promise<void>;
openNonMarkdownExternally: (relPath: string) => Promise<void>;
// CRUD operations (from useFileTreeCRUD)
onNewFile: () => Promise<void>;
onNewFileInDir: (dirPath: string) => Promise<void>;
onNewFolderInDir: (dirPath: string) => Promise<void>;
onRenameDir: (path: string, nextName: string) => Promise<string | null>;
onDeletePath: (path: string, kind: 'dir' | 'file') => Promise<boolean>;
onMovePath: (fromPath: string, toDirPath: string) => Promise<string | null>;
}import { useFileTree } from '@/hooks/useFileTree';
function FileTreePane() {
const { spacePath } = useSpace();
const { updateChildrenByDir, expandedDirs, ... } = useFileTreeContext();
const { setActiveFilePath, setActivePreviewPath, activeFilePath } = useUIContext();
const fileTree = useFileTree({
spacePath,
updateChildrenByDir,
updateExpandedDirs,
setActiveFilePath,
setActivePreviewPath,
activeFilePath,
// ... other deps
});
return (
<div>
<button onClick={() => fileTree.toggleDir('notes/projects')}>
Toggle Projects
</button>
<button onClick={() => fileTree.openFile('notes/example.md')}>
Open Example
</button>
</div>
);
}useFileTreeCRUD
Location: src/hooks/useFileTreeCRUD.ts
Create, rename, delete operations.
export function useFileTreeCRUD(deps: UseFileTreeCRUDDeps) {
const onNewFile = useCallback(async () => {
const dirPath = deps.getActiveFolderDir() || '';
const name = prompt('File name:');
if (!name) return;
const path = dirPath ? `${dirPath}/${name}` : name;
try {
await invoke('space_open_or_create_text', {
path,
text: '# ' + name.replace(/\.md$/, '')
});
await deps.loadDir(dirPath, true); // Refresh
deps.setActiveFilePath(path);
} catch (err) {
deps.setError(extractErrorMessage(err));
}
}, [deps]);
const onDeletePath = useCallback(async (
path: string,
kind: 'dir' | 'file'
): Promise<boolean> => {
const confirmed = confirm(`Delete ${kind} "${path}"?`);
if (!confirmed) return false;
try {
await invoke('space_delete_path', {
path,
recursive: kind === 'dir'
});
// Refresh parent directory
const parentPath = parentDir(path);
await deps.loadDir(parentPath, true);
// Clear active file if deleted
if (deps.activeFilePath === path) {
deps.setActiveFilePath(null);
}
return true;
} catch (err) {
deps.setError(extractErrorMessage(err));
return false;
}
}, [deps]);
return {
onNewFile,
onNewFileInDir,
onNewFolderInDir,
onRenameDir,
onDeletePath,
onMovePath,
};
}Search Hooks
useSearch
Location: src/hooks/useSearch.ts
Debounced search with result caching.
import { useCallback, useEffect, useState, useMemo } from 'react';
import { invoke } from '@/lib/tauri';
import type { SearchResult } from '@/lib/tauri';
export function useSearch(query: string, debounceMs = 300) {
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Debounce query
const debouncedQuery = useDebounce(query, debounceMs);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setError('');
try {
const searchResults = await invoke('search', {
query: debouncedQuery
});
if (!cancelled) {
setResults(searchResults);
}
} catch (err) {
if (!cancelled) {
setError(extractErrorMessage(err));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [debouncedQuery]);
return { results, loading, error };
}
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}View Loading Hooks
useViewLoader
Location: src/hooks/useViewLoader.ts
Loads and builds view documents (folder, tag, search, database).
export function useViewLoader() {
const { setActiveViewDoc, setIsLoadingView, setViewError } = useViewContext();
const loadFolderView = useCallback(async (dir: string) => {
setIsLoadingView(true);
setViewError('');
try {
const data = await invoke('space_folder_view_data', {
dir,
limit: 100,
recent_limit: 10
});
setActiveViewDoc({
type: 'folder',
dir,
files: data.files,
subfolders: data.subfolders,
note_previews: data.note_previews,
});
} catch (err) {
setViewError(extractErrorMessage(err));
} finally {
setIsLoadingView(false);
}
}, [setActiveViewDoc, setIsLoadingView, setViewError]);
const loadTagView = useCallback(async (tag: string) => {
setIsLoadingView(true);
try {
const previews = await invoke('tag_view_data', {
tag,
limit: 100
});
setActiveViewDoc({
type: 'tag',
tag,
note_previews: previews,
});
} catch (err) {
setViewError(extractErrorMessage(err));
} finally {
setIsLoadingView(false);
}
}, [setActiveViewDoc, setIsLoadingView, setViewError]);
return {
loadFolderView,
loadTagView,
loadSearchView,
loadDatabaseView,
};
}Editor Hooks
useNoteEditor
Location: src/components/editor/hooks/useNoteEditor.ts
Manages TipTap editor with auto-save and conflict detection.
import { useEditor } from '@tiptap/react';
import { useMemo, useCallback, useEffect, useState } from 'react';
import { debounce } from '@/lib/utils';
export function useNoteEditor(path: string) {
const [doc, setDoc] = useState<TextFileDoc | null>(null);
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'unsaved'>('saved');
// Load note
useEffect(() => {
let cancelled = false;
(async () => {
try {
const loaded = await invoke('space_read_text', { path });
if (!cancelled) setDoc(loaded);
} catch (err) {
toast.error('Failed to load note');
}
})();
return () => { cancelled = true; };
}, [path]);
// Auto-save handler
const handleSave = useCallback(async (text: string) => {
if (!doc) return;
setSaveState('saving');
try {
const result = await invoke('space_write_text', {
path,
text,
base_mtime_ms: doc.mtime_ms, // Conflict detection
});
setDoc(prev => prev ? { ...prev, etag: result.etag, mtime_ms: result.mtime_ms } : null);
setSaveState('saved');
} catch (err) {
if (err.message.includes('conflict')) {
toast.error('File was modified externally. Reload to see changes.');
} else {
toast.error('Failed to save');
}
setSaveState('unsaved');
}
}, [path, doc]);
const debouncedSave = useMemo(
() => debounce(handleSave, 500),
[handleSave]
);
// TipTap editor
const editor = useEditor({
extensions: [/* ... */],
content: doc?.text || '',
onUpdate: ({ editor }) => {
setSaveState('unsaved');
debouncedSave(editor.getText());
},
});
return {
editor,
doc,
saveState,
};
}AI Hooks
useRigChat
Location: src/components/ai/hooks/useRigChat.ts
Manages streaming AI chat with tool calls.
import { useState, useCallback } from 'react';
import { listen } from '@tauri-apps/api/event';
export function useRigChat() {
const [messages, setMessages] = useState<AiMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
const sendMessage = useCallback(async (content: string) => {
const userMessage: AiMessage = { role: 'user', content };
setMessages(prev => [...prev, userMessage]);
setIsStreaming(true);
try {
const { job_id } = await invoke('ai_chat_start', {
request: {
profile_id: activeProfile.id,
messages: [...messages, userMessage],
mode: 'chat',
}
});
setCurrentJobId(job_id);
// Listen for streaming chunks
const unlisten = await listen<{ delta: string }>(
`ai_stream_${job_id}`,
(event) => {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.role === 'assistant') {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + event.payload.delta }
];
} else {
return [
...prev,
{ role: 'assistant', content: event.payload.delta }
];
}
});
}
);
// Wait for completion
await listen<{ job_id: string }>(
`ai_complete_${job_id}`,
() => {
setIsStreaming(false);
setCurrentJobId(null);
unlisten();
}
);
} catch (err) {
toast.error('AI request failed');
setIsStreaming(false);
}
}, [messages, activeProfile]);
const cancelStream = useCallback(async () => {
if (!currentJobId) return;
try {
await invoke('ai_chat_cancel', { job_id: currentJobId });
setIsStreaming(false);
setCurrentJobId(null);
} catch (err) {
console.error('Failed to cancel:', err);
}
}, [currentJobId]);
return {
messages,
isStreaming,
sendMessage,
cancelStream,
};
}Database Hooks
useDatabaseTable
Location: src/hooks/database/useDatabaseTable.ts
Manages database view (table/board) state.
import { useState, useEffect, useCallback } from 'react';
import type { DatabaseConfig, DatabaseRow } from '@/lib/tauri';
export function useDatabaseTable(path: string) {
const [config, setConfig] = useState<DatabaseConfig | null>(null);
const [rows, setRows] = useState<DatabaseRow[]>([]);
const [loading, setLoading] = useState(true);
// Load database
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const data = await invoke('database_load', { path, limit: 500 });
if (!cancelled) {
setConfig(data.config);
setRows(data.rows);
}
} catch (err) {
toast.error('Failed to load database');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [path]);
// Update cell
const updateCell = useCallback(async (
row: DatabaseRow,
column: DatabaseColumn,
value: DatabaseCellValue
) => {
try {
const updatedRow = await invoke('database_update_cell', {
note_path: row.note_path,
column,
value,
});
setRows(prev => prev.map(r =>
r.note_path === row.note_path ? updatedRow : r
));
} catch (err) {
toast.error('Failed to update cell');
}
}, []);
// Create row
const createRow = useCallback(async (title?: string) => {
if (!config) return;
try {
const result = await invoke('database_create_row', {
database_path: path,
title,
});
setRows(prev => [result.row, ...prev]);
// Open new note
// ...
} catch (err) {
toast.error('Failed to create row');
}
}, [path, config]);
return {
config,
rows,
loading,
updateCell,
createRow,
};
}Hook Patterns
Dependencies Object Pattern
Instead of 20 individual parameters:
interface UseFileTreeDeps {
spacePath: string | null;
updateChildrenByDir: (...) => void;
setActiveFilePath: (path: string | null) => void;
// ... more
}
export function useFileTree(deps: UseFileTreeDeps) {
// Use deps.spacePath, deps.updateChildrenByDir, etc.
}Async Hook Pattern
For hooks that load data:
export function useAsyncData<T>(fetchFn: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await fetchFn();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err as Error);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [fetchFn]);
return { data, loading, error };
}