From f024ecdf8ef95e1b5d5114d0080fdd997bd182fc Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:06:09 +0530 Subject: [PATCH 1/2] feat(terminal): add touch selection "More" menu API and wire select dialog --- src/components/terminal/terminalManager.js | 27 ++ .../terminal/terminalTouchSelection.js | 290 +++++++++++++++--- src/lib/acode.js | 10 + 3 files changed, 290 insertions(+), 37 deletions(-) diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 87b71d64f..033661081 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -5,6 +5,7 @@ import EditorFile from "lib/editorFile"; import TerminalComponent from "./terminal"; +import TerminalTouchSelection from "./terminalTouchSelection"; import "@xterm/xterm/css/xterm.css"; import quickTools from "components/quickTools"; import toast from "components/toast"; @@ -729,6 +730,32 @@ class TerminalManager { return this.terminals; } + /** + * Register a touch-selection "More" menu option. + * @param {object} option + * @returns {string|null} + */ + addTouchSelectionMoreOption(option) { + return TerminalTouchSelection.addMoreOption(option); + } + + /** + * Remove a touch-selection "More" menu option. + * @param {string} id + * @returns {boolean} + */ + removeTouchSelectionMoreOption(id) { + return TerminalTouchSelection.removeMoreOption(id); + } + + /** + * List touch-selection "More" menu options. + * @returns {Array} + */ + getTouchSelectionMoreOptions() { + return TerminalTouchSelection.getMoreOptions(); + } + /** * Write to a specific terminal * @param {string} terminalId - Terminal ID diff --git a/src/components/terminal/terminalTouchSelection.js b/src/components/terminal/terminalTouchSelection.js index 86e74dd1b..2b145057e 100644 --- a/src/components/terminal/terminalTouchSelection.js +++ b/src/components/terminal/terminalTouchSelection.js @@ -1,10 +1,139 @@ /** * Touch Selection for Terminal */ +import select from "dialogs/select"; import "./terminalTouchSelection.css"; +const DEFAULT_MORE_OPTION_ID = "__acode_terminal_select_all__"; +const terminalMoreOptions = new Map(); +let terminalMoreOptionCounter = 0; + +function ensureDefaultMoreOption() { + if (terminalMoreOptions.has(DEFAULT_MORE_OPTION_ID)) return; + + terminalMoreOptions.set(DEFAULT_MORE_OPTION_ID, { + id: DEFAULT_MORE_OPTION_ID, + label: () => strings["select all"] || "Select all", + icon: "text_format", + action: ({ touchSelection }) => touchSelection.selectAllText(), + }); +} + +function normalizeMoreOption(option) { + if (!option || typeof option !== "object" || Array.isArray(option)) { + console.warn( + "[TerminalTouchSelection] addMoreOption expects an option object.", + ); + return null; + } + + const id = + option.id != null && option.id !== "" + ? String(option.id) + : `terminal_more_option_${++terminalMoreOptionCounter}`; + const label = option.label ?? option.text ?? option.title; + const action = option.action || option.onselect || option.onclick; + + if (!label) { + console.warn( + `[TerminalTouchSelection] More option '${id}' must provide a label/text/title.`, + ); + return null; + } + + if (typeof action !== "function") { + console.warn( + `[TerminalTouchSelection] More option '${id}' must provide an action function.`, + ); + return null; + } + + return { + id, + label, + icon: option.icon || null, + enabled: option.enabled, + action, + }; +} + +function resolveMoreOptionLabel(option, context) { + try { + const value = + typeof option.label === "function" ? option.label(context) : option.label; + return value == null ? "" : String(value); + } catch (error) { + console.warn( + `[TerminalTouchSelection] Failed to resolve label for option '${option.id}'.`, + error, + ); + return ""; + } +} + +function isMoreOptionEnabled(option, context) { + try { + if (typeof option.enabled === "function") { + return option.enabled(context) !== false; + } + if (option.enabled === undefined) return true; + return option.enabled !== false; + } catch (error) { + console.warn( + `[TerminalTouchSelection] Failed to resolve enabled state for option '${option.id}'.`, + error, + ); + return true; + } +} + export default class TerminalTouchSelection { + /** + * Register an option for the "More" menu in touch selection. + * @param {{ + * id?: string, + * label?: string|function(object):string, + * text?: string, + * title?: string, + * icon?: string, + * enabled?: boolean|function(object):boolean, + * action?: function(object):void|Promise, + * onselect?: function(object):void|Promise, + * onclick?: function(object):void|Promise + * }} option + * @returns {string|null} + */ + static addMoreOption(option) { + ensureDefaultMoreOption(); + const normalized = normalizeMoreOption(option); + if (!normalized) return null; + terminalMoreOptions.set(normalized.id, normalized); + return normalized.id; + } + + /** + * Remove a registered "More" menu option by id. + * @param {string} id + * @returns {boolean} + */ + static removeMoreOption(id) { + ensureDefaultMoreOption(); + if (id == null || id === "") return false; + return terminalMoreOptions.delete(String(id)); + } + + /** + * List all registered "More" menu options. + * @returns {Array} + */ + static getMoreOptions() { + ensureDefaultMoreOption(); + return [...terminalMoreOptions.values()].map((option) => ({ ...option })); + } + constructor(terminal, container, options = {}) { + ensureDefaultMoreOption(); + this.terminal = terminal; this.container = container; this.options = { @@ -783,17 +912,27 @@ export default class TerminalTouchSelection { // Mark that context menu should stay visible this.contextMenuShouldStayVisible = true; - // Position context menu - center it on selection with viewport bounds checking - const startPos = this.terminalCoordsToPixels(this.selectionStart); - const endPos = this.terminalCoordsToPixels(this.selectionEnd); + // Position context menu - center it on selection (or fallback to center). + const startPos = this.selectionStart + ? this.terminalCoordsToPixels(this.selectionStart) + : null; + const endPos = this.selectionEnd + ? this.terminalCoordsToPixels(this.selectionEnd) + : null; + + const menuWidth = this.contextMenu.offsetWidth || 200; + const menuHeight = this.contextMenu.offsetHeight || 50; + const containerRect = this.container.getBoundingClientRect(); + + let menuX; + let menuY; if (startPos || endPos) { - // Use whichever position is available, or center between them - let centerX, baseY; + let centerX; + let baseY; if (startPos && endPos) { centerX = (startPos.x + endPos.x) / 2; - // Position below the lower of the two positions baseY = Math.max(startPos.y, endPos.y); } else if (startPos) { centerX = startPos.x; @@ -803,36 +942,24 @@ export default class TerminalTouchSelection { baseY = endPos.y; } - const menuWidth = this.contextMenu.offsetWidth || 200; - const menuHeight = this.contextMenu.offsetHeight || 50; - - const containerRect = this.container.getBoundingClientRect(); - - // Calculate initial position - let menuX = centerX - menuWidth / 2; - let menuY = baseY + this.cellDimensions.height + 40; - - // Ensure menu stays within terminal bounds horizontally - const minX = 10; // padding from left edge - const maxX = containerRect.width - menuWidth - 10; // padding from right edge - menuX = Math.max(minX, Math.min(menuX, maxX)); + menuX = centerX - menuWidth / 2; + menuY = baseY + this.cellDimensions.height + 40; + } else { + menuX = (containerRect.width - menuWidth) / 2; + menuY = containerRect.height - menuHeight - 20; + } - // Ensure menu stays within terminal bounds vertically - const maxY = containerRect.height - menuHeight - 10; // padding from bottom - if (menuY > maxY) { - // If menu would go below terminal, position it above the selection - const topY = - startPos && endPos ? Math.min(startPos.y, endPos.y) : baseY; - menuY = topY - menuHeight - 10; - } + const minX = 10; + const maxX = containerRect.width - menuWidth - 10; + menuX = Math.max(minX, Math.min(menuX, maxX)); - // Final bounds check - menuY = Math.max(10, Math.min(menuY, maxY)); + const minY = 10; + const maxY = containerRect.height - menuHeight - 10; + menuY = Math.max(minY, Math.min(menuY, maxY)); - this.contextMenu.style.left = `${menuX}px`; - this.contextMenu.style.top = `${menuY}px`; - this.contextMenu.style.display = "flex"; - } + this.contextMenu.style.left = `${menuX}px`; + this.contextMenu.style.top = `${menuY}px`; + this.contextMenu.style.display = "flex"; } createContextMenu() { @@ -843,7 +970,10 @@ export default class TerminalTouchSelection { const menuItems = [ { label: strings["copy"], action: this.copySelection.bind(this) }, { label: strings["paste"], action: this.pasteFromClipboard.bind(this) }, - { label: "More...", action: this.showMoreOptions.bind(this) }, + { + label: `${strings["more"] || "More"}...`, + action: this.showMoreOptions.bind(this), + }, ]; menuItems.forEach((item) => { @@ -932,10 +1062,96 @@ export default class TerminalTouchSelection { } } + selectAllText() { + if (!this.terminal?.selectAll) return; + this.terminal.selectAll(); + this.currentSelection = this.terminal.getSelection(); + this.isSelecting = !!this.currentSelection; + this.selectionStart = null; + this.selectionEnd = null; + this.hideHandles(); + + if (this.options.showContextMenu && this.currentSelection) { + this.showContextMenu(); + } + } + + getMoreOptionsContext() { + return { + terminal: this.terminal, + touchSelection: this, + selection: this.currentSelection || this.terminal.getSelection(), + clearSelection: () => this.forceClearSelection(), + copySelection: () => this.copySelection(), + pasteFromClipboard: () => this.pasteFromClipboard(), + selectAll: () => this.selectAllText(), + }; + } + + getResolvedMoreOptions() { + ensureDefaultMoreOption(); + const context = this.getMoreOptionsContext(); + + return [...terminalMoreOptions.values()] + .map((option) => { + const label = resolveMoreOptionLabel(option, context); + if (!label) return null; + + return { + ...option, + label, + disabled: !isMoreOptionEnabled(option, context), + }; + }) + .filter(Boolean); + } + + async executeMoreOption(option) { + if (!option || typeof option.action !== "function" || option.disabled) { + if (this.isSelecting && this.options.showContextMenu) { + this.showContextMenu(); + } + return; + } + + try { + await option.action(this.getMoreOptionsContext()); + } catch (error) { + console.error( + `[TerminalTouchSelection] Failed to execute more option '${option.id}'.`, + error, + ); + window.toast?.("Failed to execute action."); + } finally { + if (this.isSelecting && this.options.showContextMenu) { + this.showContextMenu(); + } + } + } + showMoreOptions() { - // Implement additional options if needed - window.toast("More options are not implemented yet."); - this.forceClearSelection(); + const moreOptions = this.getResolvedMoreOptions(); + if (!moreOptions.length) return; + + const items = moreOptions.map((option) => ({ + value: option.id, + text: option.label, + icon: option.icon, + disabled: option.disabled, + })); + + this.hideContextMenu(true); + + select(strings["more"] || "More", items, true) + .then((selectedId) => { + const option = moreOptions.find((entry) => entry.id === selectedId); + return this.executeMoreOption(option); + }) + .catch(() => { + if (this.isSelecting && this.options.showContextMenu) { + this.showContextMenu(); + } + }); } clearSelection() { diff --git a/src/lib/acode.js b/src/lib/acode.js index c96db1bca..8863f593d 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -282,6 +282,12 @@ export default class Acode { removeHandler: removeIntentHandler, }; + const terminalTouchSelectionMoreOptions = { + add: (option) => TerminalManager.addTouchSelectionMoreOption(option), + remove: (id) => TerminalManager.removeTouchSelectionMoreOption(id), + list: () => TerminalManager.getTouchSelectionMoreOptions(), + }; + const terminalModule = { create: (options) => TerminalManager.createTerminal(options), createLocal: (options) => TerminalManager.createLocalTerminal(options), @@ -291,6 +297,10 @@ export default class Acode { write: (id, data) => this.#secureTerminalWrite(id, data), clear: (id) => TerminalManager.clearTerminal(id), close: (id) => TerminalManager.closeTerminal(id), + moreOptions: terminalTouchSelectionMoreOptions, + touchSelection: { + moreOptions: terminalTouchSelectionMoreOptions, + }, themes: { register: (name, theme, pluginId) => TerminalThemeManager.registerTheme(name, theme, pluginId), From 6cebc835f52be69a6f9e1124533b07c343eea3df Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:18:32 +0530 Subject: [PATCH 2/2] fix --- src/components/terminal/terminalTouchSelection.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/terminal/terminalTouchSelection.js b/src/components/terminal/terminalTouchSelection.js index 2b145057e..f4f46f094 100644 --- a/src/components/terminal/terminalTouchSelection.js +++ b/src/components/terminal/terminalTouchSelection.js @@ -944,6 +944,14 @@ export default class TerminalTouchSelection { menuX = centerX - menuWidth / 2; menuY = baseY + this.cellDimensions.height + 40; + + // If menu would overflow below, prefer placing it above selection. + const maxY = containerRect.height - menuHeight - 10; + if (menuY > maxY) { + const topY = + startPos && endPos ? Math.min(startPos.y, endPos.y) : baseY; + menuY = topY - menuHeight - 10; + } } else { menuX = (containerRect.width - menuWidth) / 2; menuY = containerRect.height - menuHeight - 20;