Skip to content

Commit 4dfb34a

Browse files
Merge pull request #23 from RtlZeroMemory/feat/filetree-context-menu
FileTreeExplorer: wire onContextMenu (right click)
2 parents e1871df + fba3dd0 commit 4dfb34a

File tree

3 files changed

+140
-0
lines changed

3 files changed

+140
-0
lines changed

docs/widgets/file-tree-explorer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ ui.fileTreeExplorer({
3636
| `onContextMenu` | `(node) => void` | - | Context menu callback |
3737
| `renderNode` | `(node, depth, state) => VNode` | - | Custom renderer |
3838

39+
## Behavior
40+
41+
- **Arrow keys** navigate. **Enter** activates the focused node.
42+
- **Right click** on a node calls `onContextMenu(node)` when provided.
43+
3944
## Notes
4045

4146
- `FileNode` includes `name`, `path`, `type`, and optional `children` and `status`.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { assert, describe, test } from "@rezi-ui/testkit";
2+
import type { RuntimeBackend } from "../../backend.js";
3+
import type { ZrevEvent } from "../../events.js";
4+
import { ui } from "../../index.js";
5+
import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js";
6+
import { defaultTheme } from "../../theme/defaultTheme.js";
7+
import type { FileNode } from "../../widgets/types.js";
8+
import { WidgetRenderer } from "../widgetRenderer.js";
9+
10+
function noRenderHooks(): { enterRender: () => void; exitRender: () => void } {
11+
return { enterRender: () => {}, exitRender: () => {} };
12+
}
13+
14+
function mouseEvent(
15+
x: number,
16+
y: number,
17+
mouseKind: 1 | 2 | 3 | 4 | 5,
18+
opts: Readonly<{ buttons?: number; timeMs?: number }> = {},
19+
): ZrevEvent {
20+
return {
21+
kind: "mouse",
22+
timeMs: opts.timeMs ?? 0,
23+
x,
24+
y,
25+
mouseKind,
26+
mods: 0,
27+
buttons: opts.buttons ?? 0,
28+
wheelX: 0,
29+
wheelY: 0,
30+
};
31+
}
32+
33+
function createNoopBackend(): RuntimeBackend {
34+
return {
35+
start: async () => {},
36+
stop: async () => {},
37+
dispose: () => {},
38+
requestFrame: async () => {},
39+
pollEvents: async () =>
40+
new Promise((_) => {
41+
// Not used by WidgetRenderer unit-style tests.
42+
}),
43+
postUserEvent: () => {},
44+
getCaps: async () => DEFAULT_TERMINAL_CAPS,
45+
};
46+
}
47+
48+
describe("FileTreeExplorer context menu", () => {
49+
test("right click calls onContextMenu for the node under cursor", () => {
50+
const backend = createNoopBackend();
51+
const renderer = new WidgetRenderer<void>({
52+
backend,
53+
requestRender: () => {},
54+
});
55+
56+
const calls: string[] = [];
57+
58+
const data: readonly FileNode[] = Object.freeze([
59+
Object.freeze({ name: "a", path: "/a", type: "file" }),
60+
Object.freeze({ name: "b", path: "/b", type: "file" }),
61+
]);
62+
63+
const vnode = ui.fileTreeExplorer({
64+
id: "fte",
65+
data,
66+
expanded: [],
67+
onToggle: () => {},
68+
onSelect: () => {},
69+
onActivate: () => {},
70+
onContextMenu: (node) => calls.push(node.path),
71+
});
72+
73+
const res = renderer.submitFrame(
74+
() => vnode,
75+
undefined,
76+
{ cols: 20, rows: 5 },
77+
defaultTheme,
78+
noRenderHooks(),
79+
);
80+
assert.ok(res.ok);
81+
82+
// Right-click (buttons bit 4) on the second row (index 1).
83+
renderer.routeEngineEvent(mouseEvent(0, 1, 3, { buttons: 4 }));
84+
assert.deepEqual(calls, ["/b"]);
85+
86+
// Middle click should not fire context menu.
87+
renderer.routeEngineEvent(mouseEvent(0, 0, 3, { buttons: 2 }));
88+
assert.deepEqual(calls, ["/b"]);
89+
90+
// Left click should not fire context menu.
91+
renderer.routeEngineEvent(mouseEvent(0, 0, 3, { buttons: 1 }));
92+
assert.deepEqual(calls, ["/b"]);
93+
});
94+
});

packages/core/src/app/widgetRenderer.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,47 @@ export class WidgetRenderer<S> {
17871787
}
17881788
}
17891789

1790+
// Right-click context menu for FileTreeExplorer.
1791+
if (event.kind === "mouse" && event.mouseKind === 3) {
1792+
const targetId = mouseTargetId;
1793+
if (targetId !== null) {
1794+
const fte = this.fileTreeExplorerById.get(targetId);
1795+
const rect = this.rectById.get(targetId);
1796+
if (fte && rect && typeof fte.onContextMenu === "function") {
1797+
const RIGHT_BUTTON = 1 << 2;
1798+
if ((event.buttons & RIGHT_BUTTON) !== 0) {
1799+
const localY = event.y - rect.y;
1800+
const inBounds = localY >= 0 && localY < rect.h;
1801+
if (inBounds) {
1802+
const state = this.treeStore.get(fte.id);
1803+
const flatNodes =
1804+
readFileNodeFlatCache(state, fte.data, fte.expanded) ??
1805+
(() => {
1806+
const next = flattenTree(
1807+
fte.data,
1808+
fileNodeGetKey,
1809+
fileNodeGetChildren,
1810+
fileNodeHasChildren,
1811+
fte.expanded,
1812+
);
1813+
this.treeStore.set(fte.id, {
1814+
flatCache: makeFileNodeFlatCache(fte.data, fte.expanded, next),
1815+
});
1816+
return next;
1817+
})();
1818+
1819+
const idx = Math.max(0, state.scrollTop) + localY;
1820+
const fn = flatNodes[idx];
1821+
if (fn) {
1822+
fte.onContextMenu(fn.node);
1823+
localNeedsRender = true;
1824+
}
1825+
}
1826+
}
1827+
}
1828+
}
1829+
}
1830+
17901831
const res: RoutingResult & { nextZoneId?: string | null } =
17911832
event.kind === "key"
17921833
? routeKeyWithZones(event, {

0 commit comments

Comments
 (0)