Initial commit

This commit is contained in:
2026-03-25 09:32:02 +01:00
commit 23ce1b7ee2
77 changed files with 21169 additions and 0 deletions

30
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "brittle-app"
version = "0.1.0"
edition = "2021"
[lib]
name = "brittle_app"
[[bin]]
name = "brittle"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
brittle-core = { path = "../brittle-core" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
dirs = "6"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
thiserror = "2"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v7"] }
[dev-dependencies]
tempfile = "3"

7
src-tauri/Trunk.toml Normal file
View File

@@ -0,0 +1,7 @@
[build]
target = "../src/index.html"
dist = "../dist"
[serve]
port = 1420
open = false

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2/capability.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

View File

@@ -0,0 +1,8 @@
{
"name": "brittle-pdfjs",
"private": true,
"description": "Pre-built pdfjs-dist for the Brittle PDF viewer.",
"dependencies": {
"pdfjs-dist": "3.11.174"
}
}

View File

@@ -0,0 +1,41 @@
//! Tauri commands for PDF annotation CRUD.
use crate::state::AppState;
use brittle_core::{Annotation, AnnotationId, AnnotationType, ReferenceId};
use tauri::State;
#[tauri::command]
pub fn create_annotation(
state: State<AppState>,
reference_id: ReferenceId,
page: u32,
annotation_type: AnnotationType,
content: Option<String>,
) -> Result<Annotation, String> {
state.with_repo(|b| b.create_annotation(reference_id, page, annotation_type, content))
}
#[tauri::command]
pub fn get_annotations(
state: State<AppState>,
reference_id: ReferenceId,
) -> Result<Vec<Annotation>, String> {
state.with_repo_read(|b| b.get_annotations(reference_id))
}
#[tauri::command]
pub fn update_annotation(
state: State<AppState>,
annotation: Annotation,
) -> Result<Annotation, String> {
state.with_repo(|b| b.update_annotation(annotation))
}
#[tauri::command]
pub fn delete_annotation(
state: State<AppState>,
reference_id: ReferenceId,
annotation_id: AnnotationId,
) -> Result<(), String> {
state.with_repo(|b| b.delete_annotation(reference_id, annotation_id))
}

View File

@@ -0,0 +1,44 @@
//! Tauri commands for BibTeX export.
use crate::state::AppState;
use brittle_core::{LibraryId, ReferenceId};
use serde::Serialize;
use tauri::State;
/// Result of a BibTeX export: the formatted string plus any non-fatal errors.
#[derive(Serialize)]
pub struct BibtexExportResult {
pub bibtex: String,
/// Warnings for references that were skipped due to missing required fields.
pub errors: Vec<String>,
}
/// Export a list of references as BibTeX.
#[tauri::command]
pub fn export_bibtex(
state: State<AppState>,
reference_ids: Vec<ReferenceId>,
) -> Result<BibtexExportResult, String> {
state.with_repo_read(|b| {
let (bibtex, errors) = b.export_bibtex(&reference_ids)?;
Ok(BibtexExportResult {
bibtex,
errors: errors.iter().map(|e| e.to_string()).collect(),
})
})
}
/// Export all references in a library as BibTeX.
#[tauri::command]
pub fn export_library_bibtex(
state: State<AppState>,
library_id: LibraryId,
) -> Result<BibtexExportResult, String> {
state.with_repo_read(|b| {
let (bibtex, errors) = b.export_library_bibtex(library_id)?;
Ok(BibtexExportResult {
bibtex,
errors: errors.iter().map(|e| e.to_string()).collect(),
})
})
}

View File

@@ -0,0 +1,73 @@
//! Tauri commands for reading and writing configuration.
use std::path::Path;
use crate::config::{GlobalConfig, ProjectConfig};
/// Load the global config from `~/.config/brittle/config.toml`.
/// Returns the default config if the file does not yet exist.
#[tauri::command]
pub fn load_global_config() -> Result<GlobalConfig, String> {
GlobalConfig::load().map_err(|e| e.to_string())
}
/// Persist the global config to `~/.config/brittle/config.toml`.
#[tauri::command]
pub fn save_global_config(config: GlobalConfig) -> Result<(), String> {
config.save().map_err(|e| e.to_string())
}
/// Load the per-project config from `{repo_path}/.brittle/config.toml`.
/// Returns the default config if the file does not yet exist.
#[tauri::command]
pub fn load_project_config(repo_path: String) -> Result<ProjectConfig, String> {
ProjectConfig::load(Path::new(&repo_path)).map_err(|e| e.to_string())
}
/// Persist the per-project config to `{repo_path}/.brittle/config.toml`.
#[tauri::command]
pub fn save_project_config(repo_path: String, config: ProjectConfig) -> Result<(), String> {
config
.save(Path::new(&repo_path))
.map_err(|e| e.to_string())
}
/// Return the current theme name (`"dark"` or `"light"`) from the global config.
#[tauri::command]
pub fn get_theme() -> Result<String, String> {
use crate::config::Theme;
GlobalConfig::load()
.map(|c| match c.appearance.theme {
Theme::Dark => "dark".to_string(),
Theme::Light => "light".to_string(),
})
.map_err(|e| e.to_string())
}
/// Persist a new theme choice to the global config.
///
/// `theme` must be `"dark"` or `"light"`.
#[tauri::command]
pub fn set_theme(theme: String) -> Result<(), String> {
use crate::config::Theme;
let parsed = match theme.as_str() {
"dark" => Theme::Dark,
"light" => Theme::Light,
other => return Err(format!("unknown theme '{other}'")),
};
let mut config = GlobalConfig::load().map_err(|e| e.to_string())?;
config.appearance.theme = parsed;
config.save().map_err(|e| e.to_string())
}
/// Return the user's keybinding overrides from the global config.
///
/// Map keys are action names in snake_case (e.g. `"tab_next"`); values are
/// key-sequence strings (e.g. `"<C-Right>"`). Returns an empty map if no
/// config file exists or the `[keybindings]` section is absent.
#[tauri::command]
pub fn get_keybindings() -> Result<std::collections::HashMap<String, String>, String> {
GlobalConfig::load()
.map(|c| c.keybindings.0)
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,97 @@
//! Tauri commands for library CRUD, hierarchy, and membership.
use crate::state::AppState;
use brittle_core::{Library, LibraryId, ReferenceId};
use tauri::State;
#[tauri::command]
pub fn create_library(
state: State<AppState>,
name: String,
parent_id: Option<LibraryId>,
) -> Result<Library, String> {
state.with_repo(|b| b.create_library(name, parent_id))
}
#[tauri::command]
pub fn get_library(state: State<AppState>, id: LibraryId) -> Result<Library, String> {
state.with_repo_read(|b| b.get_library(id))
}
#[tauri::command]
pub fn rename_library(
state: State<AppState>,
id: LibraryId,
new_name: String,
) -> Result<Library, String> {
state.with_repo(|b| b.rename_library(id, new_name))
}
#[tauri::command]
pub fn move_library(
state: State<AppState>,
id: LibraryId,
new_parent: Option<LibraryId>,
) -> Result<Library, String> {
state.with_repo(|b| b.move_library(id, new_parent))
}
/// Delete a library. Fails if it has child libraries.
#[tauri::command]
pub fn delete_library(state: State<AppState>, id: LibraryId) -> Result<(), String> {
state.with_repo(|b| b.delete_library(id))
}
/// Delete a library and all its descendants (recursive).
#[tauri::command]
pub fn force_delete_library(state: State<AppState>, id: LibraryId) -> Result<(), String> {
state.with_repo(|b| b.force_delete_library(id))
}
#[tauri::command]
pub fn list_root_libraries(state: State<AppState>) -> Result<Vec<Library>, String> {
state.with_repo_read(|b| b.list_root_libraries())
}
#[tauri::command]
pub fn list_child_libraries(
state: State<AppState>,
parent_id: LibraryId,
) -> Result<Vec<Library>, String> {
state.with_repo_read(|b| b.list_child_libraries(parent_id))
}
/// Return the ancestor chain of a library, ordered root → direct parent.
#[tauri::command]
pub fn get_library_ancestors(
state: State<AppState>,
id: LibraryId,
) -> Result<Vec<Library>, String> {
state.with_repo_read(|b| b.get_library_ancestors(id))
}
#[tauri::command]
pub fn add_to_library(
state: State<AppState>,
library_id: LibraryId,
reference_id: ReferenceId,
) -> Result<(), String> {
state.with_repo(|b| b.add_to_library(library_id, reference_id))
}
#[tauri::command]
pub fn remove_from_library(
state: State<AppState>,
library_id: LibraryId,
reference_id: ReferenceId,
) -> Result<(), String> {
state.with_repo(|b| b.remove_from_library(library_id, reference_id))
}
#[tauri::command]
pub fn list_reference_libraries(
state: State<AppState>,
reference_id: ReferenceId,
) -> Result<Vec<Library>, String> {
state.with_repo_read(|b| b.list_reference_libraries(reference_id))
}

View File

@@ -0,0 +1,9 @@
pub mod annotation;
pub mod bibtex;
pub mod config;
pub mod library;
pub mod pdf;
pub mod reference;
pub mod repository;
pub mod snapshot;
pub mod window;

View File

@@ -0,0 +1,31 @@
//! Tauri commands for PDF attachment and retrieval.
use crate::state::AppState;
use brittle_core::{PdfAttachment, ReferenceId};
use std::path::PathBuf;
use tauri::State;
/// Attach a PDF file to a reference by copying it into the repository.
///
/// `source_path` is the absolute path to the file to copy.
/// Returns the stored attachment metadata.
#[tauri::command]
pub fn attach_pdf(
state: State<AppState>,
reference_id: ReferenceId,
source_path: String,
) -> Result<PdfAttachment, String> {
let path = PathBuf::from(&source_path);
state.with_repo(|b| b.attach_pdf(reference_id, &path))
}
/// Return the absolute filesystem path to the PDF attached to a reference.
///
/// Returns an error if no PDF is attached.
#[tauri::command]
pub fn get_pdf_path(state: State<AppState>, reference_id: ReferenceId) -> Result<String, String> {
state.with_repo_read(|b| {
b.get_pdf_path(reference_id)
.map(|p| p.to_string_lossy().into_owned())
})
}

View File

@@ -0,0 +1,87 @@
//! Tauri commands for reference CRUD and search.
use crate::state::AppState;
use brittle_core::{EntryType, LibraryId, Reference, ReferenceId, ReferenceSummary};
use tauri::State;
#[tauri::command]
pub fn create_reference(
state: State<AppState>,
cite_key: String,
entry_type: EntryType,
) -> Result<Reference, String> {
state.with_repo(|b| b.create_reference(cite_key, entry_type))
}
#[tauri::command]
pub fn get_reference(state: State<AppState>, id: ReferenceId) -> Result<Reference, String> {
state.with_repo_read(|b| b.get_reference(id))
}
#[tauri::command]
pub fn update_reference(state: State<AppState>, reference: Reference) -> Result<Reference, String> {
state.with_repo(|b| b.update_reference(reference))
}
#[tauri::command]
pub fn delete_reference(state: State<AppState>, id: ReferenceId) -> Result<(), String> {
state.with_repo(|b| b.delete_reference(id))
}
#[tauri::command]
pub fn list_references(state: State<AppState>) -> Result<Vec<ReferenceSummary>, String> {
state.with_repo_read(|b| b.list_references())
}
#[tauri::command]
pub fn set_field(
state: State<AppState>,
id: ReferenceId,
field: String,
value: String,
) -> Result<(), String> {
state.with_repo(|b| b.set_field(id, &field, value))
}
#[tauri::command]
pub fn remove_field(state: State<AppState>, id: ReferenceId, field: String) -> Result<(), String> {
state.with_repo(|b| b.remove_field(id, &field))
}
#[tauri::command]
pub fn search_references(
state: State<AppState>,
query: String,
) -> Result<Vec<ReferenceSummary>, String> {
state.with_repo_read(|b| b.search_references(&query))
}
#[tauri::command]
pub fn search_library_references(
state: State<AppState>,
library_id: LibraryId,
query: String,
) -> Result<Vec<ReferenceSummary>, String> {
state.with_repo_read(|b| b.search_library_references(library_id, &query))
}
#[tauri::command]
pub fn list_library_references(
state: State<AppState>,
library_id: LibraryId,
) -> Result<Vec<ReferenceSummary>, String> {
state.with_repo_read(|b| b.list_library_references(library_id))
}
#[tauri::command]
pub fn list_library_references_recursive(
state: State<AppState>,
library_id: LibraryId,
) -> Result<Vec<ReferenceSummary>, String> {
let result = state.with_repo_read(|b| b.list_library_references_recursive(library_id));
match &result {
Ok(refs) => eprintln!("[brittle] list_library_references_recursive({library_id}): {} refs", refs.len()),
Err(e) => eprintln!("[brittle] list_library_references_recursive({library_id}): ERROR: {e}"),
}
result
}

View File

@@ -0,0 +1,60 @@
//! Tauri commands for repository lifecycle (create, open, close).
use crate::state::AppState;
use brittle_core::Brittle;
use std::path::PathBuf;
use tauri::State;
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
}
if path == "~" {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home);
}
}
PathBuf::from(path)
}
/// Create a new Brittle repository at `path` and open it.
#[tauri::command]
pub fn create_repository(state: State<AppState>, path: String) -> Result<(), String> {
let path = expand_tilde(&path);
let brittle = Brittle::create(&path).map_err(|e| e.to_string())?;
*state
.brittle
.lock()
.map_err(|_| "lock poisoned".to_string())? = Some(brittle);
Ok(())
}
/// Open an existing Brittle repository at `path`.
#[tauri::command]
pub fn open_repository(state: State<AppState>, path: String) -> Result<(), String> {
let path = expand_tilde(&path);
let brittle = Brittle::open(&path).map_err(|e| e.to_string())?;
*state
.brittle
.lock()
.map_err(|_| "lock poisoned".to_string())? = Some(brittle);
Ok(())
}
/// Close the currently open repository. No-op if none is open.
#[tauri::command]
pub fn close_repository(state: State<AppState>) -> Result<(), String> {
*state
.brittle
.lock()
.map_err(|_| "lock poisoned".to_string())? = None;
Ok(())
}
/// Return the filesystem path of the currently open repository.
#[tauri::command]
pub fn repository_root(state: State<AppState>) -> Result<String, String> {
state.with_repo_read(|b| Ok(b.repository_root().to_string_lossy().into_owned()))
}

