Add cloud browsing, autosave, and editor improvements

This commit is contained in:
2026-07-18 16:56:15 -04:00
parent bcdfd4c917
commit ea011f2d3e
12 changed files with 1281 additions and 103 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
Ok(dir)
}
fn families_in(data: &[u8]) -> Vec<String> {
pub fn families_in(data: &[u8]) -> Vec<String> {
let mut families = BTreeSet::new();
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
families.insert(font.info().family.clone());
+67 -1
View File
@@ -1,4 +1,5 @@
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::{AppHandle, Manager};
@@ -9,7 +10,15 @@ pub struct Store {
connection: Mutex<Connection>,
}
const SCHEMA: [&str; 4] = [
#[derive(Serialize, Clone)]
pub struct DocumentLink {
pub document_id: String,
pub base_hash: String,
pub role: String,
pub base_content: String,
}
const SCHEMA: [&str; 5] = [
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -27,6 +36,13 @@ const SCHEMA: [&str; 4] = [
content BLOB,
PRIMARY KEY (project_path, file_path)
)",
"CREATE TABLE IF NOT EXISTS document_links (
path TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
base_hash TEXT NOT NULL,
role TEXT NOT NULL,
base_content TEXT
)",
"CREATE TABLE IF NOT EXISTS thumbnails (
path TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@@ -257,6 +273,56 @@ impl Store {
})
}
pub fn save_document_link(
&self,
path: &str,
document_id: &str,
base_hash: &str,
role: &str,
base_content: &str,
) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO document_links (path, document_id, base_hash, role, base_content)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(path) DO UPDATE SET
document_id = excluded.document_id,
base_hash = excluded.base_hash,
role = excluded.role,
base_content = excluded.base_content",
params![path, document_id, base_hash, role, base_content],
)?;
Ok(())
})
}
pub fn document_link(&self, path: &str) -> Result<Option<DocumentLink>, String> {
self.with(|connection| {
connection
.query_row(
"SELECT document_id, base_hash, role, base_content
FROM document_links WHERE path = ?1",
params![path],
|row| {
Ok(DocumentLink {
document_id: row.get(0)?,
base_hash: row.get(1)?,
role: row.get(2)?,
base_content: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
})
},
)
.optional()
})
}
pub fn forget_document_link(&self, path: &str) -> Result<(), String> {
self.with(|connection| {
connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?;
Ok(())
})
}
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
self.with(|connection| {
connection
+235 -1
View File
@@ -57,6 +57,26 @@ fn load_project(
Ok((dir, store.meta(project)?))
}
#[derive(Serialize)]
pub struct AppInfo {
pub version: String,
pub typst_version: String,
pub authors: String,
pub license: String,
pub tauri_version: String,
}
#[tauri::command]
fn app_info() -> AppInfo {
AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
typst_version: "0.14.2".to_string(),
authors: "SirBlobby".to_string(),
license: "Apache-2.0".to_string(),
tauri_version: tauri::VERSION.to_string(),
}
}
#[tauri::command]
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
load_settings(&app, &store)
@@ -68,6 +88,8 @@ fn update_settings(
store: State<'_, Store>,
workspace_root: Option<String>,
server_url: Option<String>,
autosave_seconds: Option<u32>,
sync_minutes: Option<u32>,
) -> Result<Settings, String> {
let mut settings = load_settings(&app, &store)?;
if let Some(root) = workspace_root {
@@ -79,6 +101,12 @@ fn update_settings(
if let Some(url) = server_url {
settings.server_url = url.trim_end_matches('/').to_string();
}
if let Some(seconds) = autosave_seconds {
settings.autosave_seconds = seconds;
}
if let Some(minutes) = sync_minutes {
settings.sync_minutes = minutes;
}
save_settings(&store, &settings)?;
Ok(settings)
}
@@ -331,6 +359,7 @@ fn compile_target(
app: AppHandle,
store: State<'_, Store>,
path: String,
entrypoint: Option<String>,
overrides: Option<std::collections::HashMap<String, String>>,
) -> Result<CompileResult, CompileFailure> {
let target = resolve_target(&app, &store, &path).map_err(failure)?;
@@ -340,7 +369,12 @@ fn compile_target(
files.insert(file, content.into_bytes());
}
compiler::compile_to_svg(target.entrypoint, files)
let entrypoint = match entrypoint {
Some(file) if files.contains_key(&file) => file,
_ => target.entrypoint,
};
compiler::compile_to_svg(entrypoint, files)
.map_err(|diagnostics| CompileFailure { diagnostics })
}
@@ -397,6 +431,87 @@ fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, St
assets::list_assets(&app, &store)
}
#[derive(Serialize)]
pub struct Resource {
pub name: String,
pub reference: String,
pub path: String,
pub scope: String,
pub kind: String,
pub size: u64,
pub font_families: Vec<String>,
}
fn resource_kind(name: &str) -> String {
if assets::is_font(name) {
"font"
} else if assets::is_image(name) {
"image"
} else {
"file"
}
.to_string()
}
#[tauri::command]
fn list_resources(
app: AppHandle,
store: State<'_, Store>,
path: String,
) -> Result<Vec<Resource>, String> {
let mut resources = Vec::new();
for asset in assets::list_assets(&app, &store)? {
resources.push(Resource {
reference: asset.name.clone(),
path: format!("{}/{}", assets::ASSETS_DIR, asset.name),
scope: "shared".to_string(),
kind: asset.kind,
size: asset.size,
font_families: asset.font_families,
name: asset.name,
});
}
let target = resolve_target(&app, &store, &path)?;
let prefix = if target.standalone {
path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
} else {
path.as_str()
};
for file in list_files(&target.root)? {
if file.path.to_lowercase().ends_with(".typ") {
continue;
}
let full = target.root.join(&file.path);
let font_families = if assets::is_font(&file.name) {
std::fs::read(&full)
.map(|data| assets::families_in(&data))
.unwrap_or_default()
} else {
Vec::new()
};
resources.push(Resource {
name: file.name,
reference: file.path.clone(),
path: if prefix.is_empty() {
file.path.clone()
} else {
format!("{}/{}", prefix, file.path)
},
scope: "project".to_string(),
kind: resource_kind(&file.path),
size: file.size,
font_families,
});
}
Ok(resources)
}
#[tauri::command]
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
let files = match path {
@@ -524,6 +639,115 @@ fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Spac
sync::list_spaces(&server_url, &token)
}
#[tauri::command]
fn cloud_list_folders(
app: AppHandle,
store: State<'_, Store>,
) -> Result<Vec<sync::CloudFolder>, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_folders(&server_url, &token)
}
#[tauri::command]
fn cloud_list_documents(
app: AppHandle,
store: State<'_, Store>,
folder_id: Option<String>,
) -> Result<Vec<sync::CloudDocument>, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_documents(&server_url, &token, folder_id.as_deref())
}
#[tauri::command]
fn cloud_list_shared(
app: AppHandle,
store: State<'_, Store>,
) -> Result<sync::SharedItems, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_shared(&server_url, &token)
}
/// Downloads a cloud document into the workspace and remembers where it came
/// from so it can be synced back.
#[tauri::command]
fn cloud_download_document(
app: AppHandle,
store: State<'_, Store>,
document_id: String,
parent: String,
) -> Result<String, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let document = sync::pull_document(&server_url, &token, &document_id)?;
let mut name = document.title.replace('/', "-").trim().to_string();
if name.is_empty() {
name = "document".to_string();
}
if !name.to_lowercase().ends_with(".typ") {
name.push_str(".typ");
}
let path = join_path(&parent, &name);
let full = workspace_path(&app, &store, &path)?;
if let Some(dir) = full.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&full, &document.content).map_err(|e| e.to_string())?;
store.save_document_link(
&path,
&document_id,
&document.hash,
&document.role,
&document.content,
)?;
Ok(path)
}
#[tauri::command]
fn cloud_sync_document(
app: AppHandle,
store: State<'_, Store>,
path: String,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::sync_document(&server_url, &token, &app, &store, &path)
}
#[tauri::command]
fn cloud_resolve_document(
app: AppHandle,
store: State<'_, Store>,
path: String,
content: String,
server_hash: String,
) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::resolve_document_conflict(
&server_url,
&token,
&app,
&store,
&path,
&content,
&server_hash,
)
}
#[tauri::command]
fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), String> {
store.forget_document_link(&path)
}
#[tauri::command]
fn cloud_document_link(
store: State<'_, Store>,
path: String,
) -> Result<Option<db::DocumentLink>, String> {
store.document_link(&path)
}
#[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
@@ -667,6 +891,7 @@ pub fn run() {
}
})
.invoke_handler(tauri::generate_handler![
app_info,
get_settings,
update_settings,
browse_workspace,
@@ -686,6 +911,7 @@ pub fn run() {
read_image,
clear_thumbnails,
list_assets,
list_resources,
list_font_families,
import_assets,
delete_asset,
@@ -699,6 +925,14 @@ pub fn run() {
cloud_logout,
cloud_account,
cloud_list_spaces,
cloud_list_folders,
cloud_list_documents,
cloud_list_shared,
cloud_download_document,
cloud_sync_document,
cloud_resolve_document,
cloud_document_link,
cloud_unlink_document,
cloud_create_space,
cloud_delete_space,
cloud_clone_space,
+282
View File
@@ -566,3 +566,285 @@ pub fn resolve_conflict(
.insert(path.to_string(), server_hash.to_string());
store.save_meta(project, meta)
}
#[derive(Deserialize, Serialize, Clone)]
pub struct CloudFolder {
pub id: String,
pub name: String,
pub parent_id: Option<String>,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct CloudDocument {
pub id: String,
pub title: String,
pub folder_id: Option<String>,
pub role: String,
pub updated_at: String,
}
#[derive(Deserialize, Serialize)]
pub struct SharedItems {
pub documents: Vec<CloudDocument>,
pub spaces: Vec<SpaceSummary>,
}
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
agent()
.get(&endpoint(server_url, "/folders"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Vec<CloudFolder>>()
.map_err(|e| e.to_string())
}
pub fn list_documents(
server_url: &str,
token: &str,
folder_id: Option<&str>,
) -> Result<Vec<CloudDocument>, String> {
let mut request = agent()
.get(&endpoint(server_url, "/documents"))
.set("Authorization", &format!("Bearer {}", token));
if let Some(folder) = folder_id {
request = request.query("folder_id", folder);
}
request
.call()
.map_err(describe)?
.into_json::<Vec<CloudDocument>>()
.map_err(|e| e.to_string())
}
pub fn list_shared(server_url: &str, token: &str) -> Result<SharedItems, String> {
agent()
.get(&endpoint(server_url, "/shared"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<SharedItems>()
.map_err(|e| e.to_string())
}
#[derive(Deserialize, Serialize)]
pub struct DocumentContent {
pub id: String,
pub title: String,
pub role: String,
pub hash: String,
pub content: String,
}
pub fn pull_document(
server_url: &str,
token: &str,
document_id: &str,
) -> Result<DocumentContent, String> {
agent()
.get(&endpoint(server_url, &format!("/documents/{}", document_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<DocumentContent>()
.map_err(|e| e.to_string())
}
/// Syncs one downloaded cloud document. The merge base is the copy stored when
/// the document was last exchanged with the server.
pub fn sync_document(
server_url: &str,
token: &str,
app: &tauri::AppHandle,
store: &Store,
path: &str,
) -> Result<SyncReport, String> {
let link = store
.document_link(path)?
.ok_or("This document is not linked to the cloud")?;
let full = crate::workspace::workspace_path(app, store, path)?;
let local = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
let remote = pull_document(server_url, token, &link.document_id)?;
let mut report = SyncReport::default();
let local_hash = content_hash(local.as_bytes());
let editable = remote.role == "owner" || remote.role == "editor";
if local_hash == remote.hash {
store.save_document_link(path, &link.document_id, &remote.hash, &remote.role, &local)?;
return Ok(report);
}
if local_hash == link.base_hash {
std::fs::write(&full, &remote.content).map_err(|e| e.to_string())?;
store.save_document_link(
path,
&link.document_id,
&remote.hash,
&remote.role,
&remote.content,
)?;
report.pulled.push(path.to_string());
return Ok(report);
}
if remote.hash == link.base_hash {
if !editable {
return Err("You only have view access to this document".to_string());
}
match push_document(
server_url,
token,
&link.document_id,
&local,
Some(&link.base_hash),
)? {
PushResult::Applied => {
store.save_document_link(
path,
&link.document_id,
&local_hash,
&remote.role,
&local,
)?;
report.pushed.push(path.to_string());
}
PushResult::Conflict {
server_hash,
server_text,
} => {
report.conflicts.push(Conflict {
path: path.to_string(),
local_text: local,
remote_text: server_text.clone(),
merged_text: server_text,
server_hash,
auto_merged: false,
binary: false,
});
}
}
return Ok(report);
}
match diffy::merge(&link.base_content, &local, &remote.content) {
Ok(merged) => {
std::fs::write(&full, &merged).map_err(|e| e.to_string())?;
if editable {
match push_document(
server_url,
token,
&link.document_id,
&merged,
Some(&remote.hash),
)? {
PushResult::Applied => {
store.save_document_link(
path,
&link.document_id,
&content_hash(merged.as_bytes()),
&remote.role,
&merged,
)?;
}
PushResult::Conflict {
server_hash,
server_text,
} => {
report.conflicts.push(Conflict {
path: path.to_string(),
local_text: merged.clone(),
remote_text: server_text.clone(),
merged_text: server_text,
server_hash,
auto_merged: false,
binary: false,
});
return Ok(report);
}
}
}
report.merged.push(path.to_string());
}
Err(conflicted) => {
report.conflicts.push(Conflict {
path: path.to_string(),
local_text: local,
remote_text: remote.content,
merged_text: conflicted,
server_hash: remote.hash,
auto_merged: false,
binary: false,
});
}
}
Ok(report)
}
/// Applies a resolved cloud document and uploads it.
pub fn resolve_document_conflict(
server_url: &str,
token: &str,
app: &tauri::AppHandle,
store: &Store,
path: &str,
content: &str,
server_hash: &str,
) -> Result<(), String> {
let link = store
.document_link(path)?
.ok_or("This document is not linked to the cloud")?;
let full = crate::workspace::workspace_path(app, store, path)?;
std::fs::write(&full, content).map_err(|e| e.to_string())?;
match push_document(server_url, token, &link.document_id, content, Some(server_hash))? {
PushResult::Applied => store.save_document_link(
path,
&link.document_id,
&content_hash(content.as_bytes()),
&link.role,
content,
),
PushResult::Conflict { .. } => {
Err("The document changed again in the cloud. Sync and merge once more.".to_string())
}
}
}
pub fn push_document(
server_url: &str,
token: &str,
document_id: &str,
content: &str,
base_hash: Option<&str>,
) -> Result<PushResult, String> {
let response = agent()
.put(&endpoint(server_url, &format!("/documents/{}", document_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"content": content,
"base_hash": base_hash,
}));
match response {
Ok(_) => Ok(PushResult::Applied),
Err(ureq::Error::Status(409, body)) => {
let conflict = body
.into_json::<ConflictBody>()
.map_err(|e| format!("Malformed conflict response: {}", e))?;
Ok(PushResult::Conflict {
server_hash: conflict.server_hash,
server_text: conflict.server_content,
})
}
Err(other) => Err(describe(other)),
}
}
+6
View File
@@ -35,6 +35,10 @@ pub struct Settings {
pub account_email: Option<String>,
#[serde(default)]
pub account_username: Option<String>,
#[serde(default)]
pub autosave_seconds: u32,
#[serde(default)]
pub sync_minutes: u32,
}
impl Settings {
@@ -49,6 +53,8 @@ impl Settings {
device_token: None,
account_email: None,
account_username: None,
autosave_seconds: 5,
sync_minutes: 0,
}
}
}