Merge pull request #50 from SteelDynamite/claude/run-app-screenshot-Z02aY

This commit is contained in:
SteelDynamite 2026-04-17 15:39:16 +01:00 committed by GitHub
commit 0c2a218260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1631 additions and 78 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,23 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"jsdom": "^29.0.2",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.4"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",

View file

@ -1,6 +1,43 @@
<script lang="ts" module>
// Shared counter so sibling Escape handlers (e.g. TasksScreen's svelte:window
// listener) can tell when a ConfirmDialog is open and defer to it instead of
// popping the task-detail view behind the dialog.
let openCount = $state(0);
export function isConfirmDialogOpen(): boolean {
return openCount > 0;
}
</script>
<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
let { message, detail, confirmText = "Confirm", danger = false, onconfirm, oncancel }:
{ message: string; detail?: string; confirmText?: string; danger?: boolean; onconfirm: () => void; oncancel: () => void } = $props();
let cancelBtn: HTMLButtonElement | undefined = $state();
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
oncancel();
}
onMount(() => {
openCount += 1;
// Focus Cancel so Escape/Enter go through the dialog's own keydown handler
// (which cancels) instead of leaking to the global svelte:window listener
// in TasksScreen (which would pop the task detail view).
tick().then(() => cancelBtn?.focus());
// Belt-and-suspenders: capture-phase listener dismisses even if focus
// didn't land on Cancel (e.g. under test harnesses or headless compositors).
window.addEventListener("keydown", handleGlobalKeydown, true);
});
onDestroy(() => {
openCount -= 1;
window.removeEventListener("keydown", handleGlobalKeydown, true);
});
</script>
<div
@ -23,6 +60,7 @@
{/if}
<div class="mt-4 flex justify-end gap-2">
<button
bind:this={cancelBtn}
onclick={oncancel}
class="rounded-lg px-4 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/10"
>

View file

@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import ConfirmDialog, { isConfirmDialogOpen } from "./ConfirmDialog.svelte";
beforeEach(() => {
cleanup();
});
describe("ConfirmDialog", () => {
it("renders the message, detail and custom confirm label", () => {
render(ConfirmDialog, {
message: "Delete task?",
detail: "This cannot be undone.",
confirmText: "Delete",
onconfirm: vi.fn(),
oncancel: vi.fn(),
});
expect(screen.getByText("Delete task?")).toBeInTheDocument();
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("fires oncancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(oncancel).toHaveBeenCalledTimes(1);
});
it("fires onconfirm when Confirm is clicked and not oncancel", async () => {
const user = userEvent.setup();
const onconfirm = vi.fn();
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
confirmText: "Delete",
onconfirm,
oncancel,
});
await user.click(screen.getByRole("button", { name: "Delete" }));
expect(onconfirm).toHaveBeenCalledTimes(1);
expect(oncancel).not.toHaveBeenCalled();
});
it("cancels and stops propagation on Escape (regression: used to bubble and pop task detail)", async () => {
const oncancel = vi.fn();
// An outer bubble-phase listener emulates TasksScreen's svelte:window
// Escape handler. If the dialog leaks Escape, this spy fires too.
const outer = vi.fn();
window.addEventListener("keydown", outer);
try {
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
expect(oncancel).toHaveBeenCalledTimes(1);
expect(outer).not.toHaveBeenCalled();
} finally {
window.removeEventListener("keydown", outer);
}
});
it("ignores non-Escape keydowns", async () => {
const oncancel = vi.fn();
render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel,
});
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
expect(oncancel).not.toHaveBeenCalled();
});
it("increments the open-count singleton so parent Escape handlers can defer", () => {
expect(isConfirmDialogOpen()).toBe(false);
const { unmount } = render(ConfirmDialog, {
message: "Delete?",
onconfirm: vi.fn(),
oncancel: vi.fn(),
});
expect(isConfirmDialogOpen()).toBe(true);
unmount();
expect(isConfirmDialogOpen()).toBe(false);
});
it("tracks multiple concurrently-mounted dialogs and releases on unmount", () => {
const a = render(ConfirmDialog, { message: "A?", onconfirm: vi.fn(), oncancel: vi.fn() });
const b = render(ConfirmDialog, { message: "B?", onconfirm: vi.fn(), oncancel: vi.fn() });
expect(isConfirmDialogOpen()).toBe(true);
a.unmount();
expect(isConfirmDialogOpen()).toBe(true);
b.unmount();
expect(isConfirmDialogOpen()).toBe(false);
});
});