View File

@@ -0,0 +1,33 @@
//! Tauri commands for snapshotting (git-backed history).
use crate::state::AppState;
use brittle_core::Snapshot;
use tauri::State;
#[tauri::command]
pub fn create_snapshot(state: State<AppState>, message: String) -> Result<Snapshot, String> {
state.with_repo(|b| b.create_snapshot(&message))
}
#[tauri::command]
pub fn list_snapshots(state: State<AppState>) -> Result<Vec<Snapshot>, String> {
state.with_repo_read(|b| b.list_snapshots())
}
/// Restore to a named snapshot. Fails if there are uncommitted changes.
/// Use `discard_changes` first if needed.
#[tauri::command]
pub fn restore_snapshot(state: State<AppState>, snapshot_id: String) -> Result<(), String> {
state.with_repo(|b| b.restore_snapshot(&snapshot_id))
}
#[tauri::command]
pub fn has_uncommitted_changes(state: State<AppState>) -> Result<bool, String> {
state.with_repo_read(|b| b.has_uncommitted_changes())
}
/// Discard all uncommitted changes, reverting to the last snapshot.
#[tauri::command]
pub fn discard_changes(state: State<AppState>) -> Result<(), String> {
state.with_repo(|b| b.discard_changes())
}

View File

@@ -0,0 +1,54 @@
//! Tauri commands for managing PDF viewer windows.
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
/// Open a PDF viewer window for the given reference ID.
///
/// If a window for this reference is already open it is focused instead of
/// creating a duplicate.
///
/// The window loads `brittle://app/viewer?ref_id=<ref_id>`.
#[tauri::command]
pub fn open_pdf_window(app: AppHandle, ref_id: String) -> Result<(), String> {
let label = format!("pdf-{}", ref_id);
// If already open, just focus it.
if let Some(win) = app.get_webview_window(&label) {
win.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let url_str = format!(
"brittle://app/viewer?ref_id={}",
urlencoding::encode(&ref_id)
);
let url = url_str.parse::<url::Url>().map_err(|e| e.to_string())?;
WebviewWindowBuilder::new(&app, &label, WebviewUrl::External(url))
.title("PDF Viewer — Brittle")
.inner_size(900.0, 750.0)
.min_inner_size(600.0, 400.0)
.build()
.map_err(|e| e.to_string())?;
Ok(())
}
/// Close the PDF viewer window for the given reference ID, if open.
#[tauri::command]
pub fn close_pdf_window(app: AppHandle, ref_id: String) -> Result<(), String> {
let label = format!("pdf-{}", ref_id);
if let Some(win) = app.get_webview_window(&label) {
win.close().map_err(|e| e.to_string())?;
}
Ok(())
}
/// Return the labels of all currently open PDF viewer windows.
#[tauri::command]
pub fn list_pdf_windows(app: AppHandle) -> Vec<String> {
app.webview_windows()
.into_keys()
.filter(|label| label.starts_with("pdf-"))
.collect()
}

View File

@@ -0,0 +1,183 @@
//! Global (user-wide) configuration.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{AppearanceConfig, ConfigError, KeybindingsConfig, LayoutConfig};
/// Record of recently opened repositories, stored in the global config.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ProjectsState {
pub recent: Vec<PathBuf>,
pub last_opened: Option<PathBuf>,
}
/// User-wide configuration, stored at `~/.config/brittle/config.toml`.
///
/// All fields are optional in the file; missing fields fall back to their
/// `Default` implementations.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GlobalConfig {
pub appearance: AppearanceConfig,
pub layout: LayoutConfig,
pub projects: ProjectsState,
/// Flat map of action name → key combo string for user-defined overrides.
#[serde(with = "keybindings_map")]
pub keybindings: KeybindingsConfig,
}
impl GlobalConfig {
/// Load from the standard platform config directory.
///
/// Returns `Default` if the file does not yet exist.
pub fn load() -> Result<Self, ConfigError> {
Self::load_from(&global_config_path()?)
}
/// Load from an explicit path.
///
/// Returns `Default` if the file does not exist.
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
}
/// Save to the standard platform config directory,
/// creating parent directories as needed.
pub fn save(&self) -> Result<(), ConfigError> {
self.save_to(&global_config_path()?)
}
/// Save to an explicit path, creating parent directories as needed.
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml::to_string_pretty(self)?)?;
Ok(())
}
}
fn global_config_path() -> Result<PathBuf, ConfigError> {
dirs::config_dir()
.ok_or(ConfigError::NoConfigDir)
.map(|d| d.join("brittle").join("config.toml"))
}
/// Custom serde module so `KeybindingsConfig` round-trips as a flat TOML table.
///
/// Stored in the file as:
/// ```toml
/// [keybindings]
/// focus_left = "H"
/// tab_next = "gt"
/// ```
mod keybindings_map {
use super::*;
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(kc: &KeybindingsConfig, s: S) -> Result<S::Ok, S::Error> {
kc.0.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<KeybindingsConfig, D::Error> {
HashMap::<String, String>::deserialize(d).map(KeybindingsConfig)
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use tempfile::TempDir;
#[test]
fn global_config_defaults() {
let cfg = GlobalConfig::default();
assert_eq!(cfg.appearance.theme, Theme::Dark);
assert_eq!(cfg.appearance.font_size, 14);
assert!(cfg.projects.recent.is_empty());
assert!(cfg.keybindings.0.is_empty());
}
#[test]
fn global_config_round_trips() {
let mut cfg = GlobalConfig::default();
cfg.appearance.theme = Theme::Light;
cfg.appearance.font_size = 16;
cfg.keybindings
.0
.insert("focus_left".to_string(), "C-h".to_string());
let s = toml::to_string_pretty(&cfg).unwrap();
let parsed: GlobalConfig = toml::from_str(&s).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn empty_toml_uses_all_defaults() {
let cfg: GlobalConfig = toml::from_str("").unwrap();
assert_eq!(cfg.appearance.font_size, 14);
assert!((cfg.layout.left_pane_fraction - 0.20).abs() < f32::EPSILON);
}
#[test]
fn partial_toml_uses_defaults_for_missing_sections() {
let toml = "[appearance]\ntheme = \"light\"\n";
let cfg: GlobalConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.appearance.theme, Theme::Light);
// layout not specified — should be default
assert!((cfg.layout.left_pane_fraction - 0.20).abs() < f32::EPSILON);
}
#[test]
fn load_from_nonexistent_path_returns_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("does_not_exist.toml");
let cfg = GlobalConfig::load_from(&path).unwrap();
assert_eq!(cfg, GlobalConfig::default());
}
#[test]
fn save_to_and_load_from_round_trip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let mut original = GlobalConfig::default();
original.appearance.theme = Theme::Light;
original.appearance.font_size = 18;
original.save_to(&path).unwrap();
let loaded = GlobalConfig::load_from(&path).unwrap();
assert_eq!(loaded, original);
}
#[test]
fn save_to_creates_parent_directories() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nested").join("dirs").join("config.toml");
GlobalConfig::default().save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
fn keybinding_overrides_round_trip() {
let mut cfg = GlobalConfig::default();
cfg.keybindings
.0
.insert("tab_next".to_string(), "C-Right".to_string());
let s = toml::to_string_pretty(&cfg).unwrap();
let parsed: GlobalConfig = toml::from_str(&s).unwrap();
assert_eq!(parsed.keybindings.0["tab_next"], "C-Right");
}
}

