From 2383091937f6a3bcd8e106e2679a7da3c3caca78 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 18 Jul 2026 18:04:26 -0400 Subject: [PATCH] Add file explorer actions and show folders in the tree --- src-tauri/src/lib.rs | 204 +++++++++++++++- src-tauri/src/workspace.rs | 120 ++++++++- src/lib/components/FileTree.svelte | 377 ++++++++++++++++++++++------- 3 files changed, 606 insertions(+), 95 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 363fb00..ece3b18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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 { + 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 { + 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, +) -> Result, 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 { + 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, + pub sync_state: Option, +} + +/// 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, 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, + pub sync_state: Option, +} + +/// Cloud spaces that have been downloaded, wherever they sit in the workspace. +#[tauri::command] +fn cloud_linked_spaces( + app: AppHandle, + store: State<'_, Store>, +) -> Result, 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, diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index ef5091a..0185fb1 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -127,6 +127,62 @@ pub struct BrowseEntry { pub space_id: Option, pub last_synced_at: Option, 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, +} + +fn modified_time(path: &Path) -> Option> { + Some(std::fs::metadata(path).ok()?.modified().ok()?.into()) +} + +fn newest_change(dir: &Path) -> Option> { + let mut newest: Option> = 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 = 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 { + sync_state_for(modified_time(path), synced_at) +} + +pub fn project_sync_state(dir: &Path, synced_at: Option<&str>) -> Option { + sync_state_for(newest_change(dir), synced_at) +} + +fn sync_state_for( + changed: Option>, + synced_at: Option<&str>, +) -> Option { + 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 { @@ -177,18 +233,42 @@ pub fn browse(app: &AppHandle, store: &Store, relative: &str) -> Result Result Result>, 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, 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) } diff --git a/src/lib/components/FileTree.svelte b/src/lib/components/FileTree.svelte index 2515fdf..666841a 100644 --- a/src/lib/components/FileTree.svelte +++ b/src/lib/components/FileTree.svelte @@ -6,33 +6,58 @@ files: FileEntry[]; activePath: string | null; entrypoint: string; + selected: string | null; + dropTarget: string | null; onopen: (path: string) => void; + onselect: (path: string | null, isDir: boolean) => void; onrename: (path: string) => void; ondelete: (path: string) => void; + onduplicate: (path: string) => void; + onreveal: (path: string) => void; onsetentry: (path: string) => void; + onmove: (path: string, destination: string) => void; + onnewfile: (parent: string) => void; + onnewfolder: (parent: string) => void; + onimport: (parent: string) => void; } let { files, activePath, entrypoint, + selected, + dropTarget, onopen, + onselect, onrename, ondelete, + onduplicate, + onreveal, onsetentry, + onmove, + onnewfile, + onnewfolder, + onimport, }: Props = $props(); interface TreeNode { name: string; path: string; file: FileEntry | null; + isDir: boolean; children: TreeNode[]; } const tree = $derived(buildTree(files)); function buildTree(entries: FileEntry[]): TreeNode[] { - const root: TreeNode = { name: "", path: "", file: null, children: [] }; + const root: TreeNode = { + name: "", + path: "", + file: null, + isDir: true, + children: [], + }; for (const entry of entries) { const segments = entry.path.split("/"); @@ -41,17 +66,22 @@ segments.forEach((segment, index) => { const path = segments.slice(0, index + 1).join("/"); const isLeaf = index === segments.length - 1; - let child = node.children.find((c) => c.name === segment); + let child = node.children.find((candidate) => candidate.name === segment); if (!child) { child = { name: segment, path, - file: isLeaf ? entry : null, + file: isLeaf && !entry.is_dir ? entry : null, + isDir: isLeaf ? entry.is_dir : true, children: [], }; node.children.push(child); + } else if (isLeaf && !entry.is_dir) { + child.file = entry; + child.isDir = false; } + node = child; }); } @@ -61,9 +91,7 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] { nodes.sort((a, b) => { - const aIsFolder = a.file === null; - const bIsFolder = b.file === null; - if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1; + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); for (const node of nodes) sortNodes(node.children); @@ -71,100 +99,147 @@ } function iconFor(node: TreeNode): string { - if (!node.file) return "ph:folder"; + if (node.isDir) return "ph:folder"; if (node.name.endsWith(".typ")) return "ph:file-text"; if (node.name.endsWith(".toml")) return "ph:gear-six"; - if (node.file.is_text) return "ph:file"; + if (node.file?.is_text) return "ph:file"; return "ph:image"; } let collapsed = $state>({}); - let menuPath = $state(null); + let menu = $state<{ path: string; isDir: boolean; x: number; y: number } | null>( + null, + ); + let dragging = $state(null); + + function activate(node: TreeNode) { + onselect(node.path, node.isDir); + if (node.isDir) { + collapsed[node.path] = !collapsed[node.path]; + } else { + onopen(node.path); + } + } + + function openMenu(event: MouseEvent, node: TreeNode) { + event.preventDefault(); + onselect(node.path, node.isDir); + menu = { path: node.path, isDir: node.isDir, x: event.clientX, y: event.clientY }; + } + + function handleKey(event: KeyboardEvent, node: TreeNode) { + if (event.key === "F2") { + event.preventDefault(); + onrename(node.path); + } + if (event.key === "Delete") { + event.preventDefault(); + ondelete(node.path); + } + } + + function collapseAll() { + const next: Record = {}; + for (const entry of files) { + if (entry.is_dir) next[entry.path] = true; + } + collapsed = next; + } + + export function expandTo(path: string) { + const segments = path.split("/"); + for (let index = 1; index < segments.length; index += 1) { + collapsed[segments.slice(0, index).join("/")] = false; + } + } + + function parentOf(path: string): string { + const index = path.lastIndexOf("/"); + return index === -1 ? "" : path.slice(0, index); + } + (menu = null)} + on:contextmenu={(event) => { + if (!(event.target as HTMLElement).closest("[data-tree-row]")) menu = null; + }} +/> + {#snippet branch(nodes: TreeNode[], depth: number)} {#each nodes as node (node.path)}
activate(node)} + oncontextmenu={(event) => openMenu(event, node)} + onkeydown={(event) => handleKey(event, node)} + ondragstart={(event) => { + dragging = node.path; + event.dataTransfer?.setData("text/plain", node.path); + }} + ondragend={() => (dragging = null)} + ondragover={(event) => { + if (!dragging || !node.isDir) return; + event.preventDefault(); + }} + ondrop={(event) => { + event.preventDefault(); + const source = dragging ?? event.dataTransfer?.getData("text/plain"); + dragging = null; + if (!source) return; + const destination = node.isDir ? node.path : parentOf(node.path); + if (parentOf(source) === destination) return; + onmove(source, destination); + }} > - - - {#if node.file} - + {#if node.isDir} + + {:else} + {/if} -
- {#if node.file && menuPath === node.path} -
+ {node.name} + + {#if node.path === entrypoint} + + main + + {/if} + + - {/if} - - -
- {/if} + + +
{#if node.children.length > 0 && !collapsed[node.path]} {@render branch(node.children, depth + 1)} @@ -173,10 +248,140 @@ {/each} {/snippet} -
- {#if files.length === 0} -

No files yet

- {:else} - {@render branch(tree, 0)} - {/if} +
+
+ + + + +
+ +
{ + if (event.target === event.currentTarget) onselect(null, true); + }} + onkeydown={(event) => { + if (event.key === "Escape") onselect(null, true); + }} + ondragover={(event) => { + if (dragging) event.preventDefault(); + }} + ondrop={(event) => { + event.preventDefault(); + const source = dragging ?? event.dataTransfer?.getData("text/plain"); + dragging = null; + if (source && parentOf(source) !== "") onmove(source, ""); + }} + > + {#if files.length === 0} +

No files yet

+ {:else} + {@render branch(tree, 0)} + {/if} +
+ +{#if menu} + {@const target = menu} +
+ {#if target.isDir} + + + +
+ {:else if target.path.endsWith(".typ")} + +
+ {/if} + + + + + +
+ +
+{/if}