Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ localStorage. It is the acceptance surface for the spec's UI rows, not the produ
surface (that is Covenstead inside Coven Cave).

```sh
npm run dev # http://127.0.0.1:5178 (live: https://cauldron-shell.vercel.app)
npm run dev # http://127.0.0.1:5188 (live: https://cauldron-shell.vercel.app)
npm run test:e2e # Playwright, Chromium; tests are named by spec row
```

Expand Down
185 changes: 185 additions & 0 deletions apps/shell/e2e/rows2.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { expect, test, type Page } from "@playwright/test";

const win = (page: Page, app: string) => page.locator(`section.win[data-app="${app}"]`);
const menu = async (page: Page, title: string, item: string | RegExp) => {
await page.locator(".menu > .menu-title", { hasText: title }).first().dispatchEvent("pointerdown");
await page.locator(".menu.open .menu-items button", { hasText: item }).first().click();
};
const U = { actor: { kind: "user", id: "user" } } as const;
const exists = (page: Page, path: string) =>
page.evaluate(([p]) => window.cauldron.shell.kernel.vfs.exists({ actor: { kind: "user", id: "user" } }, p!), [path]);
const read = (page: Page, path: string) =>
page.evaluate(([p]) => window.cauldron.shell.kernel.vfs.read({ actor: { kind: "user", id: "user" } }, p!).content, [path]);
void U;

test.beforeEach(async ({ page }) => {
await page.goto("/");
await expect(win(page, "finder").first()).toBeVisible();
});

/** Two Finder windows side by side: A on /Documents (left), B on /Shared (right). */
async function twoFinders(page: Page) {
await menu(page, "✦", "New Finder Window");
await menu(page, "Go", "Shared");
await page.evaluate(() => {
const { wm } = window.cauldron.shell.kernel;
const [a, b] = wm.list().filter((w) => w.appId === "finder");
const u = { actor: { kind: "user" as const, id: "user" } };
wm.move(u, a!.id, 20, 60);
wm.move(u, b!.id, 660, 60);
});
return [win(page, "finder").nth(0), win(page, "finder").nth(1)] as const;
}

test("FND-04 drag a file to another Finder window moves it; Option/Alt-drag copies", async ({ page }) => {
const [a, b] = await twoFinders(page);
await expect(a.locator(".path")).toHaveText("/Documents");
await expect(b.locator(".path")).toHaveText("/Shared");

await a.locator('tr[data-name="Welcome.txt"]').dragTo(b.locator(".scroll"));
await expect(b.locator('tr[data-name="Welcome.txt"]')).toBeVisible();
await expect(a.locator('tr[data-name="Welcome.txt"]')).toHaveCount(0);

await page.evaluate(() => window.cauldron.shell.kernel.vfs.create({ actor: { kind: "user", id: "user" } }, "/Documents/keep.txt", "file", { content: "k" }));
await page.keyboard.down("Alt");
await a.locator('tr[data-name="keep.txt"]').dragTo(b.locator(".scroll"));
await page.keyboard.up("Alt");
await expect(b.locator('tr[data-name="keep.txt"]')).toBeVisible();
await expect(a.locator('tr[data-name="keep.txt"]')).toBeVisible();
expect(await read(page, "/Shared/keep.txt")).toBe("k");
});

test("FND-04b dropping onto a folder row moves the file into that folder", async ({ page }) => {
const f = win(page, "finder").first();
await page.evaluate(() => window.cauldron.shell.kernel.vfs.create({ actor: { kind: "user", id: "user" } }, "/Documents/Plans", "folder"));
await f.locator('tr[data-name="Welcome.txt"]').dragTo(f.locator('tr[data-name="Plans"]'));
expect(await exists(page, "/Documents/Plans/Welcome.txt")).toBe(true);
});

test("FND-07 desktop shows /Desktop as icons; dropping an icon on Trash trashes it and fills the Trash", async ({ page }) => {
const trash = page.locator('.desk-icon.trash');
await expect(trash).toHaveAttribute("data-full", "false");
await page.evaluate(() => window.cauldron.shell.kernel.vfs.create({ actor: { kind: "user", id: "user" } }, "/Desktop/notes.txt", "file", { content: "n" }));
const icon = page.locator('.desk-icon[data-name="notes.txt"]');
await expect(icon).toBeVisible();
await icon.dragTo(trash);
await expect(icon).toHaveCount(0);
await expect(trash).toHaveAttribute("data-full", "true");
expect(await exists(page, "/Trash/notes.txt")).toBe(true);
});