279
src-tauri/src/config/mod.rs Normal file
View File

@@ -0,0 +1,279 @@
//! Configuration types for Brittle.
//!
//! Two levels of config exist:
//! - [`GlobalConfig`] — stored at `~/.config/brittle/config.toml`; applies to all projects.
//! - [`ProjectConfig`] — stored at `{repo}/.brittle/config.toml`; overrides globals per project.
//!
//! Use [`MergedConfig::merge`] to produce the effective config the app should use.
mod global;
mod project;
pub use global::GlobalConfig;
pub use project::ProjectConfig;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// ── Shared types ──────────────────────────────────────────────────────────────
/// Application colour theme.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Dark,
Light,
}
/// Appearance settings (font size, theme).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppearanceConfig {
pub theme: Theme,
pub font_size: u32,
}
impl Default for AppearanceConfig {
fn default() -> Self {
Self {
theme: Theme::Dark,
font_size: 14,
}
}
}
/// Partial appearance override from a project config.
/// Only `Some` fields replace the global value.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AppearanceOverride {
pub theme: Option<Theme>,
pub font_size: Option<u32>,
}
/// Pane layout proportions (fractions of the window width, 0..1).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LayoutConfig {
pub left_pane_fraction: f32,
pub right_pane_fraction: f32,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
left_pane_fraction: 0.20,
right_pane_fraction: 0.35,
}
}
}
/// Keybinding overrides — maps action name to key combo string.
///
/// Only actions the user wants to rebind need an entry here; everything else
/// falls back to the built-in defaults defined in `keymap`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct KeybindingsConfig(pub HashMap<String, String>);
// ── Merged config ─────────────────────────────────────────────────────────────
/// The effective config the application uses at runtime, produced by merging
/// global defaults with per-project overrides.
#[derive(Debug, Clone)]
#[allow(dead_code)] // consumed in Phase 3 when AppState is wired up
pub struct MergedConfig {
pub appearance: AppearanceConfig,
pub layout: LayoutConfig,
pub keybindings: KeybindingsConfig,
/// Reference IDs of tabs that should be restored on launch.
pub open_tabs: Vec<String>,
}
impl MergedConfig {
#[allow(dead_code)] // consumed in Phase 3 when AppState is wired up
/// Merge `global` with an optional `project` override.
///
/// Project values take precedence for appearance; layout and keybindings
/// are always taken from the global config (per-project overrides for those
/// are intentionally not supported — it would be confusing).
pub fn merge(global: &GlobalConfig, project: Option<&ProjectConfig>) -> Self {
let appearance = match project.and_then(|p| p.appearance.as_ref()) {
Some(ov) => AppearanceConfig {
theme: ov
.theme
.clone()
.unwrap_or_else(|| global.appearance.theme.clone()),
font_size: ov.font_size.unwrap_or(global.appearance.font_size),
},
None => global.appearance.clone(),
};
Self {
appearance,
layout: global.layout.clone(),
keybindings: global.keybindings.clone(),
open_tabs: project
.map(|p| p.session.open_tabs.clone())
.unwrap_or_default(),
}
}
}
// ── Error type ────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("no config directory found on this platform")]
NoConfigDir,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("TOML serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::config::project::SessionConfig;
// ── AppearanceConfig ──────────────────────────────────────────────────────
#[test]
fn appearance_defaults_are_dark_14pt() {
let a = AppearanceConfig::default();
assert_eq!(a.theme, Theme::Dark);
assert_eq!(a.font_size, 14);
}
#[test]
fn appearance_round_trips() {
let original = AppearanceConfig {
theme: Theme::Light,
font_size: 16,
};
let s = toml::to_string_pretty(&original).unwrap();
let parsed: AppearanceConfig = toml::from_str(&s).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn appearance_missing_fields_use_defaults() {
// Only theme specified; font_size should fall back to 14.
let parsed: AppearanceConfig = toml::from_str("theme = \"light\"").unwrap();
assert_eq!(parsed.theme, Theme::Light);
assert_eq!(parsed.font_size, 14);
}
// ── LayoutConfig ─────────────────────────────────────────────────────────
#[test]
fn layout_defaults() {
let l = LayoutConfig::default();
assert!((l.left_pane_fraction - 0.20).abs() < f32::EPSILON);
assert!((l.right_pane_fraction - 0.35).abs() < f32::EPSILON);
}
#[test]
fn layout_round_trips() {
let original = LayoutConfig {
left_pane_fraction: 0.25,
right_pane_fraction: 0.40,
};
let s = toml::to_string_pretty(&original).unwrap();
let parsed: LayoutConfig = toml::from_str(&s).unwrap();
assert_eq!(parsed, original);
}
// ── MergedConfig ─────────────────────────────────────────────────────────
#[test]
fn merge_without_project_uses_globals() {
let global = GlobalConfig {
appearance: AppearanceConfig {
theme: Theme::Light,
font_size: 16,
},
..Default::default()
};
let merged = MergedConfig::merge(&global, None);
assert_eq!(merged.appearance.theme, Theme::Light);
assert_eq!(merged.appearance.font_size, 16);
assert!(merged.open_tabs.is_empty());
}
#[test]
fn merge_project_overrides_theme() {
let global = GlobalConfig {
appearance: AppearanceConfig {
theme: Theme::Dark,
font_size: 14,
},
..Default::default()
};
let project = ProjectConfig {
appearance: Some(AppearanceOverride {
theme: Some(Theme::Light),
font_size: None,
}),
..Default::default()
};
let merged = MergedConfig::merge(&global, Some(&project));
assert_eq!(merged.appearance.theme, Theme::Light);
// font_size not overridden — inherits from global
assert_eq!(merged.appearance.font_size, 14);
}
#[test]
fn merge_project_overrides_font_size_only() {
let global = GlobalConfig {
appearance: AppearanceConfig {
theme: Theme::Dark,
font_size: 14,
},
..Default::default()
};
let project = ProjectConfig {
appearance: Some(AppearanceOverride {
theme: None,
font_size: Some(18),
}),
..Default::default()
};
let merged = MergedConfig::merge(&global, Some(&project));
assert_eq!(merged.appearance.theme, Theme::Dark);
assert_eq!(merged.appearance.font_size, 18);
}
#[test]
fn merge_project_open_tabs_are_included() {
let global = GlobalConfig::default();
let project = ProjectConfig {
session: SessionConfig {
open_tabs: vec!["tab-a".to_string(), "tab-b".to_string()],
},
..Default::default()
};
let merged = MergedConfig::merge(&global, Some(&project));
assert_eq!(merged.open_tabs, ["tab-a", "tab-b"]);
}
#[test]
fn merge_layout_always_from_global() {
let global = GlobalConfig {
layout: LayoutConfig {
left_pane_fraction: 0.30,
right_pane_fraction: 0.40,
},
..Default::default()
};
// Project config has no layout override capability.
let merged = MergedConfig::merge(&global, None);
assert!((merged.layout.left_pane_fraction - 0.30).abs() < f32::EPSILON);
}
}

View File

@@ -0,0 +1,160 @@
//! Per-project configuration.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{AppearanceOverride, ConfigError};
/// Reference IDs of tabs to restore when the project is next opened.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SessionConfig {
pub open_tabs: Vec<String>,
}
/// Per-project configuration, stored at `{repo}/.brittle/config.toml`.
///
/// This file should be in the project's `.gitignore` — it holds local session
/// state and optional appearance overrides that are personal to this machine.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ProjectConfig {
/// Optional per-project appearance overrides. `None` means "use globals."
pub appearance: Option<AppearanceOverride>,
pub session: SessionConfig,
}
impl ProjectConfig {
/// Load from `{repo_root}/.brittle/config.toml`.
///
/// Returns `Default` if the file does not yet exist.
pub fn load(repo_root: &Path) -> Result<Self, ConfigError> {
Self::load_from(&project_config_path(repo_root))
}
/// Load from an explicit path.
///
/// Returns `Default` if the file does not exist.
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
}
/// Save to `{repo_root}/.brittle/config.toml`.
pub fn save(&self, repo_root: &Path) -> Result<(), ConfigError> {
self.save_to(&project_config_path(repo_root))
}
/// Save to an explicit path, creating parent directories as needed.
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml::to_string_pretty(self)?)?;
Ok(())
}
}
/// Returns the canonical path for a project's config file.
pub fn project_config_path(repo_root: &Path) -> PathBuf {
repo_root.join(".brittle").join("config.toml")
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use tempfile::TempDir;
#[test]
fn project_config_defaults() {
let cfg = ProjectConfig::default();
assert!(cfg.appearance.is_none());
assert!(cfg.session.open_tabs.is_empty());
}
#[test]
fn project_config_round_trips() {
let cfg = ProjectConfig {
appearance: Some(AppearanceOverride {
theme: Some(Theme::Light),
font_size: Some(16),
}),
session: SessionConfig {
open_tabs: vec!["abc-123".to_string()],
},
};
let s = toml::to_string_pretty(&cfg).unwrap();
let parsed: ProjectConfig = toml::from_str(&s).unwrap();
assert_eq!(parsed, cfg);
}
#[test]
fn empty_toml_uses_defaults() {
let cfg: ProjectConfig = toml::from_str("").unwrap();
assert_eq!(cfg, ProjectConfig::default());
}
#[test]
fn partial_appearance_override_keeps_none_fields() {
// Only theme specified; font_size should remain None.
let toml = "[appearance]\ntheme = \"light\"\n";
let cfg: ProjectConfig = toml::from_str(toml).unwrap();
let ov = cfg.appearance.unwrap();
assert_eq!(ov.theme, Some(Theme::Light));
assert!(ov.font_size.is_none());
}
#[test]
fn load_from_nonexistent_path_returns_default() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nope.toml");
let cfg = ProjectConfig::load_from(&path).unwrap();
assert_eq!(cfg, ProjectConfig::default());
}
#[test]
fn save_to_and_load_from_round_trip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("config.toml");
let original = ProjectConfig {
appearance: Some(AppearanceOverride {
theme: Some(Theme::Dark),
font_size: None,
}),
session: SessionConfig {
open_tabs: vec!["ref-1".to_string()],
},
};
original.save_to(&path).unwrap();
let loaded = ProjectConfig::load_from(&path).unwrap();
assert_eq!(loaded, original);
}
#[test]
fn load_and_save_use_brittle_subdir() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let original = ProjectConfig {
session: SessionConfig {
open_tabs: vec!["x".to_string()],
},
..Default::default()
};
original.save(repo).unwrap();
assert!(repo.join(".brittle").join("config.toml").exists());
let loaded = ProjectConfig::load(repo).unwrap();
assert_eq!(loaded, original);
}
}

