Add cloud browsing, autosave, and editor improvements
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import Icon from "@iconify/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { Asset } from "$lib/ts/api";
|
||||
import type { Resource } from "$lib/ts/api";
|
||||
import { pickFiles } from "$lib/ts/import";
|
||||
import { app } from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
oninsert?: (snippet: string) => void;
|
||||
@@ -13,14 +14,33 @@
|
||||
|
||||
let { oninsert, onchanged, onclose }: Props = $props();
|
||||
|
||||
let assets = $state<Asset[]>([]);
|
||||
let resources = $state<Resource[]>([]);
|
||||
let previews = $state<Record<string, string>>({});
|
||||
let query = $state("");
|
||||
let scope = $state<"all" | "project" | "shared">("all");
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let copied = $state<string | null>(null);
|
||||
|
||||
const filtered = $derived(
|
||||
resources.filter((resource) => {
|
||||
if (scope !== "all" && resource.scope !== scope) return false;
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return (
|
||||
resource.name.toLowerCase().includes(needle) ||
|
||||
resource.reference.toLowerCase().includes(needle) ||
|
||||
resource.font_families.some((family) =>
|
||||
family.toLowerCase().includes(needle),
|
||||
)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
async function refresh() {
|
||||
if (!app.target) return;
|
||||
try {
|
||||
assets = await api.listAssets();
|
||||
resources = await api.listResources(app.target.path);
|
||||
} catch (caught) {
|
||||
error = api.errorMessage(caught);
|
||||
}
|
||||
@@ -30,14 +50,42 @@
|
||||
refresh();
|
||||
});
|
||||
|
||||
async function importFiles() {
|
||||
$effect(() => {
|
||||
const images = filtered.filter((resource) => resource.kind === "image");
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
for (const resource of images) {
|
||||
if (cancelled) return;
|
||||
if (previews[resource.path]) continue;
|
||||
try {
|
||||
const result = await api.thumbnail(resource.path);
|
||||
if (!cancelled && result.kind === "image") {
|
||||
previews[resource.path] = result.data;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function importInto(destination: "project" | "shared") {
|
||||
const sources = await pickFiles("assets");
|
||||
if (sources.length === 0) return;
|
||||
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
if (destination === "shared") {
|
||||
await api.importAssets(sources);
|
||||
} else if (app.target) {
|
||||
await api.importIntoTarget(app.target.path, sources);
|
||||
}
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
@@ -47,9 +95,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(asset: Asset) {
|
||||
async function remove(resource: Resource) {
|
||||
try {
|
||||
await api.deleteAsset(asset.name);
|
||||
if (resource.scope === "shared") {
|
||||
await api.deleteAsset(resource.name);
|
||||
} else {
|
||||
await api.deleteEntry(resource.path);
|
||||
}
|
||||
delete previews[resource.path];
|
||||
await refresh();
|
||||
onchanged?.();
|
||||
} catch (caught) {
|
||||
@@ -57,17 +110,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function insert(asset: Asset) {
|
||||
if (asset.kind === "image") {
|
||||
oninsert?.(`#image("${asset.name}")`);
|
||||
} else if (asset.font_families.length > 0) {
|
||||
oninsert?.(`#set text(font: "${asset.font_families[0]}")`);
|
||||
function insert(resource: Resource) {
|
||||
if (resource.kind === "image") {
|
||||
oninsert?.(`#image("${resource.reference}")`);
|
||||
} else if (resource.font_families.length > 0) {
|
||||
oninsert?.(`#set text(font: "${resource.font_families[0]}")`);
|
||||
} else {
|
||||
oninsert?.(`"${resource.reference}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFamily(family: string) {
|
||||
await navigator.clipboard.writeText(family);
|
||||
copied = family;
|
||||
async function copyReference(value: string) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = value;
|
||||
setTimeout(() => (copied = null), 1200);
|
||||
}
|
||||
|
||||
@@ -77,80 +132,121 @@
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const iconFor: Record<Asset["kind"], string> = {
|
||||
const iconFor: Record<string, string> = {
|
||||
image: "ph:image",
|
||||
font: "ph:text-aa",
|
||||
file: "ph:file",
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title="Images and fonts" icon="ph:images" width="max-w-2xl" {onclose}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
Files imported here are available to every project. Reference an image by
|
||||
its file name, and a font by its family name.
|
||||
</p>
|
||||
<Modal title="Assets" icon="ph:images" width="max-w-3xl" {onclose}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex flex-1 items-center gap-2 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 focus-within:border-[var(--color-accent)]"
|
||||
>
|
||||
<Icon icon="ph:magnifying-glass" class="text-[var(--color-ink-muted)]" />
|
||||
<input
|
||||
class="min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
|
||||
placeholder="Search images, fonts, and files"
|
||||
bind:value={query}
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
class="text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (query = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<Icon icon="ph:x" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex rounded-md bg-[var(--color-surface-sunken)] p-0.5 text-xs">
|
||||
{#each [["all", "All"], ["project", "This project"], ["shared", "Shared"]] as [value, label]}
|
||||
<button
|
||||
class="rounded px-2.5 py-1.5 transition
|
||||
{scope === value
|
||||
? 'bg-[var(--color-surface)] font-medium shadow-sm'
|
||||
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]'}"
|
||||
onclick={() => (scope = value as "all" | "project" | "shared")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-xs text-[var(--color-danger)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if assets.length === 0}
|
||||
{#if filtered.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-[var(--color-line)] px-4 py-10 text-center"
|
||||
>
|
||||
<Icon icon="ph:image-square" class="text-4xl text-[var(--color-ink-muted)]" />
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
No images or fonts imported yet.
|
||||
{query ? "Nothing matches that search." : "No images or fonts yet."}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each assets as asset (asset.name)}
|
||||
<div
|
||||
class="group flex items-center gap-3 rounded-md border border-[var(--color-line)] px-3 py-2"
|
||||
class="scroll-thin grid max-h-96 grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2 overflow-y-auto"
|
||||
>
|
||||
{#each filtered as resource (resource.path)}
|
||||
<div
|
||||
class="group relative flex flex-col overflow-hidden rounded-lg border border-[var(--color-line)] transition hover:border-[var(--color-accent)]"
|
||||
>
|
||||
<Icon
|
||||
icon={iconFor[asset.kind]}
|
||||
class="text-lg text-[var(--color-accent)]"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium">{asset.name}</p>
|
||||
{#if asset.font_families.length > 0}
|
||||
<div class="mt-0.5 flex flex-wrap gap-1">
|
||||
{#each asset.font_families as family}
|
||||
<button
|
||||
class="rounded bg-[var(--color-surface-sunken)] px-1.5 py-px text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => copyFamily(family)}
|
||||
class="flex h-20 items-center justify-center overflow-hidden bg-[var(--color-surface-muted)]"
|
||||
onclick={() => insert(resource)}
|
||||
title="Insert into document"
|
||||
>
|
||||
{#if previews[resource.path]}
|
||||
<img
|
||||
src={previews[resource.path]}
|
||||
alt={resource.name}
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon={iconFor[resource.kind] ?? "ph:file"}
|
||||
class="text-2xl text-[var(--color-accent)]"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-0.5 px-2 py-1.5">
|
||||
<span class="truncate text-[11px] font-medium" title={resource.reference}>
|
||||
{resource.name}
|
||||
</span>
|
||||
|
||||
{#if resource.font_families.length > 0}
|
||||
<button
|
||||
class="truncate text-left text-[10px] text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => copyReference(resource.font_families[0])}
|
||||
title="Copy family name"
|
||||
>
|
||||
{copied === family ? "Copied" : family}
|
||||
{copied === resource.font_families[0]
|
||||
? "Copied"
|
||||
: resource.font_families[0]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{formatSize(asset.size)}
|
||||
</p>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{resource.scope === "shared" ? "Shared" : "Project"} · {formatSize(
|
||||
resource.size,
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if oninsert && (asset.kind === "image" || asset.font_families.length > 0)}
|
||||
<button
|
||||
class="rounded border border-[var(--color-line)] px-2 py-1 text-[10px] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => insert(asset)}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="rounded p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-danger)]"
|
||||
onclick={() => remove(asset)}
|
||||
class="absolute right-1 top-1 rounded bg-[var(--color-surface)]/90 p-1 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:text-[var(--color-danger)]"
|
||||
onclick={() => remove(resource)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Icon icon="ph:trash" />
|
||||
<Icon icon="ph:trash" class="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -166,12 +262,20 @@
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
class="flex items-center gap-1.5 rounded-md border border-[var(--color-line)] px-3 py-1.5 text-xs transition hover:bg-[var(--color-surface-muted)] disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={importFiles}
|
||||
onclick={() => importInto("shared")}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
{busy ? "Importing..." : "Import files"}
|
||||
Add to shared
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => importInto("project")}
|
||||
>
|
||||
<Icon icon="ph:upload-simple" />
|
||||
{busy ? "Importing..." : "Add to project"}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
app,
|
||||
breadcrumbs,
|
||||
browseTo,
|
||||
openCloudFolder,
|
||||
openTarget,
|
||||
refreshCloud,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -14,11 +16,11 @@
|
||||
onnewproject: () => void;
|
||||
onnewdocument: () => void;
|
||||
onupload: () => void;
|
||||
onassets: () => void;
|
||||
onrename: (entry: BrowseEntry) => void;
|
||||
ondelete: (entry: BrowseEntry) => void;
|
||||
onlink: (entry: BrowseEntry) => void;
|
||||
onviewimage: (paths: string[], index: number) => void;
|
||||
ondownloaddocument: (documentId: string, title: string) => void;
|
||||
onclonespace: (spaceId: string, name: string) => void;
|
||||
ondeletespace: (spaceId: string) => void;
|
||||
onnewspace: () => void;
|
||||
@@ -30,11 +32,11 @@
|
||||
onnewproject,
|
||||
onnewdocument,
|
||||
onupload,
|
||||
onassets,
|
||||
onrename,
|
||||
ondelete,
|
||||
onlink,
|
||||
onviewimage,
|
||||
ondownloaddocument,
|
||||
onclonespace,
|
||||
ondeletespace,
|
||||
onnewspace,
|
||||
@@ -45,6 +47,12 @@
|
||||
|
||||
const trail = $derived(breadcrumbs());
|
||||
|
||||
$effect(() => {
|
||||
if (app.scope === "cloud" && app.account) {
|
||||
refreshCloud();
|
||||
}
|
||||
});
|
||||
|
||||
const containers = $derived(
|
||||
app.entries.filter(
|
||||
(entry) => entry.kind === "folder" || entry.kind === "project",
|
||||
@@ -221,13 +229,6 @@
|
||||
<div class="flex-1"></div>
|
||||
|
||||
{#if app.scope === "local"}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onassets}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={onupload}
|
||||
@@ -454,14 +455,105 @@
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
{:else if app.spaces.length === 0}
|
||||
{:else}
|
||||
<div class="mb-3 flex items-center gap-1 text-xs">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||
{app.cloudFolder === null ? 'font-medium' : 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => openCloudFolder(null)}
|
||||
>
|
||||
<Icon icon="ph:cloud" />
|
||||
My Drive
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 transition hover:bg-[var(--color-surface)]
|
||||
{app.cloudFolder === 'shared'
|
||||
? 'font-medium'
|
||||
: 'text-[var(--color-ink-muted)]'}"
|
||||
onclick={() => openCloudFolder("shared")}
|
||||
>
|
||||
<Icon icon="ph:users-three" />
|
||||
Shared with me
|
||||
</button>
|
||||
|
||||
{#if app.cloudLoading}
|
||||
<Icon
|
||||
icon="ph:circle-notch"
|
||||
class="animate-spin text-[var(--color-accent)]"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if app.cloudFolders.length > 0}
|
||||
<div
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-[var(--color-ink-muted)]"
|
||||
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-2"
|
||||
>
|
||||
{#each app.cloudFolders as folder (folder.id)}
|
||||
<button
|
||||
class="flex items-center gap-2.5 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface-sunken)] px-3 py-2.5 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface)]"
|
||||
onclick={() => openCloudFolder(folder.id)}
|
||||
>
|
||||
<Icon
|
||||
icon="ph:folder-fill"
|
||||
class="shrink-0 text-2xl text-[var(--color-ink-muted)]"
|
||||
/>
|
||||
<span class="truncate text-xs font-medium">{folder.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.cloudDocuments.length > 0}
|
||||
<h2
|
||||
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Documents
|
||||
</h2>
|
||||
<div
|
||||
class="mb-4 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
|
||||
>
|
||||
{#each app.cloudDocuments as document (document.id)}
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-3 transition hover:border-[var(--color-accent)]"
|
||||
>
|
||||
<Icon
|
||||
icon="ph:file-text"
|
||||
class="text-xl text-[var(--color-accent)]"
|
||||
/>
|
||||
<span class="truncate text-xs font-medium" title={document.title}>
|
||||
{document.title}
|
||||
</span>
|
||||
<span class="text-[10px] text-[var(--color-ink-muted)]">
|
||||
{document.role} · {formatDate(document.updated_at)}
|
||||
</span>
|
||||
<button
|
||||
class="mt-1 flex items-center justify-center gap-1 rounded border border-[var(--color-line)] px-2 py-1 text-[10px] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => ondownloaddocument(document.id, document.title)}
|
||||
>
|
||||
<Icon icon="ph:download-simple" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.spaces.length === 0 && app.cloudDocuments.length === 0 && app.cloudFolders.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 py-16 text-[var(--color-ink-muted)]"
|
||||
>
|
||||
<Icon icon="ph:cloud" class="text-5xl" />
|
||||
<p class="text-sm">No cloud spaces yet.</p>
|
||||
<p class="text-sm">Nothing here yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{/if}
|
||||
|
||||
{#if app.spaces.length > 0}
|
||||
<h2
|
||||
class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Spaces
|
||||
</h2>
|
||||
{/if}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-3">
|
||||
{#each app.spaces as space (space.id)}
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import type { AppInfo } from "$lib/ts/api";
|
||||
|
||||
interface Props {
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
|
||||
let info = $state<AppInfo | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.appInfo()
|
||||
.then((result) => (info = result))
|
||||
.catch(() => (info = null));
|
||||
});
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: "Typst Documentation",
|
||||
url: "https://typst.app/docs/",
|
||||
icon: "ph:book-open",
|
||||
},
|
||||
{
|
||||
label: "Typst Universe",
|
||||
url: "https://typst.app/universe/",
|
||||
icon: "ph:planet",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Modal title="About Typst Desktop" icon="ph:info" {onclose}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="ph:file-code" class="text-3xl text-[var(--color-accent)]" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold">Typst Desktop</p>
|
||||
<p class="text-xs text-[var(--color-ink-muted)]">
|
||||
A local editor for Typst documents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if info}
|
||||
<dl class="flex flex-col gap-1 text-xs">
|
||||
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||
<dt class="text-[var(--color-ink-muted)]">Version</dt>
|
||||
<dd class="font-medium">{info.version}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||
<dt class="text-[var(--color-ink-muted)]">Typst</dt>
|
||||
<dd class="font-medium">{info.typst_version}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||
<dt class="text-[var(--color-ink-muted)]">Tauri</dt>
|
||||
<dd class="font-medium">{info.tauri_version}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-[var(--color-line)] py-1.5">
|
||||
<dt class="text-[var(--color-ink-muted)]">Author</dt>
|
||||
<dd class="font-medium">{info.authors}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between py-1.5">
|
||||
<dt class="text-[var(--color-ink-muted)]">License</dt>
|
||||
<dd class="font-medium">{info.license}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each links as link}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-md border border-[var(--color-line)] px-3 py-2 text-xs transition hover:border-[var(--color-accent)] hover:bg-[var(--color-surface-muted)]"
|
||||
onclick={() => openUrl(link.url)}
|
||||
>
|
||||
<Icon icon={link.icon} class="text-base text-[var(--color-accent)]" />
|
||||
<span class="flex-1 text-left">{link.label}</span>
|
||||
<Icon
|
||||
icon="ph:arrow-square-out"
|
||||
class="text-[var(--color-ink-muted)]"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3 text-[11px] leading-relaxed text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Typst Desktop is a community application and is not affiliated with,
|
||||
endorsed by, or supported by the official Typst project. The links above
|
||||
open the official Typst website in your browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
onclick={onclose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -3,7 +3,14 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Modal from "./Modal.svelte";
|
||||
import * as api from "$lib/ts/api";
|
||||
import { app, applyTheme, refreshEntries, setError } from "$lib/ts/state.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
app,
|
||||
applyTheme,
|
||||
refreshEntries,
|
||||
restartAutoSync,
|
||||
setError,
|
||||
} from "$lib/ts/state.svelte";
|
||||
|
||||
interface Props {
|
||||
onclose: () => void;
|
||||
@@ -12,10 +19,26 @@
|
||||
|
||||
let { onclose, onsignin }: Props = $props();
|
||||
|
||||
let workspaceRoot = $state(app.settings?.workspace_root ?? "");
|
||||
let serverUrl = $state(app.settings?.server_url ?? "");
|
||||
let workspaceRoot = $state(untrack(() => app.settings?.workspace_root ?? ""));
|
||||
let serverUrl = $state(untrack(() => app.settings?.server_url ?? ""));
|
||||
let autosaveSeconds = $state(untrack(() => app.settings?.autosave_seconds ?? 0));
|
||||
let syncMinutes = $state(untrack(() => app.settings?.sync_minutes ?? 0));
|
||||
let saving = $state(false);
|
||||
|
||||
const autosaveOptions = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 5, label: "5 seconds" },
|
||||
{ value: 10, label: "10 seconds" },
|
||||
{ value: 15, label: "15 seconds" },
|
||||
];
|
||||
|
||||
const syncOptions = [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 1, label: "1 minute" },
|
||||
{ value: 2, label: "2 minutes" },
|
||||
{ value: 5, label: "5 minutes" },
|
||||
];
|
||||
|
||||
async function browse() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (typeof selected === "string") {
|
||||
@@ -29,7 +52,10 @@
|
||||
app.settings = await api.updateSettings({
|
||||
workspaceRoot,
|
||||
serverUrl,
|
||||
autosaveSeconds,
|
||||
syncMinutes,
|
||||
});
|
||||
restartAutoSync();
|
||||
await refreshEntries();
|
||||
onclose();
|
||||
} catch (error) {
|
||||
@@ -81,6 +107,37 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Autosave</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={autosaveSeconds}
|
||||
>
|
||||
{#each autosaveOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
Saves the file being edited after you stop typing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-medium text-[var(--color-ink-muted)]">Automatic sync</span>
|
||||
<select
|
||||
class="rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm focus:border-[var(--color-accent)] focus:outline-none"
|
||||
bind:value={syncMinutes}
|
||||
>
|
||||
{#each syncOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="text-[var(--color-ink-muted)]">
|
||||
Pulls and pushes cloud-linked projects on a timer. Conflicts pause
|
||||
syncing until they are resolved.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-md border border-[var(--color-line)] bg-[var(--color-surface-muted)] p-3"
|
||||
>
|
||||
|
||||
+83
-1
@@ -6,6 +6,8 @@ export interface Settings {
|
||||
device_token: string | null;
|
||||
account_email: string | null;
|
||||
account_username: string | null;
|
||||
autosave_seconds: number;
|
||||
sync_minutes: number;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -143,8 +145,9 @@ export const setTargetEntrypoint = (path: string, entrypoint: string) =>
|
||||
|
||||
export const compileTarget = (
|
||||
path: string,
|
||||
entrypoint?: string,
|
||||
overrides?: Record<string, string>,
|
||||
) => invoke<CompileResult>("compile_target", { path, overrides });
|
||||
) => invoke<CompileResult>("compile_target", { path, entrypoint, overrides });
|
||||
|
||||
export const exportTarget = (
|
||||
path: string,
|
||||
@@ -161,6 +164,19 @@ export interface Asset {
|
||||
|
||||
export const listAssets = () => invoke<Asset[]>("list_assets");
|
||||
|
||||
export interface Resource {
|
||||
name: string;
|
||||
reference: string;
|
||||
path: string;
|
||||
scope: "shared" | "project";
|
||||
kind: "font" | "image" | "file";
|
||||
size: number;
|
||||
font_families: string[];
|
||||
}
|
||||
|
||||
export const listResources = (path: string) =>
|
||||
invoke<Resource[]>("list_resources", { path });
|
||||
|
||||
export interface Thumbnail {
|
||||
kind: "svg" | "image";
|
||||
data: string;
|
||||
@@ -204,11 +220,23 @@ export const importIntoTarget = (path: string, sources: string[]) =>
|
||||
export const importIntoFolder = (parent: string, sources: string[]) =>
|
||||
invoke<string[]>("import_into_folder", { parent, sources });
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
typst_version: string;
|
||||
authors: string;
|
||||
license: string;
|
||||
tauri_version: string;
|
||||
}
|
||||
|
||||
export const appInfo = () => invoke<AppInfo>("app_info");
|
||||
|
||||
export const getSettings = () => invoke<Settings>("get_settings");
|
||||
|
||||
export const updateSettings = (changes: {
|
||||
workspaceRoot?: string;
|
||||
serverUrl?: string;
|
||||
autosaveSeconds?: number;
|
||||
syncMinutes?: number;
|
||||
}) => invoke<Settings>("update_settings", changes);
|
||||
|
||||
export const cloudLogin = (
|
||||
@@ -224,6 +252,60 @@ export const cloudAccount = () => invoke<Account | null>("cloud_account");
|
||||
export const cloudListSpaces = () =>
|
||||
invoke<SpaceSummary[]>("cloud_list_spaces");
|
||||
|
||||
export interface CloudFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
}
|
||||
|
||||
export interface CloudDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
folder_id: string | null;
|
||||
role: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SharedItems {
|
||||
documents: CloudDocument[];
|
||||
spaces: SpaceSummary[];
|
||||
}
|
||||
|
||||
export interface DocumentLink {
|
||||
document_id: string;
|
||||
base_hash: string;
|
||||
role: string;
|
||||
base_content: string;
|
||||
}
|
||||
|
||||
export const cloudListFolders = () =>
|
||||
invoke<CloudFolder[]>("cloud_list_folders");
|
||||
|
||||
export const cloudListDocuments = (folderId?: string | null) =>
|
||||
invoke<CloudDocument[]>("cloud_list_documents", {
|
||||
folderId: folderId ?? null,
|
||||
});
|
||||
|
||||
export const cloudListShared = () => invoke<SharedItems>("cloud_list_shared");
|
||||
|
||||
export const cloudDownloadDocument = (documentId: string, parent: string) =>
|
||||
invoke<string>("cloud_download_document", { documentId, parent });
|
||||
|
||||
export const cloudSyncDocument = (path: string) =>
|
||||
invoke<SyncReport>("cloud_sync_document", { path });
|
||||
|
||||
export const cloudResolveDocument = (
|
||||
path: string,
|
||||
content: string,
|
||||
serverHash: string,
|
||||
) => invoke<void>("cloud_resolve_document", { path, content, serverHash });
|
||||
|
||||
export const cloudDocumentLink = (path: string) =>
|
||||
invoke<DocumentLink | null>("cloud_document_link", { path });
|
||||
|
||||
export const cloudUnlinkDocument = (path: string) =>
|
||||
invoke<void>("cloud_unlink_document", { path });
|
||||
|
||||
export const cloudCreateSpace = (name: string) =>
|
||||
invoke<SpaceSummary>("cloud_create_space", { name });
|
||||
|
||||
|
||||
+131
-5
@@ -2,9 +2,12 @@ import * as api from "./api";
|
||||
import type {
|
||||
Account,
|
||||
BrowseEntry,
|
||||
CloudDocument,
|
||||
CloudFolder,
|
||||
CompileResult,
|
||||
Conflict,
|
||||
Diagnostic,
|
||||
DocumentLink,
|
||||
Settings,
|
||||
SpaceSummary,
|
||||
TargetInfo,
|
||||
@@ -23,6 +26,11 @@ interface AppState {
|
||||
currentDir: string;
|
||||
entries: BrowseEntry[];
|
||||
spaces: SpaceSummary[];
|
||||
cloudFolder: string | null | "shared";
|
||||
cloudFolders: CloudFolder[];
|
||||
cloudDocuments: CloudDocument[];
|
||||
cloudLoading: boolean;
|
||||
documentLink: DocumentLink | null;
|
||||
|
||||
target: TargetInfo | null;
|
||||
activePath: string | null;
|
||||
@@ -49,6 +57,11 @@ export const app = $state<AppState>({
|
||||
currentDir: "",
|
||||
entries: [],
|
||||
spaces: [],
|
||||
cloudFolder: null,
|
||||
cloudFolders: [],
|
||||
cloudDocuments: [],
|
||||
cloudLoading: false,
|
||||
documentLink: null,
|
||||
|
||||
target: null,
|
||||
activePath: null,
|
||||
@@ -102,6 +115,7 @@ export async function bootstrap() {
|
||||
|
||||
try {
|
||||
app.settings = await api.getSettings();
|
||||
restartAutoSync();
|
||||
await browseTo("");
|
||||
await refreshAccount();
|
||||
} catch (error) {
|
||||
@@ -144,6 +158,53 @@ export async function refreshSpaces() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshCloud() {
|
||||
if (!app.account) return;
|
||||
|
||||
app.cloudLoading = true;
|
||||
try {
|
||||
if (app.cloudFolder === "shared") {
|
||||
const shared = await api.cloudListShared();
|
||||
app.cloudDocuments = shared.documents;
|
||||
app.spaces = shared.spaces;
|
||||
app.cloudFolders = [];
|
||||
} else {
|
||||
const [folders, documents, spaces] = await Promise.all([
|
||||
api.cloudListFolders(),
|
||||
api.cloudListDocuments(app.cloudFolder),
|
||||
api.cloudListSpaces(),
|
||||
]);
|
||||
app.cloudFolders = folders.filter(
|
||||
(folder) => (folder.parent_id ?? null) === app.cloudFolder,
|
||||
);
|
||||
app.cloudDocuments = documents;
|
||||
app.spaces = spaces;
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
app.cloudLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openCloudFolder(id: string | null | "shared") {
|
||||
app.cloudFolder = id;
|
||||
await refreshCloud();
|
||||
}
|
||||
|
||||
export async function downloadDocument(documentId: string, title: string) {
|
||||
try {
|
||||
const path = await api.cloudDownloadDocument(documentId, "");
|
||||
app.scope = "local";
|
||||
await browseTo("");
|
||||
setStatus(`Downloaded '${title}' to this device`);
|
||||
return path;
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openTarget(path: string) {
|
||||
try {
|
||||
const target = await api.targetInfo(path);
|
||||
@@ -157,6 +218,10 @@ export async function openTarget(path: string) {
|
||||
app.lspStatus = "off";
|
||||
clearMessages();
|
||||
|
||||
app.documentLink = target.standalone
|
||||
? await api.cloudDocumentLink(path).catch(() => null)
|
||||
: null;
|
||||
|
||||
const preferred =
|
||||
target.files.find((file) => file.path === target.entrypoint) ??
|
||||
target.files.find((file) => file.path.endsWith(".typ")) ??
|
||||
@@ -170,6 +235,7 @@ export async function openTarget(path: string) {
|
||||
|
||||
export async function closeTarget() {
|
||||
cancelScheduledCompile();
|
||||
cancelAutosave();
|
||||
if (app.dirty) await saveActiveFile();
|
||||
app.view = "files";
|
||||
app.target = null;
|
||||
@@ -194,6 +260,7 @@ export async function openFile(file: string) {
|
||||
if (!app.target) return;
|
||||
|
||||
cancelScheduledCompile();
|
||||
cancelAutosave();
|
||||
|
||||
if (app.dirty && app.activePath) await saveActiveFile();
|
||||
|
||||
@@ -242,7 +309,15 @@ export async function compile() {
|
||||
app.compiling = true;
|
||||
|
||||
try {
|
||||
const result = await api.compileTarget(app.target.path, liveOverrides());
|
||||
const previewFile =
|
||||
app.activePath && app.activePath.toLowerCase().endsWith(".typ")
|
||||
? app.activePath
|
||||
: undefined;
|
||||
const result = await api.compileTarget(
|
||||
app.target.path,
|
||||
previewFile,
|
||||
liveOverrides(),
|
||||
);
|
||||
app.compiled = result;
|
||||
app.diagnostics = result.diagnostics;
|
||||
} catch (error) {
|
||||
@@ -280,8 +355,57 @@ export function cancelScheduledCompile() {
|
||||
}
|
||||
}
|
||||
|
||||
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function scheduleAutosave() {
|
||||
const seconds = app.settings?.autosave_seconds ?? 0;
|
||||
if (autosaveTimer) clearTimeout(autosaveTimer);
|
||||
if (seconds <= 0) return;
|
||||
|
||||
autosaveTimer = setTimeout(() => {
|
||||
autosaveTimer = null;
|
||||
if (app.dirty) saveActiveFile();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
export function cancelAutosave() {
|
||||
if (autosaveTimer) {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function restartAutoSync() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
|
||||
const minutes = app.settings?.sync_minutes ?? 0;
|
||||
if (minutes <= 0) return;
|
||||
|
||||
syncTimer = setInterval(() => {
|
||||
autoSync();
|
||||
}, minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function autoSync() {
|
||||
if (!app.account || app.syncing) return;
|
||||
if (app.conflicts.length > 0) return;
|
||||
|
||||
const linked = app.target?.space_id || app.documentLink;
|
||||
const project = linked ? app.target?.path : null;
|
||||
if (!project) return;
|
||||
|
||||
if (app.dirty) await saveActiveFile();
|
||||
await runSync("sync", project, true);
|
||||
}
|
||||
|
||||
export async function saveAndCompile() {
|
||||
cancelScheduledCompile();
|
||||
cancelAutosave();
|
||||
await saveActiveFile();
|
||||
await compile();
|
||||
}
|
||||
@@ -289,15 +413,17 @@ export async function saveAndCompile() {
|
||||
export async function runSync(
|
||||
action: "sync" | "push" | "pull",
|
||||
project = app.target?.path,
|
||||
quiet = false,
|
||||
) {
|
||||
if (!project) return;
|
||||
|
||||
app.syncing = true;
|
||||
clearMessages();
|
||||
if (!quiet) clearMessages();
|
||||
|
||||
try {
|
||||
const report =
|
||||
action === "push"
|
||||
const report = app.documentLink
|
||||
? await api.cloudSyncDocument(project)
|
||||
: action === "push"
|
||||
? await api.cloudPush(project)
|
||||
: action === "pull"
|
||||
? await api.cloudPull(project)
|
||||
@@ -307,7 +433,7 @@ export async function runSync(
|
||||
|
||||
if (report.conflicts.length > 0) {
|
||||
setError(`${report.conflicts.length} file(s) need conflict resolution`);
|
||||
} else {
|
||||
} else if (!quiet) {
|
||||
setStatus(summarize(report));
|
||||
}
|
||||
|
||||
|
||||
+33
-11
@@ -16,6 +16,7 @@
|
||||
import AssetsModal from "$lib/components/AssetsModal.svelte";
|
||||
import WindowControls from "$lib/components/WindowControls.svelte";
|
||||
import ImageViewer from "$lib/components/ImageViewer.svelte";
|
||||
import InfoModal from "$lib/components/InfoModal.svelte";
|
||||
import EditorToolbar from "$lib/components/EditorToolbar.svelte";
|
||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
clearMessages,
|
||||
closeTarget,
|
||||
compile,
|
||||
downloadDocument,
|
||||
openFile,
|
||||
openTarget,
|
||||
refreshAccount,
|
||||
@@ -39,6 +41,7 @@
|
||||
refreshTarget,
|
||||
runSync,
|
||||
saveAndCompile,
|
||||
scheduleAutosave,
|
||||
scheduleCompile,
|
||||
setError,
|
||||
setStatus,
|
||||
@@ -61,6 +64,7 @@
|
||||
| { kind: "delete-file"; path: string }
|
||||
| { kind: "login" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "info" }
|
||||
| { kind: "assets" }
|
||||
| { kind: "page-settings" }
|
||||
| { kind: "conflicts" };
|
||||
@@ -231,6 +235,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDocumentConflicts = (resolutions: api.Resolution[]) =>
|
||||
guard(async () => {
|
||||
for (const resolution of resolutions) {
|
||||
await api.cloudResolveDocument(
|
||||
app.target!.path,
|
||||
resolution.content,
|
||||
resolution.server_hash,
|
||||
);
|
||||
}
|
||||
app.conflicts = [];
|
||||
if (app.activePath) await openFile(app.activePath);
|
||||
setStatus("Conflicts resolved and uploaded");
|
||||
});
|
||||
|
||||
const resolveConflicts = (resolutions: api.Resolution[]) =>
|
||||
guard(async () => {
|
||||
const report = await api.cloudResolveConflicts(
|
||||
@@ -365,14 +383,6 @@
|
||||
{lspLabel[app.lspStatus]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "assets" })}
|
||||
>
|
||||
<Icon icon="ph:images" />
|
||||
Assets
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={saveAndCompile}
|
||||
@@ -402,7 +412,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if app.target?.space_id}
|
||||
{#if app.target?.space_id || app.documentLink}
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-2.5 py-1.5 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
disabled={app.syncing}
|
||||
@@ -425,6 +435,14 @@
|
||||
<Icon icon="ph:gear-six" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-md p-1.5 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-ink)]"
|
||||
onclick={() => (dialog = { kind: "info" })}
|
||||
aria-label="About"
|
||||
>
|
||||
<Icon icon="ph:info" />
|
||||
</button>
|
||||
|
||||
<div class="ml-1 h-5 w-px bg-[var(--color-line)]"></div>
|
||||
|
||||
<WindowControls />
|
||||
@@ -461,11 +479,12 @@
|
||||
onnewfolder={() => (dialog = { kind: "new-folder" })}
|
||||
onnewdocument={() => (dialog = { kind: "new-document" })}
|
||||
onupload={importFiles}
|
||||
onassets={() => (dialog = { kind: "assets" })}
|
||||
onrename={(entry) => (dialog = { kind: "rename-entry", entry })}
|
||||
ondelete={(entry) => (dialog = { kind: "delete-entry", entry })}
|
||||
onlink={(entry) => (dialog = { kind: "link-entry", entry })}
|
||||
onviewimage={(paths, index) => (imageViewer = { paths, index })}
|
||||
ondownloaddocument={(documentId, title) =>
|
||||
downloadDocument(documentId, title)}
|
||||
onnewspace={() => (dialog = { kind: "new-space" })}
|
||||
onclonespace={(id, name) => (dialog = { kind: "clone-space", id, name })}
|
||||
ondeletespace={(id) => (dialog = { kind: "delete-space", id })}
|
||||
@@ -553,6 +572,7 @@
|
||||
app.editorContent = value;
|
||||
app.dirty = true;
|
||||
scheduleCompile();
|
||||
scheduleAutosave();
|
||||
}}
|
||||
onsave={saveAndCompile}
|
||||
onlspstatus={(status) => (app.lspStatus = status)}
|
||||
@@ -746,6 +766,8 @@
|
||||
/>
|
||||
{:else if dialog.kind === "settings"}
|
||||
<SettingsModal onclose={close} onsignin={() => (dialog = { kind: "login" })} />
|
||||
{:else if dialog.kind === "info"}
|
||||
<InfoModal onclose={close} />
|
||||
{:else if dialog.kind === "assets"}
|
||||
<AssetsModal
|
||||
oninsert={app.view === "editor" && editorView
|
||||
@@ -762,7 +784,7 @@
|
||||
{:else if dialog.kind === "conflicts"}
|
||||
<ConflictModal
|
||||
conflicts={app.conflicts}
|
||||
onresolve={resolveConflicts}
|
||||
onresolve={app.documentLink ? resolveDocumentConflicts : resolveConflicts}
|
||||
onclose={close}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user