Rename workspace and remote folders with confirmation

Add WebDAV MOVE support and update workspace rename flow to handle both
local and WebDAV-backed workspaces. The Tauri rename_workspace command
is made async and now performs filesystem rename for local workspaces
and issues a WebDAV MOVE (via a new WebDavClient::move_resource) for
remote workspaces, updating stored paths and credentials accordingly. A
confirmation dialog is added to SettingsScreen to prompt users before
renaming, and minor UI/default tweaks are included (SetupScreen default
name). This ensures renames update both local folders and remote WebDAV
folders reliably and with user confirmation.
This commit is contained in:
Tristan Michael 2026-04-05 15:10:44 -07:00
parent 50d859ef80
commit 4c57851e15
4 changed files with 116 additions and 6 deletions

View file

@ -160,15 +160,84 @@ fn remove_workspace(
}
#[tauri::command]
fn rename_workspace(
async fn rename_workspace(
id: String,
new_name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
// Extract workspace info while holding the lock briefly
let (mode, old_path, webdav_url, webdav_path) = {
let s = lock_state(&state)?;
let ws = s.config.workspaces.get(&id).ok_or("Workspace not found")?;
(
ws.mode.clone(),
ws.path.clone(),
ws.webdav_url.clone(),
ws.webdav_path.clone(),
)
};
match mode {
WorkspaceMode::Local => {
// Rename the local folder
let parent = old_path.parent().ok_or("Workspace has no parent directory")?;
let new_path = parent.join(&new_name);
if new_path != old_path {
if new_path.exists() {
return Err(format!("A folder named '{}' already exists at that location", new_name));
}
std::fs::rename(&old_path, &new_path).map_err(|e| format!("Failed to rename folder: {}", e))?;
}
let mut s = lock_state(&state)?;
s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?;
if let Some(ws) = s.config.workspaces.get_mut(&id) {
ws.path = new_path;
}
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
}
WorkspaceMode::Webdav => {
// Rename the remote folder via WebDAV MOVE
let base_url = webdav_url.as_deref().ok_or("No WebDAV URL configured")?;
let remote_path = webdav_path.as_deref().unwrap_or("");
let domain = base_url
.split("://").nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or("").to_string();
let (username, password) = tokio::task::spawn_blocking(move || {
webdav::load_credentials(&domain)
.map(|(u, p)| ((*u).clone(), (*p).clone()))
.map_err(|e| e.to_string())
}).await.map_err(|e| e.to_string())??;
let client = webdav::WebDavClient::new(base_url, &username, &password)
.map_err(|e| e.to_string())?;
// Compute new remote path by replacing the last segment
let new_remote_path = if remote_path.is_empty() || remote_path == "/" {
new_name.clone()
} else if let Some(parent) = remote_path.trim_end_matches('/').rsplit_once('/') {
format!("{}/{}", parent.0, new_name)
} else {
new_name.clone()
};
if new_remote_path != remote_path {
client.move_resource(remote_path, &new_remote_path).await.map_err(|e| e.to_string())?;
}
let mut s = lock_state(&state)?;
s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?;
if let Some(ws) = s.config.workspaces.get_mut(&id) {
ws.webdav_path = Some(new_remote_path);
}
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())?;
}
}
Ok(())
}
// ── Workspace init ───────────────────────────────────────────────────

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { app } from "../stores/app.svelte";
import ConfirmDialog from "../components/ConfirmDialog.svelte";
let { onclose, workspaceId, ondelete }: { onclose?: () => void; workspaceId: string; ondelete?: (id: string) => void } = $props();
@ -15,6 +16,7 @@
let renaming = $state(false);
let renameValue = $state("");
let showKebab = $state(false);
let confirmRename = $state(false);
$effect(() => {
if (!ws?.webdav_url) return;
@ -70,6 +72,13 @@
renaming = false;
var trimmed = renameValue.trim();
if (!trimmed || trimmed === ws?.name) return;
confirmRename = true;
}
async function doRename() {
confirmRename = false;
var trimmed = renameValue.trim();
if (!trimmed) return;
await app.renameWorkspace(workspaceId, trimmed);
}
function handleWindowClick(e: MouseEvent) {
@ -250,3 +259,13 @@
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
</main>
{#if confirmRename}
<ConfirmDialog
message="Rename workspace to '{renameValue.trim()}'?"
detail={isWebdav ? "This will rename the folder on the WebDAV server." : "This will rename the folder on disk."}
confirmText="Rename"
onconfirm={doRename}
oncancel={() => confirmRename = false}
/>
{/if}

View file

@ -194,7 +194,7 @@
function goBack() {
mode = null;
name = "";
name = "Onyx";
path = "";
webdavUrl = "";
webdavUser = "";

View file

@ -187,6 +187,28 @@ impl WebDavClient {
Ok(())
}
/// Move/rename a resource (file or directory) on the server using WebDAV MOVE.
pub async fn move_resource(&self, from: &str, to: &str) -> Result<()> {
let from_url = self.full_url(from);
let to_url = self.full_url(to);
let resp = self._client
.request(reqwest::Method::from_bytes(b"MOVE").unwrap(), &from_url)
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
.header("Destination", &to_url)
.header("Overwrite", "F")
.send()
.await?;
let status = resp.status().as_u16();
if status == 412 {
return Err(Error::WebDav("Destination already exists".into()));
}
if !(200..=299).contains(&status) {
return Err(Error::WebDav(format!("MOVE failed with status {}", status)));
}
Ok(())
}
/// Ensure a directory exists, creating it and parents as needed.
pub async fn ensure_dir(&self, path: &str) -> Result<()> {
let parts: Vec<&str> = path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();