87
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,87 @@
mod commands;
mod config;
mod pdf_protocol;
pub mod state;
use state::AppState;
pub fn run() {
tauri::Builder::default()
.manage(AppState::new())
.setup(|app| {
#[cfg(debug_assertions)]
{
use tauri::Manager;
if let Some(win) = app.get_webview_window("main") {
win.open_devtools();
}
}
Ok(())
})
.register_uri_scheme_protocol("brittle", |ctx, req| {
pdf_protocol::handle(ctx.app_handle(), &req)
})
.invoke_handler(tauri::generate_handler![
// config
commands::config::load_global_config,
commands::config::save_global_config,
commands::config::load_project_config,
commands::config::save_project_config,
commands::config::get_theme,
commands::config::set_theme,
commands::config::get_keybindings,
// repository
commands::repository::create_repository,
commands::repository::open_repository,
commands::repository::close_repository,
commands::repository::repository_root,
// reference
commands::reference::create_reference,
commands::reference::get_reference,
commands::reference::update_reference,
commands::reference::delete_reference,
commands::reference::list_references,
commands::reference::set_field,
commands::reference::remove_field,
commands::reference::search_references,
commands::reference::search_library_references,
commands::reference::list_library_references,
commands::reference::list_library_references_recursive,
// library
commands::library::create_library,
commands::library::get_library,
commands::library::rename_library,
commands::library::move_library,
commands::library::delete_library,
commands::library::force_delete_library,
commands::library::list_root_libraries,
commands::library::list_child_libraries,
commands::library::get_library_ancestors,
commands::library::add_to_library,
commands::library::remove_from_library,
commands::library::list_reference_libraries,
// annotation
commands::annotation::create_annotation,
commands::annotation::get_annotations,
commands::annotation::update_annotation,
commands::annotation::delete_annotation,
// pdf
commands::pdf::attach_pdf,
commands::pdf::get_pdf_path,
// snapshot
commands::snapshot::create_snapshot,
commands::snapshot::list_snapshots,
commands::snapshot::restore_snapshot,
commands::snapshot::has_uncommitted_changes,
commands::snapshot::discard_changes,
// bibtex
commands::bibtex::export_bibtex,
commands::bibtex::export_library_bibtex,
// window
commands::window::open_pdf_window,
commands::window::close_pdf_window,
commands::window::list_pdf_windows,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
brittle_app::run()
}

View File

@@ -0,0 +1,320 @@
//! Custom `brittle://` URI scheme handler.
//!
//! Routes:
//! `brittle://app/viewer?ref_id=<uuid>` — the PDF viewer HTML page
//! `brittle://app/pdfjs/<path>` — pdfjs-dist static assets
//! `brittle://app/pdf?ref_id=<uuid>` — raw PDF bytes from the repository
//!
//! The pure routing and path-resolution logic lives in the `routing` sub-module
//! so it can be unit-tested without a running Tauri application.
use std::path::PathBuf;
use tauri::{
http::{header, Request, Response, StatusCode},
AppHandle, Manager, Runtime,
};
use crate::state::AppState;
// ── Embedded assets ───────────────────────────────────────────────────────────
static VIEWER_HTML: &[u8] = include_bytes!("pdf_viewer.html");
// ── Public API ────────────────────────────────────────────────────────────────
/// Entry point registered with `tauri::Builder::register_uri_scheme_protocol`.
///
/// Generic over the Tauri runtime so the function can be used from a closure
/// in the builder without knowing the concrete runtime at compile time.
pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Response<Vec<u8>> {
let uri = req.uri();
match routing::classify(uri.path(), uri.query()) {
routing::Route::Viewer { ref_id } => serve_viewer(&ref_id),
routing::Route::PdfjsAsset { rel_path } => {
if rel_path.contains("..") {
return response_403();
}
serve_pdfjs_file(&pdfjs_root(app), &rel_path)
}
routing::Route::Pdf { ref_id } => serve_pdf(app, &ref_id),
routing::Route::NotFound => response_404(),
}
}
// ── Route handlers ────────────────────────────────────────────────────────────
fn serve_viewer(ref_id: &str) -> Response<Vec<u8>> {
// Substitute the ref_id into the HTML template so the viewer knows which PDF to load.
let html = String::from_utf8_lossy(VIEWER_HTML)
.replace("ref_id=\"\"", &format!("ref_id=\"{}\"", ref_id));
response_ok(html.into_bytes(), "text/html; charset=utf-8")
}
fn serve_pdfjs_file(pdfjs_root: &std::path::Path, rel_path: &str) -> Response<Vec<u8>> {
let full_path = pdfjs_root.join(rel_path);
match std::fs::read(&full_path) {
Ok(bytes) => {
let mime = routing::mime_for_path(&full_path);
let mut resp = response_ok(bytes, mime);
resp.headers_mut().insert(
header::CACHE_CONTROL,
"public, max-age=3600".parse().unwrap(),
);
resp
}
Err(_) => response_404(),
}
}
fn serve_pdf<R: Runtime>(app: &AppHandle<R>, ref_id: &str) -> Response<Vec<u8>> {
use brittle_core::{model::ids::ReferenceId, store::FsStore, Brittle};
use uuid::Uuid;
let uuid = match Uuid::parse_str(ref_id) {
Ok(u) => u,
Err(_) => return response_400("invalid ref_id: not a valid UUID"),
};
let rid = ReferenceId::from(uuid);
let state = app.state::<AppState>();
let pdf_path: Result<PathBuf, String> =
state.with_repo_read(|b: &Brittle<FsStore>| b.get_pdf_path(rid));
match pdf_path {
Err(e) => response_404_msg(&e),
Ok(path) => match std::fs::read(&path) {
Ok(bytes) => {
let mut resp = response_ok(bytes, "application/pdf");
resp.headers_mut()
.insert(header::CACHE_CONTROL, "no-store".parse().unwrap());
resp
}
Err(e) => response_500(&e.to_string()),
},
}
}
// ── Path resolution ───────────────────────────────────────────────────────────
fn pdfjs_root<R: Runtime>(app: &AppHandle<R>) -> PathBuf {
if cfg!(debug_assertions) {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("pdfjs")
.join("node_modules")
.join("pdfjs-dist")
} else {
app.path()
.resource_dir()
.unwrap_or_default()
.join("pdfjs-dist")
}
}
// ── Response builders ─────────────────────────────────────────────────────────
fn response_ok(body: Vec<u8>, content_type: &str) -> Response<Vec<u8>> {
Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.status(StatusCode::OK)
.body(body)
.unwrap()
}
fn response_404() -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(b"Not Found".to_vec())
.unwrap()
}
fn response_404_msg(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn response_400(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(msg.as_bytes().to_vec())
.unwrap()
}
fn response_403() -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::FORBIDDEN)
.body(b"Forbidden".to_vec())
.unwrap()
}
fn response_500(msg: &str) -> Response<Vec<u8>> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(msg.as_bytes().to_vec())
.unwrap()
}
// ── Pure routing logic (unit-testable) ───────────────────────────────────────
pub mod routing {
use std::path::Path;
#[derive(Debug, PartialEq)]
pub enum Route {
Viewer { ref_id: String },
PdfjsAsset { rel_path: String },
Pdf { ref_id: String },
NotFound,
}
/// Classify a `brittle://app/{path}?{query}` request into a `Route`.
pub fn classify(path: &str, query: Option<&str>) -> Route {
let ref_id = extract_ref_id(query);
if path == "/viewer" {
Route::Viewer { ref_id }
} else if let Some(rel) = path.strip_prefix("/pdfjs/") {
Route::PdfjsAsset {
rel_path: rel.to_owned(),
}
} else if path == "/pdf" {
Route::Pdf { ref_id }
} else {
Route::NotFound
}
}
/// Extract the value of `ref_id=…` from a URL query string.
pub fn extract_ref_id(query: Option<&str>) -> String {
query
.unwrap_or("")
.split('&')
.find_map(|part| part.strip_prefix("ref_id="))
.unwrap_or("")
.to_owned()
}
/// Return the appropriate MIME type for a file path based on its extension.
pub fn mime_for_path(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("js") => "application/javascript; charset=utf-8",
Some("mjs") => "application/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("html") => "text/html; charset=utf-8",
Some("pdf") => "application/pdf",
Some("woff2") => "font/woff2",
Some("woff") => "font/woff",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("svg") => "image/svg+xml",
Some("map") => "application/json",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewer_route() {
let r = classify("/viewer", Some("ref_id=abc-123"));
assert_eq!(
r,
Route::Viewer {
ref_id: "abc-123".into()
}
);
}
#[test]
fn viewer_route_no_ref_id() {
let r = classify("/viewer", None);
assert_eq!(r, Route::Viewer { ref_id: "".into() });
}
#[test]
fn pdfjs_asset_route_build_file() {
let r = classify("/pdfjs/build/pdf.min.js", None);
assert_eq!(
r,
Route::PdfjsAsset {
rel_path: "build/pdf.min.js".into()
}
);
}
#[test]
fn pdfjs_asset_route_nested() {
let r = classify("/pdfjs/web/pdf_viewer.css", None);
assert_eq!(
r,
Route::PdfjsAsset {
rel_path: "web/pdf_viewer.css".into()
}
);
}
#[test]
fn pdf_route() {
let r = classify("/pdf", Some("ref_id=01234567-89ab-cdef-0123-456789abcdef"));
assert_eq!(
r,
Route::Pdf {
ref_id: "01234567-89ab-cdef-0123-456789abcdef".into()
}
);
}
#[test]
fn unknown_paths_are_not_found() {
assert_eq!(classify("/unknown", None), Route::NotFound);
assert_eq!(classify("/", None), Route::NotFound);
assert_eq!(classify("/favicon.ico", None), Route::NotFound);
}
#[test]
fn extract_ref_id_from_compound_query() {
let id = extract_ref_id(Some("foo=bar&ref_id=my-id&baz=1"));
assert_eq!(id, "my-id");
}
#[test]
fn extract_ref_id_missing_returns_empty() {
assert_eq!(extract_ref_id(None), "");
assert_eq!(extract_ref_id(Some("foo=bar")), "");
}
#[test]
fn mime_for_js_files() {
assert!(mime_for_path(Path::new("pdf.min.js")).contains("javascript"));
}
#[test]
fn mime_for_css_files() {
assert!(mime_for_path(Path::new("viewer.css")).contains("css"));
}
#[test]
fn mime_for_unknown_extension() {
assert_eq!(
mime_for_path(Path::new("data.bin")),
"application/octet-stream"
);
}
#[test]
fn path_traversal_rel_path_contains_dotdot() {
// The handler rejects rel_paths containing ".."; verify the routing
// surfaces them so the handler can block them.
if let Route::PdfjsAsset { rel_path } = classify("/pdfjs/../secret", None) {
assert!(rel_path.contains(".."));
} else {
panic!("expected PdfjsAsset route");
}
}
}
}

