Add file explorer actions and show folders in the tree
This commit is contained in:
+203
-1
@@ -228,6 +228,98 @@ fn delete_entry(app: AppHandle, store: State<'_, Store>, path: String) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an entry into another folder, keeping its name.
|
||||
#[tauri::command]
|
||||
fn move_entry(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
destination: String,
|
||||
) -> Result<String, String> {
|
||||
let name = path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.ok_or("Invalid path")?
|
||||
.to_string();
|
||||
|
||||
let target_path = join_path(&destination, &name);
|
||||
if target_path == path {
|
||||
return Ok(path);
|
||||
}
|
||||
if destination == path || destination.starts_with(&format!("{}/", path)) {
|
||||
return Err("A folder cannot be moved into itself".to_string());
|
||||
}
|
||||
|
||||
let from = workspace_path(&app, &store, &path)?;
|
||||
let to = workspace_path(&app, &store, &target_path)?;
|
||||
|
||||
if to.exists() {
|
||||
return Err(format!("'{}' already exists there", name));
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
|
||||
store.rename_project(&path, &target_path)?;
|
||||
Ok(target_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn duplicate_entry(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<String, String> {
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
let parent = parent_of(&path);
|
||||
|
||||
let name = path.rsplit('/').next().ok_or("Invalid path")?;
|
||||
let (stem, extension) = match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), format!(".{}", ext)),
|
||||
_ => (name.to_string(), String::new()),
|
||||
};
|
||||
|
||||
let mut candidate = String::new();
|
||||
for index in 1..1000 {
|
||||
let suffix = if index == 1 {
|
||||
" copy".to_string()
|
||||
} else {
|
||||
format!(" copy {}", index)
|
||||
};
|
||||
let attempt = join_path(&parent, &format!("{}{}{}", stem, suffix, extension));
|
||||
if !workspace_path(&app, &store, &attempt)?.exists() {
|
||||
candidate = attempt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if candidate.is_empty() {
|
||||
return Err("Could not find a free name".to_string());
|
||||
}
|
||||
|
||||
let to = workspace_path(&app, &store, &candidate)?;
|
||||
if full.is_dir() {
|
||||
assets::import_paths(&[full.to_string_lossy().to_string()], &to)?;
|
||||
} else {
|
||||
std::fs::copy(&full, &to).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Absolute path on disk, used to reveal an entry in the system file manager.
|
||||
#[tauri::command]
|
||||
fn absolute_path(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<String, String> {
|
||||
Ok(workspace_path(&app, &store, &path)?
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn upload_entry(
|
||||
app: AppHandle,
|
||||
@@ -282,6 +374,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
|
||||
files: vec![FileEntry {
|
||||
path: target.entrypoint.clone(),
|
||||
name: target.entrypoint,
|
||||
is_dir: false,
|
||||
is_text: true,
|
||||
size,
|
||||
}],
|
||||
@@ -481,7 +574,7 @@ fn list_resources(
|
||||
};
|
||||
|
||||
for file in list_files(&target.root)? {
|
||||
if file.path.to_lowercase().ends_with(".typ") {
|
||||
if file.is_dir || file.path.to_lowercase().ends_with(".typ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -658,6 +751,37 @@ fn cloud_list_documents(
|
||||
sync::list_documents(&server_url, &token, folder_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_files(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<Vec<sync::CloudFile>, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_account_files(&server_url, &token, folder_id.as_deref())
|
||||
}
|
||||
|
||||
/// Downloads an account file into the shared asset library, where every
|
||||
/// project can reference it by name.
|
||||
#[tauri::command]
|
||||
fn cloud_download_file(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
file_id: String,
|
||||
) -> Result<String, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let file = sync::pull_account_file(&server_url, &token, &file_id)?;
|
||||
|
||||
let bytes = BASE64
|
||||
.decode(file.content.as_bytes())
|
||||
.map_err(|e| format!("Invalid file data: {}", e))?;
|
||||
|
||||
let destination = assets::assets_dir(&app, &store)?.join(&file.name);
|
||||
std::fs::write(&destination, bytes).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(file.name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_shared(
|
||||
app: AppHandle,
|
||||
@@ -740,6 +864,77 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St
|
||||
store.forget_document_link(&path)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LinkedDocument {
|
||||
pub path: String,
|
||||
pub document_id: String,
|
||||
pub synced_at: Option<String>,
|
||||
pub sync_state: Option<String>,
|
||||
}
|
||||
|
||||
/// Every cloud document that has been downloaded, so the cloud view can show
|
||||
/// which ones live on this device and whether they are up to date.
|
||||
#[tauri::command]
|
||||
fn cloud_linked_documents(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
) -> Result<Vec<LinkedDocument>, String> {
|
||||
let mut linked = Vec::new();
|
||||
|
||||
for (path, document_id, synced_at) in store.all_document_links()? {
|
||||
let Ok(full) = workspace_path(&app, &store, &path) else {
|
||||
continue;
|
||||
};
|
||||
if !full.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
linked.push(LinkedDocument {
|
||||
sync_state: workspace::sync_state_of(&full, synced_at.as_deref()),
|
||||
path,
|
||||
document_id,
|
||||
synced_at,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LinkedSpace {
|
||||
pub path: String,
|
||||
pub space_id: String,
|
||||
pub synced_at: Option<String>,
|
||||
pub sync_state: Option<String>,
|
||||
}
|
||||
|
||||
/// Cloud spaces that have been downloaded, wherever they sit in the workspace.
|
||||
#[tauri::command]
|
||||
fn cloud_linked_spaces(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
) -> Result<Vec<LinkedSpace>, String> {
|
||||
let mut linked = Vec::new();
|
||||
|
||||
for (path, space_id, synced_at) in store.all_space_links()? {
|
||||
let Ok(full) = workspace_path(&app, &store, &path) else {
|
||||
continue;
|
||||
};
|
||||
if !full.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
linked.push(LinkedSpace {
|
||||
sync_state: workspace::project_sync_state(&full, synced_at.as_deref()),
|
||||
path,
|
||||
space_id,
|
||||
synced_at,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_document_link(
|
||||
store: State<'_, Store>,
|
||||
@@ -899,6 +1094,9 @@ pub fn run() {
|
||||
create_document_entry,
|
||||
create_project_entry,
|
||||
rename_entry,
|
||||
move_entry,
|
||||
duplicate_entry,
|
||||
absolute_path,
|
||||
delete_entry,
|
||||
upload_entry,
|
||||
target_info,
|
||||
@@ -928,10 +1126,14 @@ pub fn run() {
|
||||
cloud_list_folders,
|
||||
cloud_list_documents,
|
||||
cloud_list_shared,
|
||||
cloud_list_files,
|
||||
cloud_download_file,
|
||||
cloud_download_document,
|
||||
cloud_sync_document,
|
||||
cloud_resolve_document,
|
||||
cloud_document_link,
|
||||
cloud_linked_documents,
|
||||
cloud_linked_spaces,
|
||||
cloud_unlink_document,
|
||||
cloud_create_space,
|
||||
cloud_delete_space,
|
||||
|
||||
+112
-8
@@ -127,6 +127,62 @@ pub struct BrowseEntry {
|
||||
pub space_id: Option<String>,
|
||||
pub last_synced_at: Option<String>,
|
||||
pub child_count: usize,
|
||||
pub cloud_linked: bool,
|
||||
/// "synced" when nothing changed since the last sync, "pending" when local
|
||||
/// edits are waiting to go up, or None when the entry is not linked.
|
||||
pub sync_state: Option<String>,
|
||||
}
|
||||
|
||||
fn modified_time(path: &Path) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
Some(std::fs::metadata(path).ok()?.modified().ok()?.into())
|
||||
}
|
||||
|
||||
fn newest_change(dir: &Path) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let mut newest: Option<chrono::DateTime<chrono::Utc>> = None;
|
||||
|
||||
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(relative) = relative_path(dir, entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
if relative.split('/').any(|segment| segment.starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
if let Some(time) = entry.metadata().ok().and_then(|m| m.modified().ok()) {
|
||||
let time: chrono::DateTime<chrono::Utc> = time.into();
|
||||
if newest.map(|current| time > current).unwrap_or(true) {
|
||||
newest = Some(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newest
|
||||
}
|
||||
|
||||
/// Compares when the entry last changed on disk against when it was last
|
||||
/// synced. Metadata only, so browsing stays cheap.
|
||||
pub fn sync_state_of(path: &Path, synced_at: Option<&str>) -> Option<String> {
|
||||
sync_state_for(modified_time(path), synced_at)
|
||||
}
|
||||
|
||||
pub fn project_sync_state(dir: &Path, synced_at: Option<&str>) -> Option<String> {
|
||||
sync_state_for(newest_change(dir), synced_at)
|
||||
}
|
||||
|
||||
fn sync_state_for(
|
||||
changed: Option<chrono::DateTime<chrono::Utc>>,
|
||||
synced_at: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let synced = chrono::DateTime::parse_from_rfc3339(synced_at?)
|
||||
.ok()
|
||||
.map(|value| value.with_timezone(&chrono::Utc))?;
|
||||
|
||||
match changed {
|
||||
Some(changed) if changed > synced => Some("pending".to_string()),
|
||||
_ => Some("synced".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn modified_at(path: &Path) -> Option<String> {
|
||||
@@ -177,18 +233,42 @@ 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 last_synced_at = meta.as_ref().and_then(|m| m.last_synced_at.clone());
|
||||
let sync_state = if space_id.is_some() {
|
||||
sync_state_for(newest_change(&full), last_synced_at.as_deref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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()),
|
||||
cloud_linked: space_id.is_some(),
|
||||
space_id,
|
||||
last_synced_at,
|
||||
sync_state,
|
||||
child_count,
|
||||
});
|
||||
} else {
|
||||
let kind = if is_typst_file(&name) { "document" } else { "file" };
|
||||
let link = store.document_link(&path)?;
|
||||
|
||||
// Cloud documents are managed from the Cloud view, so they are not
|
||||
// listed a second time here.
|
||||
if link.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sync_state = link
|
||||
.as_ref()
|
||||
.and_then(|link| {
|
||||
sync_state_for(modified_time(&full), link.synced_at.as_deref())
|
||||
});
|
||||
|
||||
entries.push(BrowseEntry {
|
||||
name,
|
||||
path,
|
||||
@@ -196,8 +276,10 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result<Vec<Brow
|
||||
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
|
||||
modified: modified_at(&full),
|
||||
space_id: None,
|
||||
last_synced_at: None,
|
||||
last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()),
|
||||
child_count: 0,
|
||||
cloud_linked: link.is_some(),
|
||||
sync_state,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -368,27 +450,49 @@ pub fn read_all_files(project_dir: &Path) -> Result<HashMap<String, Vec<u8>>, St
|
||||
pub struct FileEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub is_text: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Lists a project's contents for the editor tree. Unlike `collect_files`,
|
||||
/// which feeds sync and only cares about file contents, this includes
|
||||
/// directories so an empty folder is still visible after it is created.
|
||||
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);
|
||||
|
||||
for entry in WalkDir::new(project_dir).into_iter().filter_map(|e| e.ok()) {
|
||||
let Some(relative) = relative_path(project_dir, entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
if relative.is_empty() || relative == PROJECT_META_FILE {
|
||||
continue;
|
||||
}
|
||||
if relative.split('/').any(|segment| segment.starts_with('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.file_type().is_dir();
|
||||
let name = relative
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(&relative)
|
||||
.to_string();
|
||||
|
||||
entries.push(FileEntry {
|
||||
is_text: is_text_file(&relative),
|
||||
is_dir,
|
||||
is_text: !is_dir && is_text_file(&relative),
|
||||
size: if is_dir {
|
||||
0
|
||||
} else {
|
||||
entry.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
},
|
||||
path: relative,
|
||||
name,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user