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
79 changes: 79 additions & 0 deletions src/__tests__/moveDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MoveDialog
copy={copy}
folders={folders}
snippets={snippets}
items={items}
onMove={onMove}
onClose={onClose}
/>,
);
return { onMove, onClose };
}

const row = (name: string) =>
screen.getByRole<HTMLButtonElement>("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");
});
});
163 changes: 163 additions & 0 deletions src/__tests__/useLongPress.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="row" {...longPress}>
<button type="button" onClick={onOpen}>
open
</button>
</div>
);
}

const touch = { pointerType: "touch", isPrimary: true, pointerId: 1 };

function setup(enabled?: boolean) {
const onLongPress = vi.fn();
const onOpen = vi.fn();
render(<Row onLongPress={onLongPress} onOpen={onOpen} enabled={enabled} />);
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();
});
});
11 changes: 9 additions & 2 deletions src/components/Aside/useContextMenuGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Copy,
ExternalLink,
FilePlus,
FolderInput,
FolderPlus,
PenLine,
Pin,
Expand Down Expand Up @@ -32,6 +33,8 @@ interface UseContextMenuGroupsArgs {
onDeleteMany: (items: SelectedItem[]) => Promise<void>;
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;
Expand All @@ -57,6 +60,7 @@ export function useContextMenuGroups({
onDeleteMany,
onCut,
onCopy,
onMove,
setRenamingId,
setCreatingFolderParentId,
onOpenCreateModal,
Expand All @@ -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();
Expand Down Expand Up @@ -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) }] : []),
Expand Down Expand Up @@ -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) }] : []),
Expand All @@ -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],
);
}
19 changes: 17 additions & 2 deletions src/components/KlipCodeApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<SelectedItem[] | null>(null);

/* ── AI title generation state ────────────────────────────────────────────
Snippets whose name Workers AI is currently inferring in the background, so
Expand Down Expand Up @@ -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,
});

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -647,6 +651,17 @@ export default function KlipCodeApp({ locale }: { locale: "en" | "es" }) {
onOpen={(id) => navigate(`${base}?snippet=${id}`)}
/>

{pendingMove && (
<MoveDialog
copy={copy}
folders={folders}
snippets={snippets}
items={pendingMove}
onMove={(targetFolderId) => void mutations.handleMoveMany(pendingMove, targetFolderId)}
onClose={() => setPendingMove(null)}
/>
)}

{pendingEmptyTrash && (
<ConfirmDialog
title={copy.trash.emptyTitle}
Expand Down
Loading
Loading