Component Structure
Glyph’s frontend is organized into logical component groups:
src/components/
├── app/ # App shell & chrome
├── editor/ # TipTap markdown editor
├── ai/ # AI chat panel
├── filetree/ # File browser sidebar
├── preview/ # File preview pane
├── tasks/ # Task list views
├── database/ # Database table/board views
├── settings/ # Settings panes
├── licensing/ # License activation
└── ui/ # shadcn/ui primitivesApp Shell Components
AppShell
Location: src/components/app/AppShell.tsx
Root layout component that orchestrates the main UI:
export function AppShell() {
return (
<div className="app-shell">
<Sidebar /> {/* Left sidebar: file tree, tags, etc. */}
<MainContent /> {/* Center: editor, preview, database views */}
<CommandPalette /> {/* Cmd+K search */}
<KeyboardShortcutsHelp /> {/* ? help modal */}
</div>
);
}Sidebar
Location: src/components/app/Sidebar.tsx
Navigable sidebar with multiple panes:
export function Sidebar() {
const { activePane } = useUIContext();
return (
<aside>
<SidebarHeader /> {/* Logo, new note button */}
<SidebarContent>
{activePane === 'files' && <FileTreePane />}
{activePane === 'tags' && <TagsPane />}
{activePane === 'ai' && <AIPanel />}
{activePane === 'tasks' && <TasksPane />}
</SidebarContent>
</aside>
);
}const { setActivePane } = useUIContext();
<IconButton onClick={() => setActivePane('files')}>
<FileIcon />
</IconButton>MainContent
Location: src/components/app/MainContent.tsx
Tab-based content area:
export function MainContent() {
const { activeFilePath, activePreviewPath } = useFileTreeContext();
const { activeViewDoc } = useViewContext();
return (
<main>
<TabBar /> {/* File tabs */}
{activeFilePath?.endsWith('.md') && (
<MarkdownEditorPane path={activeFilePath} />
)}
{activePreviewPath && (
<FilePreviewPane path={activePreviewPath} />
)}
{activeViewDoc && (
<ViewRenderer doc={activeViewDoc} />
)}
</main>
);
}CommandPalette
Location: src/components/app/CommandPalette.tsx
Cmd+K quick actions:
import { Command } from 'cmdk';
export function CommandPalette() {
const { isOpen, setIsOpen } = useUIContext();
const [query, setQuery] = useState('');
return (
<Command.Dialog open={isOpen} onOpenChange={setIsOpen}>
<Command.Input
placeholder="Search notes, commands..."
value={query}
onValueChange={setQuery}
/>
<Command.List>
<CommandSearchResults query={query} />
<Command.Group heading="Actions">
<Command.Item onSelect={handleNewNote}>
<PlusIcon /> New Note
</Command.Item>
<Command.Item onSelect={handleOpenSettings}>
<SettingsIcon /> Settings
</Command.Item>
</Command.Group>
</Command.List>
</Command.Dialog>
);
}Editor Components
CanvasNoteInlineEditor
Location: src/components/editor/CanvasNoteInlineEditor.tsx
TipTap-based markdown editor:
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from '@tiptap/extension-markdown';
import { WikiLink } from './extensions/wikiLink';
export function CanvasNoteInlineEditor({ path }: { path: string }) {
const [doc, setDoc] = useState<TextFileDoc | null>(null);
const editor = useEditor({
extensions: [
StarterKit,
Markdown,
WikiLink,
// ... more extensions
],
content: doc?.text || '',
onUpdate: ({ editor }) => {
debouncedSave(editor.getText());
},
});
return (
<div className="editor-container">
<EditorRibbon editor={editor} />
<EditorContent editor={editor} />
</div>
);
}const debouncedSave = useMemo(
() => debounce(async (text: string) => {
try {
setSaveState('saving');
await invoke('space_write_text', {
path,
text,
base_mtime_ms: doc?.mtime_ms,
});
setSaveState('saved');
} catch (err) {
setSaveState('error');
toast.error('Failed to save');
}
}, 500),
[path, doc?.mtime_ms]
);EditorRibbon
Location: src/components/editor/EditorRibbon.tsx
Formatting toolbar:
export function EditorRibbon({ editor }: { editor: Editor | null }) {
if (!editor) return null;
return (
<div className="editor-ribbon">
<MotionButton
onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold')}
>
<BoldIcon />
</MotionButton>
<MotionButton
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic')}
>
<ItalicIcon />
</MotionButton>
<RibbonLinkPopover editor={editor} />
{/* ... more buttons */}
</div>
);
}NotePropertiesPanel
Location: src/components/editor/NotePropertiesPanel.tsx
Frontmatter editor:
export function NotePropertiesPanel({ path }: { path: string }) {
const [properties, setProperties] = useState<NoteProperty[]>([]);
const handleAddProperty = () => {
setProperties([...properties, {
key: '',
kind: 'text',
value_text: null,
value_bool: null,
value_list: [],
}]);
};
const handleSave = async () => {
const frontmatter = await invoke('note_frontmatter_render_properties', {
properties,
});
// Update note with new frontmatter...
};
return (
<div className="properties-panel">
{properties.map((prop, i) => (
<NotePropertyRow
key={i}
property={prop}
onChange={(updated) => updateProperty(i, updated)}
/>
))}
<Button onClick={handleAddProperty}>
<PlusIcon /> Add Property
</Button>
</div>
);
}File Tree Components
FileTreePane
Location: src/components/filetree/FileTreePane.tsx
Recursive file browser:
export function FileTreePane() {
const { rootEntries, expandedDirs } = useFileTreeContext();
const { loadDir, toggleDir, openFile } = useFileTree(/* deps */);
useEffect(() => {
loadDir(''); // Load root
}, []);
return (
<div className="file-tree">
{rootEntries.map(entry => (
entry.kind === 'dir' ? (
<FileTreeDirItem
key={entry.rel_path}
entry={entry}
isExpanded={expandedDirs.has(entry.rel_path)}
onToggle={() => toggleDir(entry.rel_path)}
/>
) : (
<FileTreeFileItem
key={entry.rel_path}
entry={entry}
onClick={() => openFile(entry.rel_path)}
/>
)
))}
</div>
);
}FileTreeDirItem
Location: src/components/filetree/FileTreeDirItem.tsx
Collapsible directory:
export function FileTreeDirItem({
entry,
isExpanded,
onToggle,
}: FileTreeDirItemProps) {
const { childrenByDir } = useFileTreeContext();
const children = childrenByDir[entry.rel_path] || [];
return (
<div className="dir-item">
<button onClick={onToggle}>
<ChevronIcon rotation={isExpanded ? 90 : 0} />
<FolderIcon />
{entry.name}
</button>
{isExpanded && (
<div className="dir-children">
{children.map(child => (
child.kind === 'dir' ? (
<FileTreeDirItem key={child.rel_path} entry={child} />
) : (
<FileTreeFileItem key={child.rel_path} entry={child} />
)
))}
</div>
)}
</div>
);
}AI Components
AIPanel
Location: src/components/ai/AIPanel.tsx
AI chat sidebar:
export function AIPanel() {
const { messages, sendMessage, isStreaming } = useRigChat();
return (
<div className="ai-panel">
<ModelSelector /> {/* GPT-4, Claude, etc. */}
<AIChatThread messages={messages} />
<AIComposer
onSend={sendMessage}
disabled={isStreaming}
/>
{isStreaming && <AIToolTimeline />}
</div>
);
}AIChatThread
Location: src/components/ai/AIChatThread.tsx
Message list:
export function AIChatThread({ messages }: { messages: AiMessage[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
return (
<div className="chat-thread">
{messages.map((msg, i) => (
<div
key={i}
className={cn('message', msg.role)}
>
{msg.role === 'user' ? <UserAvatar /> : <AIAvatar />}
<AIMessageMarkdown content={msg.content} />
</div>
))}
<div ref={scrollRef} />
</div>
);
}Database Components
DatabasePane
Location: src/components/database/DatabasePane.tsx
Database view (table or board):
export function DatabasePane({ path }: { path: string }) {
const { config, rows, loading } = useDatabaseNote(path);
return (
<div className="database-pane">
<DatabaseToolbar config={config} />
{config.view.layout === 'table' ? (
<DatabaseTable config={config} rows={rows} />
) : (
<DatabaseBoard config={config} rows={rows} />
)}
</div>
);
}DatabaseTable
Location: src/components/database/DatabaseTable.tsx
TanStack Table:
import { useReactTable, getCoreRowModel } from '@tanstack/react-table';
export function DatabaseTable({ config, rows }: DatabaseTableProps) {
const table = useReactTable({
data: rows,
columns: config.columns.map(col => ({
id: col.id,
header: col.label,
cell: ({ row }) => (
<DatabaseCell
column={col}
row={row.original}
onChange={(value) => handleCellUpdate(row.original, col, value)}
/>
),
})),
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>{header.column.columnDef.header}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{cell.column.columnDef.cell(cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}UI Primitives
shadcn/ui Components
Location: src/components/ui/shadcn/
Accessible Radix UI-based components:
Button- Buttons with variantsDialog- ModalsPopover- Floating menusDropdownMenu- Context menusInput- Text inputsTabs- Tab navigationTable- Semantic tablesScrollArea- Custom scrollbars
Motion Components
Location: src/components/ui/animations.ts
Animated wrappers:
import { motion } from 'motion/react';
export const MotionButton = motion.button;
export const MotionPanel = motion.div;
export const fadeIn = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const slideIn = {
initial: { x: -20, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: 20, opacity: 0 },
};Usage:
<MotionPanel {...fadeIn}>
Content appears with fade
</MotionPanel>Component Patterns
Compound Components
// Parent manages state, children consume via context
export function Accordion({ children }) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<AccordionContext.Provider value={{ openId, setOpenId }}>
{children}
</AccordionContext.Provider>
);
}
Accordion.Item = function AccordionItem({ id, children }) {
const { openId, setOpenId } = useAccordionContext();
const isOpen = openId === id;
return (
<div>
<button onClick={() => setOpenId(isOpen ? null : id)}>
{children}
</button>
</div>
);
};Render Props
interface FileListProps {
files: FsEntry[];
renderFile: (file: FsEntry) => ReactNode;
}
export function FileList({ files, renderFile }: FileListProps) {
return (
<div>
{files.map(file => (
<div key={file.rel_path}>
{renderFile(file)}
</div>
))}
</div>
);
}
// Usage
<FileList
files={entries}
renderFile={(file) => (
<div>{file.name}</div>
)}
/>Custom Hooks as Logic
function useFileTreeItem(entry: FsEntry) {
const { openFile, renameFile, deleteFile } = useFileTree(/* deps */);
const [isRenaming, setIsRenaming] = useState(false);
const handleRename = async (newName: string) => {
await renameFile(entry.rel_path, newName);
setIsRenaming(false);
};
return {
isRenaming,
startRename: () => setIsRenaming(true),
handleRename,
handleDelete: () => deleteFile(entry.rel_path),
handleOpen: () => openFile(entry.rel_path),
};
}