Skip to content

Commit af94716

Browse files
committed
feat(pane): Follow Symlink / Reveal Source in the context menu
The file context menu gains a Follow row on entries where Shift+Enter does something: "Reveal Source" when the entry carries a synthetic-VFS alias (a search hit), "Follow Symlink" on a symlink, hidden otherwise — the alias-beats-symlink precedence `cmd_follow_symlink` applies. The kind is read off the row the commands will act on, which is the focused row rather than the clicked one when the right-click lands inside a multi-selection. Two context-menu fixes ride along, same code: Focus restoration. The file and pane menus never set `onCloseAutoFocus`, so Radix aimed for whatever held focus when the menu opened. Opening one context menu directly from another records a node inside the first menu, which is unmounted moments later, and closing the second dropped focus to `<body>`. All three menus now share one `refocusPane` callback. Keyboard-requested menus. The Menu key does not reach the pane as a bindable keydown; it surfaces only as a native `contextmenu` event the webview aims by hit-testing a point of its own choosing — an unrelated row, in whichever pane the point fell in, which also moved pane focus there. Those are now recognised at the document level and relayed to the active pane, which anchors the menu on its focused row. Shift+F10 and right-click keep their existing path; the pane's own dispatch carries button 2 to identify itself as pointer-positioned.
1 parent 51ffc8e commit af94716

5 files changed

Lines changed: 127 additions & 12 deletions

File tree

