Initial commit
This commit is contained in:
41
src-tauri/src/commands/annotation.rs
Normal file
41
src-tauri/src/commands/annotation.rs
Normal 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))
|
||||
}
|
||||
44
src-tauri/src/commands/bibtex.rs
Normal file
44
src-tauri/src/commands/bibtex.rs
Normal 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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
73
src-tauri/src/commands/config.rs
Normal file
73
src-tauri/src/commands/config.rs
Normal 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())
|
||||
}
|
||||
97
src-tauri/src/commands/library.rs
Normal file
97
src-tauri/src/commands/library.rs
Normal 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))
|
||||
}
|
||||
9
src-tauri/src/commands/mod.rs
Normal file
9
src-tauri/src/commands/mod.rs
Normal 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;
|
||||
31
src-tauri/src/commands/pdf.rs
Normal file
31
src-tauri/src/commands/pdf.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
87
src-tauri/src/commands/reference.rs
Normal file
87
src-tauri/src/commands/reference.rs
Normal 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
|
||||
}
|
||||
60
src-tauri/src/commands/repository.rs
Normal file
60
src-tauri/src/commands/repository.rs
Normal 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()))
|
||||
}
|
||||
33
src-tauri/src/commands/snapshot.rs
Normal file
33
src-tauri/src/commands/snapshot.rs
Normal 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())
|
||||
}
|
||||
54
src-tauri/src/commands/window.rs
Normal file
54
src-tauri/src/commands/window.rs
Normal 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()
|
||||
}
|
||||
183
src-tauri/src/config/global.rs
Normal file
183
src-tauri/src/config/global.rs
Normal 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
279
src-tauri/src/config/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
160
src-tauri/src/config/project.rs
Normal file
160
src-tauri/src/config/project.rs
Normal 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
87
src-tauri/src/lib.rs
Normal 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
5
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
brittle_app::run()
|
||||
}
|
||||
320
src-tauri/src/pdf_protocol.rs
Normal file
320
src-tauri/src/pdf_protocol.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
288
src-tauri/src/pdf_viewer.html
Normal file
288
src-tauri/src/pdf_viewer.html
Normal 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
103
src-tauri/src/state.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user