View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Viewer — Brittle</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
background: #3a3a3a;
font-family: system-ui, -apple-system, sans-serif;
color: #ccc;
}
body { display: flex; flex-direction: column; }
#error-banner {
display: none;
flex-shrink: 0;
background: #5c1a1a;
color: #f99;
padding: 10px 20px;
font-size: 13px;
border-bottom: 1px solid #7a2222;
}
#toolbar {
flex-shrink: 0;
background: #252525;
border-bottom: 1px solid #444;
padding: 5px 14px;
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
}
#zoom-controls { display: flex; align-items: center; gap: 6px; }
button {
background: #3d3d3d;
color: #ccc;
border: 1px solid #555;
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
min-width: 28px;
}
button:hover { background: #4a4a4a; }
#zoom-label {
min-width: 46px;
text-align: center;
font-size: 12px;
color: #bbb;
}
#page-indicator {
color: #888;
font-size: 12px;
}
#status {
margin-left: auto;
color: #888;
font-size: 12px;
}
/* Scrollable page stack */
#canvas-container {
flex: 1;
overflow-y: auto;
overflow-x: auto;
padding: 20px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.page-wrapper { flex-shrink: 0; }
.page-wrapper canvas {
display: block;
background: #fff;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
}
</style>
</head>
<body>
<div id="error-banner"></div>
<div id="toolbar">
<div id="zoom-controls">
<button id="btn-zoom-out" title="Zoom out [ ]"></button>
<span id="zoom-label"></span>
<button id="btn-zoom-in" title="Zoom in [ + ]">+</button>
<button id="btn-zoom-fit" title="Fit width [ 0 ]">Fit</button>
</div>
<span id="page-indicator">— / —</span>
<span id="status">Loading PDF.js…</span>
</div>
<div id="canvas-container"></div>
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
<script>
"use strict";
const refId = new URLSearchParams(location.search).get("ref_id") || "";
const pdfjsLib = window.pdfjsLib;
if (!pdfjsLib) {
showError("PDF.js failed to load. Make sure the app is running inside Brittle.");
} else {
pdfjsLib.GlobalWorkerOptions.workerSrc = "";
const container = document.getElementById("canvas-container");
const statusEl = document.getElementById("status");
const zoomLabel = document.getElementById("zoom-label");
const pageIndicator = document.getElementById("page-indicator");
const DPR = window.devicePixelRatio || 1;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 5.0;
let pdfDoc = null;
let scale = 1.0; // display scale (not multiplied by DPR yet)
let renderGen = 0; // incremented on each render pass to cancel stale ones
let renderTimer = null;
// ── Utilities ──────────────────────────────────────────────────────────
function setStatus(msg) { statusEl.textContent = msg; }
function clampScale(s) {
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s));
}
function updateZoomLabel() {
zoomLabel.textContent = Math.round(scale * 100) + "%";
}
// ── Zoom ───────────────────────────────────────────────────────────────
async function applyScale(newScale) {
scale = clampScale(newScale);
updateZoomLabel();
scheduleRender();
}
async function fitToWidth() {
if (!pdfDoc) return;
const page = await pdfDoc.getPage(1);
const vp = page.getViewport({ scale: 1.0 });
const avail = container.clientWidth - 40; // padding
return clampScale(avail / vp.width);
}
// ── Rendering ──────────────────────────────────────────────────────────
function scheduleRender() {
clearTimeout(renderTimer);
renderTimer = setTimeout(renderAll, 60);
}
async function renderAll() {
if (!pdfDoc) return;
const gen = ++renderGen;
// Replace the entire container contents with fresh wrappers.
container.innerHTML = "";
const wrappers = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.dataset.page = String(i);
const canvas = document.createElement("canvas");
wrap.appendChild(canvas);
container.appendChild(wrap);
wrappers.push(wrap);
}
// Render pages one by one; abort if a newer render was requested.
for (let i = 0; i < wrappers.length; i++) {
if (renderGen !== gen) return;
await renderPage(wrappers[i], i + 1);
}
setStatus("Ready");
refreshPageIndicator();
}
async function renderPage(wrapper, pageNum) {
try {
const page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale: scale * DPR });
const canvas = wrapper.querySelector("canvas");
if (!canvas) return;
canvas.width = vp.width;
canvas.height = vp.height;
canvas.style.width = Math.round(vp.width / DPR) + "px";
canvas.style.height = Math.round(vp.height / DPR) + "px";
await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp }).promise;
} catch (e) {
if (e?.name !== "RenderingCancelledException") console.warn("render:", e);
}
}
// ── Page indicator (updates on scroll) ─────────────────────────────────
function refreshPageIndicator() {
if (!pdfDoc) return;
const top = container.getBoundingClientRect().top;
let current = 1;
for (const wrap of container.querySelectorAll(".page-wrapper")) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
current = parseInt(wrap.dataset.page, 10);
break;
}
}
pageIndicator.textContent = current + " / " + pdfDoc.numPages;
}
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
// ── Load ───────────────────────────────────────────────────────────────
async function load() {
if (!refId) { showError("No ref_id in URL."); return; }
setStatus("Loading…");
try {
const url = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
pdfDoc = await pdfjsLib.getDocument({ url, disableWorker: true }).promise;
scale = await fitToWidth();
updateZoomLabel();
setStatus("Rendering…");
await renderAll();
} catch (e) {
showError("Could not load PDF: " + e.message);
}
}
// ── Toolbar buttons ────────────────────────────────────────────────────
document.getElementById("btn-zoom-out").addEventListener("click",
() => applyScale(scale / 1.25));
document.getElementById("btn-zoom-in").addEventListener("click",
() => applyScale(scale * 1.25));
document.getElementById("btn-zoom-fit").addEventListener("click",
async () => applyScale(await fitToWidth()));
// Ctrl+Scroll zoom
container.addEventListener("wheel", ev => {
if (!ev.ctrlKey) return;
ev.preventDefault();
applyScale(scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
// Keyboard shortcuts (active when the iframe has focus)
document.addEventListener("keydown", ev => {
if (ev.target.tagName === "INPUT") return;
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); applyScale(scale * 1.25); }
if (ev.key === "-") { ev.preventDefault(); applyScale(scale / 1.25); }
if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(applyScale); }
});
load();
}
function showError(msg) {
const b = document.getElementById("error-banner");
b.textContent = msg;
b.style.display = "block";
document.getElementById("status").textContent = "Error";
}
</script>
</body>
</html>

