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:
parent
8df0edf163
commit
bb735ecd4a
|
|
@ -160,6 +160,26 @@ fn remove_workspace(
|
|||
.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 ───────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -648,6 +668,7 @@ pub fn run() {
|
|||
add_workspace,
|
||||
set_current_workspace,
|
||||
remove_workspace,
|
||||
rename_workspace,
|
||||
init_workspace,
|
||||
get_lists,
|
||||
create_list,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
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 isWebdav = $derived(ws?.mode === "webdav");
|
||||
|
|
@ -12,6 +12,10 @@
|
|||
let webdavPass = $state("");
|
||||
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
|
||||
|
||||
let renaming = $state(false);
|
||||
let renameValue = $state("");
|
||||
let showKebab = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!ws?.webdav_url) return;
|
||||
webdavUrl = ws.webdav_url;
|
||||
|
|
@ -54,12 +58,32 @@
|
|||
}
|
||||
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>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header
|
||||
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
|
||||
onclick={() => onclose?.()}
|
||||
class="rounded-lg p-1.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
|
@ -72,6 +96,56 @@
|
|||
</button>
|
||||
</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">
|
||||
<!-- WebDAV Sync (only for webdav workspaces) -->
|
||||
{#if isWebdav}
|
||||
|
|
@ -157,27 +231,22 @@
|
|||
|
||||
<!-- Theme -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide opacity-50">
|
||||
Appearance
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border-light p-4 dark:border-border-dark">
|
||||
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
|
||||
<select
|
||||
value={ws?.theme ?? ""}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
app.setTheme(val || null);
|
||||
}}
|
||||
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="">System default</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="mb-1 block text-xs font-medium opacity-60">Theme</label>
|
||||
<select
|
||||
value={ws?.theme ?? ""}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
app.setTheme(val || null);
|
||||
}}
|
||||
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="">System default</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="nord">Nord</option>
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="solarized">Solarized Dark</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<p class="mt-8 text-center text-xs opacity-30">Tauri v2 + Svelte</p>
|
||||
|
|
|
|||
|
|
@ -45,14 +45,11 @@
|
|||
showWorkspacePicker = false;
|
||||
if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
|
||||
showListMenu = false;
|
||||
const target = e.target as HTMLElement;
|
||||
if (wsMenuName && !target.closest("[data-ws-menu]")) wsMenuName = null;
|
||||
}
|
||||
|
||||
let newListName = $state("");
|
||||
let showCompleted = $state(false);
|
||||
let completedVisible = $state(false);
|
||||
let wsMenuName = $state<string | null>(null);
|
||||
let renamingListId = $state<string | null>(null);
|
||||
let renameValue = $state("");
|
||||
let showListMenu = $state(false);
|
||||
|
|
@ -137,7 +134,6 @@
|
|||
if (taskStack.length > 0) { closeDetail(); return; }
|
||||
if (showListMenu) { showListMenu = false; return; }
|
||||
if (showDrawer) { closeDrawer(); return; }
|
||||
if (wsMenuName) { wsMenuName = null; 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>
|
||||
</div>
|
||||
</button>
|
||||
<div class="relative shrink-0" data-ws-menu>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); wsMenuName = wsMenuName === name ? null : name; }}
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); settingsWorkspace = name; showSettings = true; showWorkspacePicker = false; }}
|
||||
class="shrink-0 rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<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'}"
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
try {
|
||||
await invoke("remove_workspace", { name });
|
||||
|
|
@ -393,6 +403,7 @@ export const app = {
|
|||
loadConfig,
|
||||
addWorkspace,
|
||||
switchWorkspace,
|
||||
renameWorkspace,
|
||||
removeWorkspace,
|
||||
loadLists,
|
||||
loadTasks,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,21 @@ impl AppConfig {
|
|||
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> {
|
||||
self.workspaces.get(name)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue