diff --git a/src/components/contextmenu/index.js b/src/components/contextmenu/index.js
index 025977f2fe..c3e6131925 100644
--- a/src/components/contextmenu/index.js
+++ b/src/components/contextmenu/index.js
@@ -118,13 +118,179 @@ export default function Contextmenu(content, options) {
show();
}
+ /**
+ * Whether a direct child of the menu is an enabled, actionable row.
+ *
+ * Separators (`
`) and rows marked `disabled`/`separator` are excluded,
+ * so keyboard users can neither focus them nor trigger actions the menu
+ * presents as unavailable. Without this, Enter/Space would dispatch a click
+ * on a disabled row, and a programmatic click bypasses the CSS
+ * `pointer-events: none` that normally blocks it.
+ * @param {Element|null|undefined} $item
+ * @returns {boolean}
+ */
+ function isActionableItem($item) {
+ if (!$item || $item.tagName === "HR") return false;
+ if ($item.classList.contains("disabled")) return false;
+ if ($item.classList.contains("separator")) return false;
+ return true;
+ }
+
+ /**
+ * All enabled, actionable rows in the menu, in visual order.
+ * @returns {HTMLElement[]}
+ */
+ function getActionableItems() {
+ if ($el.classList.contains("disabled")) return [];
+ return [...$el.children].filter(isActionableItem);
+ }
+
function addTabindex() {
/**@type {Array} */
const children = [...$el.children];
- for (let $el of children) $el.tabIndex = "0";
+ for (let $child of children) {
+ if (isActionableItem($child)) {
+ $child.tabIndex = "0";
+ } else {
+ $child.removeAttribute("tabindex");
+ }
+ }
+ }
+
+ /**
+ * Returns the currently focused actionable menu row, or null if focus is
+ * on a disabled row, a nested control, or outside the menu.
+ * @returns {HTMLElement|null}
+ */
+ function getFocusedItem() {
+ const $active = document.activeElement;
+ if (!$active || $active.parentElement !== $el) return null;
+ return isActionableItem($active) ? $active : null;
+ }
+
+ /**
+ * Moves focus to the next/previous actionable menu row, wrapping around at
+ * the ends. `direction` is +1 for next, -1 for previous.
+ * @param {number} direction
+ */
+ function moveFocus(direction) {
+ const items = getActionableItems();
+ if (!items.length) return;
+
+ const currentIndex = items.indexOf(document.activeElement);
+ let nextIndex;
+ if (currentIndex === -1) {
+ nextIndex = direction > 0 ? 0 : items.length - 1;
+ } else {
+ nextIndex = (currentIndex + direction + items.length) % items.length;
+ }
+ items[nextIndex]?.focus();
+ }
+
+ /**
+ * Turns a keyboard activation (Enter/Space on a focused menu row) into a
+ * real click event, so both selection patterns this component supports
+ * handle it identically to a pointer click:
+ * - the `items`/`onselect` array form, whose routing lives in the
+ * `onclick` handler above
+ * - a consumer's own click listener attached directly to $el, as used by
+ * menus built with the `innerHTML` option
+ *
+ * The dispatched event is marked with `keyboardActivated` so consumers that
+ * filter out synthetic `detail === 0` ghost clicks (e.g. to ignore the click
+ * that follows a touch-based long press) can still recognize and allow this
+ * one through.
+ *
+ * Disabled rows and separators are ignored, so they can never be activated
+ * from the keyboard.
+ * @param {HTMLElement|null} $item
+ */
+
+ function activateItem($item) {
+ if ($el.classList.contains("disabled")) return;
+ if (!$item || $item.parentElement !== $el || !isActionableItem($item))
+ return;
+ const clickEvent = new MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ view: window,
+ });
+ Object.defineProperty(clickEvent, "keyboardActivated", {
+ value: true,
+ });
+ $item.dispatchEvent(clickEvent);
+ }
+
+ /**
+ * Whether a keydown event should be handled by the menu's keyboard
+ * navigation. Keys originating from interactive descendants (e.g. the file
+ * menu's read-only checkbox) are left alone so those controls keep their
+ * native Space/Enter behavior.
+ * @param {KeyboardEvent} e
+ * @returns {boolean}
+ */
+ function isMenuKeyEvent(e) {
+ const $target = e.target;
+ if ($target === $el) return true;
+ return (
+ $target instanceof Element &&
+ $target.parentElement === $el &&
+ isActionableItem($target)
+ );
+ }
+
+ /**
+ * Keyboard support for the menu: Enter/Space activates the focused row,
+ * Up/Down arrows move focus between actionable rows (wrapping at the ends),
+ * Home/End jump to the first/last actionable row, and Escape closes the menu
+ * and returns focus to the toggler.
+ *
+ * Events originating from nested interactive controls are ignored (except
+ * Escape), so those controls are never prevented from handling their own
+ * keys.
+ * @param {KeyboardEvent} e
+ */
+ function onMenuKeydown(e) {
+ if (e.key === "Escape" || e.key === "Esc") {
+ e.preventDefault();
+ hide();
+ options.toggler?.focus?.();
+ return;
+ }
+
+ if (!isMenuKeyEvent(e)) return;
+
+ switch (e.key) {
+ case "Enter":
+ case " ":
+ case "Spacebar":
+ e.preventDefault();
+ activateItem(getFocusedItem());
+ break;
+ case "ArrowDown":
+ case "Down":
+ e.preventDefault();
+ moveFocus(1);
+ break;
+ case "ArrowUp":
+ case "Up":
+ e.preventDefault();
+ moveFocus(-1);
+ break;
+ case "Home":
+ e.preventDefault();
+ getActionableItems()[0]?.focus();
+ break;
+ case "End": {
+ const items = getActionableItems();
+ items[items.length - 1]?.focus();
+ break;
+ }
+ }
}
function destroy() {
+ $el.removeEventListener("keydown", onMenuKeydown);
$el.remove();
$mask.remove();
options.toggler?.removeEventListener("click", toggle);
@@ -134,6 +300,8 @@ export default function Contextmenu(content, options) {
options.toggler.addEventListener("click", toggle);
}
+ $el.addEventListener("keydown", onMenuKeydown);
+
$el.hide = hide;
$el.show = show;
$el.destroy = destroy;
diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js
index 77ea649ea4..8f082af8d9 100644
--- a/src/handlers/editorFileTab.js
+++ b/src/handlers/editorFileTab.js
@@ -1,10 +1,17 @@
import { focusEditorIfEditable } from "cm/editorReadOnly";
+import openTabContextMenu from "handlers/tabContextMenu";
import config from "lib/config";
import settings from "lib/settings";
import { animate } from "motion";
const opts = { passive: false };
+/**
+ * How far the pointer must travel before a long press counts as a drag
+ * instead of a tab context menu request.
+ */
+const DRAG_MENU_SLOP = 8;
+
/**
* Clone of tab being dragged
* @type {HTMLDivElement}
@@ -78,6 +85,23 @@ let prevScrollLeft = 0;
let initialNextSibling = null;
let didReorder = false;
let dragSessionId = 0;
+/**
+ * Whether the pointer moved far enough during this drag session to count as a
+ * real drag. When the drag session ends without any drag, the tab context
+ * menu is shown instead.
+ * @type {boolean}
+ */
+let didDrag = false;
+/**
+ * Pointer position where the current drag session started.
+ * @type {number}
+ */
+let dragOriginX = 0;
+/**
+ * Pointer position where the current drag session started.
+ * @type {number}
+ */
+let dragOriginY = 0;
const MIN_SCROLL_SPEED = 2;
const MAX_SCROLL_SPEED = 14;
@@ -96,6 +120,10 @@ export default function startDrag(e) {
const { clientX, clientY } = getClientPos(e);
const { editor, activeFile } = editorManager;
+ dragOriginX = clientX;
+ dragOriginY = clientY;
+ didDrag = false;
+
if (activeFile.focusedBefore) {
focusEditorIfEditable(editor);
}
@@ -177,6 +205,14 @@ function onDrag(e) {
const { clientX, clientY } = getClientPos(e);
+ if (
+ !didDrag &&
+ (Math.abs(clientX - dragOriginX) > DRAG_MENU_SLOP ||
+ Math.abs(clientY - dragOriginY) > DRAG_MENU_SLOP)
+ ) {
+ didDrag = true;
+ }
+
tabLeft = clientX - offsetX;
tabTop = clientY - offsetY;
@@ -206,6 +242,20 @@ function onDrag(e) {
function releaseDrag(e) {
const { clientX, clientY } = getClientPos(e);
+ // A long press (or right click) that ends without moving the pointer is a
+ // request for the tab context menu, not a drag.
+ const openContextMenu =
+ !didDrag &&
+ !!draggedFile &&
+ (e.type === "mouseup" || e.type === "touchend");
+
+ if (openContextMenu) {
+ const file = draggedFile;
+ finishDrag(false);
+ openTabContextMenu(file);
+ return;
+ }
+
/**@type {HTMLDivElement} target tab */
const $target = document.elementFromPoint(clientX, clientY);
const isPathDropTarget = isFilePathDropTarget($target);
@@ -311,6 +361,9 @@ function cleanupDrag(state = getCurrentDragState()) {
allowPaneTransfer = true;
initialNextSibling = null;
didReorder = false;
+ didDrag = false;
+ dragOriginX = 0;
+ dragOriginY = 0;
}
function preventDefaultScroll() {
diff --git a/src/handlers/tabContextMenu.js b/src/handlers/tabContextMenu.js
new file mode 100644
index 0000000000..d4712b7cfe
--- /dev/null
+++ b/src/handlers/tabContextMenu.js
@@ -0,0 +1,262 @@
+import Contextmenu from "components/contextmenu";
+
+const EDGE_MARGIN = 8;
+const MENU_WIDTH_ESTIMATE = 240;
+const MENU_ITEM_HEIGHT = 50;
+const MENU_ITEM_COUNT = 4;
+const MENU_HEIGHT_ESTIMATE = MENU_ITEM_COUNT * MENU_ITEM_HEIGHT;
+const GAP = 4;
+const SYNTHETIC_CLICK_WINDOW = 700;
+
+/**
+ * How far the pointer must travel before a long press counts as a drag
+ * instead of a tab context menu request. Matches the slop used in
+ * handlers/editorFileTab.js so the sidebar long-press gesture behaves
+ * consistently with the tab-bar drag gesture.
+ */
+const DRAG_MENU_SLOP = 8;
+
+/**
+ * Open the context menu for a file tab.
+ *
+ * Actions are executed through `acode.exec`, so they use the same commands
+ * and prompts as the rest of the app:
+ * - close-tab close the pressed tab
+ * - close-tabs-in-group close all tabs in the same tab group/pane
+ * - close-tabs-to-left close tabs left of the pressed tab
+ * - close-tabs-to-right close tabs right of the pressed tab
+ *
+ * @param {object} file The `EditorFile` whose tab was long pressed / right clicked
+ * @returns {HTMLElement|undefined} The context menu element, if it was shown.
+ */
+export default function openTabContextMenu(file) {
+ if (!file || !file.tab || !file.tab.isConnected) return;
+
+ const menu = Contextmenu({
+ ...positionMenu(file.tab),
+ innerHTML: getMenuItemsHtml,
+ });
+
+ const guardUntil = Date.now() + SYNTHETIC_CLICK_WINDOW;
+
+ /**
+ * Touch gestures that open a context menu can be followed by a synthetic
+ * click (detail === 0). Ignore those so releasing the finger does not
+ * accidentally activate a menu item underneath it.
+ *
+ * Keyboard-activated clicks dispatched by the Contextmenu component are
+ * also `detail === 0` (since they're synthetic MouseEvents), but they're
+ * marked with `keyboardActivated` so they're never mistaken for a
+ * touch-release ghost click and suppressed here.
+ * @param {MouseEvent} event
+ */
+ const suppressSyntheticClick = (event) => {
+ if (Date.now() > guardUntil) return;
+ if (event.keyboardActivated) return;
+ if (event.detail !== 0) return;
+ if (!menu.contains(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation?.();
+ };
+
+ const removeSuppressor = () => {
+ document.removeEventListener("click", suppressSyntheticClick, true);
+ };
+
+ // Make sure the document-level suppressor is always detached, even if the
+ // menu is destroyed without ever firing `onhide`.
+ const originalDestroy = menu.destroy;
+ menu.destroy = () => {
+ removeSuppressor();
+ originalDestroy();
+ };
+
+ document.addEventListener("click", suppressSyntheticClick, true);
+ menu.onhide = removeSuppressor;
+
+ menu.addEventListener("click", (event) => {
+ // A synthetic click with detail === 0 that isn't marked as a real
+ // keyboard activation is a touch-release ghost click; ignore it.
+ if (event.detail === 0 && !event.keyboardActivated) return;
+ const $target = event.target;
+ const action = $target?.getAttribute?.("action");
+ if (!action) return;
+ menu.hide();
+ removeSuppressor();
+ acode.exec(action, file.id);
+ });
+
+ menu.show();
+ repositionMenu(menu, file.tab);
+ return menu;
+}
+
+/**
+ * Show the tab context menu after the ongoing long press / right click ends.
+ *
+ * Used in layouts where a long press does not start a tab drag (sidebar open
+ * file list). Opening while the finger is still down would let the release
+ * hit the menu, so the menu is opened when the pointer is lifted instead.
+ *
+ * A small movement threshold (DRAG_MENU_SLOP) is allowed before the gesture
+ * is treated as a drag/scroll rather than a menu request, matching the
+ * tab-bar drag gesture in handlers/editorFileTab.js. Without this, ordinary
+ * finger drift during a long press would cancel the menu before touchend.
+ *
+ * @param {object} file The `EditorFile` whose tab was long pressed / right clicked
+ * @param {MouseEvent} event The contextmenu event
+ */
+export function openTabContextMenuOnRelease(file, event) {
+ if (!file || !file.tab || !file.tab.isConnected) return;
+ event.preventDefault?.();
+ event.stopPropagation?.();
+
+ const { clientX: originX, clientY: originY } = getEventClientPos(event);
+
+ let opened = false;
+ let cancelled = false;
+
+ const open = () => {
+ if (opened || cancelled) return;
+ opened = true;
+ cleanup();
+ openTabContextMenu(file);
+ };
+
+ const onPointerMove = (moveEvent) => {
+ const { clientX, clientY } = getEventClientPos(moveEvent);
+ if (
+ Math.abs(clientX - originX) <= DRAG_MENU_SLOP &&
+ Math.abs(clientY - originY) <= DRAG_MENU_SLOP
+ ) {
+ // Still within the slop threshold; treat as a stationary long
+ // press rather than a drag/scroll attempt.
+ return;
+ }
+ // Moved far enough after the long press means the user intends to
+ // scroll or drag, not to open a menu.
+ cancelled = true;
+ cleanup();
+ };
+
+ const onCancel = () => {
+ cancelled = true;
+ cleanup();
+ };
+
+ function cleanup() {
+ document.removeEventListener("touchmove", onPointerMove, true);
+ document.removeEventListener("touchend", open, true);
+ document.removeEventListener("touchcancel", onCancel, true);
+ document.removeEventListener("mouseup", open, true);
+ document.removeEventListener("mouseleave", onCancel, true);
+ }
+
+ document.addEventListener("touchmove", onPointerMove, true);
+ document.addEventListener("touchend", open, true);
+ document.addEventListener("touchcancel", onCancel, true);
+ document.addEventListener("mouseup", open, true);
+ document.addEventListener("mouseleave", onCancel, true);
+}
+
+/**
+ * Extracts the client X/Y position from a mouse or touch event.
+ * @param {MouseEvent|TouchEvent} e
+ * @returns {{clientX: number, clientY: number}}
+ */
+function getEventClientPos(e) {
+ const touch = e.touches?.[0] || e.changedTouches?.[0];
+ if (touch) {
+ return { clientX: touch.clientX, clientY: touch.clientY };
+ }
+ return { clientX: e.clientX ?? 0, clientY: e.clientY ?? 0 };
+}
+
+function getMenuItemsHtml() {
+ const closeText = strings["close file"] || "Close file";
+ const closeAllText = strings["close all"] || "Close all";
+ const closeLeftText = strings["close tabs to left"] || "Close Left";
+ const closeRightText = strings["close tabs to right"] || "Close Right";
+ return `
+ ${closeText}
+ ${closeAllText}
+ ${closeLeftText}
+ ${closeRightText}
+ `;
+}
+
+/**
+ * Initial position for the menu next to the tab, based on size estimates so
+ * the first paint is already close to the final place. `repositionMenu` then
+ * corrects it using the real, measured size.
+ * @param {HTMLElement} $tab
+ * @returns {object}
+ */
+function positionMenu($tab) {
+ const rect = $tab.getBoundingClientRect();
+ const style = {};
+
+ if (rect.left + MENU_WIDTH_ESTIMATE <= innerWidth - EDGE_MARGIN) {
+ style.left = `${Math.max(EDGE_MARGIN, rect.left)}px`;
+ } else {
+ style.right = `${Math.max(EDGE_MARGIN, innerWidth - rect.right)}px`;
+ }
+
+ if (rect.bottom + MENU_HEIGHT_ESTIMATE <= innerHeight - EDGE_MARGIN) {
+ style.top = `${rect.bottom + GAP}px`;
+ style.transformOrigin = "top center";
+ } else {
+ style.bottom = `${Math.max(EDGE_MARGIN, innerHeight - rect.top + GAP)}px`;
+ style.transformOrigin = "bottom center";
+ }
+
+ return style;
+}
+
+/**
+ * Correct the menu position using its measured size after it has been shown,
+ * flipping/clamping so it always stays fully inside the viewport. `offsetWidth`
+ * and `offsetHeight` are used instead of `getBoundingClientRect()` so the
+ * measurement is unaffected by the menu's open/close transform animation.
+ * @param {HTMLElement} menu
+ * @param {HTMLElement} $tab
+ */
+function repositionMenu(menu, $tab) {
+ if (!menu.isConnected) return;
+
+ const menuWidth = menu.offsetWidth;
+ const menuHeight = menu.offsetHeight;
+ if (!menuWidth || !menuHeight) return;
+
+ const tabRect = $tab.getBoundingClientRect();
+ const viewportWidth = innerWidth;
+ const viewportHeight = innerHeight;
+
+ const maxLeft = Math.max(
+ EDGE_MARGIN,
+ viewportWidth - menuWidth - EDGE_MARGIN,
+ );
+ let left = tabRect.left;
+ if (left + menuWidth > viewportWidth - EDGE_MARGIN) {
+ left = tabRect.right - menuWidth;
+ }
+ left = Math.min(Math.max(EDGE_MARGIN, left), maxLeft);
+ menu.style.left = `${left}px`;
+ menu.style.right = "auto";
+
+ let transformOrigin = "top center";
+ const maxTop = Math.max(
+ EDGE_MARGIN,
+ viewportHeight - menuHeight - EDGE_MARGIN,
+ );
+ let top = tabRect.bottom + GAP;
+ if (top + menuHeight > viewportHeight - EDGE_MARGIN) {
+ top = tabRect.top - menuHeight - GAP;
+ transformOrigin = "bottom center";
+ }
+ top = Math.min(Math.max(EDGE_MARGIN, top), maxTop);
+ menu.style.top = `${top}px`;
+ menu.style.bottom = "auto";
+ menu.style.transformOrigin = transformOrigin;
+}
diff --git a/src/lib/commands.js b/src/lib/commands.js
index ac3ed5dd69..7d7082d042 100644
--- a/src/lib/commands.js
+++ b/src/lib/commands.js
@@ -45,7 +45,32 @@ function resolveReferenceFile(referenceFile) {
return getFile(referenceFile, "id") || activeFile;
}
if (referenceFile?.id) {
- return getFile(referenceFile.id, "id") || referenceFile;
+ return getFile(referenceFile.id, "id") || null;
+ }
+
+ return referenceFile;
+}
+
+/**
+ * Resolve a file reference strictly by identity, without falling back to the
+ * active file.
+ *
+ * Used by commands that target one specific tab (e.g. the tab a context menu
+ * was opened on). If the referenced file no longer exists, these commands must
+ * do nothing instead of silently acting on whatever is currently active — a
+ * stale menu would otherwise close the wrong tab.
+ * @param {string|object} referenceFile
+ * @returns {EditorFile|null}
+ */
+function resolveExactFile(referenceFile) {
+ const { getFile } = editorManager;
+
+ if (!referenceFile) return null;
+ if (typeof referenceFile === "string") {
+ return getFile(referenceFile, "id") || null;
+ }
+ if (referenceFile?.id) {
+ return getFile(referenceFile.id, "id") || null;
}
return referenceFile;
@@ -134,6 +159,29 @@ export default {
async "close-all-tabs"() {
await closeTabs(editorManager.files);
},
+ /**
+ * Close every tab shown in the same tab group (pane tab bar) as the
+ * reference file. In the sidebar layout all tabs belong to one visible
+ * group, so all open files are closed.
+ *
+ * Resolved strictly: if the referenced file is gone (e.g. a stale context
+ * menu), do nothing rather than closing an unrelated group.
+ */
+ async "close-tabs-in-group"(referenceFile) {
+ const file = referenceFile
+ ? resolveExactFile(referenceFile)
+ : editorManager.activeFile;
+ if (!file) return false;
+
+ const { openFileListPos } = appSettings.value;
+ const isPaneTabLayout =
+ openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER ||
+ openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM;
+ const files = isPaneTabLayout
+ ? editorManager.getPaneFiles?.(file) || editorManager.files
+ : editorManager.files;
+ return closeTabs(files);
+ },
async "close-tabs-to-left"(referenceFile) {
await closeTabs(
getTabsRelativeToFile("left", referenceFile),
@@ -166,6 +214,20 @@ export default {
"close-current-tab"() {
editorManager.activeFile?.remove();
},
+ /**
+ * Close the tab of the given file (which may not be the active file).
+ *
+ * Resolved strictly by identity: if an explicit id is provided but that tab
+ * no longer exists (e.g. the menu went stale), this returns false instead of
+ * falling back to the active file and closing the wrong tab.
+ */
+ "close-tab"(referenceFile) {
+ const file = referenceFile
+ ? resolveExactFile(referenceFile)
+ : editorManager.activeFile;
+ if (!file) return false;
+ return file.remove();
+ },
"new-pane"() {
return editorManager.createPane?.();
},
diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js
index e638cdb84b..f5074fa687 100644
--- a/src/lib/editorFile.js
+++ b/src/lib/editorFile.js
@@ -15,6 +15,7 @@ import confirm from "dialogs/confirm";
import DOMPurify from "dompurify";
import startDrag from "handlers/editorFileTab";
import actions from "handlers/quickTools";
+import { openTabContextMenuOnRelease } from "handlers/tabContextMenu";
import tag from "html-tag-js";
import mimeTypes from "mime-types";
import { applyHighlightStyles } from "utils/codeHighlight";
@@ -736,9 +737,18 @@ export default class EditorFile {
openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER ||
openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM
) {
+ // In tab bar layouts a long press starts a drag; the tab context
+ // menu opens when the drag ends without moving (see editorFileTab).
this.#tab.oncontextmenu = startDrag;
} else {
- this.#tab.oncontextmenu = null;
+ // Sidebar layout has no tab drag, so open the context menu when
+ // the long press / right click ends.
+ this.#tab.oncontextmenu = (event) => {
+ if (appSettings.value.vibrateOnTap) {
+ navigator.vibrate(config.VIBRATION_TIME);
+ }
+ openTabContextMenuOnRelease(this, event);
+ };
}
};