Documents by default, cloud project rename, multi-select drag, new settings

This commit is contained in:
2026-07-20 17:48:20 -04:00
parent f498fcba1c
commit 4b685311a0
17 changed files with 1038 additions and 292 deletions
+14 -12
View File
@@ -27,7 +27,7 @@ const SCHEMA: [&str; 5] = [
"CREATE TABLE IF NOT EXISTS projects (
path TEXT PRIMARY KEY,
entrypoint TEXT NOT NULL DEFAULT 'main.typ',
space_id TEXT,
cloud_project_id TEXT,
last_synced_at TEXT
)",
"CREATE TABLE IF NOT EXISTS base_files (
@@ -53,8 +53,10 @@ const SCHEMA: [&str; 5] = [
)",
];
const MIGRATIONS: [&str; 1] =
["ALTER TABLE document_links ADD COLUMN synced_at TEXT"];
const MIGRATIONS: [&str; 2] = [
"ALTER TABLE document_links ADD COLUMN synced_at TEXT",
"ALTER TABLE projects RENAME COLUMN space_id TO cloud_project_id",
];
impl Store {
pub fn open(app: &AppHandle) -> Result<Self, String> {
@@ -130,14 +132,14 @@ impl Store {
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",
"SELECT entrypoint, cloud_project_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 {
let Some((entrypoint, cloud_project_id, last_synced_at)) = row else {
return Ok(ProjectMeta::default());
};
@@ -158,7 +160,7 @@ impl Store {
Ok(ProjectMeta {
entrypoint,
space_id,
cloud_project_id,
last_synced_at,
base_hashes,
})
@@ -180,16 +182,16 @@ impl Store {
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)
"INSERT INTO projects (path, entrypoint, cloud_project_id, last_synced_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(path) DO UPDATE SET
entrypoint = excluded.entrypoint,
space_id = excluded.space_id,
cloud_project_id = excluded.cloud_project_id,
last_synced_at = excluded.last_synced_at",
params![
project,
meta.entrypoint,
meta.space_id,
meta.cloud_project_id,
meta.last_synced_at
],
)?;
@@ -335,11 +337,11 @@ impl Store {
})
}
pub fn all_space_links(&self) -> Result<Vec<(String, String, Option<String>)>, String> {
pub fn all_cloud_project_links(&self) -> Result<Vec<(String, String, Option<String>)>, String> {
self.with(|connection| {
let mut statement = connection.prepare(
"SELECT path, space_id, last_synced_at FROM projects
WHERE space_id IS NOT NULL",
"SELECT path, cloud_project_id, last_synced_at FROM projects
WHERE cloud_project_id IS NOT NULL",
)?;
let rows = statement.query_map([], |row| {
Ok((
+64 -30
View File
@@ -16,7 +16,7 @@ use assets::Asset;
use db::Store;
use compiler::{CompileResult, Diagnostic};
use lsp::{LspHandle, LspState};
use sync::{Account, SpaceSummary, SyncReport};
use sync::{Account, ProjectSummary, 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,
@@ -351,7 +351,7 @@ pub struct TargetInfo {
pub entrypoint: String,
pub standalone: bool,
pub is_project: bool,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub files: Vec<FileEntry>,
}
@@ -368,7 +368,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint.clone(),
standalone: true,
is_project: false,
space_id: None,
cloud_project_id: None,
files: vec![FileEntry {
path: target.entrypoint.clone(),
name: target.entrypoint,
@@ -385,7 +385,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
entrypoint: target.entrypoint,
standalone: false,
is_project: is_project_dir(&target.root),
space_id: meta.space_id,
cloud_project_id: meta.cloud_project_id,
files: list_files(&target.root)?,
})
}
@@ -725,9 +725,9 @@ fn cloud_account(app: AppHandle, store: State<'_, Store>) -> Result<Option<Accou
}
#[tauri::command]
fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<SpaceSummary>, String> {
fn cloud_list_projects(app: AppHandle, store: State<'_, Store>) -> Result<Vec<ProjectSummary>, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::list_spaces(&server_url, &token)
sync::list_cloud_projects(&server_url, &token)
}
#[tauri::command]
@@ -864,6 +864,30 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St
store.forget_document_link(&path)
}
#[tauri::command]
fn cloud_create_document(
app: AppHandle,
store: State<'_, Store>,
path: String,
title: String,
) -> Result<String, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
let full = workspace_path(&app, &store, &path)?;
let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
let document = sync::create_document(&server_url, &token, title.trim(), &content)?;
store.save_document_link(
&path,
&document.id,
&document.hash,
&document.role,
&document.content,
)?;
Ok(document.id)
}
#[derive(Serialize)]
pub struct LinkedDocument {
pub path: String,
@@ -899,21 +923,21 @@ fn cloud_linked_documents(
}
#[derive(Serialize)]
pub struct LinkedSpace {
pub struct LinkedProject {
pub path: String,
pub space_id: String,
pub cloud_project_id: String,
pub synced_at: Option<String>,
pub sync_state: Option<String>,
}
#[tauri::command]
fn cloud_linked_spaces(
fn cloud_linked_projects(
app: AppHandle,
store: State<'_, Store>,
) -> Result<Vec<LinkedSpace>, String> {
) -> Result<Vec<LinkedProject>, String> {
let mut linked = Vec::new();
for (path, space_id, synced_at) in store.all_space_links()? {
for (path, cloud_project_id, synced_at) in store.all_cloud_project_links()? {
let Ok(full) = workspace_path(&app, &store, &path) else {
continue;
};
@@ -921,10 +945,10 @@ fn cloud_linked_spaces(
continue;
}
linked.push(LinkedSpace {
linked.push(LinkedProject {
sync_state: workspace::project_sync_state(&full, synced_at.as_deref()),
path,
space_id,
cloud_project_id,
synced_at,
});
}
@@ -941,22 +965,22 @@ fn cloud_document_link(
}
#[tauri::command]
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
fn cloud_create_project(app: AppHandle, store: State<'_, Store>, name: String) -> Result<ProjectSummary, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::create_space(&server_url, &token, name.trim())
sync::create_cloud_project(&server_url, &token, name.trim())
}
#[tauri::command]
fn cloud_delete_space(app: AppHandle, store: State<'_, Store>, space_id: String) -> Result<(), String> {
fn cloud_delete_project(app: AppHandle, store: State<'_, Store>, cloud_project_id: String) -> Result<(), String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
sync::delete_space(&server_url, &token, &space_id)
sync::delete_cloud_project(&server_url, &token, &cloud_project_id)
}
#[tauri::command]
fn cloud_clone_space(
fn cloud_clone_project(
app: AppHandle,
store: State<'_, Store>,
space_id: String,
cloud_project_id: String,
project_name: String,
) -> Result<SyncReport, String> {
let (server_url, token) = cloud_credentials(&app, &store)?;
@@ -965,7 +989,15 @@ fn cloud_clone_space(
if dir.exists() {
return Err(format!("A project named '{}' already exists", project_name));
}
sync::clone_space(&server_url, &token, &app, &store, &project, &dir, &space_id)
sync::clone_cloud_project(
&server_url,
&token,
&app,
&store,
&project,
&dir,
&cloud_project_id,
)
}
#[tauri::command]
@@ -973,17 +1005,17 @@ fn cloud_link_project(
app: AppHandle,
store: State<'_, Store>,
project: String,
space_id: Option<String>,
cloud_project_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 {
let cloud_project_id = match cloud_project_id {
Some(id) if !id.trim().is_empty() => id,
_ => sync::create_space(&server_url, &token, &project)?.id,
_ => sync::create_cloud_project(&server_url, &token, &project)?.id,
};
meta.space_id = Some(space_id);
meta.cloud_project_id = Some(cloud_project_id);
meta.base_hashes.clear();
store.save_meta(&project, &meta)?;
@@ -993,7 +1025,7 @@ fn cloud_link_project(
#[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.cloud_project_id = None;
meta.base_hashes.clear();
meta.last_synced_at = None;
let _ = dir;
@@ -1069,6 +1101,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_drag::init())
.setup(|app| {
let store = Store::open(&app.handle())?;
app.manage(store);
@@ -1119,7 +1152,7 @@ pub fn run() {
cloud_login,
cloud_logout,
cloud_account,
cloud_list_spaces,
cloud_list_projects,
cloud_list_folders,
cloud_list_documents,
cloud_list_shared,
@@ -1130,11 +1163,12 @@ pub fn run() {
cloud_resolve_document,
cloud_document_link,
cloud_linked_documents,
cloud_linked_spaces,
cloud_linked_projects,
cloud_unlink_document,
cloud_create_space,
cloud_delete_space,
cloud_clone_space,
cloud_create_document,
cloud_create_project,
cloud_delete_project,
cloud_clone_project,
cloud_link_project,
cloud_unlink_project,
cloud_push,
+82 -37
View File
@@ -87,7 +87,7 @@ pub fn me(server_url: &str, token: &str) -> Result<Account, String> {
}
#[derive(Deserialize, Serialize, Clone)]
pub struct SpaceSummary {
pub struct ProjectSummary {
pub id: String,
pub name: String,
pub entrypoint: String,
@@ -95,29 +95,37 @@ pub struct SpaceSummary {
pub updated_at: String,
}
pub fn list_spaces(server_url: &str, token: &str) -> Result<Vec<SpaceSummary>, String> {
pub fn list_cloud_projects(server_url: &str, token: &str) -> Result<Vec<ProjectSummary>, String> {
agent()
.get(&endpoint(server_url, "/spaces"))
.get(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<Vec<SpaceSummary>>()
.into_json::<Vec<ProjectSummary>>()
.map_err(|e| e.to_string())
}
pub fn create_space(server_url: &str, token: &str, name: &str) -> Result<SpaceSummary, String> {
pub fn create_cloud_project(
server_url: &str,
token: &str,
name: &str,
) -> Result<ProjectSummary, String> {
agent()
.post(&endpoint(server_url, "/spaces"))
.post(&endpoint(server_url, "/projects"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({ "name": name }))
.map_err(describe)?
.into_json::<SpaceSummary>()
.into_json::<ProjectSummary>()
.map_err(|e| e.to_string())
}
pub fn delete_space(server_url: &str, token: &str, space_id: &str) -> Result<(), String> {
pub fn delete_cloud_project(
server_url: &str,
token: &str,
cloud_project_id: &str,
) -> Result<(), String> {
agent()
.delete(&endpoint(server_url, &format!("/spaces/{}", space_id)))
.delete(&endpoint(server_url, &format!("/projects/{}", cloud_project_id)))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?;
@@ -131,7 +139,9 @@ pub struct ManifestEntry {
}
#[derive(Deserialize)]
pub struct SpaceManifest {
pub struct ProjectManifest {
pub project_id: String,
pub name: String,
pub entrypoint: String,
pub files: Vec<ManifestEntry>,
}
@@ -139,17 +149,17 @@ pub struct SpaceManifest {
pub fn get_manifest(
server_url: &str,
token: &str,
space_id: &str,
) -> Result<SpaceManifest, String> {
cloud_project_id: &str,
) -> Result<ProjectManifest, String> {
agent()
.get(&endpoint(
server_url,
&format!("/spaces/{}/manifest", space_id),
&format!("/projects/{}/manifest", cloud_project_id),
))
.set("Authorization", &format!("Bearer {}", token))
.call()
.map_err(describe)?
.into_json::<SpaceManifest>()
.into_json::<ProjectManifest>()
.map_err(|e| e.to_string())
}
@@ -176,11 +186,14 @@ impl FileContent {
pub fn pull_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
) -> Result<FileContent, String> {
agent()
.get(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.get(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call()
@@ -204,7 +217,7 @@ pub enum PushResult {
pub fn push_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
bytes: &[u8],
base_hash: Option<&str>,
@@ -216,7 +229,10 @@ pub fn push_file(
};
let response = agent()
.put(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.put(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"path": path,
@@ -248,11 +264,14 @@ pub fn push_file(
pub fn delete_remote_file(
server_url: &str,
token: &str,
space_id: &str,
cloud_project_id: &str,
path: &str,
) -> Result<(), String> {
let response = agent()
.delete(&endpoint(server_url, &format!("/spaces/{}/file", space_id)))
.delete(&endpoint(
server_url,
&format!("/projects/{}/file", cloud_project_id),
))
.query("path", path)
.set("Authorization", &format!("Bearer {}", token))
.call();
@@ -306,12 +325,12 @@ pub fn pull_project(
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
let cloud_project_id = meta
.cloud_project_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
.ok_or("Project is not linked to a cloud project")?;
let manifest = get_manifest(server_url, token, &space_id)?;
let manifest = get_manifest(server_url, token, &cloud_project_id)?;
let mut report = SyncReport::default();
let local_files: HashSet<String> = collect_files(project_dir)?.into_iter().collect();
@@ -327,7 +346,7 @@ pub fn pull_project(
if base.is_some() {
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
let remote = pull_file(server_url, token, &cloud_project_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());
@@ -346,7 +365,7 @@ pub fn pull_project(
continue;
}
let remote = pull_file(server_url, token, &space_id, &entry.path)?;
let remote = pull_file(server_url, token, &cloud_project_id, &entry.path)?;
let remote_bytes = remote.bytes()?;
if base.as_deref() == Some(local_hash.as_str()) {
@@ -428,10 +447,10 @@ pub fn push_project(
project_dir: &Path,
meta: &mut ProjectMeta,
) -> Result<SyncReport, String> {
let space_id = meta
.space_id
let cloud_project_id = meta
.cloud_project_id
.clone()
.ok_or("Project is not linked to a cloud space")?;
.ok_or("Project is not linked to a cloud project")?;
let mut report = SyncReport::default();
let local_files = collect_files(project_dir)?;
@@ -446,7 +465,14 @@ pub fn push_project(
continue;
}
match push_file(server_url, token, &space_id, path, &bytes, base.as_deref())? {
match push_file(
server_url,
token,
&cloud_project_id,
path,
&bytes,
base.as_deref(),
)? {
PushResult::Applied => {
meta.base_hashes.insert(path.clone(), hash);
report.pushed.push(path.clone());
@@ -481,7 +507,7 @@ pub fn push_project(
.collect();
for path in removed {
delete_remote_file(server_url, token, &space_id, &path)?;
delete_remote_file(server_url, token, &cloud_project_id, &path)?;
meta.base_hashes.remove(&path);
report.deleted_remote.push(path);
}
@@ -522,20 +548,20 @@ pub fn report_progress(
);
}
pub fn clone_space(
pub fn clone_cloud_project(
server_url: &str,
token: &str,
app: &tauri::AppHandle,
store: &Store,
project: &str,
project_dir: &Path,
space_id: &str,
cloud_project_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 manifest = get_manifest(server_url, token, cloud_project_id)?;
let mut meta = store.meta(project)?;
meta.space_id = Some(space_id.to_string());
meta.cloud_project_id = Some(cloud_project_id.to_string());
meta.entrypoint = manifest.entrypoint.clone();
let mut report = SyncReport::default();
@@ -544,7 +570,7 @@ pub fn clone_space(
for (index, entry) in manifest.files.iter().enumerate() {
report_progress(app, project, index, total, false);
let remote = pull_file(server_url, token, space_id, &entry.path)?;
let remote = pull_file(server_url, token, cloud_project_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());
@@ -621,7 +647,7 @@ pub struct CloudDocument {
#[derive(Deserialize, Serialize)]
pub struct SharedItems {
pub documents: Vec<CloudDocument>,
pub spaces: Vec<SpaceSummary>,
pub projects: Vec<ProjectSummary>,
}
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
@@ -687,6 +713,25 @@ pub fn pull_document(
.map_err(|e| e.to_string())
}
pub fn create_document(
server_url: &str,
token: &str,
title: &str,
content: &str,
) -> Result<DocumentContent, String> {
agent()
.post(&endpoint(server_url, "/documents"))
.set("Authorization", &format!("Bearer {}", token))
.send_json(ureq::json!({
"title": title,
"content": content,
"folder_id": null,
}))
.map_err(describe)?
.into_json::<DocumentContent>()
.map_err(|e| e.to_string())
}
pub fn sync_document(
server_url: &str,
token: &str,
+8 -8
View File
@@ -115,7 +115,7 @@ pub fn workspace_root(app: &AppHandle, store: &Store) -> Result<PathBuf, String>
#[derive(Serialize, Deserialize, Clone)]
pub struct ProjectMeta {
pub entrypoint: String,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>,
pub base_hashes: HashMap<String, String>,
}
@@ -124,7 +124,7 @@ impl Default for ProjectMeta {
fn default() -> Self {
ProjectMeta {
entrypoint: "main.typ".to_string(),
space_id: None,
cloud_project_id: None,
last_synced_at: None,
base_hashes: HashMap::new(),
}
@@ -154,7 +154,7 @@ pub struct BrowseEntry {
pub kind: String,
pub size: u64,
pub modified: Option<String>,
pub space_id: Option<String>,
pub cloud_project_id: Option<String>,
pub last_synced_at: Option<String>,
pub child_count: usize,
pub cloud_linked: bool,
@@ -259,9 +259,9 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
})
.unwrap_or(0);
let space_id = meta.as_ref().and_then(|m| m.space_id.clone());
let cloud_project_id = meta.as_ref().and_then(|m| m.cloud_project_id.clone());
let last_synced_at = meta.as_ref().and_then(|m| m.last_synced_at.clone());
let sync_state = if space_id.is_some() {
let sync_state = if cloud_project_id.is_some() {
sync_state_for(newest_change(&full), last_synced_at.as_deref())
} else {
None
@@ -273,8 +273,8 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
kind: if project { "project" } else { "folder" }.to_string(),
size: 0,
modified: modified_at(&full),
cloud_linked: space_id.is_some(),
space_id,
cloud_linked: cloud_project_id.is_some(),
cloud_project_id,
last_synced_at,
sync_state,
child_count,
@@ -299,7 +299,7 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
kind: kind.to_string(),
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
modified: modified_at(&full),
space_id: None,
cloud_project_id: None,
last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()),
child_count: 0,
cloud_linked: link.is_some(),