feat(tauri): move-to-list, rename, group toggle, keyboard shortcuts, WebDAV fix

- TaskDetailView: 'Move to...' submenu in kebab menu
- TasksScreen: list rename (inline input), group-by-due-date toggle,
  global Escape key handler for closing overlays
- SettingsScreen: auto-populate WebDAV URL/credentials on open
- SetupScreen: add window dragging, minimize/close buttons, 'Open
  Existing Folder' button
- Store: moveTask, renameList, setGroupByDueDate methods + fs-changed
  event listener for file watcher

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tristan Michael 2026-03-31 13:28:06 -07:00 committed by GitButler
parent 5faf285d28
commit 1a967c7fdd
5 changed files with 285 additions and 50 deletions

View file

@ -14,10 +14,13 @@
let title = $state(task.title);
let description = $state(task.description);
let showMenu = $state(false);
let showMoveSubmenu = $state(false);
let menuEl = $state<HTMLDivElement | null>(null);
let showDatePicker = $state(false);
let saveTimer: ReturnType<typeof setTimeout>;
let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId));
function handleHeaderMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button")) return;
@ -126,6 +129,34 @@
</svg>
{isCompleted ? "Restore task" : "Mark as completed"}
</button>
{#if otherLists.length > 0}
<div class="relative">
<button
onclick={() => (showMoveSubmenu = !showMoveSubmenu)}
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 d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
Move to...
<svg class="ml-auto h-3 w-3 opacity-40" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
</button>
{#if showMoveSubmenu}
<div class="absolute left-full top-0 z-50 ml-1 min-w-[160px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
{#each otherLists as list}
<button
onclick={async () => { showMenu = false; showMoveSubmenu = false; await app.moveTask(task.id, list.id); onback(); }}
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"
>
{list.title}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
onclick={handleDelete}
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"

View file

@ -9,6 +9,22 @@
let webdavPass = $state("");
let testStatus = $state<"idle" | "testing" | "ok" | "fail">("idle");
$effect(() => {
const ws = app.config?.current_workspace;
if (!ws) return;
const cfg = app.config?.workspaces[ws];
if (cfg?.webdav_url) {
webdavUrl = cfg.webdav_url;
try {
const domain = new URL(cfg.webdav_url).hostname;
invoke<[string, string]>("load_credentials", { domain }).then(([u, p]) => {
webdavUser = u;
webdavPass = p;
}).catch(() => {});
} catch {}
}
});
async function testConnection() {
testStatus = "testing";
try {

View file

@ -1,6 +1,13 @@
<script lang="ts">
import { open } from "@tauri-apps/plugin-dialog";
import { app } from "../stores/app.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
const appWindow = getCurrentWindow();
const currentPlatform = platform();
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
const isWindows = currentPlatform === "windows";
let name = $state("");
let path = $state("");
@ -14,15 +21,59 @@
if (!name.trim() || !path.trim()) return;
await app.addWorkspace(name.trim(), path.trim());
}
async function handleOpen() {
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
const folder = selected as string;
// Derive workspace name from folder name
const parts = folder.replace(/\\/g, "/").split("/");
const wsName = parts[parts.length - 1] || "workspace";
await app.addWorkspace(wsName, folder);
}
function handleDrag(e: MouseEvent) {
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("button, input")) return;
if (isDesktop) appWindow.startDragging();
}
</script>
<div class="flex h-full items-center justify-center p-6">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full flex-col" onmousedown={handleDrag}>
<!-- Title bar area with window controls -->
<header class="flex h-11 shrink-0 items-center justify-end px-2">
{#if isDesktop}
<div class="flex items-center gap-0.5">
{#if isWindows}
<button
onclick={() => appWindow.minimize()}
class="rounded p-1.5 opacity-50 hover:bg-black/10 hover:opacity-80 dark:hover:bg-white/10"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 10a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
</svg>
</button>
{/if}
<button
onclick={() => appWindow.close()}
class="rounded p-1.5 opacity-50 hover:bg-danger/20 hover:opacity-100 hover:text-danger dark:hover:bg-danger/20"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{/if}
</header>
<div class="flex flex-1 items-center justify-center p-6">
<div
class="w-full max-w-sm rounded-2xl bg-card-light p-8 shadow-lg dark:bg-card-dark"
>
<h1 class="mb-1 text-2xl font-bold">Onyx</h1>
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
Create or open a workspace to get started.
Create a new workspace or open an existing one.
</p>
<label class="mb-1 block text-sm font-medium">
@ -60,5 +111,19 @@
>
Create Workspace
</button>
<div class="my-4 flex items-center gap-3">
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
<span class="text-xs opacity-40">or</span>
<div class="h-px flex-1 bg-border-light dark:bg-border-dark"></div>
</div>
<button
onclick={handleOpen}
class="w-full rounded-lg border border-border-light py-2.5 text-sm font-medium hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
>
Open Existing Folder
</button>
</div>
</div>
</div>

View file

@ -44,6 +44,8 @@
let completedVisible = $state(false);
let listMenuId = $state<string | null>(null);
let wsMenuName = $state<string | null>(null);
let renamingListId = $state<string | null>(null);
let renameValue = $state("");
let dragId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let resizing = $state(false);
@ -77,6 +79,40 @@
await app.deleteList(id);
}
function startRenameList(id: string) {
listMenuId = null;
const list = app.lists.find(l => l.id === id);
if (!list) return;
renamingListId = id;
renameValue = list.title;
}
async function handleRenameList() {
if (!renamingListId || !renameValue.trim()) { renamingListId = null; return; }
const list = app.lists.find(l => l.id === renamingListId);
if (renameValue.trim() !== list?.title) {
await app.renameList(renamingListId, renameValue.trim());
}
renamingListId = null;
}
async function handleToggleGroupByDueDate(id: string) {
listMenuId = null;
const list = app.lists.find(l => l.id === id);
if (!list) return;
await app.setGroupByDueDate(id, !list.group_by_due_date);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (showSettings) { showSettings = false; return; }
if (selectedTaskId) { selectedTaskId = null; return; }
if (showDrawer) { closeDrawer(); return; }
if (listMenuId) { listMenuId = null; return; }
if (wsMenuName) { wsMenuName = null; return; }
if (showWorkspacePicker) { showWorkspacePicker = false; return; }
}
function handleDragStart(e: DragEvent, taskId: string) {
dragId = taskId;
if (e.dataTransfer) {
@ -148,6 +184,8 @@
let translateX = $derived(showDrawer ? '0' : '-80cqi');
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Viewport clip -->
<div class="h-full w-full overflow-hidden">
<!-- Sliding container: left drawer + main content -->
@ -238,6 +276,18 @@
<div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)}
<div class="group relative flex items-center px-2 hover:bg-black/5 dark:hover:bg-white/10">
{#if renamingListId === list.id}
<div class="flex flex-1 items-center px-3 py-1">
<input
type="text"
bind:value={renameValue}
class="w-full rounded border border-primary bg-transparent px-2 py-1.5 text-sm outline-none"
onkeydown={(e) => { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }}
onblur={handleRenameList}
autofocus
/>
</div>
{:else}
<button
onclick={() => { app.selectList(list.id); closeDrawer(); }}
class="flex flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm {list.id === app.activeListId ? 'font-bold' : ''}"
@ -249,6 +299,7 @@
{/if}
<span>{list.title}</span>
</button>
{/if}
<div class="relative shrink-0" data-list-menu>
<button
onclick={() => (listMenuId = listMenuId === list.id ? null : list.id)}
@ -259,7 +310,30 @@
</svg>
</button>
{#if listMenuId === list.id}
<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">
<div class="absolute right-0 top-full z-40 mt-1 min-w-[180px] rounded-lg border border-border-light bg-surface-light py-1 shadow-lg dark:border-border-dark dark:bg-surface-dark">
<button
onclick={() => startRenameList(list.id)}
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 d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Rename
</button>
<button
onclick={() => handleToggleGroupByDueDate(list.id)}
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="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
</svg>
Group by due date
{#if list.group_by_due_date}
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
</svg>
{/if}
</button>
<button
onclick={() => handleDeleteList(list.id)}
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"

View file

@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type {
AppConfig,
Task,
@ -7,6 +8,11 @@ import type {
SyncResult,
} from "../types";
// Listen for file system changes from the backend watcher
listen("fs-changed", () => {
loadLists();
});
// ── Reactive state ───────────────────────────────────────────────────
let screen = $state<Screen>("setup");
@ -54,6 +60,7 @@ async function addWorkspace(name: string, path: string) {
await invoke("add_workspace", { name, path });
config = await invoke<AppConfig>("get_config");
await loadLists();
invoke("watch_workspace", { path }).catch(() => {});
screen = "tasks";
error = null;
} catch (e) {
@ -67,6 +74,8 @@ async function switchWorkspace(name: string) {
config = await invoke<AppConfig>("get_config");
activeListId = null;
await loadLists();
const ws = config?.workspaces[name];
if (ws) invoke("watch_workspace", { path: ws.path }).catch(() => {});
error = null;
} catch (e) {
error = String(e);
@ -206,6 +215,43 @@ async function deleteTask(taskId: string) {
}
}
async function moveTask(taskId: string, targetListId: string) {
if (!activeListId) return;
try {
await invoke("move_task", {
fromListId: activeListId,
toListId: targetListId,
taskId,
});
tasks = tasks.filter((t) => t.id !== taskId);
} catch (e) {
error = String(e);
}
}
async function renameList(listId: string, newName: string) {
try {
await invoke("rename_list", { listId, newName });
lists = lists.map((l) =>
l.id === listId ? { ...l, title: newName } : l,
);
} catch (e) {
error = String(e);
}
}
async function setGroupByDueDate(listId: string, enabled: boolean) {
try {
await invoke("set_group_by_due_date", { listId, enabled });
lists = lists.map((l) =>
l.id === listId ? { ...l, group_by_due_date: enabled } : l,
);
if (listId === activeListId) await loadTasks();
} catch (e) {
error = String(e);
}
}
async function triggerSync() {
if (!config?.current_workspace) return;
const ws = config.workspaces[config.current_workspace];
@ -300,6 +346,9 @@ export const app = {
updateTask,
reorderTask,
deleteTask,
moveTask,
renameList,
setGroupByDueDate,
triggerSync,
toggleDarkMode,
setScreen,