What is a Space?
A space is Glyph’s fundamental organizational unit. It is just a directory on disk that Glyph opens and works inside.
Glyph does not require a rigid top-level folder structure or a space.json marker file. Your markdown files can live anywhere inside the space, and Glyph creates its own .glyph/ directory for app-managed data when needed.
Note
Spaces are portable. You can move a space folder anywhere, sync via Dropbox/iCloud, or manage it with Git.
Directory Structure
my-space/
├── Projects/ # Your folders can be named however you like
│ └── meeting-notes.md
├── Daily Notes/
│ └── 2026-03-04.md
├── assets/ # Created when attachments are added
│ └── a1b2c3...xyz.png
└── .glyph/ # App-managed data
├── glyph.sqlite # Search/index database
├── cache/
│ ├── ai/ # AI audit logs
│ └── link-previews/ # Cached link preview metadata and images
└── Glyph/
├── ai_history/ # Stored AI chat history as JSON files
└── ai_secrets.json # Per-space API key storageSome details from the current code:
- The search database path is
.glyph/glyph.sqlite - Link preview cache lives under
.glyph/cache/link-previews - AI audit logs live under
.glyph/cache/ai - AI chat history is stored as JSON files in
.glyph/Glyph/ai_history - API keys are stored per-space in
.glyph/Glyph/ai_secrets.json - AI profile definitions are not stored in the space; they live in the app config directory as
ai.json
Warning
The .glyph/ directory is app-managed. It is fine to treat it as generated support data rather than user-authored content.
Space Lifecycle
Creating a Space
User selects directory
User chooses an empty or existing folder via native file picker
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 ensures Glyph’s internal directories exist
pub fn create_or_open_impl(root: &Path) -> Result<SpaceInfo, String> {
ensure_glyph_dirs(root)?;
let _ = cleanup_tmp_files(root);
Ok(SpaceInfo {
root: root.to_string_lossy().to_string(),
schema_version: VAULT_SCHEMA_VERSION,
})
}Frontend updates state
SpaceContext tracks current space path
setSpacePath(spaceInfo.root);
setSpaceSchemaVersion(spaceInfo.schema_version);
await setCurrentSpacePath(spaceInfo.root); // Persist to settingsOpening a Space
Ensure internal directories exist
Glyph creates .glyph/, .glyph/cache/, and .glyph/Glyph/ if they are missing.
Initialize SQLite index
Create or open .glyph/glyph.sqlite
pub fn open_db(space_root: &Path) -> Result<rusqlite::Connection, String> {
let path = db_path(space_root)?;
let conn = rusqlite::Connection::open(&path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
ensure_schema(&conn)?;
Ok(conn)
}Start filesystem watcher
Monitor the whole space root for changes
watcher.watch(&root, notify::RecursiveMode::Recursive)?;Rebuild index
Markdown files across the space are indexed for search
await invoke('index_rebuild'); // Async, non-blockingClosing a Space
const closeSpace = async () => {
await invoke('space_close'); // Stop watcher, close DB
await clearCurrentSpacePath(); // Clear from settings
setSpacePath(null);
setSpaceSchemaVersion(null);
};Schema Version
Glyph still reports a schema_version in SpaceInfo, and the current value is 1, but there is no space.json marker file in the space root.
Today that version is an internal compatibility value returned by the backend, not a user-visible file on disk.
Content-Addressed Storage
Attachments are stored by SHA256 hash inside the space’s assets/ folder.
User attaches file
File is selected via file picker
const 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 the asset filename
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 exist
let dest = space_root.join("assets").join(&asset_name);
if !dest.exists() {
fs::copy(&source_path, &dest)?;
}Return markdown link
Generate relative markdown link
let rel_path = format!("../assets/{}", asset_name);
Ok(AttachmentResult {
asset_rel_path: format!("assets/{}", asset_name),
markdown: format!("", rel_path)
})Benefits
- Deduplication - The same attachment content only needs one file on disk
- Stable paths - Asset filenames are derived from content
- Simple portability - Attached files stay inside the space
State Management
Backend State (src-tauri/src/space/state.rs)
use std::sync::Mutex;
pub struct SpaceState {
pub current: Mutex<Option<CurrentSpace>>,
}
pub struct SpaceState {
pub(crate) current: Mutex<Option<PathBuf>>,
pub(crate) notes_watcher: Mutex<Option<notify::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(|root| 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
}// User-triggered actions
onOpenSpace: () => Promise<void>; // Show folder picker
onOpenSpaceAtPath: (path: string) => Promise<void>; // Open specific path
onContinueLastSpace: () => Promise<void>; // Reopen last space
onCreateSpace: () => Promise<void>; // Create new space
closeSpace: () => Promise<void>; // Close current space
startIndexRebuild: () => Promise<void>; // Manual index rebuildRecent 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:
#[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
Warning
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
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...
}