103
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,103 @@
//! Managed application state for the Tauri backend.
use brittle_core::{store::FsStore, Brittle, BrittleError};
use std::sync::Mutex;
/// Shared state held by the Tauri runtime for the lifetime of the application.
///
/// `brittle` is `None` until the user opens or creates a repository.
pub struct AppState {
pub brittle: Mutex<Option<Brittle<FsStore>>>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self {
brittle: Mutex::new(None),
}
}
/// Run a closure with mutable access to the open repository.
///
/// Errors if:
/// - the mutex is poisoned,
/// - no repository is currently open, or
/// - the operation itself returns an error.
pub fn with_repo<T, F>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&mut Brittle<FsStore>) -> Result<T, BrittleError>,
{
let mut guard = self
.brittle
.lock()
.map_err(|_| "internal: state lock poisoned".to_string())?;
let brittle = guard
.as_mut()
.ok_or_else(|| "no repository open".to_string())?;
f(brittle).map_err(|e| e.to_string())
}
/// Run a closure with read-only access to the open repository.
pub fn with_repo_read<T, F>(&self, f: F) -> Result<T, String>
where
F: FnOnce(&Brittle<FsStore>) -> Result<T, BrittleError>,
{
let guard = self
.brittle
.lock()
.map_err(|_| "internal: state lock poisoned".to_string())?;
let brittle = guard
.as_ref()
.ok_or_else(|| "no repository open".to_string())?;
f(brittle).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use brittle_core::EntryType;
fn open_state() -> (AppState, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let state = AppState::new();
let brittle = Brittle::create(tmp.path()).unwrap();
*state.brittle.lock().unwrap() = Some(brittle);
(state, tmp)
}
#[test]
fn with_repo_fails_when_no_repo_open() {
let state = AppState::new();
let result = state.with_repo(|_| Ok(()));
assert_eq!(result.unwrap_err(), "no repository open");
}
#[test]
fn with_repo_read_fails_when_no_repo_open() {
let state = AppState::new();
let result = state.with_repo_read(|_| Ok(()));
assert_eq!(result.unwrap_err(), "no repository open");
}
#[test]
fn with_repo_succeeds_when_open() {
let (state, _tmp) = open_state();
let result = state.with_repo(|b| b.create_reference("test2024", EntryType::Article));
assert!(result.is_ok());
assert_eq!(result.unwrap().cite_key, "test2024");
}
#[test]
fn with_repo_read_can_list_references() {
let (state, _tmp) = open_state();
let result = state.with_repo_read(|b| b.list_references());
assert!(result.is_ok());
}
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Brittle",
"version": "0.1.0",
"identifier": "dev.brittle.app",
"build": {
"beforeDevCommand": {
"script": "trunk serve",
"cwd": "."
},
"beforeBuildCommand": {
"script": "trunk build --release",
"cwd": "."
},
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Brittle",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": []
}
}

