diff --git a/CLAUDE.md b/CLAUDE.md
index 3a7d516..ae1093f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -48,12 +48,14 @@ Workspaces are plain folders. Each task list is a subfolder containing `.listdat
The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). Key UI patterns:
-- **Sliding drawer**: Left panel (lists) slides with main content as one piece via `translateX`. 80vw wide.
+- **Sliding drawer**: Left panel (lists) slides with main content as one piece via `translateX`. 80vw wide. List items show checkmark for active list and chevron on hover.
+- **Three-panel slide**: Main content area is 300% wide with three panels (task list, task detail, subtask detail) that slide via `translateX` using a `taskStack` array. Stack depth 0 = list, 1 = task detail, 2 = subtask detail.
- **Settings popup**: Floating overlay card with backdrop, not a sliding panel.
- **Workspace switcher**: Custom drop-up menu in drawer footer (left), settings gear (right).
- **Task animations**: Grid-rows `0fr`/`1fr` trick for smooth collapse/expand. Module-level `animateInIds` Set coordinates expand-in after toggle.
-- **Inline editing**: Click task to edit, auto-save on blur, shared `editingTaskId` across instances.
-- **Kebab menus**: Tasks, lists, and workspaces all use kebab → submenu pattern for delete.
+- **Inline editing**: Click task to edit, auto-save on blur. `debouncedSave` snapshots task before timer to prevent stale-reference errors on component destroy.
+- **Kebab menus**: Tasks and lists use kebab menus with custom `ConfirmDialog` component (not native `confirm()`). "Move to..." is inline in the menu (not a submenu) to avoid overflow.
+- **Main panel header**: Hamburger + window controls in top bar; list name (large, bold) + kebab below divider (matching task detail layout). Kebab has Rename, Group by due date, Delete completed, Delete list.
- **New task**: FAB button opens bottom toast sheet (outside sliding container for fixed positioning).
### Current state (2026-04-01)
@@ -75,10 +77,11 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- Dark mode (GNOME-style neutral grays, cyan-blue accent)
- Completed tasks section with animated show/hide
- Due date picker/editor (DateTimePicker in new task + task detail); `has_time: bool` field tracks whether time is set
-- Move task between lists (kebab menu → "Move to..." submenu)
-- List rename (inline input via list kebab menu)
-- Group-by-due-date toggle per list (list kebab menu)
-- Keyboard shortcuts (Escape priority chain: settings → detail → drawer → menus)
+- Move task between lists (inline list in kebab menu, no submenu)
+- List rename (inline input in main panel header via kebab)
+- Group-by-due-date toggle per list (main panel kebab)
+- Delete completed tasks (main panel kebab + subtask kebab, with confirmation dialogs)
+- Keyboard shortcuts (Escape priority chain: settings → detail → list menu → drawer → menus)
- WebDAV setup flow (settings auto-populates URL/credentials from config + keychain)
- File watcher (notify crate, 500ms debounce, auto-reloads on external changes)
- Setup screen with window dragging + "Open Existing Folder" option
@@ -87,7 +90,8 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`).
- Desktop packaging (Linux: AppImage + .deb)
- Flutter GUI at full parity with Tauri (WebDAV UI, has_time, sync status, sync mode)
- Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation
-- Subtask hierarchy (expand/collapse in task list, inline add in detail view, cascade toggle/delete)
+- Subtask hierarchy: subtask count shown on parent tasks in list, subtask detail via three-panel slide navigation, inline add at top of subtask list (new subtasks prepend), collapsible completed subtasks section, cascade delete (parent deletion removes all subtasks with confirmation warning)
+- Custom confirmation dialogs (ConfirmDialog component replaces native confirm())
### GUI features NOT yet done
diff --git a/apps/tauri/src/lib/components/ConfirmDialog.svelte b/apps/tauri/src/lib/components/ConfirmDialog.svelte
new file mode 100644
index 0000000..a699791
--- /dev/null
+++ b/apps/tauri/src/lib/components/ConfirmDialog.svelte
@@ -0,0 +1,37 @@
+
+
+
+
{ if (e.key === "Escape") { e.stopPropagation(); oncancel(); } }}
+>
+
+
+
e.stopPropagation()}
+ >
+
{message}
+ {#if detail}
+
{detail}
+ {/if}
+
+
+ Cancel
+
+
+ {confirmText}
+
+
+
+
diff --git a/apps/tauri/src/lib/components/TaskDetailView.svelte b/apps/tauri/src/lib/components/TaskDetailView.svelte
index d844c39..824b9ab 100644
--- a/apps/tauri/src/lib/components/TaskDetailView.svelte
+++ b/apps/tauri/src/lib/components/TaskDetailView.svelte
@@ -2,6 +2,7 @@
import type { Task } from "../types";
import { app } from "../stores/app.svelte";
import DateTimePicker from "./DateTimePicker.svelte";
+ import ConfirmDialog from "./ConfirmDialog.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
@@ -9,15 +10,19 @@
const currentPlatform = platform();
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
- let { task, onback }: { task: Task; onback: () => void } = $props();
+ let { task, onback, onopen }: { task: Task; onback: () => void; onopen?: (task: Task) => void } = $props();
let title = $state(task.title);
let description = $state(task.description);
let showMenu = $state(false);
- let showMoveSubmenu = $state(false);
let menuEl = $state(null);
let showDatePicker = $state(false);
let saveTimer: ReturnType;
+ let confirmDelete = $state(false);
+
+ $effect(() => {
+ return () => clearTimeout(saveTimer);
+ });
let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId));
@@ -29,8 +34,9 @@
function debouncedSave(fields: Partial) {
clearTimeout(saveTimer);
+ var snapshot = { ...task };
saveTimer = setTimeout(() => {
- app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() });
+ app.updateTask({ ...snapshot, ...fields, updated_at: new Date().toISOString() });
}, 400);
}
@@ -51,17 +57,22 @@
onback();
}
- async function handleDelete() {
+ function promptDelete() {
showMenu = false;
- if (!confirm(`Delete task "${task.title}"?`)) return;
+ confirmDelete = true;
+ }
+
+ async function executeDelete() {
+ confirmDelete = false;
+ // Cascade: delete subtasks first
+ for (const s of subtasks) await app.deleteTask(s.id);
await app.deleteTask(task.id);
onback();
}
function handleMenuClickOutside(e: MouseEvent) {
- if (showMenu && menuEl && !menuEl.contains(e.target as Node)) {
+ if (showMenu && menuEl && !menuEl.contains(e.target as Node))
showMenu = false;
- }
}
$effect(() => {
@@ -72,17 +83,42 @@
});
let isCompleted = $derived(task.status === "completed");
+ let isSubtask = $derived(!!task.parent_id);
let subtasks = $derived(app.getSubtasks(task.id));
+ let pendingSubtasks = $derived(subtasks.filter(s => s.status !== "completed"));
+ let completedSubtasks = $derived(subtasks.filter(s => s.status === "completed"));
let addingSubtask = $state(false);
let subtaskTitle = $state("");
+ let showSubtaskMenu = $state(false);
+ let subtaskMenuEl = $state(null);
+ let showCompletedSubtasks = $state(false);
+ let completedSubtasksVisible = $state(false);
+ let confirmDeleteCompleted = $state(false);
async function handleAddSubtask() {
if (!subtaskTitle.trim()) return;
await app.createTask(subtaskTitle.trim(), undefined, task.id);
subtaskTitle = "";
- addingSubtask = false;
}
+ async function executeDeleteCompletedSubtasks() {
+ confirmDeleteCompleted = false;
+ showSubtaskMenu = false;
+ for (const s of completedSubtasks) await app.deleteTask(s.id);
+ }
+
+ function handleSubtaskMenuClickOutside(e: MouseEvent) {
+ if (showSubtaskMenu && subtaskMenuEl && !subtaskMenuEl.contains(e.target as Node))
+ showSubtaskMenu = false;
+ }
+
+ $effect(() => {
+ if (showSubtaskMenu) {
+ window.addEventListener("mousedown", handleSubtaskMenuClickOutside);
+ return () => window.removeEventListener("mousedown", handleSubtaskMenuClickOutside);
+ }
+ });
+
function formatDateChip(iso: string): string {
const d = new Date(iso);
const today = new Date();
@@ -139,36 +175,8 @@
{isCompleted ? "Restore task" : "Mark as completed"}
- {#if otherLists.length > 0}
-
-
(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"
- >
-
-
-
- Move to...
-
-
-
-
- {#if showMoveSubmenu}
-
- {#each otherLists as list}
- { 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}
-
- {/each}
-
- {/if}
-
- {/if}
@@ -176,6 +184,21 @@
Delete
+ {#if otherLists.length > 0}
+
+ Move to...
+ {#each otherLists as list}
+ { showMenu = 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}
+
+ {/each}
+ {/if}
{/if}
@@ -236,58 +259,131 @@
{/if}
-
-
-
-
-
-
-
Subtasks{subtasks.length > 0 ? ` (${subtasks.filter(s => s.status === "completed").length}/${subtasks.length})` : ""}
-
- {#each subtasks as subtask (subtask.id)}
-
onback()}
- class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
- >
-
-
- { e.stopPropagation(); app.toggleTask(subtask.id); }}
- class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 {subtask.status === 'completed' ? 'border-primary bg-primary' : 'border-gray-400 dark:border-gray-500'}"
- >
- {#if subtask.status === "completed"}
-
-
-
- {/if}
-
- {subtask.title}
-
- {/each}
- {#if addingSubtask}
-
-
-
{ if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { addingSubtask = false; subtaskTitle = ""; } }}
- autofocus
- />
-
- {:else}
-
(addingSubtask = true)}
- class="flex w-full items-center gap-2 px-2 py-2 text-sm text-primary opacity-60 hover:opacity-100"
- >
-
-
+
+ {#if !isSubtask}
+
+
+
+
- Add subtask
-
- {/if}
-
+
Subtasks{subtasks.length > 0 ? ` (${completedSubtasks.length}/${subtasks.length})` : ""}
+
+ {#if completedSubtasks.length > 0}
+
+
(showSubtaskMenu = !showSubtaskMenu)}
+ class="rounded p-1 opacity-40 hover:opacity-70"
+ >
+
+
+
+
+ {#if showSubtaskMenu}
+
+
{ showSubtaskMenu = false; confirmDeleteCompleted = true; }}
+ 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"
+ >
+
+
+
+ Delete completed subtasks
+
+
+ {/if}
+
+ {/if}
+
+
+
+ {#if addingSubtask}
+
+
+
{ if (e.key === "Enter") handleAddSubtask(); if (e.key === "Escape") { e.stopPropagation(); addingSubtask = false; subtaskTitle = ""; } }}
+ onblur={async () => { if (subtaskTitle.trim()) { await handleAddSubtask(); addingSubtask = false; } else { addingSubtask = false; subtaskTitle = ""; } }}
+ autofocus
+ />
+
+ {:else}
+ (addingSubtask = true)}
+ class="flex w-full items-center gap-2 px-2 py-2 text-sm text-primary opacity-60 hover:opacity-100"
+ >
+
+
+
+ Add subtask
+
+ {/if}
+
+
+ {#each pendingSubtasks as subtask (subtask.id)}
+ onopen?.(subtask)}
+ class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
+ >
+
+ { e.stopPropagation(); app.toggleTask(subtask.id); }}
+ class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-gray-400 dark:border-gray-500"
+ >
+
+ {subtask.title}
+
+ {/each}
+
+
+ {#if completedSubtasks.length > 0}
+ {
+ if (showCompletedSubtasks) {
+ showCompletedSubtasks = false;
+ setTimeout(() => (completedSubtasksVisible = false), 200);
+ } else {
+ completedSubtasksVisible = true;
+ requestAnimationFrame(() => (showCompletedSubtasks = true));
+ }
+ }}
+ class="mt-2 flex w-full items-center gap-2 rounded-lg px-2 py-2 text-sm opacity-50 hover:bg-black/5 dark:hover:bg-white/10"
+ >
+
+
+
+ Completed ({completedSubtasks.length})
+
+ {#if completedSubtasksVisible}
+
+ {#each completedSubtasks as subtask (subtask.id)}
+
onopen?.(subtask)}
+ class="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-black/5 dark:hover:bg-white/10"
+ >
+
+ { e.stopPropagation(); app.toggleTask(subtask.id); }}
+ class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary"
+ >
+
+
+
+
+ {subtask.title}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+ {/if}
@@ -299,3 +395,26 @@
onclose={() => (showDatePicker = false)}
/>
{/if}
+
+
+{#if confirmDelete}
+ 0 ? `This will also delete ${subtasks.length} subtask${subtasks.length === 1 ? '' : 's'}.` : undefined}
+ confirmText="Delete"
+ danger
+ onconfirm={executeDelete}
+ oncancel={() => (confirmDelete = false)}
+ />
+{/if}
+
+
+{#if confirmDeleteCompleted}
+ (confirmDeleteCompleted = false)}
+ />
+{/if}
diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte
index fc97d8f..3e09e5d 100644
--- a/apps/tauri/src/lib/screens/TasksScreen.svelte
+++ b/apps/tauri/src/lib/screens/TasksScreen.svelte
@@ -3,6 +3,7 @@
import TaskItem from "../components/TaskItem.svelte";
import TaskDetailView from "../components/TaskDetailView.svelte";
import NewTaskInput, { newTaskState } from "../components/NewTaskInput.svelte";
+ import ConfirmDialog from "../components/ConfirmDialog.svelte";
import SettingsScreen from "./SettingsScreen.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
@@ -13,15 +14,23 @@
const isDesktop = currentPlatform === "linux" || currentPlatform === "windows";
const isWindows = currentPlatform === "windows";
- let selectedTaskId = $state(null);
- let selectedTask = $derived(selectedTaskId ? app.tasks.find(t => t.id === selectedTaskId) ?? null : null);
+ let taskStack = $state([]);
+ let parentTask = $derived(taskStack.length >= 1 ? app.tasks.find(t => t.id === taskStack[0]) ?? null : null);
+ let subtaskDetail = $derived(taskStack.length >= 2 ? app.tasks.find(t => t.id === taskStack[1]) ?? null : null);
function openTask(task: Task) {
- selectedTaskId = task.id;
+ taskStack = [task.id];
+ }
+
+ function pushTask(task: Task) {
+ taskStack = [...taskStack, task.id];
}
function closeDetail() {
- selectedTaskId = null;
+ if (taskStack.length > 1)
+ taskStack = taskStack.slice(0, -1);
+ else
+ taskStack = [];
}
let showDrawer = $state(false);
@@ -31,22 +40,24 @@
let workspacePickerEl = $state(null);
function handleWindowClick(e: MouseEvent) {
- if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node)) {
+ if (showWorkspacePicker && workspacePickerEl && !workspacePickerEl.contains(e.target as Node))
showWorkspacePicker = false;
- }
- const target = e.target as HTMLElement;
- if (listMenuId && !target.closest("[data-list-menu]")) listMenuId = null;
+ if (showListMenu && listMenuEl && !listMenuEl.contains(e.target as Node))
+ showListMenu = false;
+ var 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 listMenuId = $state(null);
let wsMenuName = $state(null);
let renamingListId = $state(null);
let renameValue = $state("");
- let expandedTasks = $state(new Set());
+ let showListMenu = $state(false);
+ let listMenuEl = $state(null);
+ let confirmDeleteList = $state(false);
+ let confirmDeleteCompleted = $state(false);
let dragId = $state(null);
let dragOverId = $state(null);
let resizing = $state(false);
@@ -73,43 +84,57 @@
showNewList = false;
}
- async function handleDeleteList(id: string) {
- listMenuId = null;
- const list = app.lists.find(l => l.id === id);
- if (!confirm(`Delete list "${list?.title ?? id}" and all its tasks?`)) return;
- await app.deleteList(id);
+ function promptDeleteCompleted() {
+ showListMenu = false;
+ confirmDeleteCompleted = true;
}
- function startRenameList(id: string) {
- listMenuId = null;
- const list = app.lists.find(l => l.id === id);
+ async function executeDeleteCompleted() {
+ confirmDeleteCompleted = false;
+ for (var t of app.completedTasks) await app.deleteTask(t.id);
+ }
+
+ function promptDeleteList() {
+ showListMenu = false;
+ confirmDeleteList = true;
+ }
+
+ async function executeDeleteList() {
+ confirmDeleteList = false;
+ if (app.activeListId) await app.deleteList(app.activeListId);
+ }
+
+ function startRenameList() {
+ showListMenu = false;
+ if (!app.activeListId) return;
+ var list = app.lists.find(l => l.id === app.activeListId);
if (!list) return;
- renamingListId = id;
+ renamingListId = app.activeListId;
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) {
+ var 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);
+ async function handleToggleGroupByDueDate() {
+ showListMenu = false;
+ if (!app.activeListId) return;
+ var list = app.lists.find(l => l.id === app.activeListId);
if (!list) return;
- await app.setGroupByDueDate(id, !list.group_by_due_date);
+ await app.setGroupByDueDate(app.activeListId, !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 (taskStack.length > 0) { closeDetail(); return; }
+ if (showListMenu) { showListMenu = false; return; }
if (showDrawer) { closeDrawer(); return; }
- if (listMenuId) { listMenuId = null; return; }
if (wsMenuName) { wsMenuName = null; return; }
if (showWorkspacePicker) { showWorkspacePicker = false; return; }
}
@@ -164,7 +189,6 @@
function closeDrawer() {
showDrawer = false;
showNewList = false;
- listMenuId = null;
}
function openSettings() {
@@ -276,78 +300,20 @@
{#each app.lists as list (list.id)}
-
- {#if renamingListId === list.id}
-
- { if (e.key === "Enter") handleRenameList(); if (e.key === "Escape") renamingListId = null; }}
- onblur={handleRenameList}
- autofocus
- />
-
- {:else}
-
{ 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' : ''}"
- >
- {#if list.id === app.activeListId}
-
-
-
- {/if}
- {list.title}
-
+
{ app.selectList(list.id); closeDrawer(); }}
+ class="group flex w-full items-center gap-2 px-5 py-2.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10 {list.id === app.activeListId ? 'font-bold' : ''}"
+ >
+ {#if list.id === app.activeListId}
+
+
+
{/if}
-
-
(listMenuId = listMenuId === list.id ? null : list.id)}
- class="rounded p-1 opacity-0 transition-opacity group-hover:opacity-40 hover:!opacity-80 {listMenuId === list.id ? '!opacity-80' : ''}"
- >
-
-
-
-
- {#if listMenuId === list.id}
-
-
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"
- >
-
-
-
- Rename
-
-
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"
- >
-
-
-
- Group by due date
- {#if list.group_by_due_date}
-
-
-
- {/if}
-
-
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"
- >
-
-
-
- Delete
-
-
- {/if}
-
-
+
{list.title}
+
+
+
+
{/each}
@@ -410,10 +376,10 @@