What is a Space?
A space is Glyph’s fundamental organizational unit. Each space is a directory on the user’s filesystem containing:
- Notes - Markdown files with YAML frontmatter
- Assets - Content-addressed files (images, PDFs, etc.)
- Cache - Derived data (link previews, thumbnails)
- Metadata - Schema version and configuration
Spaces are portable. You can move a space folder anywhere, sync via Dropbox/iCloud, or manage it with Git.
Directory Structure
my-space/
├── notes/ # User-created markdown files
│ ├── daily/ # Daily notes (optional)
│ ├── projects/ # Project notes
│ └── meeting-notes.md
│
├── assets/ # Content-addressed storage
│ ├── a1b2c3...xyz.png # SHA256 hash as filename
│ └── f4e5d6...abc.pdf
│
├── cache/ # Temporary/derived data
│ ├── links/ # Link preview metadata
│ └── images/ # Cached external images
│
├── .glyph/ # App-managed data (not in space root)
│ ├── index.db # SQLite FTS + tags + links
│ ├── ai_history.db # AI chat conversations
│ ├── profiles.json # AI provider configs
│ └── settings.json # Space-specific settings
│
└── space.json # Schema version marker
The .glyph/ folder is derived data. It can be safely deleted and will regenerate on next space open. Do not sync it.
Space Lifecycle
Creating a Space
User selects directory
User chooses an empty or existing folder via native file pickersrc/contexts/SpaceContext.tsx
const onCreateSpace = async () => {
const { open } = await import('@tauri-apps/plugin-dialog');
const selection = await open({ directory: true });
await invoke('space_create', { path: selection });
};
Backend initializes structure
Rust backend creates directories and schema markersrc-tauri/src/space/commands.rs
pub fn space_create(path: String) -> Result<SpaceInfo, String> {
fs::create_dir_all(path.join("notes"))?;
fs::create_dir_all(path.join("assets"))?;
fs::create_dir_all(path.join("cache"))?;
let schema = SpaceSchema { version: CURRENT_SCHEMA_VERSION };
write_atomic(path.join("space.json"), serde_json::to_vec(&schema)?)?;
Ok(SpaceInfo { root: path, schema_version: CURRENT_SCHEMA_VERSION })
}
Frontend updates state
SpaceContext tracks current space pathsetSpacePath(spaceInfo.root);
setSpaceSchemaVersion(spaceInfo.schema_version);
await setCurrentSpacePath(spaceInfo.root); // Persist to settings
Opening a Space
Validate schema version
Ensure space is compatible with app versionconst CURRENT_SCHEMA_VERSION: u32 = 1;
if space_schema.version != CURRENT_SCHEMA_VERSION {
return Err(format!("Incompatible space version: {}", space_schema.version));
}
Initialize SQLite index
Create or open .glyph/index.dbsrc-tauri/src/index/db.rs
pub fn open_db(glyph_dir: &Path) -> Result<Connection, rusqlite::Error> {
let db = Connection::open(glyph_dir.join("index.db"))?;
schema::ensure_schema(&db)?; // Create FTS tables
Ok(db)
}
Start filesystem watcher
Monitor notes/ directory for changessrc-tauri/src/space/watcher.rs
let watcher = notify::recommended_watcher(move |event| {
if let Ok(Event { kind: EventKind::Modify(_), paths, .. }) = event {
for path in paths {
indexer::reindex_file(&path)?;
}
}
})?;
watcher.watch(&space_root.join("notes"), RecursiveMode::Recursive)?;
Rebuild index
Scan all notes and populate SQLite FTSsrc/contexts/SpaceContext.tsx
await invoke('index_rebuild'); // Async, non-blocking
Closing a Space
src/contexts/SpaceContext.tsx
const closeSpace = async () => {
await invoke('space_close'); // Stop watcher, close DB
await clearCurrentSpacePath(); // Clear from settings
setSpacePath(null);
setSpaceSchemaVersion(null);
};
Space.json Schema
The space.json file marks a directory as a Glyph space and tracks schema version.
Schema version. Current version is 1.If this doesn’t match CURRENT_SCHEMA_VERSION in Rust code, the space cannot be opened.
Content-Addressed Storage
Assets (images, PDFs, etc.) are stored by SHA256 hash to deduplicate files.
User attaches file
File is selected via file pickerconst result = await invoke('note_attach_file', {
note_id: 'notes/example.md',
source_path: '/Users/me/Downloads/diagram.png'
});
// Returns: { asset_rel_path: 'assets/a1b2...xyz.png', markdown: '' }
Backend computes hash
SHA256 hash of file contents determines storage pathsrc-tauri/src/notes/attachments.rs
let mut hasher = Sha256::new();
io::copy(&mut file, &mut hasher)?;
let hash = hex::encode(hasher.finalize());
let asset_name = format!("{}.{}", hash, extension);
Copy if not exists
Only copy file if hash doesn’t already existlet dest = space_root.join("assets").join(&asset_name);
if !dest.exists() {
fs::copy(&source_path, &dest)?;
}
Return markdown link
Generate relative markdown linklet rel_path = format!("../assets/{}", asset_name);
Ok(AttachmentResult {
asset_rel_path: format!("assets/{}", asset_name),
markdown: format!("", rel_path)
})
Benefits
- Deduplication - Same image used in 10 notes = 1 file on disk
- Integrity - Hash mismatch = corrupted file
- Immutability - Content can’t change without changing hash
State Management
Backend State (src-tauri/src/space/state.rs)
use std::sync::Mutex;
pub struct SpaceState {
pub current: Mutex<Option<CurrentSpace>>,
}
pub struct CurrentSpace {
pub root: PathBuf,
pub schema_version: u32,
pub db: Connection,
pub watcher: RecommendedWatcher,
}
Accessed via Tauri’s state management:
#[tauri::command]
fn space_get_current(state: State<SpaceState>) -> Result<Option<String>, String> {
let current = state.current.lock().unwrap();
Ok(current.as_ref().map(|c| c.root.display().to_string()))
}
Frontend State (src/contexts/SpaceContext.tsx)
interface SpaceContextValue {
spacePath: string | null; // Current space root path
spaceSchemaVersion: number | null; // Schema version
lastSpacePath: string | null; // Last opened space (for "Continue")
recentSpaces: string[]; // Recent space paths (max 20)
isIndexing: boolean; // Index rebuild in progress
settingsLoaded: boolean; // Settings loaded from disk
}
Recent Spaces
Glyph tracks up to 20 recently opened spaces, stored in Tauri’s persistent store:
import { Store } from '@tauri-apps/plugin-store';
const store = new Store('settings.json');
export async function setCurrentSpacePath(path: string) {
await store.set('currentSpacePath', path);
// Update recent spaces
const recent = (await store.get<string[]>('recentSpaces')) || [];
const updated = [path, ...recent.filter(p => p !== path)].slice(0, 20);
await store.set('recentSpaces', updated);
await store.save();
}
Path Safety
Preventing Path Traversal
All user-provided paths are validated to prevent traversal attacks:
pub fn join_under(base: &Path, rel: &str) -> Result<PathBuf, String> {
let normalized = PathBuf::from(rel)
.components()
.filter(|c| !matches!(c, Component::ParentDir))
.collect::<PathBuf>();
let joined = base.join(&normalized);
if !joined.starts_with(base) {
return Err("Path traversal detected".to_string());
}
Ok(joined)
}
Usage:
src-tauri/src/space_fs/read_write/text.rs
#[tauri::command]
pub fn space_read_text(
path: String,
state: State<SpaceState>
) -> Result<TextFileDoc, String> {
let current = state.current.lock().unwrap();
let space = current.as_ref().ok_or("No space open")?;
// Safe path join - rejects "../../../etc/passwd"
let abs_path = paths::join_under(&space.root, &path)?;
let text = fs::read_to_string(abs_path)?;
// ...
}
Schema Migration
Glyph uses a hard cutover migration policy. When schema version changes:
- Old app versions cannot open new spaces
- New app versions cannot open old spaces
- Users must export data and re-import
This is intentional to keep the codebase simple.
Version Check
src-tauri/src/space/commands.rs
const CURRENT_SCHEMA_VERSION: u32 = 1;
pub fn space_open(path: String) -> Result<SpaceInfo, String> {
let schema_path = PathBuf::from(&path).join("space.json");
let schema: SpaceSchema = serde_json::from_str(&fs::read_to_string(schema_path)?)?;
if schema.version != CURRENT_SCHEMA_VERSION {
return Err(format!(
"Space schema version {} is not compatible with app version {} (requires version {})",
schema.version,
env!("CARGO_PKG_VERSION"),
CURRENT_SCHEMA_VERSION
));
}
// Continue with space open...
}