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
36 changes: 11 additions & 25 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import * as Cause from "effect/Cause";
import { ChevronsDownUp, ChevronsUpDown } from "lucide";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useEffect, useRef, useState } from "react";

import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
Expand Down Expand Up @@ -105,15 +105,14 @@ function FileSearchField(props: {
);
}

// The tree diffs `treePaths` by identity, so these derivations must stay cached
// across the hooks that read them.
function useTreeEntries(
// The tree diffs `treePaths` by identity, so the panel must keep this result cached
// across the hooks that read it.
function buildTreeEntries(
directoryEntries: readonly ProjectEntry[],
searchEntries: readonly ProjectEntry[],
searchPending: boolean,
query: string,
) {
"use memo";
const result = new Map(directoryEntries.map((entry) => [entry.path, entry]));
if (query.trim() && !searchPending) {
for (const entry of searchEntries) {
Expand All @@ -134,18 +133,6 @@ function useTreeEntries(
return { entries, entryKinds, treePaths, directoryPaths };
}

function useDragMentionController(
cwd: string,
hostFolder: boolean,
treeModelRef: RefObject<ReturnType<typeof useFileTree>["model"] | null>,
) {
"use memo";
return createFileTreeDragMentionController({
deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
...(hostFolder ? { mentionPath: (path: string) => joinHostPath(cwd, path) } : {}),
});
}

export default function FileBrowserPanel({
environmentId,
cwd,
Expand Down Expand Up @@ -177,7 +164,7 @@ export default function FileBrowserPanel({
{ environmentId, cwd: hostFolder ? null : cwd, query: query.slice(0, 256) },
200,
);
const { entries, entryKinds, treePaths, directoryPaths } = useTreeEntries(
const { entries, entryKinds, treePaths, directoryPaths } = buildTreeEntries(
directoryEntries,
pathSearch.entries,
pathSearch.isPending,
Expand Down Expand Up @@ -281,8 +268,9 @@ export default function FileBrowserPanel({
);

const loadingDirectoriesRef = useRef(loadingDirectories);
const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
const dragMention = useDragMentionController(cwd, hostFolder, treeModelRef);
const dragMention = createFileTreeDragMentionController(
hostFolder ? { mentionPath: (path: string) => joinHostPath(cwd, path) } : {},
);
const { model } = useFileTree({
composition: {
contextMenu: {
Expand Down Expand Up @@ -498,23 +486,21 @@ export default function FileBrowserPanel({
// The capture phase runs before the tree's own dragstart handler selects
// the dragged row, so the drag flag is up before that selection emits.
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
treeModelRef.current = model;
}, [model]);
useEffect(() => {
const panel = panelRef.current;
if (panel === null) {
return;
}
const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event);
const handleDragEnd = () => dragMention.handleDragEnd();
const handleDragEnd = () =>
dragMention.handleDragEnd((path) => model.getItem(path)?.deselect());
panel.addEventListener("dragstart", handleDragStart, true);
panel.addEventListener("dragend", handleDragEnd);
return () => {
panel.removeEventListener("dragstart", handleDragStart, true);
panel.removeEventListener("dragend", handleDragEnd);
};
}, [dragMention]);
}, [dragMention, model]);

return (
<div
Expand Down
34 changes: 15 additions & 19 deletions apps/web/src/components/files/fileTreeDragMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const rowNode = (path: string) => ({

describe("createFileTreeDragMentionController", () => {
it("tags a row drag with the mention payload and flags the drag", () => {
const controller = createFileTreeDragMentionController({ deselect: () => {} });
const controller = createFileTreeDragMentionController({});
const transfer = makeTransfer();
controller.handleDragStart({
dataTransfer: transfer,
Expand All @@ -29,7 +29,7 @@ describe("createFileTreeDragMentionController", () => {
});

it("strips the trailing slash from directory rows", () => {
const controller = createFileTreeDragMentionController({ deselect: () => {} });
const controller = createFileTreeDragMentionController({});
const transfer = makeTransfer();
controller.handleDragStart({
dataTransfer: transfer,
Expand All @@ -40,7 +40,6 @@ describe("createFileTreeDragMentionController", () => {

it("mentions rows of a host folder by their host path", () => {
const controller = createFileTreeDragMentionController({
deselect: () => {},
mentionPath: (path) => `/Users/me/scope/${path}`,
});
const transfer = makeTransfer();
Expand All @@ -52,15 +51,15 @@ describe("createFileTreeDragMentionController", () => {
});

it("does not tag drags of selected text from the panel chrome", () => {
const controller = createFileTreeDragMentionController({ deselect: () => {} });
const controller = createFileTreeDragMentionController({});
const transfer = makeTransfer("selected text");
controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] });
expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false);
expect(controller.isDragInProgress()).toBe(false);
});

it("ignores drags that carry no row path", () => {
const controller = createFileTreeDragMentionController({ deselect: () => {} });
const controller = createFileTreeDragMentionController({});
const transfer = makeTransfer();
controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] });
expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false);
Expand All @@ -69,24 +68,22 @@ describe("createFileTreeDragMentionController", () => {

it("deselects the dragged row exactly once when the drag ends", () => {
const deselected: Array<string> = [];
const controller = createFileTreeDragMentionController({
deselect: (path) => deselected.push(path),
});
const controller = createFileTreeDragMentionController({});
const deselect = (path: string) => void deselected.push(path);
controller.handleDragStart({
dataTransfer: makeTransfer(),
composedPath: () => [rowNode("src/app.ts")],
});
controller.handleDragEnd();
controller.handleDragEnd();
controller.handleDragEnd(deselect);
controller.handleDragEnd(deselect);
expect(deselected).toEqual(["src/app.ts"]);
expect(controller.isDragInProgress()).toBe(false);
});

it("drags the whole selection when the dragged row is part of it", () => {
const deselected: Array<string> = [];
const controller = createFileTreeDragMentionController({
deselect: (path) => deselected.push(path),
});
const controller = createFileTreeDragMentionController({});
const deselect = (path: string) => void deselected.push(path);
controller.handleSelectionChange(["docs/index.md", "docs/api.md", "src/app.ts"]);
const transfer = makeTransfer();
controller.handleDragStart({
Expand All @@ -96,12 +93,12 @@ describe("createFileTreeDragMentionController", () => {
expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe(
"[index.md](docs/index.md) [api.md](docs/api.md) [app.ts](src/app.ts)",
);
controller.handleDragEnd();
controller.handleDragEnd(deselect);
expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]);
});

it("drags only the row under the cursor when it is outside the selection", () => {
const controller = createFileTreeDragMentionController({ deselect: () => {} });
const controller = createFileTreeDragMentionController({});
controller.handleSelectionChange(["docs/index.md"]);
const transfer = makeTransfer();
controller.handleDragStart({
Expand All @@ -113,10 +110,9 @@ describe("createFileTreeDragMentionController", () => {

it("does not deselect anything when no drag was started", () => {
const deselected: Array<string> = [];
const controller = createFileTreeDragMentionController({
deselect: (path) => deselected.push(path),
});
controller.handleDragEnd();
const controller = createFileTreeDragMentionController({});
const deselect = (path: string) => void deselected.push(path);
controller.handleDragEnd(deselect);
expect(deselected).toEqual([]);
});
});
7 changes: 3 additions & 4 deletions apps/web/src/components/files/fileTreeDragMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export interface FileTreeDragStartEvent {
}

export interface FileTreeDragMentionHost {
deselect(treePath: string): void;
/** The path a dragged row mentions; defaults to the tree path itself. */
mentionPath?(treePath: string): string;
}
Expand All @@ -22,7 +21,7 @@ export interface FileTreeDragMentionController {
isDragInProgress(): boolean;
handleSelectionChange(selectedPaths: ReadonlyArray<string>): void;
handleDragStart(event: FileTreeDragStartEvent): void;
handleDragEnd(): void;
handleDragEnd(deselect: (treePath: string) => void): void;
}

const itemPathOf = (node: unknown): string | null => {
Expand Down Expand Up @@ -74,12 +73,12 @@ export function createFileTreeDragMentionController(
draggedPaths = dragged;
event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" "));
},
handleDragEnd() {
handleDragEnd(deselect) {
if (draggedPaths.length === 0) {
return;
}
for (const path of draggedPaths) {
host.deselect(path);
deselect(path);
}
draggedPaths = [];
},
Expand Down
Loading