дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(services): reorder services within a run configuration type (#345)
Services followed configuration detection order, so frequently used services could not be kept together. Services can now be dragged before or after a sibling, with an insertion indicator and edge scrolling, or moved with Move Up/Down from the toolbar and context menu. The order is stored per project location and type, separately from configuration definitions. New configurations append, windows stay in sync, and a failed save keeps the order for the session with a warning. Sorting in Tree is opt-in, so other trees are unchanged. Co-authored-by: wangxga <[email protected]>
Цей коміт міститься в:
1 джерело
0660f3c020
коміт
a3cd99db1f
16 files changed
+985
-17
No files matched your search
@@ -71,6 +71,23 @@
|
||||
.loading {
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.before::before,
|
||||
.after::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--focus-ring);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.before::before {
|
||||
top: 0;
|
||||
}
|
||||
.after::after {
|
||||
bottom: 0;
|
||||
}
|
||||
.chevron {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { Tree, type TreeReorder } from "./Tree";
|
||||
import { setDragRequiresAlt } from "./pointerDrag";
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
let hit: Element | null;
|
||||
beforeEach(() => {
|
||||
hit = null;
|
||||
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => hit });
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const top = Number(this.dataset.row ?? 0) * 24;
|
||||
return new DOMRect(0, top, 400, this.dataset.row === undefined ? 600 : 24);
|
||||
});
|
||||
setDragRequiresAlt(() => false);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setDragRequiresAlt(() => false);
|
||||
});
|
||||
|
||||
function Harness({ enabled = true }: { enabled?: boolean }) {
|
||||
const [roots, setRoots] = useState<Node[]>([
|
||||
{ id: "group", children: [{ id: "a" }, { id: "b" }, { id: "c" }] },
|
||||
{ id: "other", children: [{ id: "d" }] },
|
||||
]);
|
||||
const [activated, setActivated] = useState("");
|
||||
const reorder: TreeReorder<Node> = {
|
||||
canMove: (source, target) => !source.children && !target.children,
|
||||
move: (source, target, position) =>
|
||||
setRoots((list) =>
|
||||
list.map((group) => {
|
||||
if (!group.children?.some((n) => n.id === source.id)) return group;
|
||||
const children = group.children.filter((n) => n.id !== source.id);
|
||||
children.splice(children.findIndex((n) => n.id === target.id) + (position === "after" ? 1 : 0), 0, source);
|
||||
return { ...group, children };
|
||||
}),
|
||||
),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Tree
|
||||
roots={roots}
|
||||
getKey={(n) => n.id}
|
||||
getLabel={(n) => n.id}
|
||||
getChildren={(n) => n.children}
|
||||
isExpandable={(n) => !!n.children}
|
||||
defaultExpanded={["group", "other"]}
|
||||
rowClassName={(n) => (n.id === "b" ? "custom-row" : undefined)}
|
||||
reorder={enabled ? reorder : undefined}
|
||||
onActivate={(n) => setActivated(n.id)}
|
||||
aria-label="Reorder"
|
||||
/>
|
||||
<output>{activated}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string, tree = screen.getByRole("tree")) => within(tree).getByRole("treeitem", { name });
|
||||
const labels = () =>
|
||||
within(screen.getByRole("tree"))
|
||||
.getAllByRole("treeitem")
|
||||
.map((el) => el.textContent);
|
||||
const pointer = (el: Element | Window, type: string, y: number, altKey = false) =>
|
||||
fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: 30, clientY: y, altKey }));
|
||||
function drag(source: HTMLElement, target: HTMLElement, after = false, alt = false) {
|
||||
pointer(source, "pointerdown", source.getBoundingClientRect().top + 12, alt);
|
||||
hit = target;
|
||||
const y = target.getBoundingClientRect().top + (after ? 20 : 2);
|
||||
pointer(window, "pointermove", y, alt);
|
||||
pointer(window, "pointerup", y, alt);
|
||||
}
|
||||
|
||||
describe("Tree pointer reordering", () => {
|
||||
it("moves a sibling before or after its target, preserving its selected identity", () => {
|
||||
render(<Harness />);
|
||||
fireEvent.mouseDown(row("b"));
|
||||
drag(row("b"), row("a"));
|
||||
expect(labels()).toEqual(["group", "b", "a", "c", "other", "d"]);
|
||||
expect(row("b")).toHaveAttribute("aria-selected", "true");
|
||||
expect(row("b")).toHaveClass("custom-row");
|
||||
drag(row("b"), row("c"), true);
|
||||
expect(labels()).toEqual(["group", "a", "c", "b", "other", "d"]);
|
||||
});
|
||||
|
||||
it("does not reorder across parents or when the feature is absent", () => {
|
||||
const { rerender } = render(<Harness />);
|
||||
drag(row("a"), row("d"));
|
||||
expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
rerender(<Harness enabled={false} />);
|
||||
drag(row("b"), row("a"));
|
||||
expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
});
|
||||
|
||||
it("leaves pointer transport and activation untouched without reordering", () => {
|
||||
const listeners = vi.spyOn(window, "addEventListener");
|
||||
const frames = vi.spyOn(window, "requestAnimationFrame");
|
||||
render(<Harness enabled={false} />);
|
||||
listeners.mockClear();
|
||||
frames.mockClear();
|
||||
drag(row("b"), row("a"));
|
||||
expect(
|
||||
listeners.mock.calls.filter(([type]) => ["pointermove", "pointerup", "pointercancel"].includes(type)),
|
||||
).toEqual([]);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[aria-hidden="true"][role="treeitem"]')).toBeNull();
|
||||
fireEvent.doubleClick(row("b"));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("b");
|
||||
});
|
||||
|
||||
it("cancels with Escape or pointercancel and never activates after a drag", () => {
|
||||
render(<Harness />);
|
||||
for (const cancel of ["Escape", "pointercancel"]) {
|
||||
pointer(row("b"), "pointerdown", 60);
|
||||
hit = row("a");
|
||||
pointer(window, "pointermove", 26);
|
||||
if (cancel === "Escape") fireEvent.keyDown(window, { key: "Escape" });
|
||||
else pointer(window, "pointercancel", 26);
|
||||
pointer(window, "pointerup", 26);
|
||||
expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
}
|
||||
drag(row("b"), row("a"));
|
||||
fireEvent.doubleClick(row("b"));
|
||||
expect(screen.getByRole("status")).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("honors Alt-only dragging and cleans up when unmounted mid-drag", () => {
|
||||
setDragRequiresAlt(() => true);
|
||||
const { unmount } = render(<Harness />);
|
||||
drag(row("b"), row("a"));
|
||||
expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
drag(row("b"), row("a"), false, true);
|
||||
expect(labels()).toEqual(["group", "b", "a", "c", "other", "d"]);
|
||||
pointer(row("b"), "pointerdown", 36, true);
|
||||
hit = row("c");
|
||||
pointer(window, "pointermove", 74, true);
|
||||
unmount();
|
||||
expect(document.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
hit = null;
|
||||
pointer(window, "pointerup", 74, true);
|
||||
});
|
||||
|
||||
it("registers the current container after empty states and async loading", () => {
|
||||
const move = vi.fn();
|
||||
const props = {
|
||||
getKey: (n: Node) => n.id,
|
||||
getLabel: (n: Node) => n.id,
|
||||
getChildren: (n: Node) => n.children,
|
||||
isExpandable: () => false,
|
||||
empty: "Nothing",
|
||||
reorder: { canMove: () => true, move },
|
||||
};
|
||||
const { rerender } = render(<Tree {...props} roots={[]} />);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
rerender(<Tree {...props} roots={[{ id: "a" }, { id: "b" }]} />);
|
||||
drag(row("b"), row("a"));
|
||||
expect(move).toHaveBeenCalledTimes(i + 1);
|
||||
rerender(<Tree {...props} roots={[]} />);
|
||||
}
|
||||
});
|
||||
|
||||
it("scrolls at the edge and stops scrolling when cancelled", () => {
|
||||
let frame: FrameRequestCallback | undefined;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((fn) => {
|
||||
frame = fn;
|
||||
return 1;
|
||||
});
|
||||
const cancel = vi.spyOn(window, "cancelAnimationFrame");
|
||||
render(<Harness />);
|
||||
const tree = screen.getByRole("tree");
|
||||
Object.defineProperties(tree, {
|
||||
scrollHeight: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
});
|
||||
pointer(row("b"), "pointerdown", 60);
|
||||
hit = row("c");
|
||||
pointer(window, "pointermove", 590);
|
||||
act(() => frame?.(0));
|
||||
expect(tree.scrollTop).toBe(8);
|
||||
act(() => frame?.(16));
|
||||
expect(tree.scrollTop).toBe(16);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(cancel).toHaveBeenCalledWith(1);
|
||||
act(() => frame?.(32));
|
||||
expect(tree.scrollTop).toBe(16);
|
||||
});
|
||||
|
||||
it("does not accept a drag from another tree with matching keys", () => {
|
||||
render(
|
||||
<>
|
||||
<Harness />
|
||||
<Harness />
|
||||
</>,
|
||||
);
|
||||
const trees = screen.getAllByRole("tree");
|
||||
drag(row("b", trees[0]!), row("a", trees[1]!));
|
||||
expect(
|
||||
within(trees[0]!)
|
||||
.getAllByRole("treeitem")
|
||||
.map((el) => el.textContent),
|
||||
).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
expect(
|
||||
within(trees[1]!)
|
||||
.getAllByRole("treeitem")
|
||||
.map((el) => el.textContent),
|
||||
).toEqual(["group", "a", "b", "c", "other", "d"]);
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -29,6 +29,8 @@ import { acquirePopup } from "./popupState";
|
||||
import styles from "./Tree.module.css";
|
||||
import { T } from "./T";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useTreeReorder, type TreeReorder } from "./treeReorder";
|
||||
export type { TreeReorder } from "./treeReorder";
|
||||
|
||||
export interface TreeRowState {
|
||||
depth: number;
|
||||
@@ -107,6 +109,8 @@ export interface TreeProps<T> {
|
||||
"aria-label"?: string;
|
||||
handleRef?: Ref<TreeHandle>;
|
||||
empty?: ReactNode;
|
||||
/** Optional display reordering between siblings; the caller owns the order. */
|
||||
reorder?: TreeReorder<T>;
|
||||
}
|
||||
|
||||
interface Row<T> {
|
||||
@@ -211,6 +215,8 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
}, [allRows, matchMap, query, speedSearchMode]);
|
||||
|
||||
const byKey = useMemo(() => new Map(allRows.map((r) => [r.key, r])), [allRows]);
|
||||
const emptyState = !rows.length && !!empty && !query;
|
||||
const reordering = useTreeReorder(scrollRef, rows, emptyState ? undefined : props.reorder);
|
||||
// aria-posinset / aria-setsize among visible siblings (rows are virtualised).
|
||||
const positions = useMemo(() => siblingPositions(rows), [rows]);
|
||||
const indexOfKey = useMemo(() => new Map(rows.map((r, i) => [r.key, i])), [rows]);
|
||||
@@ -462,8 +468,7 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
const leadRow = leadIndex >= 0 ? rows[leadIndex] : undefined;
|
||||
const noMatch = !!query && matchMap.size === 0;
|
||||
|
||||
if (!rows.length && empty && !query)
|
||||
return <div className={clsx(styles.tree, styles.emptyWrap, className)}>{empty}</div>;
|
||||
if (emptyState) return <div className={clsx(styles.tree, styles.emptyWrap, className)}>{empty}</div>;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.wrap, className)}>
|
||||
@@ -544,13 +549,18 @@ export function Tree<T>(props: TreeProps<T>) {
|
||||
rowClassName?.(row.node),
|
||||
isSelected && styles.selected,
|
||||
isLead && styles.lead,
|
||||
reordering.drop?.key === row.key && styles[reordering.drop.position],
|
||||
)}
|
||||
style={style}
|
||||
onPointerDown={props.reorder ? (e) => reordering.start(e, row) : undefined}
|
||||
onMouseDown={(e) => onRowMouseDown(e, v.index)}
|
||||
onDoubleClick={(e) => activate(row, e)}
|
||||
onDoubleClick={(e) => {
|
||||
if (!props.reorder || !reordering.suppressActivation.current) activate(row, e);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={styles.chevron}
|
||||
data-tree-toggle
|
||||
onMouseDown={(e) => {
|
||||
if (!row.expandable || e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -179,4 +179,5 @@ export function startPointerDrag(
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return onCancel;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Optional sibling reordering using the same pointer transport as editor tabs. */
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type PointerEvent, type RefObject } from "react";
|
||||
import { registerDropTarget, startPointerDrag } from "./pointerDrag";
|
||||
|
||||
export interface TreeReorder<T> {
|
||||
canMove(source: T, target: T): boolean;
|
||||
move(source: T, target: T, position: "before" | "after"): void;
|
||||
}
|
||||
|
||||
interface ReorderRow<T> {
|
||||
node: T;
|
||||
key: string;
|
||||
parentKey: string | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function useTreeReorder<T>(
|
||||
container: RefObject<HTMLDivElement | null>,
|
||||
rows: readonly ReorderRow<T>[],
|
||||
reorder: TreeReorder<T> | undefined,
|
||||
) {
|
||||
const type = `application/x-openidea-tree-${useId()}`;
|
||||
const latest = useRef({ rows, reorder });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { rows, reorder };
|
||||
});
|
||||
const cancel = useRef<(() => void) | undefined>(undefined);
|
||||
const suppressActivation = useRef(false);
|
||||
const [drop, setDrop] = useState<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const enabled = !!reorder;
|
||||
|
||||
useEffect(() => {
|
||||
const el = container.current;
|
||||
if (!el || !enabled) return;
|
||||
let frame = 0;
|
||||
let pointer: { key: string; x: number; y: number } | null = null;
|
||||
const targetAt = (key: string, x: number, y: number) => {
|
||||
const rowEl = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-row]");
|
||||
if (!rowEl || !el.contains(rowEl)) return null;
|
||||
const { rows, reorder } = latest.current;
|
||||
const source = rows.find((r) => r.key === key);
|
||||
const target = rows[Number(rowEl.dataset.row)];
|
||||
if (
|
||||
!source ||
|
||||
!target ||
|
||||
source.loading ||
|
||||
target.loading ||
|
||||
source.key === target.key ||
|
||||
source.parentKey !== target.parentKey ||
|
||||
!reorder?.canMove(source.node, target.node)
|
||||
)
|
||||
return null;
|
||||
const rect = rowEl.getBoundingClientRect();
|
||||
const position = y < rect.top + rect.height / 2 ? "before" : "after";
|
||||
return { source, target, position } as const;
|
||||
};
|
||||
const update = () => {
|
||||
const target = pointer && targetAt(pointer.key, pointer.x, pointer.y);
|
||||
setDrop(target ? { key: target.target.key, position: target.position } : null);
|
||||
};
|
||||
const scroll = () => {
|
||||
frame = 0;
|
||||
if (!pointer) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const delta = pointer.y < rect.top + 24 ? -8 : pointer.y > rect.bottom - 24 ? 8 : 0;
|
||||
const before = el.scrollTop;
|
||||
el.scrollTop = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, before + delta));
|
||||
if (el.scrollTop !== before) {
|
||||
update();
|
||||
frame = requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
const leave = () => {
|
||||
pointer = null;
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
setDrop(null);
|
||||
};
|
||||
const unregister = registerDropTarget(el, {
|
||||
accepts: (candidate) => candidate === type,
|
||||
over: (data, x, y) => {
|
||||
pointer = { key: data.payload, x, y };
|
||||
update();
|
||||
if (!frame) frame = requestAnimationFrame(scroll);
|
||||
},
|
||||
leave,
|
||||
drop: (data, x, y) => {
|
||||
const target = targetAt(data.payload, x, y);
|
||||
leave();
|
||||
if (target) latest.current.reorder?.move(target.source.node, target.target.node, target.position);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancel.current?.();
|
||||
leave();
|
||||
unregister();
|
||||
};
|
||||
}, [container, enabled, type]);
|
||||
|
||||
const start = (e: PointerEvent<HTMLElement>, row: ReorderRow<T>) => {
|
||||
suppressActivation.current = false;
|
||||
if (
|
||||
!reorder ||
|
||||
e.button !== 0 ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
e.shiftKey ||
|
||||
(e.target as Element).closest("[data-tree-toggle], button, input, a, textarea, select")
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!rows.some(
|
||||
(target) =>
|
||||
target.key !== row.key &&
|
||||
target.parentKey === row.parentKey &&
|
||||
!target.loading &&
|
||||
reorder.canMove(row.node, target.node),
|
||||
)
|
||||
)
|
||||
return;
|
||||
cancel.current?.();
|
||||
cancel.current = startPointerDrag(
|
||||
e,
|
||||
e.currentTarget,
|
||||
{ type, payload: row.key },
|
||||
{
|
||||
onEnd: (dragged) => {
|
||||
suppressActivation.current = dragged;
|
||||
cancel.current = undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
return { drop, start, suppressActivation };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача