Add Typst Desktop app

This commit is contained in:
2026-07-18 15:00:22 -04:00
commit 8853c614d7
72 changed files with 15548 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use tauri::AppHandle;
use crate::db::Store;
use crate::workspace::workspace_root;
pub const ASSETS_DIR: &str = ".assets";
const FONT_EXTENSIONS: [&str; 4] = ["ttf", "otf", "ttc", "otc"];
const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
fn extension_of(name: &str) -> String {
Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase()
}
pub fn is_font(name: &str) -> bool {
FONT_EXTENSIONS.contains(&extension_of(name).as_str())
}
pub fn is_image(name: &str) -> bool {
IMAGE_EXTENSIONS.contains(&extension_of(name).as_str())
}
pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
let dir = workspace_root(app, store)?.join(ASSETS_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("Cannot create assets folder: {}", e))?;
Ok(dir)
}
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());
}
families.into_iter().collect()
}
#[derive(Serialize)]
pub struct Asset {
pub name: String,
pub kind: String,
pub size: u64,
pub font_families: Vec<String>,
}
pub fn list_assets(app: &AppHandle, store: &Store) -> Result<Vec<Asset>, String> {
let dir = assets_dir(app, store)?;
let mut assets = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.path().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let font_families = if is_font(&name) {
std::fs::read(entry.path())
.map(|data| families_in(&data))
.unwrap_or_default()
} else {
Vec::new()
};
assets.push(Asset {
kind: if is_font(&name) {
"font"
} else if is_image(&name) {
"image"
} else {
"file"
}
.to_string(),
name,
size,
font_families,
});
}
assets.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(assets)
}
pub fn font_families(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
let mut families = BTreeSet::new();
for data in typst_assets::fonts() {
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data)) {
families.insert(font.info().family.clone());
}
}
for (name, data) in files {
if is_font(name) {
for family in families_in(data) {
families.insert(family);
}
}
}
families.into_iter().collect()
}
fn unique_destination(dir: &Path, name: &str) -> PathBuf {
let candidate = dir.join(name);
if !candidate.exists() {
return candidate;
}
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_string();
let extension = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e))
.unwrap_or_default();
for index in 2..1000 {
let candidate = dir.join(format!("{}-{}{}", stem, index, extension));
if !candidate.exists() {
return candidate;
}
}
dir.join(name)
}
fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(source).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
let from = entry.path();
let to = destination.join(&name);
if from.is_dir() {
copy_tree(&from, &to)?;
} else {
std::fs::copy(&from, &to).map_err(|e| e.to_string())?;
}
}
Ok(())
}
pub fn import_paths(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
let mut imported = Vec::new();
for source in sources {
let source_path = Path::new(source);
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.ok_or_else(|| format!("'{}' has no name", source))?;
if source_path.is_dir() {
let target = unique_destination(destination, &name);
copy_tree(source_path, &target)?;
} else if source_path.is_file() {
let target = unique_destination(destination, &name);
std::fs::copy(source_path, &target)
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
} else {
return Err(format!("'{}' could not be read", source));
}
imported.push(name);
}
Ok(imported)
}
pub fn import_files(sources: &[String], destination: &Path) -> Result<Vec<String>, String> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
let mut imported = Vec::new();
for source in sources {
let source_path = Path::new(source);
if !source_path.is_file() {
return Err(format!("'{}' is not a file", source));
}
let name = source_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.ok_or_else(|| format!("'{}' has no file name", source))?;
let target = unique_destination(destination, &name);
std::fs::copy(source_path, &target)
.map_err(|e| format!("Could not import '{}': {}", name, e))?;
imported.push(
target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(name),
);
}
Ok(imported)
}
pub fn delete_asset(app: &AppHandle, store: &Store, name: &str) -> Result<(), String> {
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("Invalid asset name".to_string());
}
let path = assets_dir(app, store)?.join(name);
std::fs::remove_file(path).map_err(|e| e.to_string())
}
pub fn asset_files(app: &AppHandle, store: &Store) -> HashMap<String, Vec<u8>> {
let Ok(dir) = assets_dir(app, store) else {
return HashMap::new();
};
let mut files = HashMap::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return files;
};
for entry in entries.filter_map(|entry| entry.ok()) {
if !entry.path().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if let Ok(data) = std::fs::read(entry.path()) {
files.insert(name, data);
}
}
files
}
+213
View File
@@ -0,0 +1,213 @@
use serde::Serialize;
use std::collections::HashMap;
use typst::diag::Warned;
use typst::layout::{Frame, FrameItem};
use typst::WorldExt;
use typst_html::HtmlDocument;
use typst_layout::PagedDocument;
use typst_pdf::{pdf, PdfOptions};
use typst_render::{render, RenderOptions};
use typst_svg::SvgOptions;
use crate::world::ProjectWorld;
#[derive(Serialize, Clone)]
pub struct DocumentStats {
pub pages: usize,
pub words: usize,
pub characters: usize,
}
#[derive(Serialize, Clone)]
pub struct Diagnostic {
pub message: String,
pub severity: String,
pub line: Option<usize>,
pub column: Option<usize>,
}
#[derive(Serialize)]
pub struct CompileResult {
pub pages: Vec<String>,
pub stats: DocumentStats,
pub diagnostics: Vec<Diagnostic>,
}
fn extract_frame_text(frame: &Frame, text: &mut String) {
for (_, item) in frame.items() {
match item {
FrameItem::Text(text_item) => {
text.push_str(&text_item.text);
text.push(' ');
}
FrameItem::Group(group) => extract_frame_text(&group.frame, text),
_ => {}
}
}
}
fn extract_stats(document: &PagedDocument) -> DocumentStats {
let mut text = String::new();
for page in document.pages() {
extract_frame_text(&page.frame, &mut text);
}
DocumentStats {
pages: document.pages().len(),
words: text.split_whitespace().count(),
characters: text.chars().filter(|c| !c.is_whitespace()).count(),
}
}
fn line_and_column(source: &str, offset: usize) -> (usize, usize) {
let mut line = 1;
let mut column = 1;
for (index, character) in source.char_indices() {
if index >= offset {
break;
}
if character == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
(line, column)
}
fn collect_diagnostics(
world: &ProjectWorld,
entrypoint_source: &str,
errors: impl IntoIterator<Item = typst::diag::SourceDiagnostic>,
) -> Vec<Diagnostic> {
errors
.into_iter()
.map(|diagnostic| {
let (line, column) = match world.range(diagnostic.span) {
Some(range) => {
let (line, column) = line_and_column(entrypoint_source, range.start);
(Some(line), Some(column))
}
None => (None, None),
};
Diagnostic {
message: diagnostic.message.to_string(),
severity: format!("{:?}", diagnostic.severity).to_lowercase(),
line,
column,
}
})
.collect()
}
fn entrypoint_text(files: &HashMap<String, Vec<u8>>, entrypoint: &str) -> String {
files
.get(entrypoint)
.map(|bytes| String::from_utf8_lossy(bytes).to_string())
.unwrap_or_default()
}
pub fn compile_to_svg(
entrypoint: String,
files: HashMap<String, Vec<u8>>,
) -> Result<CompileResult, Vec<Diagnostic>> {
let source_text = entrypoint_text(&files, &entrypoint);
let world = ProjectWorld::new(entrypoint, files, false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(document),
warnings,
} => {
let options = SvgOptions::default();
let pages = document
.pages()
.iter()
.map(|page| typst_svg::svg(page, &options))
.collect();
Ok(CompileResult {
pages,
stats: extract_stats(&document),
diagnostics: collect_diagnostics(&world, &source_text, warnings),
})
}
Warned {
output: Err(errors),
warnings: _,
} => Err(collect_diagnostics(&world, &source_text, errors)),
}
}
pub fn export_pdf(
entrypoint: String,
files: HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let source_text = entrypoint_text(&files, &entrypoint);
let world = ProjectWorld::new(entrypoint, files, false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(document),
warnings: _,
} => pdf(&document, &PdfOptions::default()).map_err(|errors| {
collect_diagnostics(&world, &source_text, errors)
}),
Warned {
output: Err(errors),
warnings: _,
} => Err(collect_diagnostics(&world, &source_text, errors)),
}
}
pub fn export_png(
entrypoint: String,
files: HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let source_text = entrypoint_text(&files, &entrypoint);
let world = ProjectWorld::new(entrypoint, files, false);
match typst::compile::<PagedDocument>(&world) {
Warned {
output: Ok(document),
warnings: _,
} => {
let options = RenderOptions {
pixel_per_pt: 2.0,
..RenderOptions::default()
};
match document.pages().first() {
Some(page) => Ok(render(page, &options).encode_png().unwrap_or_default()),
None => Ok(Vec::new()),
}
}
Warned {
output: Err(errors),
warnings: _,
} => Err(collect_diagnostics(&world, &source_text, errors)),
}
}
pub fn export_html(
entrypoint: String,
files: HashMap<String, Vec<u8>>,
) -> Result<Vec<u8>, Vec<Diagnostic>> {
let source_text = entrypoint_text(&files, &entrypoint);
let world = ProjectWorld::new(entrypoint, files, true);
let document = match typst::compile::<HtmlDocument>(&world) {
Warned {
output: Ok(document),
warnings: _,
} => document,
Warned {
output: Err(errors),
warnings: _,
} => return Err(collect_diagnostics(&world, &source_text, errors)),
};
typst_html::html(&document)
.map(|html| html.into_bytes())
.map_err(|errors| collect_diagnostics(&world, &source_text, errors))
}
+300
View File
@@ -0,0 +1,300 @@
use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::{AppHandle, Manager};
use crate::workspace::{ProjectMeta, Settings};
pub struct Store {
connection: Mutex<Connection>,
}
const SCHEMA: [&str; 4] = [
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY,
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
space_id TEXT,
last_synced_at TEXT
)",
"CREATE TABLE IF NOT EXISTS base_files (
project_path TEXT NOT NULL,
file_path TEXT NOT NULL,
hash TEXT NOT NULL,
content BLOB,
PRIMARY KEY (project_path, file_path)
)",
"CREATE TABLE IF NOT EXISTS thumbnails (
path TEXT PRIMARY KEY,
kind TEXT NOT NULL,
data TEXT NOT NULL,
source_modified INTEGER NOT NULL
)",
];
impl Store {
pub fn open(app: &AppHandle) -> Result<Self, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve data directory: {}", e))?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let connection =
Connection::open(dir.join("typst-desktop.db")).map_err(|e| e.to_string())?;
connection
.pragma_update(None, "journal_mode", "WAL")
.map_err(|e| e.to_string())?;
connection
.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| e.to_string())?;
for statement in SCHEMA {
connection.execute(statement, []).map_err(|e| e.to_string())?;
}
Ok(Store {
connection: Mutex::new(connection),
})
}
fn with<T>(&self, run: impl FnOnce(&Connection) -> rusqlite::Result<T>) -> Result<T, String> {
let connection = self
.connection
.lock()
.map_err(|_| "Local database lock poisoned".to_string())?;
run(&connection).map_err(|e| e.to_string())
}
pub fn settings(&self) -> Result<Option<Settings>, String> {
let raw: Option<String> = self.with(|connection| {
connection
.query_row(
"SELECT value FROM settings WHERE key = 'settings'",
[],
|row| row.get(0),
)
.optional()
})?;
match raw {
Some(raw) => serde_json::from_str(&raw)
.map(Some)
.map_err(|e| format!("Stored settings are invalid: {}", e)),
None => Ok(None),
}
}
pub fn save_settings(&self, settings: &Settings) -> Result<(), String> {
let raw = serde_json::to_string(settings).map_err(|e| e.to_string())?;
self.with(|connection| {
connection.execute(
"INSERT INTO settings (key, value) VALUES ('settings', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![raw],
)
})?;
Ok(())
}
pub fn meta(&self, project: &str) -> Result<ProjectMeta, String> {
let row: Option<(String, Option<String>, Option<String>)> = self.with(|connection| {
connection
.query_row(
"SELECT entrypoint, space_id, last_synced_at FROM projects WHERE path = ?1",
params![project],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()
})?;
let Some((entrypoint, space_id, last_synced_at)) = row else {
return Ok(ProjectMeta::default());
};
let base_hashes = self.with(|connection| {
let mut statement = connection
.prepare("SELECT file_path, hash FROM base_files WHERE project_path = ?1")?;
let rows = statement.query_map(params![project], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut map = HashMap::new();
for row in rows {
let (path, hash) = row?;
map.insert(path, hash);
}
Ok(map)
})?;
Ok(ProjectMeta {
entrypoint,
space_id,
last_synced_at,
base_hashes,
})
}
pub fn has_project(&self, project: &str) -> Result<bool, String> {
let found: Option<i64> = self.with(|connection| {
connection
.query_row(
"SELECT 1 FROM projects WHERE path = ?1",
params![project],
|row| row.get(0),
)
.optional()
})?;
Ok(found.is_some())
}
pub fn save_meta(&self, project: &str, meta: &ProjectMeta) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO projects (path, entrypoint, space_id, last_synced_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET
entrypoint = excluded.entrypoint,
space_id = excluded.space_id,
last_synced_at = excluded.last_synced_at",
params![
project,
meta.entrypoint,
meta.space_id,
meta.last_synced_at
],
)?;
let mut keep: Vec<String> = Vec::new();
for (file, hash) in &meta.base_hashes {
connection.execute(
"INSERT INTO base_files (project_path, file_path, hash)
VALUES (?1, ?2, ?3)
ON CONFLICT(project_path, file_path) DO UPDATE SET hash = excluded.hash",
params![project, file, hash],
)?;
keep.push(file.clone());
}
let mut statement = connection
.prepare("SELECT file_path FROM base_files WHERE project_path = ?1")?;
let existing: Vec<String> = statement
.query_map(params![project], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<String>>>()?;
for file in existing {
if !keep.contains(&file) {
connection.execute(
"DELETE FROM base_files WHERE project_path = ?1 AND file_path = ?2",
params![project, file],
)?;
}
}
Ok(())
})
}
pub fn forget_project(&self, project: &str) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"DELETE FROM base_files WHERE project_path = ?1",
params![project],
)?;
connection.execute("DELETE FROM projects WHERE path = ?1", params![project])?;
Ok(())
})
}
pub fn rename_project(&self, from: &str, to: &str) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"UPDATE projects SET path = ?2 WHERE path = ?1",
params![from, to],
)?;
connection.execute(
"UPDATE base_files SET project_path = ?2 WHERE project_path = ?1",
params![from, to],
)?;
Ok(())
})
}
pub fn base_snapshot(&self, project: &str, file: &str) -> Result<Option<Vec<u8>>, String> {
self.with(|connection| {
connection
.query_row(
"SELECT content FROM base_files WHERE project_path = ?1 AND file_path = ?2",
params![project, file],
|row| row.get::<_, Option<Vec<u8>>>(0),
)
.optional()
.map(|value| value.flatten())
})
}
pub fn save_base_snapshot(
&self,
project: &str,
file: &str,
hash: &str,
content: &[u8],
) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO base_files (project_path, file_path, hash, content)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(project_path, file_path) DO UPDATE SET
hash = excluded.hash,
content = excluded.content",
params![project, file, hash, content],
)?;
Ok(())
})
}
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
self.with(|connection| {
connection
.query_row(
"SELECT kind, data FROM thumbnails
WHERE path = ?1 AND source_modified >= ?2",
params![path, modified],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()
})
}
pub fn save_thumbnail(
&self,
path: &str,
kind: &str,
data: &str,
modified: i64,
) -> Result<(), String> {
self.with(|connection| {
connection.execute(
"INSERT INTO thumbnails (path, kind, data, source_modified)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET
kind = excluded.kind,
data = excluded.data,
source_modified = excluded.source_modified",
params![path, kind, data, modified],
)?;
Ok(())
})
}
pub fn clear_thumbnails(&self) -> Result<(), String> {
self.with(|connection| {
connection.execute("DELETE FROM thumbnails", [])?;
Ok(())
})
}
}
+714
View File
@@ -0,0 +1,714 @@
mod assets;
mod compiler;
mod db;
mod lsp;
mod sync;
mod thumbnails;
mod workspace;
mod world;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager, State};
use assets::Asset;
use db::Store;
use compiler::{CompileResult, Diagnostic};
use lsp::{LspHandle, LspState};
use sync::{Account, SpaceSummary, SyncReport};
use workspace::{
browse, is_project_dir, is_text_file, list_files, load_settings, project_file_path,
read_target_files, resolve_target, save_settings, workspace_path, BrowseEntry,
FileEntry, ProjectMeta, Settings, NEW_PROJECT_MAIN,
};
#[derive(Serialize)]
pub struct CompileFailure {
pub diagnostics: Vec<Diagnostic>,
}
fn failure(message: String) -> CompileFailure {
CompileFailure {
diagnostics: vec![Diagnostic {
message,
severity: "error".to_string(),
line: None,
column: None,
}],
}
}
fn cloud_credentials(app: &AppHandle, store: &Store) -> Result<(String, String), String> {
let settings = load_settings(app, store)?;
let token = settings.device_token.ok_or("Not signed in to TypstDrive")?;
Ok((settings.server_url, token))
}
fn load_project(
app: &AppHandle,
store: &Store,
project: &str,
) -> Result<(PathBuf, ProjectMeta), String> {
let dir = workspace_path(app, store, project)?;
if !dir.is_dir() {
return Err(format!("Project '{}' not found", project));
}
Ok((dir, store.meta(project)?))
}
#[tauri::command]
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
load_settings(&app, &store)
}
#[tauri::command]
fn update_settings(
app: AppHandle,
store: State<'_, Store>,
workspace_root: Option<String>,
server_url: Option<String>,
) -> Result<Settings, String> {
let mut settings = load_settings(&app, &store)?;
if let Some(root) = workspace_root {
if root.trim().is_empty() {
return Err("Workspace folder cannot be empty".to_string());
}
settings.workspace_root = root;
}
if let Some(url) = server_url {
settings.server_url = url.trim_end_matches('/').to_string();
}
save_settings(&store, &settings)?;
Ok(settings)
}
#[tauri::command]
fn browse_workspace(app: AppHandle, store: State<'_, Store>, path: String) -> Result<Vec<BrowseEntry>, String> {
browse(&app, &store, &path)
}
fn parent_of(path: &str) -> String {
match path.rsplit_once('/') {
Some((parent, _)) => parent.to_string(),
None => String::new(),
}
}
fn join_path(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{}/{}", parent.trim_end_matches('/'), name)
}
}
#[tauri::command]
fn create_folder_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
let path = join_path(&parent, name.trim());
let full = workspace_path(&app, &store, &path)?;
if full.exists() {
return Err(format!("'{}' already exists", name));
}
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
Ok(path)
}
#[tauri::command]
fn create_document_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
let mut name = name.trim().to_string();
if name.is_empty() {
return Err("Document name cannot be empty".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 full.exists() {
return Err(format!("'{}' already exists", name));
}
if let Some(dir) = full.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let title = name.trim_end_matches(".typ").trim_end_matches(".TYP");
std::fs::write(&full, format!("= {}\n\nStart writing here.\n", title))
.map_err(|e| e.to_string())?;
Ok(path)
}
#[tauri::command]
fn create_project_entry(app: AppHandle, store: State<'_, Store>, parent: String, name: String) -> Result<String, String> {
let name = name.trim().to_string();
if name.is_empty() {
return Err("Project name cannot be empty".to_string());
}
let path = join_path(&parent, &name);
let full = workspace_path(&app, &store, &path)?;
if full.exists() {
return Err(format!("'{}' already exists", name));
}
std::fs::create_dir_all(&full).map_err(|e| e.to_string())?;
std::fs::write(full.join("main.typ"), NEW_PROJECT_MAIN).map_err(|e| e.to_string())?;
std::fs::write(full.join("typst.toml"), workspace::manifest_for(&name))
.map_err(|e| e.to_string())?;
store.save_meta(
&path,
&ProjectMeta {
entrypoint: "main.typ".to_string(),
..Default::default()
},
)?;
Ok(path)
}
#[tauri::command]
fn rename_entry(app: AppHandle, store: State<'_, Store>, path: String, new_name: String) -> Result<String, String> {
let new_name = new_name.trim();
if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
return Err("Invalid name".to_string());
}
let from = workspace_path(&app, &store, &path)?;
let target_path = join_path(&parent_of(&path), new_name);
let to = workspace_path(&app, &store, &target_path)?;
if to.exists() {
return Err(format!("'{}' already exists", new_name));
}
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
store.rename_project(&path, &target_path)?;
Ok(target_path)
}
#[tauri::command]
fn delete_entry(app: AppHandle, store: State<'_, Store>, path: String) -> Result<(), String> {
let full = workspace_path(&app, &store, &path)?;
if full.is_dir() {
std::fs::remove_dir_all(&full).map_err(|e| e.to_string())?;
store.forget_project(&path)?;
Ok(())
} else {
std::fs::remove_file(&full).map_err(|e| e.to_string())
}
}
#[tauri::command]
fn upload_entry(
app: AppHandle,
store: State<'_, Store>,
parent: String,
name: String,
base64_content: String,
) -> Result<String, String> {
let path = join_path(&parent, &name);
let full = workspace_path(&app, &store, &path)?;
let bytes = BASE64
.decode(base64_content.as_bytes())
.map_err(|e| format!("Invalid file data: {}", e))?;
if let Some(dir) = full.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&full, bytes).map_err(|e| e.to_string())?;
Ok(path)
}
#[derive(Serialize)]
pub struct FilePayload {
pub path: String,
pub is_text: bool,
pub content: String,
}
#[derive(Serialize)]
pub struct TargetInfo {
pub path: String,
pub entrypoint: String,
pub standalone: bool,
pub is_project: bool,
pub space_id: Option<String>,
pub files: Vec<FileEntry>,
}
#[tauri::command]
fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<TargetInfo, String> {
let target = resolve_target(&app, &store, &path)?;
if target.standalone {
let size = std::fs::metadata(target.root.join(&target.entrypoint))
.map(|m| m.len())
.unwrap_or(0);
return Ok(TargetInfo {
path,
entrypoint: target.entrypoint.clone(),
standalone: true,
is_project: false,
space_id: None,
files: vec![FileEntry {
path: target.entrypoint.clone(),
name: target.entrypoint,
is_text: true,
size,
}],
});
}
let meta = store.meta(&path)?;
Ok(TargetInfo {
path,
entrypoint: target.entrypoint,
standalone: false,
is_project: is_project_dir(&target.root),
space_id: meta.space_id,
files: list_files(&target.root)?,
})
}
#[tauri::command]
fn read_target_file(
app: AppHandle,
store: State<'_, Store>,
path: String,
file: String,
) -> Result<FilePayload, String> {
let target = resolve_target(&app, &store, &path)?;
let full = project_file_path(&target.root, &file)?;
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
let is_text = is_text_file(&file);
Ok(FilePayload {
path: file,
is_text,
content: if is_text {
String::from_utf8_lossy(&bytes).to_string()
} else {
BASE64.encode(&bytes)
},
})
}
#[tauri::command]
fn write_target_file(
app: AppHandle,
store: State<'_, Store>,
path: String,
file: String,
content: String,
) -> Result<(), String> {
let target = resolve_target(&app, &store, &path)?;
let full = project_file_path(&target.root, &file)?;
if let Some(dir) = full.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&full, content).map_err(|e| e.to_string())
}
#[tauri::command]
fn set_target_entrypoint(
app: AppHandle,
store: State<'_, Store>,
path: String,
entrypoint: String,
) -> Result<(), String> {
let target = resolve_target(&app, &store, &path)?;
if target.standalone {
return Err("A standalone document is its own entrypoint".to_string());
}
let mut meta = store.meta(&path)?;
meta.entrypoint = entrypoint;
store.save_meta(&path, &meta)
}
#[tauri::command]
fn compile_target(
app: AppHandle,
store: State<'_, Store>,
path: String,
overrides: Option<std::collections::HashMap<String, String>>,
) -> Result<CompileResult, CompileFailure> {
let target = resolve_target(&app, &store, &path).map_err(failure)?;
let mut files = read_target_files(&app, &store, &target).map_err(failure)?;
for (file, content) in overrides.unwrap_or_default() {
files.insert(file, content.into_bytes());
}
compiler::compile_to_svg(target.entrypoint, files)
.map_err(|diagnostics| CompileFailure { diagnostics })
}
#[tauri::command]
fn export_target(
app: AppHandle,
store: State<'_, Store>,
path: String,
format: String,
destination: String,
) -> Result<String, String> {
let target = resolve_target(&app, &store, &path)?;
let files = read_target_files(&app, &store, &target)?;
let bytes = match format.as_str() {
"pdf" => compiler::export_pdf(target.entrypoint, files),
"png" => compiler::export_png(target.entrypoint, files),
"html" => compiler::export_html(target.entrypoint, files),
other => return Err(format!("Unsupported export format '{}'", other)),
}
.map_err(|diagnostics| {
diagnostics
.into_iter()
.map(|d| d.message)
.collect::<Vec<_>>()
.join("; ")
})?;
std::fs::write(&destination, bytes).map_err(|e| e.to_string())?;
Ok(destination)
}
#[tauri::command]
fn thumbnail(app: AppHandle, store: State<'_, Store>, path: String) -> Result<thumbnails::Thumbnail, String> {
thumbnails::thumbnail(&app, &store, &path)
}
#[tauri::command]
fn read_image(
app: AppHandle,
store: State<'_, Store>,
path: String,
) -> Result<thumbnails::ImageData, String> {
thumbnails::read_image(&app, &store, &path)
}
#[tauri::command]
fn clear_thumbnails(store: State<'_, Store>) -> Result<(), String> {
store.clear_thumbnails()
}
#[tauri::command]
fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, String> {
assets::list_assets(&app, &store)
}
#[tauri::command]
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
let files = match path {
Some(path) if !path.is_empty() => {
let target = resolve_target(&app, &store, &path)?;
read_target_files(&app, &store, &target)?
}
_ => assets::asset_files(&app, &store),
};
Ok(assets::font_families(&files))
}
#[tauri::command]
fn import_assets(app: AppHandle, store: State<'_, Store>, sources: Vec<String>) -> Result<Vec<String>, String> {
let destination = assets::assets_dir(&app, &store)?;
assets::import_files(&sources, &destination)
}
#[tauri::command]
fn delete_asset(app: AppHandle, store: State<'_, Store>, name: String) -> Result<(), String> {
assets::delete_asset(&app, &store, &name)
}
#[tauri::command]
fn import_into_target(
app: AppHandle,
store: State<'_, Store>,
path: String,
sources: Vec<String>,
) -> Result<Vec<String>, String> {
let target = resolve_target(&app, &store, &path)?;
assets::import_paths(&sources, &target.root)
}
#[tauri::command]
fn import_into_folder(
app: AppHandle,
store: State<'_, Store>,
parent: String,
sources: Vec<String>,
) -> Result<Vec<String>, String> {
let destination = workspace_path(&app, &store, &parent)?;
assets::import_paths(&sources, &destination)
}
#[tauri::command]
fn lsp_start(
app: AppHandle,
store: State<'_, Store>,
state: State<'_, LspState>,
path: String,
) -> Result<LspHandle, String> {
let target = resolve_target(&app, &store, &path)?;
state.start(&app, &target.root, &target.entrypoint)
}
#[tauri::command]
fn lsp_send(state: State<'_, LspState>, message: String) -> Result<(), String> {
state.send(&message)
}
#[tauri::command]
fn lsp_stop(state: State<'_, LspState>) {
state.stop();
}
#[tauri::command]
fn lsp_running(state: State<'_, LspState>) -> bool {
state.is_running()
}
#[tauri::command]
fn cloud_login(
app: AppHandle,
store: State<'_, Store>,
server_url: String,
email: String,
password: String,
) -> Result<Account, String> {
let server_url = server_url.trim_end_matches('/').to_string();
let device_name = format!("Typst Desktop ({})", std::env::consts::OS);
let response = sync::login(&server_url, &email, &password, &device_name)?;
let mut settings = load_settings(&app, &store)?;
settings.server_url = server_url;
settings.device_token = Some(response.token);
settings.account_email = Some(response.email.clone());
settings.account_username = Some(response.username.clone());
save_settings(&store, &settings)?;
Ok(Account {
user_id: response.user_id,
username: response.username,
email: response.email,
})
}
#[tauri::command]
fn cloud_logout(app: AppHandle, store: State<'_, Store>) -> Result<(), String> {
let mut settings = load_settings(&app, &store)?;
if let Some(token) = &settings.device_token {
let _ = sync::logout(&settings.server_url, token);
}
settings.device_token = None;
settings.account_email = None;
settings.account_username = None;
save_settings(&store, &settings)
}
#[tauri::command]
fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Account>, String> {
let settings = load_settings(&app, &store)?;
let Some(token) = settings.device_token else {
return Ok(None);
};
Ok(sync::me(&settings.server_url, &token).ok())
}
#[tauri::command]
fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<SpaceSummary>, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_spaces(&server_url, &token)
}
#[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::create_space(&server_url, &token, name.trim())
}
#[tauri::command]
fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_space(&server_url, &token, &space_id)
}
#[tauri::command]
fn cloud_clone_space(
app: AppHandle,
store: State<'_, Store>,
space_id: String,
project_name: String,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let project = project_name.trim().to_string();
let dir = workspace_path(&app, &store, &project)?;
if dir.exists() {
return Err(format!("A project named '{}' already exists", project_name));
}
sync::clone_space(&server_url, &token, &store, &project, &dir, &space_id)
}
#[tauri::command]
fn cloud_link_project(
app: AppHandle,
store: State<'_, Store>,
project: String,
space_id: Option<String>,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
let space_id = match space_id {
Some(id) if !id.trim().is_empty() => id,
_ => sync::create_space(&server_url, &token, &project)?.id,
};
meta.space_id = Some(space_id);
meta.base_hashes.clear();
store.save_meta(&project, &meta)?;
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
}
#[tauri::command]
fn cloud_unlink_project(app: AppHandle, store: State<'_, Store>, project: String) -> Result<(), String> {
let (dir, mut meta) = load_project(&app, &store, &project)?;
meta.space_id = None;
meta.base_hashes.clear();
meta.last_synced_at = None;
let _ = dir;
store.forget_project(&project)
}
#[tauri::command]
fn cloud_push(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
}
#[tauri::command]
fn cloud_pull(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)
}
#[tauri::command]
fn cloud_sync(app: AppHandle, store: State<'_, Store>, project: String) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
let mut report = sync::pull_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
if !report.conflicts.is_empty() {
return Ok(report);
}
let pushed = sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)?;
report.pushed = pushed.pushed;
report.deleted_remote = pushed.deleted_remote;
report.conflicts = pushed.conflicts;
Ok(report)
}
#[derive(Deserialize)]
pub struct ResolutionRequest {
pub path: String,
pub content: String,
pub server_hash: String,
}
#[tauri::command]
fn cloud_resolve_conflicts(
app: AppHandle,
store: State<'_, Store>,
project: String,
resolutions: Vec<ResolutionRequest>,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let (dir, mut meta) = load_project(&app, &store, &project)?;
for resolution in &resolutions {
sync::resolve_conflict(
&store,
&project,
&dir,
&mut meta,
&resolution.path,
&resolution.content,
&resolution.server_hash,
)?;
}
sync::push_project(&server_url, &token, &store, &project, &dir, &mut meta)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let store = Store::open(&app.handle())?;
app.manage(store);
Ok(())
})
.manage(LspState::default())
.on_window_event(|window, event| {
if matches!(event, tauri::WindowEvent::Destroyed) {
if let Some(state) = window.app_handle().try_state::<LspState>() {
state.stop();
}
}
})
.invoke_handler(tauri::generate_handler![
get_settings,
update_settings,
browse_workspace,
create_folder_entry,
create_document_entry,
create_project_entry,
rename_entry,
delete_entry,
upload_entry,
target_info,
read_target_file,
write_target_file,
set_target_entrypoint,
compile_target,
export_target,
thumbnail,
read_image,
clear_thumbnails,
list_assets,
list_font_families,
import_assets,
delete_asset,
import_into_target,
import_into_folder,
lsp_start,
lsp_send,
lsp_stop,
lsp_running,
cloud_login,
cloud_logout,
cloud_account,
cloud_list_spaces,
cloud_create_space,
cloud_delete_space,
cloud_clone_space,
cloud_link_project,
cloud_unlink_project,
cloud_push,
cloud_pull,
cloud_sync,
cloud_resolve_conflicts,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+153
View File
@@ -0,0 +1,153 @@
use serde::Serialize;
use std::io::{BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
pub const MESSAGE_EVENT: &str = "lsp://message";
pub const CLOSED_EVENT: &str = "lsp://closed";
#[derive(Default)]
pub struct LspState {
inner: Mutex<Option<Session>>,
}
struct Session {
child: Child,
stdin: ChildStdin,
}
#[derive(Serialize, Clone)]
pub struct LspHandle {
pub root_uri: String,
pub document_uri: String,
}
fn file_uri(path: &Path) -> String {
let text = path.to_string_lossy().replace('\\', "/");
if text.starts_with('/') {
format!("file://{}", text)
} else {
format!("file:///{}", text)
}
}
impl LspState {
pub fn is_running(&self) -> bool {
self.inner.lock().map(|slot| slot.is_some()).unwrap_or(false)
}
pub fn start(
&self,
app: &AppHandle,
root: &Path,
entrypoint: &str,
) -> Result<LspHandle, String> {
self.stop();
let mut child = Command::new("tinymist")
.arg("lsp")
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| {
format!(
"Could not start the Typst language server (tinymist): {}. \
Install tinymist and make sure it is on your PATH.",
e
)
})?;
let stdin = child.stdin.take().ok_or("Language server has no stdin")?;
let stdout = child.stdout.take().ok_or("Language server has no stdout")?;
let emitter = app.clone();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
match read_message(&mut reader) {
Some(message) => {
let _ = emitter.emit(MESSAGE_EVENT, message);
}
None => {
let _ = emitter.emit(CLOSED_EVENT, ());
break;
}
}
}
});
let handle = LspHandle {
root_uri: file_uri(root),
document_uri: file_uri(&root.join(entrypoint)),
};
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
*slot = Some(Session { child, stdin });
Ok(handle)
}
pub fn send(&self, message: &str) -> Result<(), String> {
let mut slot = self.inner.lock().map_err(|_| "Language server lock poisoned")?;
let session = slot.as_mut().ok_or("Language server is not running")?;
session
.stdin
.write_all(format!("Content-Length: {}\r\n\r\n", message.len()).as_bytes())
.map_err(|e| e.to_string())?;
session
.stdin
.write_all(message.as_bytes())
.map_err(|e| e.to_string())?;
session.stdin.flush().map_err(|e| e.to_string())
}
pub fn stop(&self) {
if let Ok(mut slot) = self.inner.lock() {
if let Some(mut session) = slot.take() {
let _ = session.child.kill();
let _ = session.child.wait();
}
}
}
}
fn read_message(reader: &mut BufReader<std::process::ChildStdout>) -> Option<String> {
let mut header = String::new();
loop {
let mut byte = [0u8; 1];
if reader.read_exact(&mut byte).is_err() {
return None;
}
header.push(byte[0] as char);
if header.ends_with("\r\n\r\n") {
break;
}
if header.len() > 8192 {
return None;
}
}
let mut content_length = 0usize;
for line in header.split("\r\n") {
if let Some(value) = line.strip_prefix("Content-Length: ") {
content_length = value.trim().parse().unwrap_or(0);
}
}
if content_length == 0 {
return Some(String::new());
}
let mut body = vec![0u8; content_length];
if reader.read_exact(&mut body).is_err() {
return None;
}
String::from_utf8(body).ok()
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
typst_desktop_lib::run()
}
+572
View File
@@ -0,0 +1,572 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use crate::workspace::{
collect_files, content_hash, is_text_file, project_file_path, ProjectMeta,
};
use crate::db::Store;
const REQUEST_TIMEOUT_SECS: u64 = 30;
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
}
fn endpoint(server_url: &str, path: &str) -> String {
format!("{}/api/desktop{}", server_url.trim_end_matches('/'), path)
}
fn describe(error: ureq::Error) -> String {
match error {
ureq::Error::Status(code, response) => {
let body = response.into_string().unwrap_or_default();
if body.is_empty() {
format!("Server returned {}", code)
} else {
body
}
}
other => other.to_string(),
}
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Account {
pub user_id: String,
pub username: String,
pub email: String,
}
#[derive(Deserialize)]
pub struct LoginResponse {
pub token: String,
pub user_id: String,
pub username: String,
pub email: String,
}
pub fn login(
server_url: &str,
email: &str,
password: &str,
device_name: &str,
) -> Result<LoginResponse, String> {
agent()
.post(&endpoint(server_url, "/auth/login"))
.send_json(ureq::json!({
"email": email,
"password": password,
"device_name": device_name,
}))
.map_err(describe)?
.into_json::<LoginResponse>()
.map_err(|e| e.to_string())
}
pub fn logout(server_url: &str, token: &str) -> Result<(), String> {
agent()
.post(&endpoint(server_url, "/auth/logout"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
agent()
.get(&endpoint(server_url, "/auth/me"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Account>()
.map_err(|e| e.to_string())
}
#[derive(Deserialize, Serialize, Clone)]
pub struct SpaceSummary {
pub id: String,
pub name: String,
pub entrypoint: String,
pub role: String,
pub updated_at: String,
}
pub fn list_spaces(server_url: &str, token: &str) -> Result<Vec<SpaceSummary>, String> {
agent()
.get(&endpoint(server_url, "/spaces"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Vec<SpaceSummary>>()
.map_err(|e| e.to_string())
}
pub fn create_space(server_url: &str, token: &str, name: &str) -> Result<SpaceSummary, String> {
agent()
.post(&endpoint(server_url, "/spaces"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name }))
.map_err(describe)?
.into_json::<SpaceSummary>()
.map_err(|e| e.to_string())
}
pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
Ok(())
}
#[derive(Deserialize)]
pub struct ManifestEntry {
pub path: String,
pub kind: String,
pub hash: String,
}
#[derive(Deserialize)]
pub struct SpaceManifest {
pub space_id: String,
pub name: String,
pub entrypoint: String,
pub files: Vec<ManifestEntry>,
}
pub fn get_manifest(
server_url: &str,
token: &str,
space_id: &str,
) -> Result<SpaceManifest, String> {
agent()
.get(&endpoint(
server_url,
&format!("/spaces/{}/manifest", space_id),
))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<SpaceManifest>()
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct FileContent {
pub path: String,
pub kind: String,
pub hash: String,
pub encoding: String,
pub content: String,
}
impl FileContent {
pub fn bytes(&self) -> Result<Vec<u8>, String> {
if self.encoding == "base64" {
BASE64
.decode(self.content.as_bytes())
.map_err(|e| format!("Invalid base64 from server: {}", e))
} else {
Ok(self.content.clone().into_bytes())
}
}
}
pub fn pull_file(
server_url: &str,
token: &str,
space_id: &str,
path: &str,
) -> Result<FileContent, String> {
agent()
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<FileContent>()
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
struct ConflictBody {
server_hash: String,
encoding: String,
server_content: String,
}
pub enum PushResult {
Applied,
Conflict { server_hash: String, server_text: String },
}
pub fn push_file(
server_url: &str,
token: &str,
space_id: &str,
path: &str,
bytes: &[u8],
base_hash: Option<&str>,
) -> Result<PushResult, String> {
let (encoding, content) = if is_text_file(path) {
("utf8", String::from_utf8_lossy(bytes).to_string())
} else {
("base64", BASE64.encode(bytes))
};
let response = agent()
.put(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"path": path,
"content": content,
"encoding": encoding,
"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))?;
let server_text = if conflict.encoding == "base64" {
String::new()
} else {
conflict.server_content
};
Ok(PushResult::Conflict {
server_hash: conflict.server_hash,
server_text,
})
}
Err(other) => Err(describe(other)),
}
}
pub fn delete_remote_file(
server_url: &str,
token: &str,
space_id: &str,
path: &str,
) -> Result<(), String> {
let response = agent()
.delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call();
match response {
Ok(_) => Ok(()),
Err(ureq::Error::Status(404, _)) => Ok(()),
Err(other) => Err(describe(other)),
}
}
#[derive(Serialize, Clone)]
pub struct Conflict {
pub path: String,
pub local_text: String,
pub remote_text: String,
pub merged_text: String,
pub server_hash: String,
pub auto_merged: bool,
pub binary: bool,
}
#[derive(Serialize, Default)]
pub struct SyncReport {
pub pushed: Vec<String>,
pub pulled: Vec<String>,
pub deleted_local: Vec<String>,
pub deleted_remote: Vec<String>,
pub merged: Vec<String>,
pub conflicts: Vec<Conflict>,
}
fn read_local(project_dir: &Path, relative: &str) -> Result<Vec<u8>, String> {
let full = project_file_path(project_dir, relative)?;
std::fs::read(&full).map_err(|e| e.to_string())
}
fn write_local(project_dir: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> {
let full = project_file_path(project_dir, relative)?;
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&full, bytes).map_err(|e| e.to_string())
}
pub fn pull_project(
server_url: &str,
token: &str,
store: &Store,
project: &str,
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
let manifest = get_manifest(server_url, token, &space_id)?;
let mut report = SyncReport::default();
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
let mut remote_paths = HashSet::new();
for entry in &manifest.files {
remote_paths.insert(entry.path.clone());
let base = meta.base_hashes.get(&entry.path).cloned();
let local_exists = local_files.contains(&entry.path);
if !local_exists {
if base.is_some() {
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone());
continue;
}
let local_bytes = read_local(project_dir, &entry.path)?;
let local_hash = content_hash(&local_bytes);
if local_hash == entry.hash {
meta.base_hashes.insert(entry.path.clone(), entry.hash.clone());
continue;
}
if base.as_deref() == Some(entry.hash.as_str()) {
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
let remote_bytes = remote.bytes()?;
if base.as_deref() == Some(local_hash.as_str()) {
write_local(project_dir, &entry.path, &remote_bytes)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone());
continue;
}
if !is_text_file(&entry.path) || remote.kind == "binary" {
report.conflicts.push(Conflict {
path: entry.path.clone(),
local_text: String::new(),
remote_text: String::new(),
merged_text: String::new(),
server_hash: remote.hash,
auto_merged: false,
binary: true,
});
continue;
}
let local_text = String::from_utf8_lossy(&local_bytes).to_string();
let remote_text = String::from_utf8_lossy(&remote_bytes).to_string();
let base_text = read_base_snapshot(store, project, &entry.path);
match diffy::merge(&base_text, &local_text, &remote_text) {
Ok(merged) => {
write_local(project_dir, &entry.path, merged.as_bytes())?;
meta.base_hashes
.insert(entry.path.clone(), content_hash(merged.as_bytes()));
report.merged.push(entry.path.clone());
}
Err(conflicted) => {
report.conflicts.push(Conflict {
path: entry.path.clone(),
local_text,
remote_text,
merged_text: conflicted,
server_hash: remote.hash,
auto_merged: false,
binary: false,
});
}
}
}
let vanished: Vec<String> = meta
.base_hashes
.keys()
.filter(|path| !remote_paths.contains(*path) && local_files.contains(*path))
.cloned()
.collect();
for path in vanished {
let local_bytes = read_local(project_dir, &path)?;
let base = meta.base_hashes.get(&path).cloned().unwrap_or_default();
if content_hash(&local_bytes) == base {
let full = project_file_path(project_dir, &path)?;
let _ = std::fs::remove_file(full);
meta.base_hashes.remove(&path);
report.deleted_local.push(path);
}
}
meta.entrypoint = manifest.entrypoint;
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
store.save_meta(project, meta)?;
save_base_snapshots(store, project, project_dir, meta)?;
Ok(report)
}
pub fn push_project(
server_url: &str,
token: &str,
store: &Store,
project: &str,
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
let mut report = SyncReport::default();
let local_files = collect_files(project_dir)?;
let local_set: HashSet<String> = local_files.iter().cloned().collect();
for path in &local_files {
let bytes = read_local(project_dir, path)?;
let hash = content_hash(&bytes);
let base = meta.base_hashes.get(path).cloned();
if base.as_deref() == Some(hash.as_str()) {
continue;
}
match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? {
PushResult::Applied => {
meta.base_hashes.insert(path.clone(), hash);
report.pushed.push(path.clone());
}
PushResult::Conflict {
server_hash,
server_text,
} => {
let binary = !is_text_file(path);
report.conflicts.push(Conflict {
path: path.clone(),
local_text: if binary {
String::new()
} else {
String::from_utf8_lossy(&bytes).to_string()
},
remote_text: server_text.clone(),
merged_text: server_text,
server_hash,
auto_merged: false,
binary,
});
}
}
}
let removed: Vec<String> = meta
.base_hashes
.keys()
.filter(|path| !local_set.contains(*path))
.cloned()
.collect();
for path in removed {
delete_remote_file(server_url, token, &space_id, &path)?;
meta.base_hashes.remove(&path);
report.deleted_remote.push(path);
}
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
store.save_meta(project, meta)?;
save_base_snapshots(store, project, project_dir, meta)?;
Ok(report)
}
pub fn clone_space(
server_url: &str,
token: &str,
store: &Store,
project: &str,
project_dir: &Path,
space_id: &str,
) -> Result<SyncReport, String> {
std::fs::create_dir_all(project_dir).map_err(|e| e.to_string())?;
let manifest = get_manifest(server_url, token, space_id)?;
let mut meta = store.meta(project)?;
meta.space_id = Some(space_id.to_string());
meta.entrypoint = manifest.entrypoint.clone();
let mut report = SyncReport::default();
for entry in &manifest.files {
let remote = pull_file(server_url, token, space_id, &entry.path)?;
write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone());
}
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
store.save_meta(project, &meta)?;
save_base_snapshots(store, project, project_dir, &meta)?;
Ok(report)
}
pub fn save_base_snapshots(
store: &Store,
project: &str,
project_dir: &Path,
meta: &ProjectMeta,
) -> Result<(), String> {
for (path, base) in &meta.base_hashes {
let full = project_file_path(project_dir, path)?;
let Ok(bytes) = std::fs::read(&full) else {
continue;
};
if content_hash(&bytes) == *base {
store.save_base_snapshot(project, path, base, &bytes)?;
}
}
Ok(())
}
fn read_base_snapshot(store: &Store, project: &str, relative: &str) -> String {
store
.base_snapshot(project, relative)
.ok()
.flatten()
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
.unwrap_or_default()
}
pub fn resolve_conflict(
store: &Store,
project: &str,
project_dir: &Path,
meta: &mut ProjectMeta,
path: &str,
content: &str,
server_hash: &str,
) -> Result<(), String> {
write_local(project_dir, path, content.as_bytes())?;
meta.base_hashes
.insert(path.to_string(), server_hash.to_string());
store.save_meta(project, meta)
}
+160
View File
@@ -0,0 +1,160 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::Serialize;
use std::path::Path;
use std::time::UNIX_EPOCH;
use tauri::AppHandle;
use crate::assets::is_image;
use crate::compiler;
use crate::db::Store;
use crate::workspace::{read_target_files, resolve_target, workspace_path};
const MAX_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_VIEWER_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Serialize)]
pub struct Thumbnail {
pub kind: String,
pub data: String,
}
fn modified_seconds(path: &Path) -> i64 {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or(0)
}
fn mime_for(name: &str) -> &'static str {
let lower = name.to_lowercase();
if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".gif") {
"image/gif"
} else if lower.ends_with(".svg") {
"image/svg+xml"
} else if lower.ends_with(".webp") {
"image/webp"
} else {
"image/jpeg"
}
}
#[derive(Serialize)]
pub struct ImageData {
pub name: String,
pub data: String,
pub size: u64,
pub width: Option<u32>,
pub height: Option<u32>,
}
pub fn read_image(app: &AppHandle, store: &Store, path: &str) -> Result<ImageData, String> {
let full = workspace_path(app, store, path)?;
let name = full
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if !is_image(&name) {
return Err("Not an image file".to_string());
}
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
if size > MAX_VIEWER_BYTES {
return Err("Image is too large to open".to_string());
}
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
let (width, height) = image_dimensions(&bytes);
Ok(ImageData {
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
name,
size,
width,
height,
})
}
fn image_dimensions(bytes: &[u8]) -> (Option<u32>, Option<u32>) {
if bytes.len() > 24 && bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
return (Some(width), Some(height));
}
if bytes.len() > 10 && bytes.starts_with(&[0xFF, 0xD8]) {
let mut index = 2usize;
while index + 9 < bytes.len() {
if bytes[index] != 0xFF {
index += 1;
continue;
}
let marker = bytes[index + 1];
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC
{
let height = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as u32;
let width = u16::from_be_bytes([bytes[index + 7], bytes[index + 8]]) as u32;
return (Some(width), Some(height));
}
let length = u16::from_be_bytes([bytes[index + 2], bytes[index + 3]]) as usize;
index += 2 + length;
}
}
(None, None)
}
pub fn thumbnail(app: &AppHandle, store: &Store, path: &str) -> Result<Thumbnail, String> {
let full = workspace_path(app, store, path)?;
let name = full
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let image = is_image(&name);
if !image && !name.to_lowercase().ends_with(".typ") {
return Err("No preview available".to_string());
}
let modified = modified_seconds(&full);
if let Some((kind, data)) = store.thumbnail(path, modified)? {
return Ok(Thumbnail { kind, data });
}
let thumbnail = if image {
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
if size > MAX_IMAGE_BYTES {
return Err("Image is too large to preview".to_string());
}
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
Thumbnail {
kind: "image".to_string(),
data: format!("data:{};base64,{}", mime_for(&name), BASE64.encode(&bytes)),
}
} else {
let target = resolve_target(app, store, path)?;
let files = read_target_files(app, store, &target)?;
let result = compiler::compile_to_svg(target.entrypoint, files)
.map_err(|_| "Document does not compile".to_string())?;
let svg = result
.pages
.into_iter()
.next()
.ok_or("Document has no pages")?;
Thumbnail {
kind: "svg".to_string(),
data: svg,
}
};
store.save_thumbnail(path, &thumbnail.kind, &thumbnail.data, modified)?;
Ok(thumbnail)
}
+407
View File
@@ -0,0 +1,407 @@
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
use crate::db::Store;
use walkdir::WalkDir;
pub const PROJECT_META_FILE: &str = ".typst-desktop.json";
const TEXT_EXTENSIONS: [&str; 10] = [
"typ", "toml", "bib", "csl", "yml", "yaml", "json", "md", "txt", "csv",
];
pub fn is_text_file(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
}
pub fn content_hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Settings {
pub workspace_root: String,
pub server_url: String,
#[serde(default)]
pub device_token: Option<String>,
#[serde(default)]
pub account_email: Option<String>,
#[serde(default)]
pub account_username: Option<String>,
}
impl Settings {
pub fn fallback(app: &AppHandle) -> Self {
let home = app
.path()
.home_dir()
.unwrap_or_else(|_| PathBuf::from("."));
Settings {
workspace_root: home.join("typst").to_string_lossy().to_string(),
server_url: "http://localhost:3000".to_string(),
device_token: None,
account_email: None,
account_username: None,
}
}
}
pub fn load_settings(app: &AppHandle, store: &Store) -> Result<Settings, String> {
match store.settings()? {
Some(settings) => Ok(settings),
None => {
let settings = Settings::fallback(app);
store.save_settings(&settings)?;
Ok(settings)
}
}
}
pub fn save_settings(store: &Store, settings: &Settings) -> Result<(), String> {
store.save_settings(settings)
}
pub fn workspace_root(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
let settings = load_settings(app, store)?;
let root = PathBuf::from(&settings.workspace_root);
std::fs::create_dir_all(&root)
.map_err(|e| format!("Cannot create workspace directory: {}", e))?;
Ok(root)
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ProjectMeta {
pub entrypoint: String,
pub space_id: Option<String>,
pub last_synced_at: Option<String>,
pub base_hashes: HashMap<String, String>,
}
impl Default for ProjectMeta {
fn default() -> Self {
ProjectMeta {
entrypoint: "main.typ".to_string(),
space_id: None,
last_synced_at: None,
base_hashes: HashMap::new(),
}
}
}
pub fn workspace_path(app: &AppHandle, store: &Store, relative: &str) -> Result<PathBuf, String> {
let root = workspace_root(app, store)?;
if relative.is_empty() {
return Ok(root);
}
project_file_path(&root, relative)
}
pub fn is_project_dir(path: &Path) -> bool {
path.join(PROJECT_META_FILE).exists() || path.join("typst.toml").exists()
}
pub fn is_typst_file(path: &str) -> bool {
path.to_lowercase().ends_with(".typ")
}
#[derive(Serialize)]
pub struct BrowseEntry {
pub name: String,
pub path: String,
pub kind: String,
pub size: u64,
pub modified: Option<String>,
pub space_id: Option<String>,
pub last_synced_at: Option<String>,
pub child_count: usize,
}
fn modified_at(path: &Path) -> Option<String> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
let datetime: chrono::DateTime<chrono::Utc> = modified.into();
Some(datetime.to_rfc3339())
}
pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<BrowseEntry>, String> {
let dir = workspace_path(app, store, relative)?;
if !dir.is_dir() {
return Err(format!("'{}' is not a folder", relative));
}
let prefix = if relative.is_empty() {
String::new()
} else {
format!("{}/", relative.trim_end_matches('/'))
};
let mut entries = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = format!("{}{}", prefix, name);
let full = entry.path();
if full.is_dir() {
let project = is_project_dir(&full) || store.has_project(&path)?;
let meta = if project {
Some(store.meta(&path)?)
} else {
None
};
let child_count = std::fs::read_dir(&full)
.map(|children| {
children
.filter_map(|child| child.ok())
.filter(|child| {
!child.file_name().to_string_lossy().starts_with('.')
})
.count()
})
.unwrap_or(0);
entries.push(BrowseEntry {
name,
path,
kind: if project { "project" } else { "folder" }.to_string(),
size: 0,
modified: modified_at(&full),
space_id: meta.as_ref().and_then(|m| m.space_id.clone()),
last_synced_at: meta.as_ref().and_then(|m| m.last_synced_at.clone()),
child_count,
});
} else {
let kind = if is_typst_file(&name) { "document" } else { "file" };
entries.push(BrowseEntry {
name,
path,
kind: kind.to_string(),
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
modified: modified_at(&full),
space_id: None,
last_synced_at: None,
child_count: 0,
});
}
}
entries.sort_by(|a, b| {
let rank = |kind: &str| match kind {
"project" => 0,
"folder" => 1,
"document" => 2,
_ => 3,
};
rank(&a.kind)
.cmp(&rank(&b.kind))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
pub struct Target {
pub root: PathBuf,
pub entrypoint: String,
pub standalone: bool,
}
pub fn resolve_target(app: &AppHandle, store: &Store, path: &str) -> Result<Target, String> {
let full = workspace_path(app, store, path)?;
if full.is_dir() {
let meta = store.meta(path)?;
return Ok(Target {
root: full,
entrypoint: meta.entrypoint,
standalone: false,
});
}
if !full.is_file() {
return Err(format!("'{}' does not exist", path));
}
let parent = full
.parent()
.ok_or("File has no parent folder")?
.to_path_buf();
let name = full
.file_name()
.map(|n| n.to_string_lossy().to_string())
.ok_or("File has no name")?;
Ok(Target {
root: parent,
entrypoint: name,
standalone: true,
})
}
pub fn read_target_files(
app: &AppHandle,
store: &Store,
target: &Target,
) -> Result<HashMap<String, Vec<u8>>, String> {
let mut map = crate::assets::asset_files(app, store);
if !target.standalone {
for (path, bytes) in read_all_files(&target.root)? {
map.insert(path, bytes);
}
return Ok(map);
}
collect_loose_files(&target.root, "", &mut map)?;
Ok(map)
}
fn collect_loose_files(
dir: &Path,
prefix: &str,
map: &mut HashMap<String, Vec<u8>>,
) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
let key = if prefix.is_empty() {
name
} else {
format!("{}/{}", prefix, name)
};
if path.is_dir() {
if is_project_dir(&path) {
continue;
}
collect_loose_files(&path, &key, map)?;
} else if let Ok(bytes) = std::fs::read(&path) {
map.insert(key, bytes);
}
}
Ok(())
}
pub fn project_file_path(project_dir: &Path, relative: &str) -> Result<PathBuf, String> {
if relative.is_empty() {
return Err("Path cannot be empty".to_string());
}
let mut resolved = project_dir.to_path_buf();
for component in relative.replace('\\', "/").split('/') {
if component.is_empty() || component == "." {
continue;
}
if component == ".." {
return Err("Path cannot escape the project".to_string());
}
resolved.push(component);
}
if !resolved.starts_with(project_dir) {
return Err("Path cannot escape the project".to_string());
}
Ok(resolved)
}
pub fn relative_path(project_dir: &Path, path: &Path) -> Option<String> {
path.strip_prefix(project_dir)
.ok()
.map(|rest| rest.to_string_lossy().replace('\\', "/"))
}
pub fn collect_files(project_dir: &Path) -> Result<Vec<String>, String> {
let mut files = Vec::new();
for entry in WalkDir::new(project_dir).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let Some(relative) = relative_path(project_dir, entry.path()) else {
continue;
};
if relative == PROJECT_META_FILE || relative.starts_with('.') {
continue;
}
files.push(relative);
}
files.sort();
Ok(files)
}
pub fn read_all_files(project_dir: &Path) -> Result<HashMap<String, Vec<u8>>, String> {
let mut map = HashMap::new();
for relative in collect_files(project_dir)? {
let full = project_file_path(project_dir, &relative)?;
let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
map.insert(relative, bytes);
}
Ok(map)
}
#[derive(Serialize)]
pub struct FileEntry {
pub path: String,
pub name: String,
pub is_text: bool,
pub size: u64,
}
pub fn list_files(project_dir: &Path) -> Result<Vec<FileEntry>, String> {
let mut entries = Vec::new();
for relative in collect_files(project_dir)? {
let full = project_file_path(project_dir, &relative)?;
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
let name = relative
.rsplit('/')
.next()
.unwrap_or(&relative)
.to_string();
entries.push(FileEntry {
is_text: is_text_file(&relative),
path: relative,
name,
size,
});
}
Ok(entries)
}
pub const NEW_PROJECT_MAIN: &str = "= New Project\n\nStart writing here.\n";
pub fn manifest_for(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let slug = slug.trim_matches('-').to_string();
let slug = if slug.is_empty() {
"my-project".to_string()
} else {
slug
};
format!(
"[package]\nname = \"{slug}\"\nversion = \"0.1.0\"\nentrypoint = \"main.typ\"\nauthors = [\"Anonymous\"]\nlicense = \"MIT\"\ndescription = \"\"\n"
)
}
+138
View File
@@ -0,0 +1,138 @@
use chrono::Datelike;
use std::collections::HashMap;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Duration};
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::World;
use typst::{Library, LibraryExt};
use typst_kit::downloader::SystemDownloader;
use typst_kit::packages::SystemPackages;
pub struct ProjectWorld {
library: typst::utils::LazyHash<Library>,
main: FileId,
files: HashMap<String, Vec<u8>>,
book: typst::utils::LazyHash<FontBook>,
fonts: Vec<Font>,
packages: SystemPackages,
}
fn normalize_path(path: &str) -> String {
path.trim_start_matches('/').replace('\\', "/")
}
impl ProjectWorld {
pub fn new(entrypoint: String, files: HashMap<String, Vec<u8>>, enable_html: bool) -> Self {
let main = FileId::new(RootedPath::new(
VirtualRoot::Project,
VirtualPath::new(&entrypoint).unwrap_or_else(|_| VirtualPath::new("main.typ").unwrap()),
));
let downloader = SystemDownloader::new("TypstDesktop (typst-kit)");
let packages = SystemPackages::new(downloader);
let mut book = FontBook::new();
let mut fonts = Vec::new();
for data in typst_assets::fonts() {
let buffer = Bytes::new(data);
for font in Font::iter(buffer) {
book.push(font.info().clone());
fonts.push(font);
}
}
for (name, data) in &files {
let lower = name.to_lowercase();
if [".ttf", ".otf", ".ttc", ".otc"]
.iter()
.any(|ext| lower.ends_with(ext))
{
for font in Font::iter(Bytes::new(data.clone())) {
book.push(font.info().clone());
fonts.push(font);
}
}
}
let library = if enable_html {
Library::builder()
.with_features([typst::Feature::Html].into_iter().collect())
.build()
} else {
Library::builder().build()
};
Self {
library: typst::utils::LazyHash::new(library),
main,
files,
book: typst::utils::LazyHash::new(book),
fonts,
packages,
}
}
fn load_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
let path = normalize_path(id.vpath().get_without_slash());
if let VirtualRoot::Package(package) = id.root() {
let root = self
.packages
.obtain(package)
.map_err(|e| FileError::Other(Some(e.to_string().into())))?;
return root.load(id.vpath()).map(|bytes| bytes.to_vec());
}
self.files
.get(&path)
.cloned()
.ok_or_else(|| FileError::NotFound(path.into()))
}
}
impl World for ProjectWorld {
fn library(&self) -> &typst::utils::LazyHash<Library> {
&self.library
}
fn book(&self) -> &typst::utils::LazyHash<FontBook> {
&self.book
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
let data = self.load_bytes(id)?;
let text = std::str::from_utf8(&data)
.map_err(|_| FileError::InvalidUtf8)?
.to_owned();
Ok(Source::new(id, text))
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
let data = self.load_bytes(id)?;
Ok(Bytes::new(data))
}
fn font(&self, index: usize) -> Option<Font> {
self.fonts.get(index).cloned()
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
let now = chrono::Local::now();
let date = if let Some(offset) = offset {
let offset_secs = offset.hours() as i32 * 3600;
let offset_chrono = chrono::FixedOffset::east_opt(offset_secs)?;
now.with_timezone(&offset_chrono).date_naive()
} else {
now.date_naive()
};
Datetime::from_ymd(date.year(), date.month() as u8, date.day() as u8)
}
}