test("FND-07b dragging a Finder row onto the desktop moves it to /Desktop; double-click opens it", async ({ page }) => {
const f = win(page, "finder").first();
await f.locator('tr[data-name="Welcome.txt"]').dragTo(page.locator("#desktop-icons"), { targetPosition: { x: 60, y: 600 } });
const icon = page.locator('.desk-icon[data-name="Welcome.txt"]');
await expect(icon).toBeVisible();
expect(await exists(page, "/Desktop/Welcome.txt")).toBe(true);
await icon.dblclick();
await expect(win(page, "textedit").first().locator("textarea")).toHaveValue(/^Welcome\./);
});

test("TXT-03 three edits, Undo ×3, Redo ×3 — content matches at each step", async ({ page }) => {
await menu(page, "✦", "New TextEdit Document");
const t = win(page, "textedit").first().locator("textarea");
await t.fill("a");
await t.fill("ab");
await t.fill("abc");
for (const want of ["ab", "a", ""]) {
await page.keyboard.press("ControlOrMeta+z");
await expect(t).toHaveValue(want);
}
for (const want of ["a", "ab", "abc"]) {
await page.keyboard.press("ControlOrMeta+Shift+z");
await expect(t).toHaveValue(want);
}
});

test("TXT-03b history holds at least 100 steps", async ({ page }) => {
await menu(page, "✦", "New TextEdit Document");
const t = win(page, "textedit").first().locator("textarea");
await t.evaluate((el: HTMLTextAreaElement) => {
for (let i = 0; i < 120; i++) {
el.value += "x";
el.dispatchEvent(new InputEvent("input", { bubbles: true }));
}
});
await t.focus();
for (let i = 0; i < 105; i++) await page.keyboard.press("ControlOrMeta+z");
await expect(t).toHaveValue("x".repeat(15));
});

test("TXT-04 renaming the open file in Finder retitles TextEdit; Save writes to the new path", async ({ page }) => {
const f = win(page, "finder").first();
await f.locator('tr[data-name="Welcome.txt"]').dblclick();
const te = win(page, "textedit").first();
await expect(te.locator(".title")).toHaveText("Welcome.txt");
await menu(page, "Window", "Documents"); // raise Finder; TextEdit sits on top of it
await expect(f).toHaveAttribute("data-focused", "true");
await f.locator('tr[data-name="Welcome.txt"]').click();
await menu(page, "File", "Rename");
await f.locator("input.rename").fill("Hello.txt");
await f.locator("input.rename").press("Enter");
await expect(te.locator(".title")).toHaveText("Hello.txt");
await menu(page, "Window", "Hello.txt");
await te.locator("textarea").fill("renamed, then saved");
await page.keyboard.press("ControlOrMeta+s");
await expect(te.locator(".title")).toHaveText("Hello.txt");
expect(await read(page, "/Documents/Hello.txt")).toBe("renamed, then saved");
expect(await exists(page, "/Documents/Welcome.txt")).toBe(false);
});

test("TXT-06 Find shows the match count; Enter cycles and wraps", async ({ page }) => {
await menu(page, "✦", "New TextEdit Document");
const te = win(page, "textedit").first();
const t = te.locator("textarea");
await t.fill("foo bar foo baz FOO");
await page.keyboard.press("ControlOrMeta+f");
const find = te.getByRole("textbox", { name: "Find" });
await expect(find).toBeFocused();
await find.fill("foo");
const count = te.locator(".findbar .count");
await expect(count).toHaveText("1 of 3");
await find.press("Enter");
await expect(count).toHaveText("2 of 3");
expect(await t.evaluate((el: HTMLTextAreaElement) => [el.selectionStart, el.selectionEnd])).toEqual([8, 11]);
await find.press("Enter");
await find.press("Enter");
await expect(count).toHaveText("1 of 3");
await find.fill("zzz");
await expect(count).toHaveText("No matches");
await find.press("Escape");
await expect(te.locator(".findbar")).toHaveCount(0);
});

test("TXT-04b a case-only rename of the open file does not raise a false conflict", async ({ page }) => {
await win(page, "finder").first().locator('tr[data-name="Welcome.txt"]').dblclick();
const te = win(page, "textedit").first();
await page.evaluate(() => window.cauldron.shell.kernel.vfs.rename({ actor: { kind: "user", id: "user" } }, "/Documents/Welcome.txt", "WELCOME.txt"));
await expect(te.locator(".title")).toHaveText("WELCOME.txt");
await expect(te.locator(".banner")).toBeHidden();
await te.locator("textarea").fill("still mine");
await page.keyboard.press("ControlOrMeta+s");
await expect(te.locator(".title")).toHaveText("WELCOME.txt");
expect(await read(page, "/Documents/WELCOME.txt")).toBe("still mine");
});