269
src-tauri/tests/commands.rs Normal file
View File

@@ -0,0 +1,269 @@
//! Integration tests for the Tauri command layer.
//!
//! These tests exercise `AppState` and the command logic end-to-end using a
//! real `Brittle<FsStore>` repository in a temp directory. Tauri IPC is not
//! involved — the functions under test are plain Rust.
use brittle_app::state::AppState;
use brittle_core::{Brittle, EntryType, Person};
fn open_state() -> (AppState, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let state = AppState::new();
let brittle = Brittle::create(tmp.path()).unwrap();
*state.brittle.lock().unwrap() = Some(brittle);
(state, tmp)
}
// ── AppState ─────────────────────────────────────────────────────────────────
#[test]
fn no_repo_open_returns_error() {
let state = AppState::new();
let err = state.with_repo(|_| Ok(())).unwrap_err();
assert_eq!(err, "no repository open");
}
#[test]
fn with_repo_propagates_brittle_errors() {
let (state, _tmp) = open_state();
// Trying to get a non-existent reference propagates the StoreError.
let err = state
.with_repo_read(|b| {
use brittle_core::model::ids::ReferenceId;
b.get_reference(ReferenceId::new())
})
.unwrap_err();
assert!(!err.is_empty());
}
// ── Repository lifecycle ──────────────────────────────────────────────────────
#[test]
fn create_and_reopen_repository() {
let tmp = tempfile::tempdir().unwrap();
let state = AppState::new();
// Create
{
let brittle = Brittle::create(tmp.path()).unwrap();
*state.brittle.lock().unwrap() = Some(brittle);
}
// Close
*state.brittle.lock().unwrap() = None;
assert!(state.with_repo_read(|_| Ok(())).is_err());
// Reopen
{
let brittle = Brittle::open(tmp.path()).unwrap();
*state.brittle.lock().unwrap() = Some(brittle);
}
assert!(state.with_repo_read(|_| Ok(())).is_ok());
}
// ── Reference CRUD ───────────────────────────────────────────────────────────
#[test]
fn create_and_list_references() {
let (state, _tmp) = open_state();
state
.with_repo(|b| b.create_reference("turing1950", EntryType::Article))
.unwrap();
state
.with_repo(|b| b.create_reference("knuth1984", EntryType::Book))
.unwrap();
let refs = state.with_repo_read(|b| b.list_references()).unwrap();
assert_eq!(refs.len(), 2);
let keys: Vec<&str> = refs.iter().map(|r| r.cite_key.as_str()).collect();
assert!(keys.contains(&"turing1950"));
assert!(keys.contains(&"knuth1984"));
}
#[test]
fn delete_reference_removes_it() {
let (state, _tmp) = open_state();
let r = state
.with_repo(|b| b.create_reference("gone2024", EntryType::Misc))
.unwrap();
state.with_repo(|b| b.delete_reference(r.id)).unwrap();
let refs = state.with_repo_read(|b| b.list_references()).unwrap();
assert!(refs.is_empty());
}
#[test]
fn set_and_remove_field() {
let (state, _tmp) = open_state();
let r = state
.with_repo(|b| b.create_reference("fields2024", EntryType::Article))
.unwrap();
state
.with_repo(|b| b.set_field(r.id, "title", "A Test Title"))
.unwrap();
let fetched = state.with_repo_read(|b| b.get_reference(r.id)).unwrap();
assert_eq!(
fetched.fields.get("title").map(String::as_str),
Some("A Test Title")
);
state.with_repo(|b| b.remove_field(r.id, "title")).unwrap();
let fetched2 = state.with_repo_read(|b| b.get_reference(r.id)).unwrap();
assert!(!fetched2.fields.contains_key("title"));
}
#[test]
fn search_references_filters_by_query() {
let (state, _tmp) = open_state();
state
.with_repo(|b| b.create_reference("turing1950", EntryType::Article))
.unwrap();
state
.with_repo(|b| b.create_reference("knuth1984", EntryType::Book))
.unwrap();
let results = state
.with_repo_read(|b| b.search_references("turing"))
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].cite_key, "turing1950");
}
// ── Library ───────────────────────────────────────────────────────────────────
#[test]
fn create_nested_libraries_and_query_hierarchy() {
let (state, _tmp) = open_state();
let root = state.with_repo(|b| b.create_library("Root", None)).unwrap();
let child = state
.with_repo(|b| b.create_library("Child", Some(root.id)))
.unwrap();
let roots = state.with_repo_read(|b| b.list_root_libraries()).unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].id, root.id);
let children = state
.with_repo_read(|b| b.list_child_libraries(root.id))
.unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].id, child.id);
let ancestors = state
.with_repo_read(|b| b.get_library_ancestors(child.id))
.unwrap();
assert_eq!(ancestors.len(), 1);
assert_eq!(ancestors[0].id, root.id);
}
#[test]
fn add_reference_to_library_and_query() {
let (state, _tmp) = open_state();
let r = state
.with_repo(|b| b.create_reference("member2024", EntryType::Article))
.unwrap();
let lib = state.with_repo(|b| b.create_library("Lib", None)).unwrap();
state.with_repo(|b| b.add_to_library(lib.id, r.id)).unwrap();
let members = state
.with_repo_read(|b| b.list_library_references(lib.id))
.unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].id, r.id);
}
#[test]
fn force_delete_library_removes_subtree() {
let (state, _tmp) = open_state();
let root = state.with_repo(|b| b.create_library("Root", None)).unwrap();
state
.with_repo(|b| b.create_library("Child", Some(root.id)))
.unwrap();
state
.with_repo(|b| b.force_delete_library(root.id))
.unwrap();
let all = state.with_repo_read(|b| b.list_root_libraries()).unwrap();
assert!(all.is_empty());
}
// ── BibTeX export ─────────────────────────────────────────────────────────────
#[test]
fn export_library_bibtex_contains_entries() {
let (state, _tmp) = open_state();
let mut r = state
.with_repo(|b| b.create_reference("turing1950", EntryType::Article))
.unwrap();
r.authors.push(Person::new("Turing"));
r.fields.insert(
"title".into(),
"Computing Machinery and Intelligence".into(),
);
r.fields.insert("journal".into(), "Mind".into());
r.fields.insert("year".into(), "1950".into());
let r = state.with_repo(|b| b.update_reference(r)).unwrap();
let lib = state.with_repo(|b| b.create_library("CS", None)).unwrap();
state.with_repo(|b| b.add_to_library(lib.id, r.id)).unwrap();
let (bibtex, errors) = state
.with_repo_read(|b| b.export_library_bibtex(lib.id))
.unwrap();
assert!(errors.is_empty());
assert!(bibtex.contains("@article{turing1950,"));
assert!(bibtex.contains("Computing Machinery and Intelligence"));
}
// ── Snapshot ──────────────────────────────────────────────────────────────────
#[test]
fn snapshot_and_discard_changes() {
let (state, _tmp) = open_state();
state
.with_repo(|b| b.create_reference("snap2024", EntryType::Misc))
.unwrap();
let snap = state.with_repo(|b| b.create_snapshot("baseline")).unwrap();
assert!(!snap.id.is_empty());
let snapshots = state.with_repo_read(|b| b.list_snapshots()).unwrap();
assert!(snapshots.iter().any(|s| s.message == "baseline"));
// Delete the reference (uncommitted change).
let r_id = state
.with_repo_read(|b| b.list_references())
.unwrap()
.into_iter()
.next()
.unwrap()
.id;
state.with_repo(|b| b.delete_reference(r_id)).unwrap();
assert!(state
.with_repo_read(|b| b.has_uncommitted_changes())
.unwrap());
// Discard → reference comes back.
state.with_repo(|b| b.discard_changes()).unwrap();
let refs = state.with_repo_read(|b| b.list_references()).unwrap();
assert_eq!(refs.len(), 1);
}