View file

@ -25,6 +25,20 @@
return () => clearTimeout(saveTimer);
});
// Re-sync local editor state when the task prop's content changes from elsewhere
// (sync pull, external file edit). Skip the reset while the user is actively
// editing an input so we don't clobber in-progress typing.
$effect(() => {
const incomingTitle = task.title;
const incomingDesc = task.description;
const active = document.activeElement;
const editing = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
if (!editing) {
if (incomingTitle !== title) title = incomingTitle;
if (incomingDesc !== description) description = incomingDesc;
}
});
let otherLists = $derived(app.lists.filter((l) => l.id !== app.activeListId));
function handleHeaderMouseDown(e: MouseEvent) {
@ -64,10 +78,12 @@
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();
// Cascade: delete subtasks first. Bail out on first failure so we don't
// remove the parent while orphaning subtasks; the error is already surfaced.
for (const s of subtasks) {
if (!(await app.deleteTask(s.id))) return;
}
if (await app.deleteTask(task.id)) onback();
}
function handleMenuClickOutside(e: MouseEvent) {

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { groupTasksByDate } from "./grouping";
import type { Task } from "./types";
// 2026-04-17 12:00 local time — "today" in the fixtures below.
const NOW = new Date(2026, 3, 17, 12, 0, 0);
function task(partial: Partial<Task> & { id: string }): Task {
return {
id: partial.id,
title: partial.title ?? partial.id,
description: "",
status: "backlog",
date: partial.date ?? null,
has_time: partial.has_time ?? false,
version: 1,
parent_id: null,
...partial,
};
}
describe("groupTasksByDate", () => {
it("returns an empty array when there are no pending tasks", () => {
expect(groupTasksByDate([], NOW)).toEqual([]);
});
it("puts 'No Date' last — regression: was first, burying urgent tasks", () => {
const tasks = [
task({ id: "overdue", date: "2026-04-15T00:00:00Z" }),
task({ id: "no-date" }),
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
];
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
expect(labels).toEqual(["Overdue", "Today", "No Date"]);
});
it("orders dated buckets: Overdue, Today, Tomorrow, future…, then No Date", () => {
const tasks = [
task({ id: "nd1" }),
task({ id: "future", date: "2026-04-20T00:00:00Z" }),
task({ id: "tomorrow", date: "2026-04-18T00:00:00Z" }),
task({ id: "today", date: "2026-04-17T09:00:00Z" }),
task({ id: "overdue", date: "2026-04-10T00:00:00Z" }),
];
const labels = groupTasksByDate(tasks, NOW).map((g) => g.label);
expect(labels[0]).toBe("Overdue");
expect(labels[1]).toBe("Today");
expect(labels[2]).toBe("Tomorrow");
// One future day label between tomorrow and No Date
expect(labels[labels.length - 1]).toBe("No Date");
expect(labels).toHaveLength(5);
});
it("drops empty buckets", () => {
const tasks = [task({ id: "t1", date: "2026-04-17T08:00:00Z" })];
expect(groupTasksByDate(tasks, NOW).map((g) => g.label)).toEqual(["Today"]);
});
it("sorts tasks within a bucket by due time ascending, stable on ties", () => {
const tasks = [
task({ id: "b", date: "2026-04-17T15:00:00Z", has_time: true }),
task({ id: "a", date: "2026-04-17T09:00:00Z", has_time: true }),
task({ id: "c", date: "2026-04-17T15:00:00Z", has_time: true }),
];
const today = groupTasksByDate(tasks, NOW).find((g) => g.label === "Today")!;
expect(today.tasks.map((t) => t.id)).toEqual(["a", "b", "c"]);
});
it("places a task with today's date but time before 'now' in the Today bucket (not Overdue)", () => {
const tasks = [task({ id: "earlier-today", date: "2026-04-17T08:00:00Z" })];
const groups = groupTasksByDate(tasks, NOW);
expect(groups.map((g) => g.label)).toEqual(["Today"]);
});
it("preserves No Date order as given by the caller", () => {
const tasks = [
task({ id: "z" }),
task({ id: "a" }),
task({ id: "m" }),
];
const nd = groupTasksByDate(tasks, NOW).find((g) => g.label === "No Date")!;
expect(nd.tasks.map((t) => t.id)).toEqual(["z", "a", "m"]);
});
it("groups multiple tasks on the same future day under one label", () => {
const tasks = [
task({ id: "f1", date: "2026-04-25T09:00:00Z", has_time: true }),
task({ id: "f2", date: "2026-04-25T14:00:00Z", has_time: true }),
];
const groups = groupTasksByDate(tasks, NOW);
const future = groups.find((g) => g.date?.getDate() === 25);
expect(future).toBeDefined();
expect(future!.tasks.map((t) => t.id)).toEqual(["f1", "f2"]);
// And it comes before No Date (which is absent here).
expect(groups).toHaveLength(1);
});
});

View file

@ -0,0 +1,70 @@
import type { Task } from "./types";
export type TaskGroup = { label: string; tasks: Task[]; date: Date | null };
/**
* Group pending tasks into date buckets for the "group by date" view.
*
* Order:
* Overdue Today Tomorrow future days (chronological) No Date
*
* Within each dated bucket tasks sort by due date+time ascending, with the
* original `pendingTasks` index as a stable tiebreaker. "No Date" preserves
* the caller-supplied order.
*/
export function groupTasksByDate(pendingTasks: Task[], now: Date = new Date()): TaskGroup[] {
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(todayStart.getDate() + 1);
const overdue: Task[] = [];
const today: Task[] = [];
const tomorrow: Task[] = [];
const futureByDay = new Map<string, { date: Date; tasks: Task[] }>();
const noDate: Task[] = [];
for (const task of pendingTasks) {
if (!task.date) {
noDate.push(task);
} else {
const d = new Date(task.date);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (dayStart < todayStart) overdue.push(task);
else if (dayStart.getTime() === todayStart.getTime()) today.push(task);
else if (dayStart.getTime() === tomorrowStart.getTime()) tomorrow.push(task);
else {
const key = dayStart.toISOString();
if (!futureByDay.has(key)) futureByDay.set(key, { date: dayStart, tasks: [] });
futureByDay.get(key)!.tasks.push(task);
}
}
}
const taskOrderIndex = new Map(pendingTasks.map((t, i) => [t.id, i]));
const sortByDue = (a: Task, b: Task) => {
const dateDiff = new Date(a.date!).getTime() - new Date(b.date!).getTime();
if (dateDiff !== 0) return dateDiff;
return (taskOrderIndex.get(a.id) ?? 0) - (taskOrderIndex.get(b.id) ?? 0);
};
overdue.sort(sortByDue);
today.sort(sortByDue);
tomorrow.sort(sortByDue);
const groups: TaskGroup[] = [];
if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
const currentYear = now.getFullYear();
for (const [, { date, tasks }] of [...futureByDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tasks.sort(sortByDue);
const opts: Intl.DateTimeFormatOptions = date.getFullYear() !== currentYear
? { weekday: "short", month: "short", day: "numeric", year: "numeric" }
: { weekday: "short", month: "short", day: "numeric" };
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
}
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
return groups;
}

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { workspaceNameFromPath } from "./paths";
describe("workspaceNameFromPath", () => {
it("returns the last path component of a POSIX path", () => {
expect(workspaceNameFromPath("/home/me/Tasks")).toBe("Tasks");
});
it("strips a trailing slash (regression: used to fall back to 'workspace')", () => {
expect(workspaceNameFromPath("/home/me/Tasks/")).toBe("Tasks");
});
it("strips multiple trailing slashes", () => {
expect(workspaceNameFromPath("/home/me/Tasks///")).toBe("Tasks");
});
it("handles Windows-style backslash paths", () => {
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks")).toBe("Tasks");
});
it("strips a trailing backslash on Windows paths", () => {
expect(workspaceNameFromPath("C:\\Users\\me\\Tasks\\")).toBe("Tasks");
});
it("handles mixed separators", () => {
expect(workspaceNameFromPath("C:\\Users/me\\Tasks")).toBe("Tasks");
});
it("falls back to 'workspace' when the path has no usable tail", () => {
expect(workspaceNameFromPath("/")).toBe("workspace");
expect(workspaceNameFromPath("\\")).toBe("workspace");
expect(workspaceNameFromPath("")).toBe("workspace");
});
it("preserves names with spaces", () => {
expect(workspaceNameFromPath("/home/me/My Tasks/")).toBe("My Tasks");
});
});

View file

@ -0,0 +1,9 @@
/**
* Derive a workspace display name from a folder path picked via the file
* dialog. Handles both `/` and `\` separators and tolerates trailing
* separators (e.g. `"/home/me/Tasks/"` `"Tasks"`, not `"workspace"`).
*/
export function workspaceNameFromPath(folder: string): string {
const parts = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
}

View file

@ -5,6 +5,7 @@
import { app } from "../stores/app.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
import { workspaceNameFromPath } from "../paths";
let { cancellable = false }: { cancellable?: boolean } = $props();
@ -71,9 +72,7 @@
const selected = await open({ directory: true, multiple: false });
if (!selected) return;
const folder = selected as string;
const parts = folder.replace(/\\/g, "/").split("/");
const wsName = parts[parts.length - 1] || "workspace";
await app.addWorkspace(wsName, folder);
await app.addWorkspace(workspaceNameFromPath(folder), folder);
}
// ── WebDAV handlers ───────────────────────────────────────────────

View file

@ -3,7 +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 ConfirmDialog, { isConfirmDialogOpen } from "../components/ConfirmDialog.svelte";
import SettingsScreen from "./SettingsScreen.svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { platform } from "@tauri-apps/plugin-os";
@ -18,10 +18,15 @@
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);
// Clear taskStack when the viewed task no longer exists (e.g. deleted or list switched)
// Clear taskStack when the viewed task no longer exists (e.g. deleted or list switched).
// Handles both the parent-gone case (clear entirely) and the subtask-gone case
// (collapse back to parent detail) so an externally deleted subtask doesn't leave
// the slider parked over a blank third panel.
$effect(() => {
if (taskStack.length > 0 && !parentTask) {
taskStack = [];
} else if (taskStack.length >= 2 && !subtaskDetail) {
taskStack = taskStack.slice(0, 1);
}
});
@ -48,6 +53,7 @@
let showWorkspacePicker = $state(false);
let newListName = $state("");
let newListInput = $state<HTMLInputElement | null>(null);
let showCompleted = $state(false);
let completedVisible = $state(false);
let renamingListId = $state<string | null>(null);
@ -73,6 +79,12 @@
return () => window.removeEventListener("resize", handleResize);
});
// Focus the new-list input when it appears. Svelte's native `autofocus`
// attribute is unreliable for conditional blocks, so focus imperatively.
$effect(() => {
if (showNewList && newListInput) newListInput.focus();
});
async function handleNewList() {
if (!newListName.trim()) return;
@ -128,6 +140,9 @@
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
// Defer to any open ConfirmDialog — it installs a capture-phase listener
// that dismisses itself; we must not also pop the task-detail view behind it.
if (isConfirmDialogOpen()) return;
if (showSettings) { showSettings = false; return; }
if (taskStack.length > 0) { closeDetail(); return; }
if (showListMenu) { showListMenu = false; return; }
@ -367,7 +382,7 @@
<div class="flex-1 overflow-y-auto py-2">
{#each app.lists as list (list.id)}
<button
onclick={() => { app.selectList(list.id); taskStack = []; closeDrawer(); }}
onclick={() => { app.selectList(list.id); taskStack = []; showCompleted = false; completedVisible = false; 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}
@ -388,6 +403,7 @@
{#if showNewList}
<div class="flex gap-2 px-1">
<input
bind:this={newListInput}
type="text"
bind:value={newListName}
placeholder="List name"

View file

@ -8,6 +8,7 @@ import type {
Screen,
SyncResult,
} from "../types";
import { groupTasksByDate, type TaskGroup } from "../grouping";
// Listen for file system changes from the backend watcher.
listen("fs-changed", () => {
@ -52,64 +53,9 @@ let activeList = $derived(lists.find((l) => l.id === activeListId) ?? null);
let pendingTasks = $derived(tasks.filter((t) => t.status === "backlog" && !t.parent_id));
let completedTasks = $derived(tasks.filter((t) => t.status === "completed" && !t.parent_id));
type TaskGroup = { label: string; tasks: Task[]; date: Date | null };
let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
if (!activeList?.group_by_date) return null;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(todayStart.getDate() + 1);
const overdue: Task[] = [];
const today: Task[] = [];
const tomorrow: Task[] = [];
const futureByDay = new Map<string, { date: Date; tasks: Task[] }>();
const noDate: Task[] = [];
for (const task of pendingTasks) {
if (!task.date) {
noDate.push(task);
} else {
const d = new Date(task.date);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (dayStart < todayStart) overdue.push(task);
else if (dayStart.getTime() === todayStart.getTime()) today.push(task);
else if (dayStart.getTime() === tomorrowStart.getTime()) tomorrow.push(task);
else {
const key = dayStart.toISOString();
if (!futureByDay.has(key)) futureByDay.set(key, { date: dayStart, tasks: [] });
futureByDay.get(key)!.tasks.push(task);
}
}
}
const taskOrderIndex = new Map(pendingTasks.map((t, i) => [t.id, i]));
const sortByDue = (a: Task, b: Task) => {
const dateDiff = new Date(a.date!).getTime() - new Date(b.date!).getTime();
if (dateDiff !== 0) return dateDiff;
return (taskOrderIndex.get(a.id) ?? 0) - (taskOrderIndex.get(b.id) ?? 0);
};
overdue.sort(sortByDue);
today.sort(sortByDue);
tomorrow.sort(sortByDue);
const groups: TaskGroup[] = [];
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
const currentYear = now.getFullYear();
for (const [, { date, tasks }] of [...futureByDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tasks.sort(sortByDue);
const opts: Intl.DateTimeFormatOptions = date.getFullYear() !== currentYear
? { weekday: "short", month: "short", day: "numeric", year: "numeric" }
: { weekday: "short", month: "short", day: "numeric" };
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
}
return groups;
return groupTasksByDate(pendingTasks);
});
// Build a map of parent_id -> children for subtask hierarchy
@ -366,13 +312,15 @@ async function reorderTask(taskId: string, newPosition: number) {
}
}
async function deleteTask(taskId: string) {
if (!activeListId) return;
async function deleteTask(taskId: string): Promise<boolean> {
if (!activeListId) return false;
try {
await invoke("delete_task", { listId: activeListId, taskId });
tasks = tasks.filter((t) => t.id !== taskId);
return true;
} catch (e) {
error = String(e);
return false;
}
}

View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View file

@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
@ -14,4 +15,17 @@ export default defineConfig({
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,svelte}"],
// Resolve Svelte's client (browser) entry under Vitest — without the
// browser condition mount() picks up Svelte's SSR export and throws
// lifecycle_function_unavailable.
server: { deps: { inline: ["@testing-library/svelte"] } },
},
resolve: {
conditions: process.env.VITEST ? ["browser"] : [],
},
});

View file

@ -448,12 +448,12 @@ pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result
let user_entry = keyring::Entry::new(&service, "username")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
user_entry.setpassword(username)
user_entry.set_password(username)
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
let pass_entry = keyring::Entry::new(&scoped_service, "password")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
pass_entry.setpassword(password)
pass_entry.set_password(password)
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
// Clean up legacy unscoped password entry if present
@ -478,18 +478,18 @@ pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<St
let user_entry = keyring::Entry::new(&service, "username")
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
if let Ok(user) = user_entry.getpassword() {
if let Ok(user) = user_entry.get_password() {
// Try scoped password key first (domain+username), fall back to legacy unscoped key
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
let found = keyring::Entry::new(&scoped_service, "password")
.ok()
.and_then(|e| e.getpassword().ok())
.and_then(|e| e.get_password().ok())
.map(|p| (p, false))
.or_else(|| {
// Migration fallback: try legacy unscoped password entry
keyring::Entry::new(&service, "password")
.ok()
.and_then(|e| e.getpassword().ok())
.and_then(|e| e.get_password().ok())
.map(|p| (p, true))
});
@ -497,7 +497,7 @@ pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<St
// Auto-migrate legacy credentials to scoped format
if needs_migration {
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
let _ = entry.setpassword(&pass);
let _ = entry.set_password(&pass);
}
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
let _ = legacy.delete_credential();
@ -547,7 +547,7 @@ pub fn delete_credentials(domain: &str) -> Result<()> {
// Load username first so we can delete the scoped password entry
let username = keyring::Entry::new(&service, "username")
.ok()
.and_then(|e| e.getpassword().ok());
.and_then(|e| e.get_password().ok());
if let Some(user) = &username {
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB