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]>
Цей коміт міститься в:
wangxgaтаwangxga authored and GitHub committed 2026-10-31 01:28:41 +08:00
1 джерело 0660f3c020
коміт a3cd99db1f
16 files changed
+985 -17

No files matched your search

+17
Переглянути файл
@@ -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;
+213
Переглянути файл
@@ -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();
+1
Переглянути файл
@@ -179,4 +179,5 @@ export function startPointerDrag(
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey, true);
return onCancel;
}
+135
Переглянути файл
@@ -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 };
}