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]
|
#[tauri::command]
|
||||||
fn upload_entry(
|
fn upload_entry(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -282,6 +374,7 @@ fn target_info(app: AppHandle, store: State<'_, Store>, path: String) -> Result<
|
|||||||
files: vec![FileEntry {
|
files: vec![FileEntry {
|
||||||
path: target.entrypoint.clone(),
|
path: target.entrypoint.clone(),
|
||||||
name: target.entrypoint,
|
name: target.entrypoint,
|
||||||
|
is_dir: false,
|
||||||
is_text: true,
|
is_text: true,
|
||||||
size,
|
size,
|
||||||
}],
|
}],
|
||||||
@@ -481,7 +574,7 @@ fn list_resources(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for file in list_files(&target.root)? {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +751,37 @@ fn cloud_list_documents(
|
|||||||
sync::list_documents(&server_url, &token, folder_id.as_deref())
|
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]
|
#[tauri::command]
|
||||||
fn cloud_list_shared(
|
fn cloud_list_shared(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -740,6 +864,77 @@ fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), St
|
|||||||
store.forget_document_link(&path)
|
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]
|
#[tauri::command]
|
||||||
fn cloud_document_link(
|
fn cloud_document_link(
|
||||||
store: State<'_, Store>,
|
store: State<'_, Store>,
|
||||||
@@ -899,6 +1094,9 @@ pub fn run() {
|
|||||||
create_document_entry,
|
create_document_entry,
|
||||||
create_project_entry,
|
create_project_entry,
|
||||||
rename_entry,
|
rename_entry,
|
||||||
|
move_entry,
|
||||||
|
duplicate_entry,
|
||||||
|
absolute_path,
|
||||||
delete_entry,
|
delete_entry,
|
||||||
upload_entry,
|
upload_entry,
|
||||||
target_info,
|
target_info,
|
||||||
@@ -928,10 +1126,14 @@ pub fn run() {
|
|||||||
cloud_list_folders,
|
cloud_list_folders,
|
||||||
cloud_list_documents,
|
cloud_list_documents,
|
||||||
cloud_list_shared,
|
cloud_list_shared,
|
||||||
|
cloud_list_files,
|
||||||
|
cloud_download_file,
|
||||||
cloud_download_document,
|
cloud_download_document,
|
||||||
cloud_sync_document,
|
cloud_sync_document,
|
||||||
cloud_resolve_document,
|
cloud_resolve_document,
|
||||||
cloud_document_link,
|
cloud_document_link,
|
||||||
|
cloud_linked_documents,
|
||||||
|
cloud_linked_spaces,
|
||||||
cloud_unlink_document,
|
cloud_unlink_document,
|
||||||
cloud_create_space,
|
cloud_create_space,
|
||||||
cloud_delete_space,
|
cloud_delete_space,
|
||||||
|
|||||||
+112
-8
@@ -127,6 +127,62 @@ pub struct BrowseEntry {
|
|||||||
pub space_id: Option<String>,
|
pub space_id: Option<String>,
|
||||||
pub last_synced_at: Option<String>,
|
pub last_synced_at: Option<String>,
|
||||||
pub child_count: usize,
|
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> {
|
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);
|
.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 {
|
entries.push(BrowseEntry {
|
||||||
name,
|
name,
|
||||||
path,
|
path,
|
||||||
kind: if project { "project" } else { "folder" }.to_string(),
|
kind: if project { "project" } else { "folder" }.to_string(),
|
||||||
size: 0,
|
size: 0,
|
||||||
modified: modified_at(&full),
|
modified: modified_at(&full),
|
||||||
space_id: meta.as_ref().and_then(|m| m.space_id.clone()),
|
cloud_linked: space_id.is_some(),
|
||||||
last_synced_at: meta.as_ref().and_then(|m| m.last_synced_at.clone()),
|
space_id,
|
||||||
|
last_synced_at,
|
||||||
|
sync_state,
|
||||||
child_count,
|
child_count,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let kind = if is_typst_file(&name) { "document" } else { "file" };
|
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 {
|
entries.push(BrowseEntry {
|
||||||
name,
|
name,
|
||||||
path,
|
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),
|
size: std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0),
|
||||||
modified: modified_at(&full),
|
modified: modified_at(&full),
|
||||||
space_id: None,
|
space_id: None,
|
||||||
last_synced_at: None,
|
last_synced_at: link.as_ref().and_then(|link| link.synced_at.clone()),
|
||||||
child_count: 0,
|
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 struct FileEntry {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
pub is_text: bool,
|
pub is_text: bool,
|
||||||
pub size: u64,
|
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> {
|
pub fn list_files(project_dir: &Path) -> Result<Vec<FileEntry>, String> {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for relative in collect_files(project_dir)? {
|
|
||||||
let full = project_file_path(project_dir, &relative)?;
|
for entry in WalkDir::new(project_dir).into_iter().filter_map(|e| e.ok()) {
|
||||||
let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0);
|
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
|
let name = relative
|
||||||
.rsplit('/')
|
.rsplit('/')
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or(&relative)
|
.unwrap_or(&relative)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
entries.push(FileEntry {
|
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,
|
path: relative,
|
||||||
name,
|
name,
|
||||||
size,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,33 +6,58 @@
|
|||||||
files: FileEntry[];
|
files: FileEntry[];
|
||||||
activePath: string | null;
|
activePath: string | null;
|
||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
|
selected: string | null;
|
||||||
|
dropTarget: string | null;
|
||||||
onopen: (path: string) => void;
|
onopen: (path: string) => void;
|
||||||
|
onselect: (path: string | null, isDir: boolean) => void;
|
||||||
onrename: (path: string) => void;
|
onrename: (path: string) => void;
|
||||||
ondelete: (path: string) => void;
|
ondelete: (path: string) => void;
|
||||||
|
onduplicate: (path: string) => void;
|
||||||
|
onreveal: (path: string) => void;
|
||||||
onsetentry: (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 {
|
let {
|
||||||
files,
|
files,
|
||||||
activePath,
|
activePath,
|
||||||
entrypoint,
|
entrypoint,
|
||||||
|
selected,
|
||||||
|
dropTarget,
|
||||||
onopen,
|
onopen,
|
||||||
|
onselect,
|
||||||
onrename,
|
onrename,
|
||||||
ondelete,
|
ondelete,
|
||||||
|
onduplicate,
|
||||||
|
onreveal,
|
||||||
onsetentry,
|
onsetentry,
|
||||||
|
onmove,
|
||||||
|
onnewfile,
|
||||||
|
onnewfolder,
|
||||||
|
onimport,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
file: FileEntry | null;
|
file: FileEntry | null;
|
||||||
|
isDir: boolean;
|
||||||
children: TreeNode[];
|
children: TreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = $derived(buildTree(files));
|
const tree = $derived(buildTree(files));
|
||||||
|
|
||||||
function buildTree(entries: FileEntry[]): TreeNode[] {
|
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) {
|
for (const entry of entries) {
|
||||||
const segments = entry.path.split("/");
|
const segments = entry.path.split("/");
|
||||||
@@ -41,17 +66,22 @@
|
|||||||
segments.forEach((segment, index) => {
|
segments.forEach((segment, index) => {
|
||||||
const path = segments.slice(0, index + 1).join("/");
|
const path = segments.slice(0, index + 1).join("/");
|
||||||
const isLeaf = index === segments.length - 1;
|
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) {
|
if (!child) {
|
||||||
child = {
|
child = {
|
||||||
name: segment,
|
name: segment,
|
||||||
path,
|
path,
|
||||||
file: isLeaf ? entry : null,
|
file: isLeaf && !entry.is_dir ? entry : null,
|
||||||
|
isDir: isLeaf ? entry.is_dir : true,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
node.children.push(child);
|
node.children.push(child);
|
||||||
|
} else if (isLeaf && !entry.is_dir) {
|
||||||
|
child.file = entry;
|
||||||
|
child.isDir = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
node = child;
|
node = child;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -61,9 +91,7 @@
|
|||||||
|
|
||||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
nodes.sort((a, b) => {
|
nodes.sort((a, b) => {
|
||||||
const aIsFolder = a.file === null;
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||||
const bIsFolder = b.file === null;
|
|
||||||
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
for (const node of nodes) sortNodes(node.children);
|
for (const node of nodes) sortNodes(node.children);
|
||||||
@@ -71,100 +99,147 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function iconFor(node: TreeNode): string {
|
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(".typ")) return "ph:file-text";
|
||||||
if (node.name.endsWith(".toml")) return "ph:gear-six";
|
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";
|
return "ph:image";
|
||||||
}
|
}
|
||||||
|
|
||||||
let collapsed = $state<Record<string, boolean>>({});
|
let collapsed = $state<Record<string, boolean>>({});
|
||||||
let menuPath = $state<string | null>(null);
|
let menu = $state<{ path: string; isDir: boolean; x: number; y: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
let dragging = $state<string | null>(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<string, boolean> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:click={() => (menu = null)}
|
||||||
|
on:contextmenu={(event) => {
|
||||||
|
if (!(event.target as HTMLElement).closest("[data-tree-row]")) menu = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet branch(nodes: TreeNode[], depth: number)}
|
{#snippet branch(nodes: TreeNode[], depth: number)}
|
||||||
{#each nodes as node (node.path)}
|
{#each nodes as node (node.path)}
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
data-tree-row
|
||||||
|
data-tree-path={node.path}
|
||||||
|
data-tree-dir={node.isDir ? "true" : "false"}
|
||||||
|
role="treeitem"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={node.path === selected}
|
||||||
|
draggable="true"
|
||||||
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
|
class="group flex items-center gap-1.5 rounded px-2 py-1 text-xs transition
|
||||||
{node.path === activePath
|
{node.path === activePath
|
||||||
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
? 'bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
|
||||||
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}"
|
: node.path === selected
|
||||||
|
? 'bg-[var(--color-surface-sunken)]'
|
||||||
|
: 'text-[var(--color-ink)] hover:bg-[var(--color-surface-sunken)]'}
|
||||||
|
{dropTarget === node.path
|
||||||
|
? 'ring-1 ring-inset ring-[var(--color-accent)]'
|
||||||
|
: ''}"
|
||||||
style="padding-left: {depth * 12 + 8}px"
|
style="padding-left: {depth * 12 + 8}px"
|
||||||
|
onclick={() => 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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{#if node.isDir}
|
||||||
class="flex min-w-0 flex-1 items-center gap-1.5 text-left"
|
<Icon
|
||||||
onclick={() => {
|
icon={collapsed[node.path] ? "ph:caret-right" : "ph:caret-down"}
|
||||||
if (node.file) {
|
class="shrink-0 text-[10px] text-[var(--color-ink-muted)]"
|
||||||
onopen(node.path);
|
/>
|
||||||
} else {
|
{:else}
|
||||||
collapsed[node.path] = !collapsed[node.path];
|
<span class="w-2.5 shrink-0"></span>
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if !node.file}
|
|
||||||
<Icon
|
|
||||||
icon={collapsed[node.path] ? "ph:caret-right" : "ph:caret-down"}
|
|
||||||
class="shrink-0 text-[10px] text-[var(--color-ink-muted)]"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<Icon icon={iconFor(node)} class="shrink-0 text-sm" />
|
|
||||||
<span class="truncate">{node.name}</span>
|
|
||||||
{#if node.path === entrypoint}
|
|
||||||
<span
|
|
||||||
class="shrink-0 rounded bg-[var(--color-accent)] px-1 py-px text-[9px] font-medium text-white"
|
|
||||||
>
|
|
||||||
main
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if node.file}
|
|
||||||
<button
|
|
||||||
class="shrink-0 rounded p-0.5 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface)]"
|
|
||||||
onclick={() => (menuPath = menuPath === node.path ? null : node.path)}
|
|
||||||
aria-label="File actions"
|
|
||||||
>
|
|
||||||
<Icon icon="ph:dots-three-vertical" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if node.file && menuPath === node.path}
|
<Icon icon={iconFor(node)} class="shrink-0 text-sm" />
|
||||||
<div
|
<span class="min-w-0 flex-1 truncate">{node.name}</span>
|
||||||
class="ml-6 mb-1 flex flex-col rounded border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-sm"
|
|
||||||
|
{#if node.path === entrypoint}
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded bg-[var(--color-accent)] px-1 py-px text-[9px] font-medium text-white"
|
||||||
|
>
|
||||||
|
main
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded p-0.5 text-[var(--color-ink-muted)] opacity-0 transition group-hover:opacity-100 hover:bg-[var(--color-surface)]"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openMenu(event, node);
|
||||||
|
}}
|
||||||
|
aria-label="Actions"
|
||||||
>
|
>
|
||||||
{#if node.name.endsWith(".typ")}
|
<Icon icon="ph:dots-three-vertical" />
|
||||||
<button
|
</button>
|
||||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
</div>
|
||||||
onclick={() => {
|
|
||||||
onsetentry(node.path);
|
|
||||||
menuPath = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Set as entrypoint
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
|
||||||
onclick={() => {
|
|
||||||
onrename(node.path);
|
|
||||||
menuPath = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
|
||||||
onclick={() => {
|
|
||||||
ondelete(node.path);
|
|
||||||
menuPath = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if node.children.length > 0 && !collapsed[node.path]}
|
{#if node.children.length > 0 && !collapsed[node.path]}
|
||||||
{@render branch(node.children, depth + 1)}
|
{@render branch(node.children, depth + 1)}
|
||||||
@@ -173,10 +248,140 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="scroll-thin flex-1 overflow-y-auto py-1">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
{#if files.length === 0}
|
<div
|
||||||
<p class="px-3 py-4 text-xs text-[var(--color-ink-muted)]">No files yet</p>
|
class="flex items-center justify-end gap-0.5 border-b border-[var(--color-line)] px-2 py-1"
|
||||||
{:else}
|
>
|
||||||
{@render branch(tree, 0)}
|
<button
|
||||||
{/if}
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => onnewfile(selected ?? "")}
|
||||||
|
title="New file"
|
||||||
|
aria-label="New file"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:file-plus" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => onnewfolder(selected ?? "")}
|
||||||
|
title="New folder"
|
||||||
|
aria-label="New folder"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:folder-plus" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={() => onimport(selected ?? "")}
|
||||||
|
title="Import files"
|
||||||
|
aria-label="Import files"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:upload-simple" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
|
||||||
|
onclick={collapseAll}
|
||||||
|
title="Collapse all"
|
||||||
|
aria-label="Collapse all"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:arrows-in-line-vertical" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="scroll-thin flex-1 overflow-y-auto py-1"
|
||||||
|
role="tree"
|
||||||
|
tabindex="-1"
|
||||||
|
data-tree-path=""
|
||||||
|
data-tree-dir="true"
|
||||||
|
onclick={(event) => {
|
||||||
|
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}
|
||||||
|
<p class="px-3 py-4 text-xs text-[var(--color-ink-muted)]">No files yet</p>
|
||||||
|
{:else}
|
||||||
|
{@render branch(tree, 0)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if menu}
|
||||||
|
{@const target = menu}
|
||||||
|
<div
|
||||||
|
class="fixed z-50 flex w-48 flex-col rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] py-1 text-xs shadow-lg"
|
||||||
|
style="left: {target.x}px; top: {target.y}px"
|
||||||
|
>
|
||||||
|
{#if target.isDir}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onnewfile(target.path)}
|
||||||
|
>
|
||||||
|
New file
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onnewfolder(target.path)}
|
||||||
|
>
|
||||||
|
New folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onimport(target.path)}
|
||||||
|
>
|
||||||
|
Import files here
|
||||||
|
</button>
|
||||||
|
<div class="my-1 h-px bg-[var(--color-line)]"></div>
|
||||||
|
{:else if target.path.endsWith(".typ")}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onsetentry(target.path)}
|
||||||
|
>
|
||||||
|
Set as entrypoint
|
||||||
|
</button>
|
||||||
|
<div class="my-1 h-px bg-[var(--color-line)]"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onrename(target.path)}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onduplicate(target.path)}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => navigator.clipboard.writeText(target.path)}
|
||||||
|
>
|
||||||
|
Copy path
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => onreveal(target.path)}
|
||||||
|
>
|
||||||
|
Reveal in file manager
|
||||||
|
</button>
|
||||||
|
<div class="my-1 h-px bg-[var(--color-line)]"></div>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-left text-[var(--color-danger)] hover:bg-[var(--color-surface-sunken)]"
|
||||||
|
onclick={() => ondelete(target.path)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user