‎FEATURE_DUMP.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ The default browser context menu is suppressed in the main window (but not in th
265265
| Item | Shortcut |
266266
|------|----------|
267267
| Open | |
268+
| Follow Symlink / Reveal Source | Shift+Enter (symlinks and aliased entries only) |
268269
| View | F3 |
269270
| Edit | F4 |
270271
| Copy Path | Mod+C |
@@ -275,6 +276,8 @@ The default browser context menu is suppressed in the main window (but not in th
275276
| Properties | Alt+Enter |
276277
| Windows Menu | Shift+RClick (Windows host + host-local files only) |
277278

279+
The Follow row appears only when it would do something: labelled "Reveal Source" on an entry carrying a synthetic-VFS alias (a search hit), "Follow Symlink" on a symlink, absent otherwise — the same precedence `cmd_follow_symlink` applies. Like the rest of the menu it describes the entry the commands act on, which is the focused row rather than the clicked one when the right-click lands inside a multi-selection.
280+
278281
**Right-click empty space** in the file list:
279282

280283
| Item | Shortcut |

‎src/main_window/ContextMenu.tsx‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,35 @@ function ShellMenuItem({ onShellMenu }: { onShellMenu?: () => void }) {
4444
);
4545
}
4646

47+
/// What Shift+Enter offers on the entry the menu was opened over: the
48+
/// target of a symlink, or the real file behind a synthetic-VFS alias
49+
/// (a search hit). `null` on entries that are neither.
50+
export type FollowKind = "symlink" | "source" | null;
51+
4752
type FileContextMenuProps = {
4853
paneHandle: number;
4954
isParentDir: boolean;
55+
follow: FollowKind;
5056
onShellMenu?: () => void;
57+
onCloseAutoFocus?: (e: Event) => void;
5158
};
5259

5360
export function FileContextMenuContent({
5461
paneHandle,
5562
isParentDir,
63+
follow,
5664
onShellMenu,
65+
onCloseAutoFocus,
5766
}: FileContextMenuProps) {
5867
const commands = useCommands();
5968

6069
return (
6170
<CM.Portal>
62-
<CM.Content className={styles.content} loop>
71+
<CM.Content
72+
className={styles.content}
73+
loop
74+
onCloseAutoFocus={onCloseAutoFocus}
75+
>
6376
<CM.Item
6477
className={styles.item}
6578
disabled={isParentDir}
@@ -68,6 +81,15 @@ export function FileContextMenuContent({
6881
Open
6982
<Shortcut commands={commands} id="open" />
7083
</CM.Item>
84+
{follow && (
85+
<CM.Item
86+
className={styles.item}
87+
onSelect={() => safe(ipc.cmdFollowSymlink(paneHandle))}
88+
>
89+
{follow === "source" ? "Reveal Source" : "Follow Symlink"}
90+
<Shortcut commands={commands} id="follow_symlink" />
91+
</CM.Item>
92+
)}
7193
<CM.Item
7294
className={styles.item}
7395
disabled={isParentDir}
@@ -147,18 +169,24 @@ type PaneContextMenuProps = {
147169
paneHandle: number;
148170
isHostLocal: boolean;
149171
onShellMenu?: () => void;
172+
onCloseAutoFocus?: (e: Event) => void;
150173
};
151174

152175
export function PaneContextMenuContent({
153176
paneHandle,
154177
isHostLocal,
155178
onShellMenu,
179+
onCloseAutoFocus,
156180
}: PaneContextMenuProps) {
157181
const commands = useCommands();
158182

159183
return (
160184
<CM.Portal>
161-
<CM.Content className={styles.content} loop>
185+
<CM.Content
186+
className={styles.content}
187+
loop
188+
onCloseAutoFocus={onCloseAutoFocus}
189+
>
162190
{isHostLocal && (
163191
<>
164192
<CM.Item

‎src/main_window/MainWindow.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ import {
3737
} from "../lib/commands";
3838
import ModalRouter from "./modals/ModalRouter";
3939
import OperationsPanel, { OperationProgressModal } from "./OperationsPanel";
40-
import { MainWindowState } from "./types";
40+
import {
41+
KEYBOARD_MENU_EVENT,
42+
MainWindowState,
43+
isKeyboardContextMenu,
44+
} from "./types";
4145
import Pane from "./Pane";
4246
import TerminalPanel from "./TerminalPanel";
4347
import { usePreferences } from "../lib/preferences";
@@ -261,6 +265,9 @@ function App() {
261265
return;
262266
}
263267
e.preventDefault();
268+
if (isKeyboardContextMenu(e)) {
269+
window.dispatchEvent(new Event(KEYBOARD_MENU_EVENT));
270+
}
264271
};
265272
document.addEventListener("contextmenu", handler);
266273
return () => document.removeEventListener("contextmenu", handler);

‎src/main_window/Pane.tsx‎

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
PaneState,
2828
DndFileInfo,
2929
FileRowContext,
30+
KEYBOARD_MENU_EVENT,
31+
isKeyboardContextMenu,
3032
} from "./types";
3133
import type { VfsProgress } from "../lib/bindings";
3234
import { useFormatBytes, useSizeUnits } from "../lib/size";
@@ -41,6 +43,7 @@ import {
4143
PaneContextMenuContent,
4244
BreadcrumbContextMenuContent,
4345
ColumnsContextMenuContent,
46+
type FollowKind,
4447
} from "./ContextMenu";
4548
import styles from "./Pane.module.scss";
4649
import menuStyles from "./Menu.module.scss";
@@ -554,6 +557,15 @@ function FilterInput({
554557
return <div className={styles.filterInput}>{input}</div>;
555558
}
556559

560+
/// What Shift+Enter would do on a row, or `null` when it would do nothing.
561+
/// An alias source wins over the entry's own symlink target, mirroring
562+
/// `cmd_follow_symlink`.
563+
function followKind(row: FileView | undefined): FollowKind {
564+
if (!row) return null;
565+
if (row.source_display) return "source";
566+
return row.is_symlink ? "symlink" : null;
567+
}
568+
557569
// --- Shared DnD helpers (used by both internal and external drag-and-drop) ---
558570

559571
/** Clear all drop-target highlights in the document. */
@@ -825,6 +837,8 @@ function PaneInner(
825837
fileWindowRef.current = file_window;
826838
const selectedLookupRef = useRef(selectedLookup);
827839
selectedLookupRef.current = selectedLookup;
840+
const focusedRef = useRef(focused);
841+
focusedRef.current = focused;
828842

829843
// --- DnD (drag-and-drop between panes) refs ---
830844
const dndRef = useRef<LocalDndState | null>(null);
@@ -1367,15 +1381,28 @@ function PaneInner(
13671381
if (!li) return;
13681382
const rect = li.getBoundingClientRect();
13691383
setContextMenuIsParentDir(focused === "..");
1384+
// `button: 2` marks the event as carrying a real position — see the
1385+
// keyboard branch in `onContextMenu`.
13701386
li.dispatchEvent(
13711387
new MouseEvent("contextmenu", {
13721388
bubbles: true,
1389+
button: 2,
1390+
buttons: 2,
13731391
clientX: rect.left,
13741392
clientY: rect.bottom,
13751393
}),
13761394
);
13771395
}, [focused]);
13781396

1397+
// Serve keyboard-requested menus while this is the active pane, wherever
1398+
// the webview aimed its event (or if it never reached a pane at all).
1399+
useEffect(() => {
1400+
if (!active) return;
1401+
const onRequest = () => openContextMenu();
1402+
window.addEventListener(KEYBOARD_MENU_EVENT, onRequest);
1403+
return () => window.removeEventListener(KEYBOARD_MENU_EVENT, onRequest);
1404+
}, [active, openContextMenu]);
1405+
13791406
const onkeydown = (e: React.KeyboardEvent<Element>) => {
13801407
const { noModifiers } = modifiers(e);
13811408

@@ -1514,6 +1541,7 @@ function PaneInner(
15141541
const contextMenuFileRef = useRef<string | null>(null);
15151542
const contextMenuPosRef = useRef({ x: 0, y: 0 });
15161543
const [contextMenuIsParentDir, setContextMenuIsParentDir] = useState(false);
1544+
const [contextMenuFollow, setContextMenuFollow] = useState<FollowKind>(null);
15171545

15181546
const [contextMenuOnFile, setContextMenuOnFile] = useState(true);
15191547

@@ -1536,8 +1564,30 @@ function PaneInner(
15361564
[paneHandle],
15371565
);
15381566

1567+
/// Restore focus ourselves when a context menu closes. Radix would aim
1568+
/// for whatever held focus when the menu opened, which is a node inside
1569+
/// the previous menu whenever one context menu is opened directly from
1570+
/// another — unmounted by then, leaving focus on `<body>`. The column
1571+
/// header additionally isn't focusable at all.
1572+
const refocusPane = useCallback(
1573+
(e: Event) => {
1574+
e.preventDefault();
1575+
if (active && !modalOpen) {
1576+
(filter != null ? inputRef : containerRef).current?.focus();
1577+
}
1578+
},
1579+
[active, modalOpen, filter],
1580+
);
1581+
15391582
const onContextMenu = useCallback(
15401583
(e: React.MouseEvent<HTMLUListElement>) => {
1584+
// Keyboard-requested menu — MainWindow relays it to the active pane.
1585+
// preventDefault keeps Radix from anchoring at the webview's point.
1586+
if (isKeyboardContextMenu(e.nativeEvent)) {
1587+
e.preventDefault();
1588+
return;
1589+
}
1590+
15411591
// Find which file row was right-clicked
15421592
const target = e.target as HTMLElement;
15431593
const li = target.closest("li[data-name]") as HTMLElement | null;
@@ -1555,6 +1605,7 @@ function PaneInner(
15551605
// Right-clicked on empty space — show the pane-level context menu
15561606
setContextMenuOnFile(false);
15571607
setContextMenuIsParentDir(false);
1608+
setContextMenuFollow(null);
15581609
return;
15591610
}
15601611

@@ -1564,9 +1615,24 @@ function PaneInner(
15641615
setContextMenuIsParentDir(fileName === "..");
15651616

15661617
// If right-clicked file is not in the selection, focus it (clearing selection)
1567-
if (fileName !== ".." && !selectedLookupRef.current.has(fileName)) {
1618+
const inSelection = selectedLookupRef.current.has(fileName);
1619+
if (fileName !== ".." && !inSelection) {
15681620
safeSilent(commands.focus(paneHandle, fileName));
15691621
}
1622+
1623+
// Entry-specific items describe what the commands will act on: focus
1624+
// stays put when the click lands inside the selection, so that row —
1625+
// not the clicked one — is the target.
1626+
const targetKey = inSelection
1627+
? (focusedRef.current ?? fileName)
1628+
: fileName;
1629+
setContextMenuFollow(
1630+
followKind(
1631+
fileWindowRef.current.items.find(
1632+
(f) => (f.key ?? f.name) === targetKey,
1633+
),
1634+
),
1635+
);
15701636
},
15711637
[paneHandle, shellMenuAvailable, openShellMenu],
15721638
);
@@ -1857,14 +1923,7 @@ function PaneInner(
18571923
<ColumnsContextMenuContent
18581924
columns={preferences?.settings?.appearance?.columns}
18591925
traits={metadata_traits}
1860-
onCloseAutoFocus={(e) => {
1861-
// The header is not focusable — mirror the pane focus effect
1862-
// instead of letting Radix focus the trigger div.
1863-
e.preventDefault();
1864-
if (active && !modalOpen) {
1865-
(filter != null ? inputRef : containerRef).current?.focus();
1866-
}
1867-
}}
1926+
onCloseAutoFocus={refocusPane}
18681927
/>
18691928
</ContextMenu.Root>
18701929
{file_window && (
@@ -1920,6 +1979,8 @@ function PaneInner(
19201979
<FileContextMenuContent
19211980
paneHandle={paneHandle}
19221981
isParentDir={contextMenuIsParentDir}
1982+
follow={contextMenuFollow}
1983+
onCloseAutoFocus={refocusPane}
19231984
onShellMenu={
19241985
shellMenuAvailable
19251986
? () => void openShellMenu(contextMenuFileRef.current)
@@ -1930,6 +1991,7 @@ function PaneInner(
19301991
<PaneContextMenuContent
19311992
paneHandle={paneHandle}
19321993
isHostLocal={props.is_host_local}
1994+
onCloseAutoFocus={refocusPane}
19331995
onShellMenu={
19341996
shellMenuAvailable ? () => void openShellMenu(null) : undefined
19351997
}

‎src/main_window/types.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,21 @@ export type Terminal = {
164164
/// doesn't drift when DndFile gains optional fields.
165165
export type DndFileInfo = DndFile;
166166

167+
/// Window event carrying a keyboard-requested context menu to the active
168+
/// pane, dispatched by the document-level handler in MainWindow.
169+
export const KEYBOARD_MENU_EVENT = "newt:keyboard-context-menu";
170+
171+
/// Whether a `contextmenu` event came from the keyboard rather than a
172+
/// pointer. The Menu key produces no keydown the panes can bind, only this
173+
/// event, and the webview aims it by hit-testing a point of its own
174+
/// choosing — an unrelated row, in whichever pane the point fell in — so it
175+
/// must be recognised and re-routed rather than acted on where it landed.
176+
/// A pointer carries button 2; macOS ctrl+click reports no button either,
177+
/// but carries `ctrlKey`.
178+
export function isKeyboardContextMenu(e: MouseEvent): boolean {
179+
return e.button !== 2 && !e.ctrlKey;
180+
}
181+
167182
export type MainWindowState = {
168183
connection_status: ConnectionStatus;
169184
askpass?: AskpassPrompt;

0 commit comments

Comments
 (0)