import { Injectable, inject, signal } from '@angular/core'; import type { ColumnKey, EntryType } from '@shared/types'; import type { FileCommand } from './file-actions.service'; import { I18nService } from './i18n.service'; import { stepIndex } from './menu-nav'; import type { Side } from './panels.service'; import { IS_MAC, IS_WINDOWS } from './platform'; import { RpcService } from './rpc.service'; import type { ShortcutId } from './shortcuts.service'; /** Optional list columns, in display order (Name is fixed). */ export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group']; export interface MenuItem { /** separator: a rule; header: a non-pickable section title (the source picker). */ type?: 'separator' | 'header'; label?: string; /** Handed to the request's onPick, else emitted to the action dispatcher * (`col:*` / `tab:*`). */ action?: string; checked?: boolean; tooltip?: string; /** The keymap command the item runs: its first key shows as the soft hint, * and pressing that key while the menu is open picks the item. */ shortcut?: ShortcutId; /** A registered icon (core/icons.ts), drawn in the check column. */ icon?: string; /** Greyed and unclickable. Menus disable rather than hide, so the set of * commands stays in the same place from one right-click to the next. */ disabled?: boolean; } /** Anything with a viewport point: a MouseEvent, or a row's rect for a * keyboard-opened menu. */ export interface MenuPoint { clientX: number; clientY: number; } export interface MenuRequest { x: number; y: number; items: MenuItem[]; /** Highlighted on open: the first item when the keyboard opened the menu. */ initialIndex: number; /** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */ onPick?: (action: string) => void; } export interface TabMenuContext { /** The right-clicked tab (NOT necessarily the active one). */ id: number; /** Position in the strip, for "close to the right". */ index: number; tabCount: number; /** Any pane in this tab holds a live remote session. */ isRemote: boolean; } export interface RowMenuContext { side: Side; name: string; type: EntryType; isLocal: boolean; hasEntry: boolean; // false = right-click on empty panel space } /** Items the highlight can land on. */ export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled; // PORTABILITY: shell-integration wording per OS (resolved at build time for locale) const revealKey = (): string => IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager'; const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash'); /** * Builds and opens the HTML context menus. This used to be a native * electrobun ContextMenu on the bun side, but `showContextMenu` is a documented * no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md), * which left the whole right-click surface dead there. The menu is plain HTML * now — one code path on every platform. */ @Injectable({ providedIn: 'root' }) export class ContextMenuService { private readonly i18n = inject(I18nService); private readonly rpc = inject(RpcService); readonly request = signal(null); open( point: MenuPoint, items: MenuItem[], opts: { keyboard?: boolean; onPick?: (action: string) => void } = {}, ): void { const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1; this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick }); } close(): void { this.request.set(null); } /** A pick without its own handler goes through the dispatcher the app * menubar uses. */ emit(action: string): void { this.rpc.emitMenuAction(action); } /** * Row / empty-space menu for a file panel. Actions are FileCommand ids, * picked through FileActionsService.run. Icons only on the everyday verbs — * the same glyphs as their toolbar buttons. */ rowItems(ctx: RowMenuContext): MenuItem[] { const t = (key: string) => this.i18n.t(key); const cmd = (id: FileCommand, label: string, extra: Partial = {}): MenuItem => ({ label, action: id, shortcut: id, ...extra, }); const items: MenuItem[] = []; if (!ctx.hasEntry || ctx.name === '..') { // empty space (or the parent row): folder-level actions items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'tdesignFolderAdd' })); items.push(cmd('new-file', t('ctx.newFile'))); items.push({ type: 'separator' }); items.push(cmd('refresh', t('ctx.refresh'))); if (ctx.isLocal) { items.push({ type: 'separator' }); items.push(cmd('reveal', t(revealKey()))); } return items; } // the arrow points at the other panel, as on the toolbar's Send button const sendIcon = ctx.side === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'; items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon })); if (ctx.type === 'dir') { items.push(cmd('open', t('ctx.open'))); } else { if (ctx.isLocal) { items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') })); } items.push(cmd('edit', t('ctx.edit'), { icon: 'tdesignFile1' })); } items.push(cmd('rename', t('ctx.rename'), { icon: 'tdesignEdit' })); // chmod on Windows local files only flips the read-only bit — hide the lie if (!(ctx.isLocal && IS_WINDOWS)) { items.push(cmd('chmod', t('ctx.permissions'))); } items.push({ type: 'separator' }); items.push(cmd('copy-path', t('ctx.copyPath'))); if (ctx.isLocal) { items.push(cmd('reveal', t(revealKey()))); } items.push({ type: 'separator' }); if (ctx.isLocal) { items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {})); } items.push(cmd('delete', t('ctx.delete'), { icon: 'tdesignDelete' })); return items; } /** * The workspace-tab menu. Actions carry the target tab's id, because the * right-clicked tab is not necessarily the active one — nothing here may * assume `activeWorkspace`. (The hints name the keys, which act on the * ACTIVE tab; picking by key while this menu is open acts on THIS one.) * * Everything illegal is DISABLED, not omitted: a menu whose items move * between right-clicks is worse than a dead row, and "Close Tab" greyed out * explains why the last tab cannot be closed better than its absence does. * * Duplicate/Rename/Save-session are deliberately absent until tabs have an * identity to duplicate (a stored connection id) — the sessions round. */ tabItems(ctx: TabMenuContext): MenuItem[] { const t = (key: string) => this.i18n.t(key); const last = ctx.tabCount <= 1; return [ { label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'tdesignAdd' }, { type: 'separator' }, { label: t('ctx.tabClose'), action: `tab:close:${ctx.id}`, shortcut: 'close-tab', icon: 'tdesignClose', disabled: last, }, { label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, shortcut: 'close-other-tabs', disabled: last, }, { label: t('ctx.tabCloseRight'), action: `tab:close-right:${ctx.id}`, shortcut: 'close-tabs-right', disabled: ctx.index >= ctx.tabCount - 1, }, { type: 'separator' }, { label: t('ctx.tabDisconnect'), action: `tab:disconnect:${ctx.id}`, shortcut: 'disconnect-tab', disabled: !ctx.isRemote, }, ]; } /** Column picker for the list headers: checkable toggles per column. */ columnItems(visible: ColumnKey[]): MenuItem[] { return COLUMN_KEYS.map((key) => ({ label: this.i18n.t(`columns.${key}`), action: `col:${key}`, checked: visible.includes(key), })); } }