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
90 changes: 89 additions & 1 deletion src/features/projects/FileTreePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from "react";
import type {
DragEventHandler,
KeyboardEventHandler,
MouseEventHandler,
ReactNode,
} from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appSystem } from "@/theme/system";
import { FILE_TREE_TERMINAL_DROP_MIME } from "@/shared/lib/fileTreeTerminalDrop";
import FileTreePanel from "./FileTreePanel";
import {
useCreateFileTreePath,
Expand Down Expand Up @@ -85,11 +91,13 @@ vi.mock("@/shared/lib/clipboard", () => ({
vi.mock("@pierre/trees/react", () => ({
FileTree: ({
onClick,
onDragStart,
onKeyUp,
onMouseDown,
renderContextMenu,
}: {
onClick?: MouseEventHandler<HTMLElement>;
onDragStart?: DragEventHandler<HTMLElement>;
onKeyUp?: KeyboardEventHandler<HTMLElement>;
onMouseDown?: MouseEventHandler<HTMLElement>;
renderContextMenu?: (
Expand Down Expand Up @@ -120,6 +128,9 @@ vi.mock("@pierre/trees/react", () => ({
}
onClick?.(event);
}}
onDragStart={
onDragStart as DragEventHandler<HTMLButtonElement>
}
onMouseDown={
onMouseDown as MouseEventHandler<HTMLButtonElement>
}
Expand All @@ -130,6 +141,9 @@ vi.mock("@pierre/trees/react", () => ({
<button
data-item-path="src/index.ts"
onClick={onClick as MouseEventHandler<HTMLButtonElement>}
onDragStart={
onDragStart as DragEventHandler<HTMLButtonElement>
}
onMouseDown={
onMouseDown as MouseEventHandler<HTMLButtonElement>
}
Expand All @@ -140,6 +154,9 @@ vi.mock("@pierre/trees/react", () => ({
<button
data-item-path="ignored.log"
onClick={onClick as MouseEventHandler<HTMLButtonElement>}
onDragStart={
onDragStart as DragEventHandler<HTMLButtonElement>
}
onMouseDown={
onMouseDown as MouseEventHandler<HTMLButtonElement>
}
Expand Down Expand Up @@ -258,6 +275,35 @@ function getLastMenuItem(name: string) {
return item;
}

function createTestDataTransfer(): DataTransfer {
const data = new Map<string, string>();
const types: string[] = [];
const transfer = {
dropEffect: "none",
effectAllowed: "uninitialized",
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
types,
clearData: vi.fn((format?: string) => {
if (format) {
data.delete(format);
const index = types.indexOf(format);
if (index >= 0) types.splice(index, 1);
} else {
data.clear();
types.splice(0);
}
}),
getData: vi.fn((format: string) => data.get(format) ?? ""),
setData: vi.fn((format: string, value: string) => {
data.set(format, value);
if (!types.includes(format)) types.push(format);
}),
setDragImage: vi.fn(),
} as unknown as DataTransfer;
return transfer;
}

describe("fileTreePanel", () => {
beforeEach(() => {
addPathMock.mockReset();
Expand Down Expand Up @@ -517,6 +563,23 @@ describe("fileTreePanel", () => {
expect(onOpenFile).toHaveBeenCalledWith("/root/src/index.ts");
});

it("does not open files while mouse selection is waiting for click", () => {
const { onOpenFile } = renderPanel();

fireEvent.mouseDown(screen.getByText("index.ts"));
act(() => {
useFileTreeOptionsRef.current?.onSelectionChange?.([
"src/index.ts",
]);
});

expect(onOpenFile).not.toHaveBeenCalled();

fireEvent.click(screen.getByText("index.ts"));

expect(onOpenFile).toHaveBeenCalledWith("/root/src/index.ts");
});

it("does not open files while extending multi-selection", () => {
const { onOpenFile } = renderPanel();

Expand All @@ -531,6 +594,31 @@ describe("fileTreePanel", () => {
expect(onOpenFile).not.toHaveBeenCalled();
});

it("adds absolute file paths to file tree drag data without opening the file", () => {
const { onOpenFile } = renderPanel();
const dataTransfer = createTestDataTransfer();

fireEvent.mouseDown(screen.getByText("index.ts"));
act(() => {
useFileTreeOptionsRef.current?.onSelectionChange?.([
"src/index.ts",
]);
});
fireEvent.dragStart(screen.getByText("index.ts"), { dataTransfer });

expect(onOpenFile).not.toHaveBeenCalled();
expect(dataTransfer.effectAllowed).toBe("copyMove");
expect(dataTransfer.getData("text/plain")).toBe("/root/src/index.ts");
expect(
JSON.parse(dataTransfer.getData(FILE_TREE_TERMINAL_DROP_MIME)),
).toMatchObject({
profileId,
rootPath,
relativePaths: ["src/index.ts"],
absolutePaths: ["/root/src/index.ts"],
});
});

it("does not open directory rows", () => {
const { onOpenFile } = renderPanel();

Expand Down
100 changes: 95 additions & 5 deletions src/features/projects/FileTreePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FileTree, useFileTree } from "@pierre/trees/react";
import { motion, useReducedMotion } from "motion/react";
import {
type CSSProperties,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
useCallback,
Expand All @@ -25,6 +26,14 @@ import * as m from "@/paraglide/messages.js";
import { useHorizontalResize } from "@/shared/hooks/useHorizontalResize";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { getErrorMessage } from "@/shared/lib/errors";
import {
createFileTreeTerminalDropPayload,
FILE_TREE_TERMINAL_DROP_EVENT,
type FileTreeTerminalDropEventDetail,
type FileTreeTerminalDropPayload,
getFileTreeTerminalDropTargetAtPoint,
writeFileTreeTerminalDropPayload,
} from "@/shared/lib/fileTreeTerminalDrop";
import { toaster } from "@/shared/providers/appToaster";
import FileViewerDialog from "./FileViewerDialog";
import { toFileTreeGitStatus } from "./fileTreeGitStatus";
Expand Down Expand Up @@ -92,8 +101,8 @@ const EMPTY_LOADED_CHILD_PATHS_BY_DIRECTORY = new Map<
readonly string[]
>();

function getTreeItemPath(event: MouseEvent<HTMLElement>) {
for (const target of event.nativeEvent.composedPath()) {
function getTreeItemPathFromComposedPath(composedPath: readonly EventTarget[]) {
for (const target of composedPath) {
if (target instanceof HTMLElement) {
const itemPath = target.dataset.itemPath;
if (itemPath) return itemPath;
Expand All @@ -102,6 +111,10 @@ function getTreeItemPath(event: MouseEvent<HTMLElement>) {
return null;
}

function getTreeItemPath(event: MouseEvent<HTMLElement>) {
return getTreeItemPathFromComposedPath(event.nativeEvent.composedPath());
}

function toAbsolutePath(rootPath: string, relativePath: string) {
const normalizedRoot = rootPath.replace(TRAILING_PATH_SEPARATOR_RE, "");
return `${normalizedRoot}/${relativePath}`;
Expand Down Expand Up @@ -535,6 +548,9 @@ export default function FileTreePanel({
Map<string, Promise<readonly string[]>>
>(new Map());
const skipNextSelectionOpenRef = useRef(false);
const skipNextClickOpenRef = useRef(false);
const skipNextClickOpenUntilRef = useRef(0);
const dragPayloadRef = useRef<FileTreeTerminalDropPayload | null>(null);
const restoreModelRef = useRef(() => {});
const renameFileTreePathRef = useRef((_event: FileTreeRenameEvent) => {});
const moveFileTreePathsRef = useRef((_event: FileTreeDropResult) => {});
Expand Down Expand Up @@ -847,10 +863,18 @@ export default function FileTreePanel({
const handleTreeClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
setRootContextMenu(null);
if (skipNextClickOpenRef.current) {
const shouldSkipClick = Date.now() <= skipNextClickOpenUntilRef.current;
skipNextClickOpenRef.current = false;
skipNextClickOpenUntilRef.current = 0;
skipNextSelectionOpenRef.current = false;
if (shouldSkipClick) return;
}
if (event.metaKey || event.ctrlKey || event.shiftKey) {
skipNextSelectionOpenRef.current = false;
return;
}
skipNextSelectionOpenRef.current = false;
const itemPath = getTreeItemPath(event);
if (itemPath && filePathSetRef.current.has(itemPath)) {
openRelativeFile(itemPath);
Expand Down Expand Up @@ -885,9 +909,8 @@ export default function FileTreePanel({
);

const handleTreeMouseDown = useCallback(
(event: MouseEvent<HTMLElement>) => {
skipNextSelectionOpenRef.current =
event.metaKey || event.ctrlKey || event.shiftKey;
() => {
skipNextSelectionOpenRef.current = true;
},
[],
);
Expand Down Expand Up @@ -940,6 +963,71 @@ export default function FileTreePanel({
},
[],
);
const handleTreeDragStart = useCallback(
(event: DragEvent<HTMLElement>) => {
skipNextSelectionOpenRef.current = true;
skipNextClickOpenRef.current = true;
skipNextClickOpenUntilRef.current = Date.now() + 500;

const itemPath = getTreeItemPathFromComposedPath(
event.nativeEvent.composedPath(),
);
if (!itemPath || !hasTreePath(treePathSetRef.current, itemPath)) {
return;
}

const candidatePaths =
selectedPaths.includes(itemPath) && selectedPaths.length > 0
? selectedPaths
: [itemPath];
const relativePaths = candidatePaths.filter((path) =>
hasTreePath(treePathSetRef.current, path),
);
if (relativePaths.length === 0) {
return;
}

const rootPath = rootPathRef.current;
const absolutePaths = relativePaths.map((path) =>
toAbsolutePath(rootPath, path),
);
const payload = createFileTreeTerminalDropPayload({
profileId,
rootPath,
relativePaths: [...relativePaths],
absolutePaths,
});
dragPayloadRef.current = payload;
writeFileTreeTerminalDropPayload(event.dataTransfer, payload);
},
[profileId, selectedPaths],
);
const handleTreeDragEnd = useCallback((event: DragEvent<HTMLElement>) => {
if (skipNextClickOpenRef.current) {
skipNextClickOpenUntilRef.current = Date.now() + 500;
}
const payload = dragPayloadRef.current;
dragPayloadRef.current = null;
if (payload) {
const target = getFileTreeTerminalDropTargetAtPoint(
event.clientX,
event.clientY,
);
target?.dispatchEvent(
Comment on lines +1011 to +1016
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore canceled drags before dispatching terminal drops

This dispatches the terminal-drop event for any dragend that finishes over a terminal target, even if the user canceled the drag (for example by pressing Escape while hovering over the terminal); MDN documents dropEffect === "none" on dragend for canceled drags (https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#finishing_the_drag). In that scenario the path is still written into the PTY despite no drop being performed, so the handler needs to distinguish a real accepted drop from cancellation.

Useful? React with 👍 / 👎.

new CustomEvent<FileTreeTerminalDropEventDetail>(
FILE_TREE_TERMINAL_DROP_EVENT,
{
bubbles: true,
detail: {
clientX: event.clientX,
clientY: event.clientY,
payload,
},
},
),
);
}
}, []);
const closeRootContextMenu = useCallback(() => {
setRootContextMenu(null);
}, []);
Expand Down Expand Up @@ -1066,6 +1154,8 @@ export default function FileTreePanel({
<FileTree
model={model}
onClick={handleTreeClick}
onDragEnd={handleTreeDragEnd}
onDragStart={handleTreeDragStart}
onKeyUp={handleTreeKeyUp}
onMouseDown={handleTreeMouseDown}
renderContextMenu={(
Expand Down
10 changes: 9 additions & 1 deletion src/features/terminal/TabStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Box, CloseButton, HStack } from "@chakra-ui/react";
import { AnimatePresence, motion } from "motion/react";
import type { KeyboardEvent, ReactNode } from "react";
import type {
KeyboardEvent,
ReactNode,
RefCallback,
} from "react";

const TAB_MIN_WIDTH = "140px";
export const TAB_STRIP_HEIGHT = "32px";
Expand All @@ -12,6 +16,7 @@ export interface TabStripItem {
title: string;
maxTitleLength: number;
badge?: ReactNode;
elementRef?: RefCallback<HTMLDivElement>;
isSelected?: boolean;
onClose?: () => void;
}
Expand All @@ -31,6 +36,7 @@ function TabButton({
title,
maxTitleLength,
badge,
elementRef,
isSelected,
onClose,
onSelect,
Expand Down Expand Up @@ -90,6 +96,7 @@ function TabButton({
outlineOffset: "-2px",
}}
draggable={false}
ref={elementRef}
onClick={selectTab}
onKeyDown={handleKeyDown}
>
Expand Down Expand Up @@ -144,6 +151,7 @@ function TabMotionItem({
title={item.title}
maxTitleLength={item.maxTitleLength}
badge={item.badge}
elementRef={item.elementRef}
isSelected={item.isSelected}
onClose={item.onClose}
onSelect={onSelect}
Expand Down
Loading
Loading