test("TXT-06b every match is highlighted; the current one is marked", async ({ page }) => {
await menu(page, "✦", "New TextEdit Document");
const te = win(page, "textedit").first();
await te.locator("textarea").fill("foo bar foo baz FOO");
await page.keyboard.press("ControlOrMeta+f");
await te.getByRole("textbox", { name: "Find" }).fill("foo");
await expect(te.locator(".hl mark")).toHaveCount(3);
await te.getByRole("textbox", { name: "Find" }).press("Enter");
await expect(te.locator(".hl mark.current")).toHaveText("foo");
expect(await te.locator(".hl mark").evaluateAll((ms) => ms.findIndex((m) => m.classList.contains("current")))).toBe(1);
await te.getByRole("textbox", { name: "Find" }).press("Escape");
await expect(te.locator(".hl mark")).toHaveCount(0);
});

test("WM-04b the resize grip stays on top of window content (TextEdit)", async ({ page }) => {
await menu(page, "✦", "New TextEdit Document");
const g = (await win(page, "textedit").first().locator(".resize").boundingBox())!;
const hit = await page.evaluate(([x, y]) => (document.elementFromPoint(x!, y!) as HTMLElement).className, [g.x + 8, g.y + 8]);
expect(hit).toBe("resize");
});
19 changes: 19 additions & 0 deletions apps/shell/e2e/screenshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,22 @@ test("capture: shared desktop with a familiar at work", async ({ page }) => {
await page.locator('.dialog[data-kind="consent"] button', { hasText: "Not now" }).click();
await pending;
});

test("capture: desktop icons, full Trash, and TextEdit find", async ({ page }) => {
await page.goto("/");
await page.evaluate(async () => {
const { vfs } = window.cauldron.shell.kernel;
const u = { actor: { kind: "user" as const, id: "user" } };
await vfs.create(u, "/Desktop/Ideas", "folder");
await vfs.create(u, "/Desktop/todo.txt", "file", { content: "foo bar foo baz FOO" });
await vfs.create(u, "/Desktop/old.txt", "file");
await vfs.trash(u, "/Desktop/old.txt");
});
await page.locator('.desk-icon[data-name="todo.txt"]').dblclick();
const te = page.locator('section.win[data-app="textedit"]').first();
await expect(te.locator("textarea")).toHaveValue(/foo/);
await page.keyboard.press("ControlOrMeta+f");
await te.getByRole("textbox", { name: "Find" }).fill("foo");
await te.getByRole("textbox", { name: "Find" }).press("Enter");
await page.screenshot({ path: `${dir}/05-desktop-icons-find.png` });
});
7 changes: 4 additions & 3 deletions apps/shell/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ export default defineConfig({
fullyParallel: true,
reporter: process.env.CI ? [["list"], ["github"]] : "list",
use: {
baseURL: "http://127.0.0.1:5178",
baseURL: "http://127.0.0.1:5188",
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 800 },
},
webServer: {
command: "npx vite --host 127.0.0.1",
url: "http://127.0.0.1:5178",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:5188",
// Never reuse: a server left running from another checkout would test the wrong code.
reuseExistingServer: false,
timeout: 60_000,
},
});
62 changes: 62 additions & 0 deletions apps/shell/src/desktop-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { USER } from "@opencoven/cauldron";
import { makeDraggable, makeDropTarget } from "./dnd.ts";
import type { Shell } from "./shell.ts";

const u = { actor: USER };

