diff --git a/src/__tests__/moveDialog.test.tsx b/src/__tests__/moveDialog.test.tsx new file mode 100644 index 0000000..b7df743 --- /dev/null +++ b/src/__tests__/moveDialog.test.tsx @@ -0,0 +1,79 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MoveDialog } from "@/components/MoveDialog/MoveDialog"; +import { getDictionary } from "@/i18n"; +import type { FolderRecord, SelectedItem, SnippetRecord } from "@/lib/types"; + +const copy = getDictionary("en"); + +function folder(id: string, parentId: string | null): FolderRecord { + return { id, name: id, parentId } as FolderRecord; +} + +// parent ─┬─ child ── grandchild +// └─ (sibling of parent at root: other) +const folders = [ + folder("parent", null), + folder("child", "parent"), + folder("grandchild", "child"), + folder("other", null), +]; +const snippets = [ + { id: "s1", title: "note", language: "markdown", folderId: "other" } as SnippetRecord, +]; + +function renderDialog(items: SelectedItem[]) { + const onMove = vi.fn(); + const onClose = vi.fn(); + render( + , + ); + return { onMove, onClose }; +} + +const row = (name: string) => + screen.getByRole("button", { name: new RegExp(`^${name}$`) }); +const expand = (name: string) => + fireEvent.click( + screen + .getAllByRole("button", { name: copy.folderSelect.expandFolder }) + .find((b) => b.nextElementSibling?.textContent === name)!, + ); + +describe("MoveDialog", () => { + afterEach(cleanup); + + it("won't move a folder into itself or its own subfolders", () => { + renderDialog([{ type: "folder", id: "child" }]); + // Moving it to where it already lives is a harmless no-op, so allowed. + expect(row("parent").disabled).toBe(false); + expand("parent"); + expect(row("child").disabled).toBe(true); + expand("child"); + expect(row("grandchild").disabled).toBe(true); + expect(row("other").disabled).toBe(false); + }); + + it("moves to the root as null and closes", () => { + const { onMove, onClose } = renderDialog([{ type: "snippet", id: "s1" }]); + fireEvent.click(row(copy.workspace.rootOption)); + expect(onMove).toHaveBeenCalledExactlyOnceWith(null); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("moves into the picked folder by id", () => { + const { onMove } = renderDialog([{ type: "snippet", id: "s1" }]); + fireEvent.click(row("parent")); + expect(onMove).toHaveBeenCalledExactlyOnceWith("parent"); + }); +}); diff --git a/src/__tests__/useLongPress.test.tsx b/src/__tests__/useLongPress.test.tsx new file mode 100644 index 0000000..b8faa69 --- /dev/null +++ b/src/__tests__/useLongPress.test.tsx @@ -0,0 +1,163 @@ +/** @vitest-environment jsdom */ + +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LONG_PRESS_MS, useLongPress, type PressPoint } from "@/hooks/useLongPress"; + +function Row({ + onLongPress, + onOpen, + enabled, +}: { + onLongPress: (point: PressPoint) => void; + onOpen: () => void; + enabled?: boolean; +}) { + const longPress = useLongPress(onLongPress, { enabled }); + return ( +
+ +
+ ); +} + +const touch = { pointerType: "touch", isPrimary: true, pointerId: 1 }; + +function setup(enabled?: boolean) { + const onLongPress = vi.fn(); + const onOpen = vi.fn(); + render(); + return { onLongPress, onOpen, button: screen.getByRole("button") }; +} + +describe("useLongPress", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("fires at the press point once the finger has rested long enough", () => { + const { onLongPress, button } = setup(); + fireEvent.pointerDown(button, { ...touch, clientX: 40, clientY: 80 }); + + act(() => vi.advanceTimersByTime(LONG_PRESS_MS - 1)); + expect(onLongPress).not.toHaveBeenCalled(); + + act(() => vi.advanceTimersByTime(1)); + expect(onLongPress).toHaveBeenCalledExactlyOnceWith({ x: 40, y: 80 }); + }); + + it("swallows the click that ends a fired press, but not the next tap", () => { + const { onOpen, button } = setup(); + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + fireEvent.pointerUp(button, touch); + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + + fireEvent.pointerDown(button, touch); + fireEvent.pointerUp(button, touch); + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it("keeps the menu open when the platform's own long press lands on its backdrop", () => { + const { button } = setup(); + // Stands in for the menu's backdrop, which closes it on any contextmenu. + const onBackdropContextMenu = vi.fn(); + const backdrop = document.createElement("div"); + backdrop.addEventListener("contextmenu", onBackdropContextMenu); + document.body.appendChild(backdrop); + + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + // Android fires its native contextmenu ~500ms in, hit-tested at the finger. + const handled = fireEvent.contextMenu(backdrop); + expect(handled).toBe(false); + expect(onBackdropContextMenu).not.toHaveBeenCalled(); + backdrop.remove(); + }); + + it("lets clicks through again shortly after the finger lifts", () => { + const { onOpen, button } = setup(); + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + fireEvent.touchEnd(window); + act(() => vi.advanceTimersByTime(1000)); + // e.g. a keyboard or screen reader, which sends no pointerdown first. + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it("keeps guarding when a second finger lands during the press", () => { + const { onOpen, button } = setup(); + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + fireEvent.pointerDown(window, { pointerType: "touch", isPrimary: false, pointerId: 2 }); + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("lets clicks through again after the touch is cancelled", () => { + const { onOpen, button } = setup(); + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + fireEvent.touchCancel(window); + act(() => vi.advanceTimersByTime(1000)); + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it("gives up when the finger drifts, since that is a scroll or a swipe", () => { + const { onLongPress, button } = setup(); + fireEvent.pointerDown(button, { ...touch, clientX: 0, clientY: 0 }); + fireEvent.pointerMove(button, { ...touch, clientX: 0, clientY: 20 }); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("gives up when the finger lifts or the browser takes the pointer", () => { + const { onLongPress, button } = setup(); + fireEvent.pointerDown(button, touch); + fireEvent.pointerUp(button, touch); + fireEvent.pointerDown(button, touch); + fireEvent.pointerCancel(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("fires once when the platform's own long press beats the timer", () => { + const { onLongPress, button } = setup(); + fireEvent.pointerDown(button, { ...touch, clientX: 5, clientY: 6 }); + fireEvent.contextMenu(button, { clientX: 5, clientY: 6 }); + expect(onLongPress).toHaveBeenCalledExactlyOnceWith({ x: 5, y: 6 }); + + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + expect(onLongPress).toHaveBeenCalledOnce(); + }); + + it("opens on right-click without eating the next click", () => { + const { onLongPress, onOpen, button } = setup(); + fireEvent.pointerDown(button, { pointerType: "mouse", isPrimary: true, button: 2 }); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + expect(onLongPress).not.toHaveBeenCalled(); + + fireEvent.contextMenu(button, { clientX: 12, clientY: 34 }); + expect(onLongPress).toHaveBeenCalledExactlyOnceWith({ x: 12, y: 34 }); + + fireEvent.click(button); + expect(onOpen).toHaveBeenCalledOnce(); + }); + + it("does nothing while disabled", () => { + const { onLongPress, button } = setup(false); + fireEvent.pointerDown(button, touch); + act(() => vi.advanceTimersByTime(LONG_PRESS_MS)); + fireEvent.contextMenu(button, { clientX: 1, clientY: 1 }); + expect(onLongPress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Aside/useContextMenuGroups.ts b/src/components/Aside/useContextMenuGroups.ts index 501d37e..546d1cb 100644 --- a/src/components/Aside/useContextMenuGroups.ts +++ b/src/components/Aside/useContextMenuGroups.ts @@ -4,6 +4,7 @@ import { Copy, ExternalLink, FilePlus, + FolderInput, FolderPlus, PenLine, Pin, @@ -32,6 +33,8 @@ interface UseContextMenuGroupsArgs { onDeleteMany: (items: SelectedItem[]) => Promise; onCut: (entry: ClipboardEntry) => void; onCopy: (entry: ClipboardEntry) => void; + /** Open the "Move to…" picker for these items. */ + onMove: (items: SelectedItem[]) => void; setRenamingId: (id: string | null) => void; setCreatingFolderParentId: (id: string | null | undefined) => void; onOpenCreateModal: (folderId: string | null) => void; @@ -57,6 +60,7 @@ export function useContextMenuGroups({ onDeleteMany, onCut, onCopy, + onMove, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, @@ -74,8 +78,9 @@ export function useContextMenuGroups({ * keyboard shortcuts and drag-move. (selectForMenu has already collapsed * the selection to this row if it was clicked from outside the set.) */ const batchActive = !!id && selectedIds.has(id) && selectedIds.size > 1; + const targetItems = (fallback: SelectedItem) => (batchActive ? getSelectedItems() : [fallback]); const clipboardItems = (fallback: SelectedItem) => - (batchActive ? getSelectedItems() : [fallback]).map((i) => ({ itemType: i.type, id: i.id })); + targetItems(fallback).map((i) => ({ itemType: i.type, id: i.id })); const deleteSelection = () => { const items = getSelectedItems(); clearSelection(); @@ -158,6 +163,7 @@ export function useContextMenuGroups({ }, { items: [ + { id: "move", label: cm.moveTo, Icon: FolderInput, onClick: () => onMove(targetItems({ id, type: "folder" })) }, { id: "cut", label: cm.cut, Icon: Scissors, onClick: () => onCut({ type: "cut", items: clipboardItems({ id, type: "folder" }) }) }, { id: "copy", label: cm.copy, Icon: Copy, onClick: () => onCopy({ type: "copy", items: clipboardItems({ id, type: "folder" }) }) }, ...(clipboard ? [{ id: "paste", label: cm.paste, Icon: Clipboard, onClick: () => void onPaste(id) }] : []), @@ -215,6 +221,7 @@ export function useContextMenuGroups({ }, { items: [ + { id: "move", label: cm.moveTo, Icon: FolderInput, onClick: () => onMove(targetItems({ id, type: "snippet" })) }, { id: "cut", label: cm.cut, Icon: Scissors, onClick: () => onCut({ type: "cut", items: clipboardItems({ id, type: "snippet" }) }) }, { id: "copy", label: cm.copy, Icon: Copy, onClick: () => onCopy({ type: "copy", items: clipboardItems({ id, type: "snippet" }) }) }, ...(clipboard ? [{ id: "paste", label: cm.paste, Icon: Clipboard, onClick: () => void onPaste(snippet.folderId) }] : []), @@ -234,6 +241,6 @@ export function useContextMenuGroups({ return []; }, - [clipboard, copy.contextMenu, folders, snippets, onPaste, onPinFolder, onPinSnippet, onDeleteFolder, onDeleteSnippet, onDeleteMany, onCut, onCopy, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, selectedIds, getSelectedItems, clearSelection], + [clipboard, copy.contextMenu, folders, snippets, onPaste, onPinFolder, onPinSnippet, onDeleteFolder, onDeleteSnippet, onDeleteMany, onCut, onCopy, onMove, setRenamingId, setCreatingFolderParentId, onOpenCreateModal, selectedIds, getSelectedItems, clearSelection], ); } diff --git a/src/components/KlipCodeApp.tsx b/src/components/KlipCodeApp.tsx index d530afe..601f574 100644 --- a/src/components/KlipCodeApp.tsx +++ b/src/components/KlipCodeApp.tsx @@ -7,7 +7,7 @@ import { ArrowLeft } from "lucide-react"; import { readTrash } from "@/lib/db"; import { readInitialWorkspace } from "@/lib/seed"; -import type { ClipboardEntry, SnippetRecord, WorkspaceSnapshot } from "@/lib/types"; +import type { ClipboardEntry, SelectedItem, SnippetRecord, WorkspaceSnapshot } from "@/lib/types"; import { getDictionary } from "@/i18n"; import { localeHref, LOCALE_COOKIE, type Locale } from "@/lib/locale"; import { SPACE_ROOT_ID, TRASH_ROOT_ID } from "@/lib/navigation"; @@ -29,6 +29,7 @@ import type { WorkspaceShellProps } from "@/components/Aside/types"; import { MobileHome } from "@/components/MobileHome/MobileHome"; import { useMobileFeedState } from "@/components/MobileHome/useMobileFeedState"; import { ConfirmDialog } from "@/components/ConfirmDialog/ConfirmDialog"; +import { MoveDialog } from "@/components/MoveDialog/MoveDialog"; import { CreateSnippetModal } from "@/components/CreateSnippetModal/CreateSnippetModal"; import { CreatedSnippetToast } from "@/components/CreatedSnippetToast/CreatedSnippetToast"; import { DragProvider } from "@/components/DragContext"; @@ -138,6 +139,8 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { const [copyNonce, setCopyNonce] = useState(0); const [undoNonce, setUndoNonce] = useState(0); const [pendingEmptyTrash, setPendingEmptyTrash] = useState(false); + /** Items waiting on a destination in the "Move to…" dialog. */ + const [pendingMove, setPendingMove] = useState(null); /* ── AI title generation state ──────────────────────────────────────────── Snippets whose name Workers AI is currently inferring in the background, so @@ -292,7 +295,7 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { }); }, hasOpenSnippet: !!selectedSnippet, - overlayOpen: searchOpen || helpOpen || prefsOpen || pendingEmptyTrash || createModalOpen, + overlayOpen: searchOpen || helpOpen || prefsOpen || pendingEmptyTrash || createModalOpen || !!pendingMove, }); /** @@ -347,6 +350,7 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { onCut: setClipboard, onCopy: (entry) => setClipboard({ ...entry, type: "copy" }), onPaste: mutations.handlePaste, + onMove: setPendingMove, onOpenSearch: () => setSearchOpen(true), onOpenPreferences: () => setPrefsOpen(true), onSignIn: auth.handleGitHubSignIn, @@ -647,6 +651,17 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) { onOpen={(id) => navigate(`${base}?snippet=${id}`)} /> + {pendingMove && ( + void mutations.handleMoveMany(pendingMove, targetFolderId)} + onClose={() => setPendingMove(null)} + /> + )} + {pendingEmptyTrash && ( void; - onMore: (event: React.MouseEvent) => void; + /** Opens the row's actions menu, anchored at viewport coordinates. */ + onOpenMenu: (point: PressPoint) => void; onSubmitRename: (value: string) => void; onCancelRename: () => void; }) { const { copied, copy: copyToClipboard } = useCopyFeedback(); + // Off while renaming: holding inside the field has to select text. + const longPress = useLongPress(onOpenMenu, { enabled: !isRenaming }); const isGeneratingTitle = useIsGeneratingTitle(snippet.id); const displayName = getSnippetDisplayName( snippet.title, @@ -62,8 +71,11 @@ export function FeedCard({ return (
+ {/* Only for a mouse — a finger holds the card instead. Hidden visually + rather than removed, so screen readers, which can't hold, keep it. */} - - {/* Folder tree */} - {visible.map(({ node, depth }) => { - const { folder, children } = node; - const hasChildren = children.length > 0; - const isExpanded = expanded.has(folder.id); - const isSelected = folder.id === value; - - return ( -
- {/* Expand/collapse chevron */} - {hasChildren ? ( - - ) : ( -
- ); - })} - - {folders.length === 0 && ( -

{copy.noFolders}

- )} + , document.body, diff --git a/src/ui/FolderTreeList.tsx b/src/ui/FolderTreeList.tsx new file mode 100644 index 0000000..dcd47e6 --- /dev/null +++ b/src/ui/FolderTreeList.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronRight, Folder, FolderOpen } from "lucide-react"; + +import type { Dictionary } from "@/i18n"; +import type { FolderRecord } from "@/lib/types"; + +/* ── Tree helpers ──────────────────────────────────────────────────────────── */ + +interface TreeNode { + folder: FolderRecord; + children: TreeNode[]; +} + +function buildTree(folders: FolderRecord[]): TreeNode[] { + const byParent = new Map(); + for (const folder of folders) { + const siblings = byParent.get(folder.parentId) ?? []; + siblings.push(folder); + byParent.set(folder.parentId, siblings); + } + + function build(currentParentId: string | null): TreeNode[] { + return (byParent.get(currentParentId) ?? []).map((folder) => ({ + folder, + children: build(folder.id), + })); + } + + return build(null); +} + +/** DFS: return nodes in display order, skipping collapsed subtrees */ +function flatVisible(nodes: TreeNode[], expanded: Set): Array<{ node: TreeNode; depth: number }> { + const result: Array<{ node: TreeNode; depth: number }> = []; + function walk(list: TreeNode[], depth: number) { + for (const node of list) { + result.push({ node, depth }); + if (expanded.has(node.folder.id) && node.children.length > 0) { + walk(node.children, depth + 1); + } + } + } + walk(nodes, 0); + return result; +} + +/** Return ancestor IDs of a given folder (excluding itself) */ +function ancestorIds(targetId: string, folders: FolderRecord[]): Set { + const ids = new Set(); + let current = folders.find((f) => f.id === targetId); + while (current?.parentId) { + ids.add(current.parentId); + current = folders.find((f) => f.id === current!.parentId); + } + return ids; +} + +/* ── Component ──────────────────────────────────────────────────────────────── */ + +const INDENT = 16; + +interface FolderTreeListProps { + folders: FolderRecord[]; + /** "" = root, a folder id otherwise, undefined = nothing is marked current. */ + value: string | undefined; + onSelect: (value: string) => void; + rootLabel: string; + copy: Dictionary["folderSelect"]; + /** Folders that can't be picked (e.g. a folder being moved into itself). */ + isDisabled?: (folderId: string) => boolean; +} + +/** + * A pickable folder tree: a root entry, then every folder with collapsible + * children. Shared by every "choose a destination" surface — the + * {@link FolderSelect} dropdown and the "Move to…" dialog — so expanding, + * marking the current folder and the touch sizing only exist once. + * + * Mount it when it opens: the path to `value` starts expanded, so the current + * folder is always in view. + */ +export function FolderTreeList({ + folders, + value, + onSelect, + rootLabel, + copy, + isDisabled, +}: FolderTreeListProps) { + const [expanded, setExpanded] = useState>(() => + value ? ancestorIds(value, folders) : new Set(), + ); + + const visible = flatVisible(buildTree(folders), expanded); + + function toggleExpand(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + // Rows grow below `lg`: on touch the list is picked with a finger, not a cursor. + const rowClass = (isSelected: boolean) => + [ + "flex min-w-0 items-center gap-2 rounded-lg text-left text-[13px] leading-none", + "transition-colors duration-75 max-lg:py-3", + "disabled:pointer-events-none disabled:opacity-30", + isSelected + ? "bg-ink/[0.08] text-ink" + : "text-ink/60 hover:bg-ink/[0.06] hover:text-ink/90 active:bg-ink/[0.06]", + ].join(" "); + + return ( + <> + {/* Root option */} + + + {/* Folder tree */} + {visible.map(({ node, depth }) => { + const { folder, children } = node; + const hasChildren = children.length > 0; + const isExpanded = expanded.has(folder.id); + const isSelected = folder.id === value; + + return ( +
+ {/* Expand/collapse chevron */} + {hasChildren ? ( + + ) : ( +
+ ); + })} + + {folders.length === 0 && ( +

{copy.noFolders}

+ )} + + ); +}