Add workspace rename and restructure settings screen

Full-stack workspace rename: renames folder on disk, updates config
key/path, refreshes frontend. Restructure settings screen with generic
'Workspace Settings' header, workspace name row with kebab menu
(Rename + Delete). Replace per-workspace kebab dropdown in workspace
list with a direct settings gear button. Remove Appearance heading
and border box from theme section. Clean up unused wsMenuName state.
This commit is contained in:
Tristan Michael 2026-04-05 12:56:53 -07:00
parent 8df0edf163
commit bb735ecd4a
5 changed files with 148 additions and 60 deletions

View file

@ -160,6 +160,26 @@ fn remove_workspace(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
fn rename_workspace(
old_name: String,
new_name: String,
state: State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let mut s = lock_state(&state)?;
let ws = s.config.get_workspace(&old_name)
.ok_or_else(|| format!("Workspace '{}' not found", old_name))?;
let old_path = ws.path.clone();
let new_path = old_path.parent()
.ok_or("Workspace path has no parent directory")?
.join(&new_name);
std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string())?;
s.config.rename_workspace(&old_name, new_name.clone()).map_err(|e| e.to_string())?;
s.config.workspaces.get_mut(&new_name).unwrap().path = new_path;
s.repo = None;
s.config.save_to_file(&s.config_path.clone()).map_err(|e| e.to_string())
}
// ── Workspace init ─────────────────────────────────────────────────── // ── Workspace init ───────────────────────────────────────────────────
#[tauri::command] #[tauri::command]
@ -648,6 +668,7 @@ pub fn run() {
add_workspace, add_workspace,
set_current_workspace, set_current_workspace,
remove_workspace, remove_workspace,
rename_workspace,
init_workspace, init_workspace,
get_lists, get_lists,
create_list, create_list,

View file

@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { app } from "../stores/app.svelte"; import { app } from "../stores/app.svelte";
let { onclose, workspaceName }: { onclose?: () => void; workspaceName: string } = $props(); let { onclose, workspaceName, onrename, ondelete }: { onclose?: () => void; workspaceName: string; onrename?: (newName: string) => void; ondelete?: (name: string) => void } = $props();
let ws = $derived(app.config?.workspaces[workspaceName]); let ws = $derived(app.config?.workspaces[workspaceName]);
let isWebdav = $derived(ws?.mode === "webdav"); let isWebdav = $derived(ws?.mode === "webdav");
@ -12,6 +12,10 @@
let webdavPass = $state(""); let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle"); let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
let renaming = $state(false);
let renameValue = $state("");
let showKebab = $state(false);
$effect(() => { $effect(() => {
if (!ws?.webdav_url) return; if (!ws?.webdav_url) return;
webdavUrl = ws.webdav_url; webdavUrl = ws.webdav_url;
@ -54,12 +58,32 @@
} }
await app.loadConfig(); await app.loadConfig();
} }
function startRename() {
showKebab = false;
renaming = true;
renameValue = workspaceName;
}
async function handleRename() {
if (!renaming) return;
renaming = false;
var trimmed = renameValue.trim();
if (!trimmed || trimmed === workspaceName) return;
await app.renameWorkspace(workspaceName, trimmed);
onrename?.(trimmed);
}
function handleWindowClick(e: MouseEvent) {
if (showKebab && !(e.target as HTMLElement).closest("[data-settings-kebab]")) showKebab = false;
}
</script> </script>
<svelte:window onclick={handleWindowClick} />
<header <header
class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark" class="flex items-center justify-between border-b border-border-light px-4 py-3 dark:border-border-dark"
> >
<h1 class="text-lg font-bold">{workspaceName} Settings</h1> <h1 class="text-lg font-bold">Workspace Settings</h1>
<button <button
onclick={() => onclose?.()} onclick={() => onclose?.()}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10" class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
@ -72,6 +96,56 @@
</button> </button>
</header> </header>
<!-- Workspace name + kebab -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="min-w-0 flex-1">
{#if renaming}
<input
type="text"
bind:value={renameValue}
class="w-full bg-transparent text-xl font-bold outline-none"
onkeydown={(e) => { if (e.key === "Enter") handleRename(); if (e.key === "Escape") { renaming = false; } }}
onblur={handleRename}
autofocus
/>
{:else}
<p class="text-xl font-bold">{workspaceName}</p>
{/if}
</div>
<div class="relative shrink-0" data-settings-kebab>
<button
onclick={() => showKebab = !showKebab}
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 6a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z" />
</svg>
</button>
{#if showKebab}
<div class="absolute right-0 top-full z-10 mt-1 w-40 rounded-xl border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
<button
onclick={startRename}
class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4 opacity-60" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
</svg>
Rename
</button>
<button
onclick={() => { showKebab = false; ondelete?.(workspaceName); }}
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete
</button>
</div>
{/if}
</div>
</div>
<main class="flex-1 overflow-y-auto p-4"> <main class="flex-1 overflow-y-auto p-4">
<!-- WebDAV Sync (only for webdav workspaces) --> <!-- WebDAV Sync (only for webdav workspaces) -->
{#if isWebdav} {#if isWebdav}
@ -157,27 +231,22 @@
<!-- Theme --> <!-- Theme -->
<section> <section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50"> <label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
Appearance <select
</h2> value={ws?.theme ?? ""}
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark"> onchange={(e) => {
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label> const val = (e.target as HTMLSelectElement).value;
<select app.setTheme(val || null);
value={ws?.theme ?? ""} }}
onchange={(e) => { class="w-full appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
const val = (e.target as HTMLSelectElement).value; >
app.setTheme(val || null); <option value="">System default</option>
}} <option value="light">Light</option>
class="w-full appearance-none rounded-lg border border-border-light bg-surface-light px-3 py-2 text-sm text-text-light outline-none focus:border-primary dark:border-border-dark dark:bg-surface-dark dark:text-text-dark" <option value="dark">Dark</option>
> <option value="nord">Nord</option>
<option value="">System default</option> <option value="dracula">Dracula</option>
<option value="light">Light</option> <option value="solarized">Solarized Dark</option>
<option value="dark">Dark</option> </select>
<option value="nord">Nord</option>
<option value="dracula">Dracula</option>
<option value="solarized">Solarized Dark</option>
</select>
</div>
</section> </section>
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p> <p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>

View file

@ -45,14 +45,11 @@
showWorkspacePicker = false; showWorkspacePicker = false;
if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node)) if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
showListMenu = false; showListMenu = false;
const target = e.target as HTMLElement;
if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null;
} }
let newListName = $state(""); let newListName = $state("");
let showCompleted = $state(false); let showCompleted = $state(false);
let completedVisible = $state(false); let completedVisible = $state(false);
let wsMenuName = $state<string | null>(null);
let renamingListId = $state<string | null>(null); let renamingListId = $state<string | null>(null);
let renameValue = $state(""); let renameValue = $state("");
let showListMenu = $state(false); let showListMenu = $state(false);
@ -137,7 +134,6 @@
if (taskStack.length > 0) { closeDetail(); return; } if (taskStack.length > 0) { closeDetail(); return; }
if (showListMenu) { showListMenu = false; return; } if (showListMenu) { showListMenu = false; return; }
if (showDrawer) { closeDrawer(); return; } if (showDrawer) { closeDrawer(); return; }
if (wsMenuName) { wsMenuName = null; return; }
if (showWorkspacePicker) { showWorkspacePicker = false; return; } if (showWorkspacePicker) { showWorkspacePicker = false; return; }
} }
@ -257,38 +253,14 @@
<p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}</p> <p class="truncate text-xs opacity-40">{ws?.mode === "webdav" ? ws.webdav_url ?? "WebDAV" : ws?.path ?? ""}</p>
</div> </div>
</button> </button>
<div class="relative shrink-0" data-ws-menu> <button
<button onclick={(e) => { e.stopPropagation(); settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
onclick={(e) => { e.stopPropagation(); wsMenuName = wsMenuName === name ? null : name; }} class="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80"
class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {wsMenuName === name ? '!opacity-80' : ''}" >
> <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" /> </svg>
</svg> </button>
</button>
{#if wsMenuName === name}
<div class="absolute right-0 top-full z-40 mt-1 min-w-[140px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
<button
onclick={() => { wsMenuName = null; settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Settings
</button>
<button
onclick={() => { wsMenuName = null; confirmRemoveWorkspace = name; }}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
Delete
</button>
</div>
{/if}
</div>
</div> </div>
{/each} {/each}
<div class="mt-1 border-t border-border-light px-1 pt-1 dark:border-border-dark"> <div class="mt-1 border-t border-border-light px-1 pt-1 dark:border-border-dark">
@ -625,7 +597,7 @@
class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}" class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface-light transition-transform duration-200 dark:bg-surface-dark {showSettings ? 'scale-100' : 'scale-95'}"
style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)" style="border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 25px 60px rgba(0,0,0,0.7), 0 10px 20px rgba(0,0,0,0.5)"
> >
<SettingsScreen onclose={closeSettings} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} /> <SettingsScreen onclose={closeSettings} workspaceName={settingsWorkspace ?? app.config?.current_workspace ?? ""} onrename={(newName) => settingsWorkspace = newName} ondelete={(name) => { closeSettings(); confirmRemoveWorkspace = name; }} />
</div> </div>
</div> </div>

View file

@ -109,6 +109,16 @@ async function switchWorkspace(name: string) {
} }
} }
async function renameWorkspace(oldName: string, newName: string) {
try {
await invoke("rename_workspace", { oldName, newName });
config = await invoke<AppConfig>("get_config");
error = null;
} catch (e) {
error = String(e);
}
}
async function removeWorkspace(name: string) { async function removeWorkspace(name: string) {
try { try {
await invoke("remove_workspace", { name }); await invoke("remove_workspace", { name });
@ -393,6 +403,7 @@ export const app = {
loadConfig, loadConfig,
addWorkspace, addWorkspace,
switchWorkspace, switchWorkspace,
renameWorkspace,
removeWorkspace, removeWorkspace,
loadLists, loadLists,
loadTasks, loadTasks,

View file

@ -60,6 +60,21 @@ impl AppConfig {
self.workspaces.remove(name) self.workspaces.remove(name)
} }
pub fn rename_workspace(&mut self, old_name: &str, new_name: String) -> Result<()> {
if !self.workspaces.contains_key(old_name) {
return Err(Error::InvalidData(format!("Workspace '{}' not found", old_name)));
}
if self.workspaces.contains_key(&new_name) {
return Err(Error::InvalidData(format!("Workspace '{}' already exists", new_name)));
}
let ws = self.workspaces.remove(old_name).unwrap();
if self.current_workspace.as_deref() == Some(old_name) {
self.current_workspace = Some(new_name.clone());
}
self.workspaces.insert(new_name, ws);
Ok(())
}
pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> { pub fn get_workspace(&self, name: &str) -> Option<&WorkspaceConfig> {
self.workspaces.get(name) self.workspaces.get(name)
} }