/**
* The desktop background: /Desktop's contents as icons in a right-aligned
* grid, plus the Trash bottom-right (spec §6.9). Sits under every window.
*/
export class DesktopIcons {
readonly el = document.createElement("div");
#shell: Shell;
#selected: string | null = null;

constructor(shell: Shell) {
this.#shell = shell;
this.el.id = "desktop-icons";
makeDropTarget(shell, this.el, () => "/Desktop");
this.el.addEventListener("pointerdown", (e) => {
if (e.target === this.el) this.select(null);
});
}

/** Toggle selection in place — re-rendering here would swap the element mid double-click. */
select(name: string | null) {
this.#selected = name;
for (const el of this.el.querySelectorAll<HTMLElement>(".desk-icon")) {
el.classList.toggle("selected", el.dataset.name === name);
}
}

render() {
const vfs = this.#shell.kernel.vfs;
const items = vfs.exists(u, "/Desktop") ? vfs.list(u, "/Desktop") : [];
const trashFull = vfs.exists(u, "/Trash") && vfs.list(u, "/Trash").length > 0;
this.el.replaceChildren();
items.forEach((s, i) => {
const icon = document.createElement("div");
icon.className = "desk-icon" + (s.node.name === this.#selected ? " selected" : "");
icon.dataset.name = s.node.name;
icon.dataset.kind = s.node.kind;
icon.style.top = `${12 + i * 92}px`;
icon.innerHTML = `<div class="glyph">${s.node.kind === "folder" ? "▣" : "▤"}</div><div class="label"></div>`;
icon.querySelector(".label")!.textContent = s.node.name;
icon.onpointerdown = () => this.select(s.node.name);
icon.ondblclick = () =>
s.node.kind === "folder" ? this.#shell.launch("finder", { path: s.path }) : this.#shell.openFile(s.path);
makeDraggable(icon, () => s.path);
if (s.node.kind === "folder") makeDropTarget(this.#shell, icon, () => s.path, { stop: true });
this.el.append(icon);
});
const trash = document.createElement("div");
trash.className = "desk-icon trash";
trash.dataset.name = "Trash";
trash.dataset.full = String(trashFull);
trash.innerHTML = `<div class="glyph">${trashFull ? "▦" : "□"}</div><div class="label">Trash</div>`;
trash.ondblclick = () => this.#shell.launch("finder", { path: "/Trash" });
makeDropTarget(this.#shell, trash, () => "trash", { stop: true });
this.el.append(trash);
}
}
60 changes: 60 additions & 0 deletions apps/shell/src/dnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { KernelError, USER } from "@opencoven/cauldron";
import { dialog, esc } from "./dialogs.ts";
import { explain } from "./finder.ts";
import type { Shell } from "./shell.ts";

/** Drag payload: VFS paths, newline-separated. */
export const DRAG_MIME = "application/x-cauldron-paths";
const u = { actor: USER };

export function makeDraggable(el: HTMLElement, path: () => string) {
el.draggable = true;
el.addEventListener("dragstart", (e) => {
e.dataTransfer!.setData(DRAG_MIME, path());
e.dataTransfer!.effectAllowed = "copyMove";
});
}

export const carriesPaths = (e: DragEvent) => Boolean(e.dataTransfer?.types.includes(DRAG_MIME));

/**
* Make `el` a drop target. `dest()` names the folder (or "trash").
* Plain drop moves; Alt/Option-drop copies (spec §6.7).
*/
export function makeDropTarget(shell: Shell, el: HTMLElement, dest: () => string | "trash", opts: { stop?: boolean } = {}) {
el.addEventListener("dragover", (e) => {
if (!carriesPaths(e)) return;
e.preventDefault();
if (opts.stop) e.stopPropagation();
e.dataTransfer!.dropEffect = e.altKey ? "copy" : "move";
el.classList.add("drop-hover");
});
el.addEventListener("dragleave", () => el.classList.remove("drop-hover"));
el.addEventListener("drop", (e) => {
if (!carriesPaths(e)) return;
e.preventDefault();
if (opts.stop) e.stopPropagation();
el.classList.remove("drop-hover");
const paths = e.dataTransfer!.getData(DRAG_MIME).split("\n").filter(Boolean);
void dropPaths(shell, paths, dest(), e.altKey);
});
}

export async function dropPaths(shell: Shell, paths: string[], dest: string | "trash", copy: boolean) {
const vfs = shell.kernel.vfs;
for (const p of paths) {
try {
if (!vfs.exists(u, p)) continue;
if (dest === "trash") {
await vfs.trash(u, p);
continue;
}
const parent = p.slice(0, p.lastIndexOf("/")) || "/";
if (copy) await vfs.copy(u, p, dest);
else if (parent.toLowerCase() !== dest.toLowerCase()) await vfs.move(u, p, dest);
} catch (err) {
const msg = err instanceof KernelError ? explain(err) : String(err);
await dialog({ html: esc(msg), buttons: [{ id: "ok", label: "OK", primary: true }] });
}
}
}
4 changes: 4 additions & 0 deletions apps/shell/src/finder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { KernelError, USER, type KernelEvent, type Stat } from "@opencoven/cauldron";
import { dialog, esc } from "./dialogs.ts";
import { makeDraggable, makeDropTarget } from "./dnd.ts";
import type { Shell } from "./shell.ts";
import type { AppView, Menu } from "./types.ts";

Expand Down Expand Up @@ -231,6 +232,7 @@ export class FinderView implements AppView {

const scroll = document.createElement("div");
scroll.className = "scroll";
makeDropTarget(this.#shell, scroll, () => this.path);
const table = document.createElement("table");
table.className = "list";
const head = document.createElement("tr");
Expand Down Expand Up @@ -285,6 +287,8 @@ export class FinderView implements AppView {
this.render();
};
tr.ondblclick = () => this.open(n.name);
if (this.#renaming !== n.name) makeDraggable(tr, () => s.path);
if (n.kind === "folder") makeDropTarget(this.#shell, tr, () => s.path, { stop: true });
tbody.append(tr);
}
table.append(thead, tbody);
Expand Down
Loading
Loading