From 9fc1567bce70710afbbd6feed9b16d68ab4f8f26 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Mon, 12 Aug 2024 17:49:16 +0800 Subject: [PATCH] feat: Context menu for Nunjucks tag[INS-4273] (#7828) * Initial check-in for tag context menu * Remove useless codes --- packages/insomnia/src/main/ipc/electron.ts | 112 +++++++++++------- packages/insomnia/src/main/ipc/main.ts | 3 +- packages/insomnia/src/templating/utils.ts | 29 +++++ .../ui/components/codemirror/code-editor.tsx | 46 ++++++- .../components/codemirror/one-line-editor.tsx | 47 +++++++- 5 files changed, 186 insertions(+), 51 deletions(-) diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 05e15fc878..c0f882dc1a 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -2,7 +2,7 @@ import type { IpcMainEvent, IpcMainInvokeEvent, MenuItemConstructorOptions, Open import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron'; import { fnOrString } from '../../common/misc'; -import type { NunjucksParsedTagArg } from '../../templating/utils'; +import { extractNunjucksTagFromCoords, type NunjucksParsedTagArg, type NunjucksTagContextMenuAction } from '../../templating/utils'; import { localTemplateTags } from '../../ui/components/templating/local-template-tags'; import { invariant } from '../../utils/invariant'; @@ -111,50 +111,78 @@ const getTemplateValue = (arg: NunjucksParsedTagArg) => { return arg.defaultValue; }; export function registerElectronHandlers() { - ipcMainOn('show-context-menu', (event, options) => { + ipcMainOn('show-context-menu', (event, options: { key: string; nunjucksTag: ReturnType }) => { + const { key, nunjucksTag } = options; + const sendNunjuckTagContextMsg = (type: NunjucksTagContextMenuAction) => { + event.sender.send('context-menu-command', { key, nunjucksTag: { ...nunjucksTag, type } }); + }; try { - const template: MenuItemConstructorOptions[] = [ - { - role: 'cut', - }, - { - role: 'copy', - }, - { - role: 'paste', - }, - { type: 'separator' }, - ...localTemplateTags - // sort alphabetically - .sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName))) - .map(l => { - const actions = l.templateTag.args?.[0]; - const additionalArgs = l.templateTag.args?.slice(1); - const hasSubmenu = actions?.options?.length; - return { - label: fnOrString(l.templateTag.displayName), - ...(!hasSubmenu ? - { + const baseTemplate: MenuItemConstructorOptions[] = nunjucksTag ? + [ + { + label: 'Edit', + click: () => sendNunjuckTagContextMsg('edit'), + }, + { + label: 'Copy', + click: () => { + clipboard.writeText(nunjucksTag.template); + }, + }, + { + label: 'Cut', + click: () => { + clipboard.writeText(nunjucksTag.template); + sendNunjuckTagContextMsg('delete'); + }, + }, + { + label: 'Delete', + click: () => sendNunjuckTagContextMsg('delete'), + }, + { type: 'separator' }, + ] : + [ + { + role: 'cut', + }, + { + role: 'copy', + }, + { + role: 'paste', + }, + { type: 'separator' }, + ]; + const localTemplate: MenuItemConstructorOptions[] = localTemplateTags + // sort alphabetically + .sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName))) + .map(l => { + const actions = l.templateTag.args?.[0]; + const additionalArgs = l.templateTag.args?.slice(1); + const hasSubmenu = actions?.options?.length; + return { + label: fnOrString(l.templateTag.displayName), + ...(!hasSubmenu ? + { + click: () => { + const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`; + event.sender.send('context-menu-command', { key, tag }); + }, + } : + { + submenu: actions?.options?.map(action => ({ + label: fnOrString(action.displayName), click: () => { - const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`; - event.sender.send('context-menu-command', { key: options.key, tag }); + const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : ''; + const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`; + event.sender.send('context-menu-command', { key, tag }); }, - } : - { - submenu: actions?.options?.map(action => ({ - label: fnOrString(action.displayName), - click: () => { - const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : ''; - const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`; - event.sender.send('context-menu-command', { key: options.key, tag }); - }, - })), - }), - }; - }), - - ]; - const menu = Menu.buildFromTemplate(template); + })), + }), + }; + }); + const menu = Menu.buildFromTemplate([...baseTemplate, ...localTemplate]); const win = BrowserWindow.fromWebContents(event.sender); invariant(win, 'expected window'); menu.popup({ window: win }); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 881b3b2e00..75da13e0a9 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/electron/main'; +import type { MarkerRange } from 'codemirror'; import { app, BrowserWindow, type IpcRendererEvent, shell } from 'electron'; import fs from 'fs'; @@ -37,7 +38,7 @@ export interface RendererToMainBridgeAPI { curl: CurlBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; - showContextMenu: (options: { key: string }) => void; + showContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void; database: { caCertificate: { create: (options: { parentId: string; path: string }) => Promise; diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index d3b72a0119..5614ac8e21 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -1,3 +1,5 @@ +import type { EditorFromTextArea, MarkerRange } from 'codemirror'; + import type { DisplayName, PluginArgumentEnumOption, PluginTemplateTagActionContext } from './extensions'; import objectPath from './third_party/objectPath'; @@ -36,6 +38,8 @@ export interface NunjucksParsedTag { disablePreview?: (arg0: NunjucksParsedTagArg[]) => boolean; } +export type NunjucksTagContextMenuAction = 'edit' | 'delete'; + interface Key { name: string; value: any; @@ -284,3 +288,28 @@ export function extractVariableKey(text: string = '', line: number, column: numb const res = errorText?.match(regexVariable); return res?.[1] || ''; } + +export function extractNunjucksTagFromCoords( + coordinates: { left: number; top: number }, + cm: React.MutableRefObject +): { range: MarkerRange; template: string } | void { + if (cm && cm.current) { + const { left, top } = coordinates; + // get position from left and right position + const textMarkerPos = cm.current.coordsChar({ left, top }); + // get textMarker from position + const textMarker = cm.current?.getDoc().findMarksAt(textMarkerPos)[0]; + if (textMarker) { + const range = textMarker.find() as MarkerRange; + return { + range, + // @ts-expect-error __template shoule be property of nunjucks tag markText + template: textMarker.__template || '', + }; + } + } +} + +export interface nunjucksTagContextMenuOptions extends Exclude, void> { + type: NunjucksTagContextMenuAction; +} diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index bb3e5e2a20..b52aef27fa 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -16,7 +16,7 @@ import { DEBOUNCE_MILLIS, isMac } from '../../../common/constants'; import * as misc from '../../../common/misc'; import type { KeyCombination } from '../../../common/settings'; import { getTagDefinitions } from '../../../templating/index'; -import type { NunjucksParsedTag } from '../../../templating/utils'; +import { extractNunjucksTagFromCoords, type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '../../../templating/utils'; import { jsonPrettify } from '../../../utils/prettify/json'; import { queryXPath } from '../../../utils/xpath/query'; import { useGatedNunjucks } from '../../context/nunjucks/use-gated-nunjucks'; @@ -26,6 +26,7 @@ import { Icon } from '../icon'; import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder'; import { FilterHelpModal } from '../modals/filter-help-modal'; import { showModal } from '../modals/index'; +import { NunjucksModal } from '../modals/nunjucks-modal'; import { isKeyCombinationInRegistry } from '../settings/shortcuts'; import { normalizeIrregularWhitespace } from './normalizeIrregularWhitespace'; const TAB_SIZE = 4; @@ -521,8 +522,34 @@ export const CodeEditor = memo(forwardRef(({ } }; useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag }) => - id === key && codeMirror.current?.replaceSelection(tag)); + const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + if (id === key) { + if (nunjucksTag) { + const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; + switch (type) { + case 'edit': + showModal(NunjucksModal, { + template: template, + onDone: (template: string | null) => { + const { from, to } = range; + codeMirror.current?.replaceRange(template!, from, to); + }, + }); + return; + + case 'delete': + const { from, to } = range; + codeMirror.current?.replaceRange('', from, to); + return; + + default: + return; + }; + } else { + codeMirror.current?.replaceSelection(tag); + } + } + }); return () => { unsubscribe(); }; @@ -577,7 +604,18 @@ export const CodeEditor = memo(forwardRef(({ return; } event.preventDefault(); - window.main.showContextMenu({ key: id }); + const target = event.target as HTMLElement; + // right click on nunjucks tag + if (target?.classList?.contains('nunjucks-tag')) { + const { clientX, clientY } = event; + const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror); + if (nunjucksTag) { + // show context menu for nunjucks tag + window.main.showContextMenu({ key: id, nunjucksTag }); + } + } else { + window.main.showContextMenu({ key: id }); + } }} >
}, [onChange]); useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag }) => - id === key && codeMirror.current?.replaceSelection(tag)); + const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + if (id === key) { + if (nunjucksTag) { + const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; + switch (type) { + case 'edit': + showModal(NunjucksModal, { + template: template, + onDone: (template: string | null) => { + const { from, to } = range; + codeMirror.current?.replaceRange(template!, from, to); + }, + }); + return; + + case 'delete': + const { from, to } = range; + codeMirror.current?.replaceRange('', from, to); + return; + + default: + return; + }; + } else { + codeMirror.current?.replaceSelection(tag); + } + } + }); return () => { unsubscribe(); }; @@ -255,7 +283,18 @@ export const OneLineEditor = forwardRef return; } event.preventDefault(); - window.main.showContextMenu({ key: id }); + const target = event.target as HTMLElement; + // right click on nunjucks tag + if (target?.classList?.contains('nunjucks-tag')) { + const { clientX, clientY } = event; + const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror); + if (nunjucksTag) { + // show context menu for nunjucks tag + window.main.showContextMenu({ key: id, nunjucksTag }); + } + } else { + window.main.showContextMenu({ key: id }); + } }} >