From b31f625a4346460c0942bc8a212e962a2d53afd6 Mon Sep 17 00:00:00 2001 From: TonyGeez Date: Mon, 7 Sep 2026 22:39:41 -0400 Subject: [PATCH 1/4] Added long press on files tab > close all files --- .mcp.json | 12 +++ src/handlers/editorFileTab.js | 53 +++++++++++ src/handlers/tabContextMenu.js | 159 +++++++++++++++++++++++++++++++++ src/lib/commands.js | 24 +++++ src/lib/editorFile.js | 12 ++- 5 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 .mcp.json create mode 100644 src/handlers/tabContextMenu.js diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..fca413010 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "fs": { + "type": "stdio", + "command": "node", + "args": [ + "/home/tony/mcp-tools/filesystem/dist/index.js" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 77ea649ea..5b1ec94e5 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -2,9 +2,16 @@ import { focusEditorIfEditable } from "cm/editorReadOnly"; import config from "lib/config"; import settings from "lib/settings"; import { animate } from "motion"; +import openTabContextMenu from "handlers/tabContextMenu"; 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 000000000..fd44d393d --- /dev/null +++ b/src/handlers/tabContextMenu.js @@ -0,0 +1,159 @@ +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; + +/** + * 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. + * @param {MouseEvent} event + */ + const suppressSyntheticClick = (event) => { + if (Date.now() > guardUntil) 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); + }; + + document.addEventListener("click", suppressSyntheticClick, true); + menu.onhide = removeSuppressor; + + menu.addEventListener("click", (event) => { + if (event.detail === 0) return; // synthetic click, ignore + const $target = event.target; + const action = $target?.getAttribute?.("action"); + if (!action) return; + menu.hide(); + removeSuppressor(); + acode.exec(action, file.id); + }); + + menu.show(); + 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. + * + * @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?.(); + + let opened = false; + let cancelled = false; + + const open = () => { + if (opened || cancelled) return; + opened = true; + cleanup(); + openTabContextMenu(file); + }; + + const onPointerMove = () => { + // Moving 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); +} + +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}
  • + `; +} + +/** + * Position the menu next to the tab, flipping when there is not enough room. + * @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; +} diff --git a/src/lib/commands.js b/src/lib/commands.js index ac3ed5dd6..afa1be0d9 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -134,6 +134,22 @@ 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. + */ + async "close-tabs-in-group"(referenceFile) { + const file = resolveReferenceFile(referenceFile); + 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; + await closeTabs(files); + }, async "close-tabs-to-left"(referenceFile) { await closeTabs( getTabsRelativeToFile("left", referenceFile), @@ -166,6 +182,14 @@ export default { "close-current-tab"() { editorManager.activeFile?.remove(); }, + /** + * Close the tab of the given file (which may not be the active file). + */ + "close-tab"(referenceFile) { + const file = resolveReferenceFile(referenceFile); + 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 e638cdb84..1fa4522a7 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -14,6 +14,7 @@ import toast from "components/toast"; import confirm from "dialogs/confirm"; import DOMPurify from "dompurify"; import startDrag from "handlers/editorFileTab"; +import { openTabContextMenuOnRelease } from "handlers/tabContextMenu"; import actions from "handlers/quickTools"; import tag from "html-tag-js"; import mimeTypes from "mime-types"; @@ -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); + }; } }; From f189eaba4f9489e868ef1d2b34c7e8db500faa18 Mon Sep 17 00:00:00 2001 From: Tony <141154740+TonyGeez@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:55:57 -0400 Subject: [PATCH 2/4] rm local tools --- .mcp.json | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index fca413010..000000000 --- a/.mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "fs": { - "type": "stdio", - "command": "node", - "args": [ - "/home/tony/mcp-tools/filesystem/dist/index.js" - ], - "env": {} - } - } -} \ No newline at end of file From f2bda610bd1d519ae97844855692fc6d518c07a1 Mon Sep 17 00:00:00 2001 From: TonyGeez Date: Mon, 7 Sep 2026 23:35:15 -0400 Subject: [PATCH 3/4] Fix drag-jitter cancelling tab menu; add keyboard nav to Contextmenu --- src/components/contextmenu/index.js | 103 ++++++++++++++++++++++++++++ src/handlers/tabContextMenu.js | 53 ++++++++++++-- 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/components/contextmenu/index.js b/src/components/contextmenu/index.js index 025977f2f..332254f6c 100644 --- a/src/components/contextmenu/index.js +++ b/src/components/contextmenu/index.js @@ -124,7 +124,108 @@ export default function Contextmenu(content, options) { for (let $el of children) $el.tabIndex = "0"; } + /** + * Returns the currently focused menu item, or null if focus is + * elsewhere (e.g. nothing focused yet, or focus left the menu). + * @returns {HTMLElement|null} + */ + function getFocusedItem() { + const items = [...$el.children]; + const index = items.indexOf(document.activeElement); + return index === -1 ? null : document.activeElement; + } + + /** + * Moves focus to the next/previous menu item, wrapping around at the + * ends. `direction` is +1 for next, -1 for previous. + * @param {number} direction + */ + function moveFocus(direction) { + const items = [...$el.children]; + 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 item) 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. + * @param {HTMLElement} $item + */ + function activateItem($item) { + if (!$item || !$el.contains($item)) return; + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + view: window, + }); + Object.defineProperty(clickEvent, "keyboardActivated", { + value: true, + }); + $item.dispatchEvent(clickEvent); + } + + /** + * Keyboard support for the menu: Enter/Space activates the focused + * item, Up/Down arrows move focus between items (wrapping at the + * ends), Home/End jump to the first/last item, and Escape closes the + * menu and returns focus to the toggler. + * @param {KeyboardEvent} e + */ + function onMenuKeydown(e) { + 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(); + $el.firstElementChild?.focus(); + break; + case "End": + e.preventDefault(); + $el.lastElementChild?.focus(); + break; + case "Escape": + case "Esc": + e.preventDefault(); + hide(); + options.toggler?.focus?.(); + break; + } + } + function destroy() { + $el.removeEventListener("keydown", onMenuKeydown); $el.remove(); $mask.remove(); options.toggler?.removeEventListener("click", toggle); @@ -134,6 +235,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/tabContextMenu.js b/src/handlers/tabContextMenu.js index fd44d393d..906b76551 100644 --- a/src/handlers/tabContextMenu.js +++ b/src/handlers/tabContextMenu.js @@ -8,6 +8,14 @@ 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. * @@ -35,10 +43,16 @@ export default function openTabContextMenu(file) { * 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(); @@ -54,7 +68,9 @@ export default function openTabContextMenu(file) { menu.onhide = removeSuppressor; menu.addEventListener("click", (event) => { - if (event.detail === 0) return; // synthetic click, ignore + // 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; @@ -74,6 +90,11 @@ export default function openTabContextMenu(file) { * 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 */ @@ -82,6 +103,8 @@ export function openTabContextMenuOnRelease(file, event) { event.preventDefault?.(); event.stopPropagation?.(); + const { clientX: originX, clientY: originY } = getEventClientPos(event); + let opened = false; let cancelled = false; @@ -92,9 +115,18 @@ export function openTabContextMenuOnRelease(file, event) { openTabContextMenu(file); }; - const onPointerMove = () => { - // Moving after the long press means the user intends to scroll or drag, - // not to open a menu. + 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(); }; @@ -119,6 +151,19 @@ export function openTabContextMenuOnRelease(file, event) { 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"; From dc418ee5a33a17f7b5ab94e7f1aea2743fa00ca2 Mon Sep 17 00:00:00 2001 From: TonyGeez Date: Mon, 7 Sep 2026 23:56:32 -0400 Subject: [PATCH 4/4] Fix import order per Biome organizeImports --- src/handlers/editorFileTab.js | 2 +- src/lib/editorFile.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 5b1ec94e5..8f082af8d 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -1,8 +1,8 @@ import { focusEditorIfEditable } from "cm/editorReadOnly"; +import openTabContextMenu from "handlers/tabContextMenu"; import config from "lib/config"; import settings from "lib/settings"; import { animate } from "motion"; -import openTabContextMenu from "handlers/tabContextMenu"; const opts = { passive: false }; diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 1fa4522a7..f5074fa68 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -14,8 +14,8 @@ import toast from "components/toast"; import confirm from "dialogs/confirm"; import DOMPurify from "dompurify"; import startDrag from "handlers/editorFileTab"; -import { openTabContextMenuOnRelease } from "handlers/tabContextMenu"; 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";