diff --git a/README.md b/README.md index b9fb20a..1a6c08d 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/apps/shell/e2e/rows2.spec.ts b/apps/shell/e2e/rows2.spec.ts new file mode 100644 index 0000000..bc2a2ef --- /dev/null +++ b/apps/shell/e2e/rows2.spec.ts @@ -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"); +}); diff --git a/apps/shell/e2e/screenshots.spec.ts b/apps/shell/e2e/screenshots.spec.ts index ccebafb..dc8115f 100644 --- a/apps/shell/e2e/screenshots.spec.ts +++ b/apps/shell/e2e/screenshots.spec.ts @@ -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` }); +}); diff --git a/apps/shell/playwright.config.ts b/apps/shell/playwright.config.ts index 34debca..c5dc9a0 100644 --- a/apps/shell/playwright.config.ts +++ b/apps/shell/playwright.config.ts @@ -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, }, }); diff --git a/apps/shell/src/desktop-icons.ts b/apps/shell/src/desktop-icons.ts new file mode 100644 index 0000000..436f6da --- /dev/null +++ b/apps/shell/src/desktop-icons.ts @@ -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(".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 = `
${s.node.kind === "folder" ? "▣" : "▤"}
`; + 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 = `
${trashFull ? "▦" : "□"}
Trash
`; + trash.ondblclick = () => this.#shell.launch("finder", { path: "/Trash" }); + makeDropTarget(this.#shell, trash, () => "trash", { stop: true }); + this.el.append(trash); + } +} diff --git a/apps/shell/src/dnd.ts b/apps/shell/src/dnd.ts new file mode 100644 index 0000000..31ca58f --- /dev/null +++ b/apps/shell/src/dnd.ts @@ -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 }] }); + } + } +} diff --git a/apps/shell/src/finder.ts b/apps/shell/src/finder.ts index e71dde8..fd2da29 100644 --- a/apps/shell/src/finder.ts +++ b/apps/shell/src/finder.ts @@ -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"; @@ -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"); @@ -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); diff --git a/apps/shell/src/shell.ts b/apps/shell/src/shell.ts index cb918f2..7f56e3a 100644 --- a/apps/shell/src/shell.ts +++ b/apps/shell/src/shell.ts @@ -10,6 +10,7 @@ import { type WmSnapshot, } from "@opencoven/cauldron"; import { dialog, esc } from "./dialogs.ts"; +import { DesktopIcons } from "./desktop-icons.ts"; import { FinderView, cap } from "./finder.ts"; import { DESKTOP_KEY, LocalStorageAdapter } from "./storage.ts"; import { TextEditView } from "./textedit.ts"; @@ -58,6 +59,7 @@ export class Shell { #untitled = 0; #openMenu: string | null = null; #restoring = false; + icons!: DesktopIcons; readonly desktop: HTMLElement; readonly menubar: HTMLElement; @@ -72,6 +74,8 @@ export class Shell { }); this.kernel.apps.register(FINDER); this.kernel.apps.register(TEXTEDIT); + this.icons = new DesktopIcons(this); + this.desktop.append(this.icons.el); this.kernel.bus.subscribe((e) => this.#onEvent(e)); addEventListener("resize", () => { this.kernel.wm.setViewport({ w: innerWidth, h: innerHeight }); @@ -93,6 +97,7 @@ export class Shell { } this.#restoring = false; if (this.kernel.wm.list().length === 0) this.launch("finder", { path: "/Documents" }); + this.icons.render(); this.render(); this.persist(); } @@ -170,7 +175,10 @@ export class Shell { const w = this.kernel.wm.get(e.windowId as string); if (!this.#views.has(w.id)) this.#createView(w, this.#pending ?? {}); } - if (e.type === "vfs:changed") for (const v of this.#views.values()) v.onVfs(e); + if (e.type === "vfs:changed") { + for (const v of this.#views.values()) v.onVfs(e); + this.icons.render(); + } if (e.type.startsWith("window:")) { this.render(); if (!this.#restoring) this.persist(); @@ -266,6 +274,9 @@ export class Shell { #drag(handle: HTMLElement, id: string, apply: (dx: number, dy: number, start: { x: number; y: number; w: number; h: number }) => void) { handle.addEventListener("pointerdown", (e) => { if ((e.target as HTMLElement).closest("button")) return; + // Stop native drag/selection: once the window moves, the browser would otherwise + // hit-test the original press point and start dragging whatever is now beneath it. + e.preventDefault(); const start = { ...this.kernel.wm.get(id).rect }; const ox = e.clientX; const oy = e.clientY; diff --git a/apps/shell/src/slate.css b/apps/shell/src/slate.css index e103310..27cd29a 100644 --- a/apps/shell/src/slate.css +++ b/apps/shell/src/slate.css @@ -42,7 +42,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .owner { font-size: 11px; color: var(--paper); background: var(--agent); padding: 0 6px; border-radius: 2px; } .badge { width: 8px; height: 8px; border-radius: 50%; background: var(--agent); } .content { flex: 1; min-height: 0; overflow: auto; position: relative; } -.resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, var(--line) 50% 58%, transparent 58% 70%, var(--line) 70% 78%, transparent 78%); touch-action: none; } +.resize { position: absolute; z-index: 5; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, var(--line) 50% 58%, transparent 58% 70%, var(--line) 70% 78%, transparent 78%); touch-action: none; } .toolbar { display: flex; gap: 4px; padding: 4px; border-bottom: 1px solid var(--line); align-items: center; } .path { flex: 1; font-family: ui-monospace, monospace; font-size: 12px; padding: 1px 4px; border: 1px solid transparent; } @@ -58,7 +58,12 @@ table.list { width: 100%; border-collapse: collapse; } .finder .scroll { flex: 1; overflow: auto; } .textedit { display: flex; flex-direction: column; height: 100%; } -.textedit textarea { flex: 1; border: 0; resize: none; padding: 8px; font: 13px/1.5 ui-monospace, monospace; background: #fff; color: var(--ink); } +.textedit .editor { flex: 1; min-height: 0; position: relative; background: #fff; } +.textedit textarea, .textedit .hl { position: absolute; inset: 0; margin: 0; border: 0; padding: 8px; font: 13px/1.5 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: break-word; } +.textedit textarea { resize: none; background: transparent; color: var(--ink); z-index: 1; overflow: auto; } +.textedit .hl { color: transparent; overflow: hidden; pointer-events: none; } +.textedit .hl mark { color: transparent; background: #d9ecec; border-radius: 1px; } +.textedit .hl mark.current { background: #f2c14e; } .banner { background: #efe6fa; border-bottom: 1px solid var(--agent); padding: 4px 8px; display: flex; gap: 6px; align-items: center; } .banner span { flex: 1; } @@ -75,3 +80,18 @@ table.list { width: 100%; border-collapse: collapse; } .dialog .primary { font-weight: 600; border-width: 2px; } .dialog label { display: block; margin-bottom: 10px; } .dialog input { font: inherit; width: 100%; margin-top: 4px; padding: 2px 4px; border: 1px solid var(--line); } + +/* Desktop icons (spec §6.9) — sit beneath every window */ +#desktop-icons { position: absolute; inset: 0; z-index: 0; } +.desk-icon { position: absolute; right: 16px; width: 80px; text-align: center; user-select: none; color: var(--paper); } +.desk-icon .glyph { font-size: 36px; line-height: 44px; } +.desk-icon .label { font-size: 12px; padding: 0 3px; word-break: break-word; } +.desk-icon.selected .label { background: var(--ink); color: var(--paper); } +.desk-icon.trash { top: auto; bottom: 16px; } +.drop-hover { outline: 2px dashed var(--accent); outline-offset: -2px; } +.desk-icon.drop-hover .glyph { color: #fff; } + +/* TextEdit find bar (spec §7.4) */ +.findbar { display: flex; gap: 4px; align-items: center; padding: 3px 6px; border-bottom: 1px solid var(--line); } +.findbar input { flex: 1; font: inherit; padding: 1px 4px; border: 1px solid var(--line); } +.findbar .count { font-size: 11px; color: var(--muted); min-width: 56px; text-align: right; } diff --git a/apps/shell/src/textedit.ts b/apps/shell/src/textedit.ts index 0617f03..ff07243 100644 --- a/apps/shell/src/textedit.ts +++ b/apps/shell/src/textedit.ts @@ -17,7 +17,14 @@ export class TextEditView implements AppView { #untitled: string; #text: HTMLTextAreaElement; #banner: HTMLDivElement; + #hl: HTMLDivElement; #shell: Shell; + /** Own history (spec §7.4: ≥100 steps). Native undo breaks on programmatic value changes. */ + #past: string[] = []; + #future: string[] = []; + #last = ""; + #find: { bar: HTMLDivElement; input: HTMLInputElement; count: HTMLSpanElement; idx: number; hits: number[] } | null = null; + static readonly HISTORY = 500; readonly windowId: string; @@ -32,15 +39,31 @@ export class TextEditView implements AppView { this.#text.spellcheck = false; this.#text.setAttribute("aria-label", "Document"); this.#text.oninput = () => { - localStorage.setItem(draftKey(this.windowId), this.#text.value); - this.#retitle(); + this.#past.push(this.#last); + if (this.#past.length > TextEditView.HISTORY) this.#past.shift(); + this.#future = []; + this.#last = this.#text.value; + this.#changed(); }; - this.el.append(this.#banner, this.#text); + // Highlight layer behind a transparent textarea: find matches stay visible while + // focus is in the find field (a textarea hides its selection when unfocused). + const editor = document.createElement("div"); + editor.className = "editor"; + this.#hl = document.createElement("div"); + this.#hl.className = "hl"; + this.#hl.setAttribute("aria-hidden", "true"); + this.#text.addEventListener("scroll", () => { + this.#hl.scrollTop = this.#text.scrollTop; + this.#hl.scrollLeft = this.#text.scrollLeft; + }); + editor.append(this.#hl, this.#text); + this.el.append(this.#banner, editor); this.#untitled = state.untitled ?? shell.nextUntitled(); this.path = null; if (state.path && shell.kernel.vfs.exists(u, state.path)) this.#load(state.path); const draft = localStorage.getItem(draftKey(windowId)); if (draft !== null && draft !== this.#saved) this.#text.value = draft; + this.#last = this.#text.value; this.#retitle(); } @@ -66,18 +89,139 @@ export class TextEditView implements AppView { { label: "Save As…", shortcut: "⇧⌘S", action: () => void this.saveAs() }, ], }, + { + title: "Edit", + items: [ + { label: "Undo", shortcut: "⌘Z", disabled: this.#past.length === 0, action: () => this.undo() }, + { label: "Redo", shortcut: "⇧⌘Z", disabled: this.#future.length === 0, action: () => this.redo() }, + { label: "Find…", shortcut: "⌘F", action: () => this.openFind() }, + ], + }, ]; } onKey(e: KeyboardEvent): boolean { const mod = e.metaKey || e.ctrlKey; - if (mod && e.key.toLowerCase() === "s") { + const k = e.key.toLowerCase(); + if (mod && k === "s") { void (e.shiftKey ? this.saveAs() : this.save()); return true; } + if (mod && k === "z") return e.shiftKey ? this.redo() : this.undo(), true; + if (mod && k === "y") return this.redo(), true; + if (mod && k === "f") return this.openFind(), true; return false; } + undo() { + const prev = this.#past.pop(); + if (prev === undefined) return; + this.#future.push(this.#text.value); + this.#apply(prev); + } + + redo() { + const next = this.#future.pop(); + if (next === undefined) return; + this.#past.push(this.#text.value); + this.#apply(next); + } + + #apply(value: string) { + this.#text.value = value; + this.#last = value; + this.#changed(); + } + + #changed() { + localStorage.setItem(draftKey(this.windowId), this.#text.value); + this.#retitle(); + if (this.#find) this.#search(false); + } + + // ---------- find (spec §7.4) ---------- + + openFind() { + if (!this.#find) { + const bar = document.createElement("div"); + bar.className = "findbar"; + const input = document.createElement("input"); + input.setAttribute("aria-label", "Find"); + input.placeholder = "Find"; + const count = document.createElement("span"); + count.className = "count"; + const btn = (label: string, title: string, fn: () => void) => { + const b = document.createElement("button"); + b.textContent = label; + b.setAttribute("aria-label", title); + b.onclick = fn; + return b; + }; + bar.append(input, count, btn("‹", "Previous match", () => this.#step(-1)), btn("›", "Next match", () => this.#step(1)), btn("Done", "Close find", () => this.closeFind())); + input.oninput = () => this.#search(true); + input.onkeydown = (e) => { + e.stopPropagation(); + if (e.key === "Enter") { e.preventDefault(); this.#step(e.shiftKey ? -1 : 1); } + if (e.key === "Escape") { e.preventDefault(); this.closeFind(); } + }; + this.#find = { bar, input, count, idx: -1, hits: [] }; + this.#banner.after(bar); + } + this.#find.input.focus(); + this.#find.input.select(); + } + + closeFind() { + this.#find?.bar.remove(); + this.#find = null; + this.#hl.replaceChildren(); + this.#text.focus(); + } + + #search(jump: boolean) { + const f = this.#find!; + const q = f.input.value.toLowerCase(); + const hay = this.#text.value.toLowerCase(); + f.hits = []; + if (q) for (let i = hay.indexOf(q); i !== -1; i = hay.indexOf(q, i + Math.max(1, q.length))) f.hits.push(i); + if (f.hits.length === 0) f.idx = -1; + else if (jump || f.idx < 0 || f.idx >= f.hits.length) f.idx = 0; + this.#showHit(); + } + + #step(dir: 1 | -1) { + const f = this.#find; + if (!f || f.hits.length === 0) return; + f.idx = (f.idx + dir + f.hits.length) % f.hits.length; + this.#showHit(); + } + + #showHit() { + const f = this.#find!; + f.count.textContent = !f.input.value ? "" : f.hits.length === 0 ? "No matches" : `${f.idx + 1} of ${f.hits.length}`; + const len = f.input.value.length; + if (f.idx >= 0) { + const start = f.hits[f.idx]!; + this.#text.setSelectionRange(start, start + len); + } + // Paint every match; the current one is marked "current". + const text = this.#text.value; + const frag = document.createDocumentFragment(); + let at = 0; + f.hits.forEach((h, i) => { + frag.append(text.slice(at, h)); + const m = document.createElement("mark"); + if (i === f.idx) m.className = "current"; + m.textContent = text.slice(h, h + len); + frag.append(m); + at = h + len; + }); + frag.append(text.slice(at) + "\n"); + this.#hl.replaceChildren(frag); + this.#hl.scrollTop = this.#text.scrollTop; + this.el.querySelector("mark.current")?.scrollIntoView({ block: "nearest" }); + } + focusText() { this.#text.focus(); } @@ -90,6 +234,9 @@ export class TextEditView implements AppView { this.#loadedRev = r.rev; this.#saved = r.content; this.#text.value = r.content; + this.#last = r.content; + this.#past = []; + this.#future = []; this.#shell.setDocumentPath(this.windowId, st.path); } @@ -183,7 +330,9 @@ export class TextEditView implements AppView { const candidates = dest === "/Trash" ? [] : [dest, dest && `${dest}/${this.name}`].filter(Boolean) as string[]; const found = candidates.find((p) => vfs.exists(u, p) && vfs.stat(u, p).node.id === this.#nodeId); if (found) { - this.path = vfs.stat(u, found).path; + const moved = vfs.stat(u, found); + this.path = moved.path; + this.#loadedRev = moved.node.rev; // rename/move bumps rev but not content this.#shell.setDocumentPath(this.windowId, this.path); this.#retitle(); this.#shell.persist(); @@ -201,6 +350,16 @@ export class TextEditView implements AppView { if (!vfs.exists(u, this.path)) return; const st = vfs.stat(u, this.path); if (st.node.id !== this.#nodeId || st.node.rev <= this.#loadedRev) return; + if ((st.node.content ?? "") === this.#saved) { + // Metadata-only change (e.g. case-only rename): adopt the new rev and path quietly. + this.#loadedRev = st.node.rev; + if (st.path !== this.path) { + this.path = st.path; + this.#shell.setDocumentPath(this.windowId, st.path); + this.#retitle(); + } + return; + } if (e.actor.kind === "user" && !this.dirty) { this.reload(); return; diff --git a/apps/shell/vite.config.ts b/apps/shell/vite.config.ts index c0692a6..dda0b91 100644 --- a/apps/shell/vite.config.ts +++ b/apps/shell/vite.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "vite"; export default defineConfig({ - server: { port: 5178, strictPort: true }, + server: { port: 5188, strictPort: true }, });