diff --git a/src-web/components/RecentRequestsDropdown.tsx b/src-web/components/RecentRequestsDropdown.tsx index 8dee9450..84634398 100644 --- a/src-web/components/RecentRequestsDropdown.tsx +++ b/src-web/components/RecentRequestsDropdown.tsx @@ -10,11 +10,13 @@ import { CountBadge } from './core/CountBadge'; import type { DropdownItem, DropdownRef } from './core/Dropdown'; import { Dropdown } from './core/Dropdown'; import classNames from 'classnames'; +import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; export function RecentRequestsDropdown() { const dropdownRef = useRef(null); const activeRequest = useActiveRequest(); const activeWorkspaceId = useActiveWorkspaceId(); + const activeEnvironmentId = useActiveEnvironmentId(); const requests = useRequests(); const routes = useAppRoutes(); const allRecentRequestIds = useRecentRequests(); @@ -65,6 +67,7 @@ export function RecentRequestsDropdown() { onSelect: () => { routes.navigate('request', { requestId: request.id, + environmentId: activeEnvironmentId ?? undefined, workspaceId: activeWorkspaceId, }); }, @@ -77,7 +80,7 @@ export function RecentRequestsDropdown() { } return recentRequestItems.slice(0, 20); - }, [activeWorkspaceId, recentRequestIds, requests, routes]); + }, [activeWorkspaceId, activeEnvironmentId, recentRequestIds, requests, routes]); return ( diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 3584f0ed..e0d8e08f 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -115,7 +115,11 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { const request = requests[selectedIndex ?? -1]; if (!request || request.id === activeRequestId) return; e.preventDefault(); - routes.navigate('request', { requestId: request.id, workspaceId: request.workspaceId }); + routes.navigate('request', { + requestId: request.id, + workspaceId: request.workspaceId, + environmentId: activeEnvironmentId ?? undefined, + }); }); useKey( @@ -161,12 +165,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { 'h-full relative grid grid-rows-[auto_minmax(0,1fr)_auto]', )} > - + ; @@ -27,7 +26,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ const workspaces = useWorkspaces(); const activeWorkspace = useActiveWorkspace(); const activeWorkspaceId = activeWorkspace?.id ?? null; - const environmentId = useActiveEnvironmentId(); const createWorkspace = useCreateWorkspace({ navigateAfter: true }); const updateWorkspace = useUpdateWorkspace(activeWorkspaceId); const deleteWorkspace = useDeleteWorkspace(activeWorkspace); @@ -58,10 +56,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ color="gray" onClick={() => { hide(); - routes.navigate('workspace', { - workspaceId: w.id, - environmentId: environmentId ?? undefined, - }); + routes.navigate('workspace', { workspaceId: w.id }); }} > This Window @@ -74,10 +69,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ onClick={async () => { hide(); await invoke('new_window', { - url: routes.paths.workspace({ - workspaceId: w.id, - environmentId: environmentId ?? undefined, - }), + url: routes.paths.workspace({ workspaceId: w.id }), }); }} > @@ -152,7 +144,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ createWorkspace, deleteWorkspace.mutate, dialog, - environmentId, prompt, routes, updateWorkspace, @@ -165,7 +156,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({ size="sm" className={classNames(className, 'text-gray-800 !px-2 truncate')} forDropdown - leftSlot={Workspace logo} + leftSlot={ + Workspace logo + } {...buttonProps} > {activeWorkspace?.name} diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index f5f62db4..7d8210ae 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -50,12 +50,16 @@ @apply text-xs text-gray-800 dark:text-gray-900 px-1 rounded cursor-default dark:shadow; /* NOTE: Background and border are translucent so we can see text selection through it */ - @apply bg-gray-300/40 border border-gray-300 border-opacity-40 hover:border-opacity-80; + @apply bg-gray-300/40 border border-gray-300 border-opacity-40; /* Bring above on hover */ @apply hover:z-10 relative; -webkit-text-security: none; + + &.placeholder-widget-error { + @apply bg-red-300/40 border-red-300 border-opacity-40; + } } } diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 2e1b335e..1b94a25c 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -148,6 +148,7 @@ export const multiLineExtensions = [ }, }), EditorState.allowMultipleSelections.of(true), + drawSelection(), indentOnInput(), closeBrackets(), rectangularSelection(), diff --git a/src-web/components/core/Editor/placeholder.ts b/src-web/components/core/Editor/placeholder.ts deleted file mode 100644 index 0d4e4a97..00000000 --- a/src-web/components/core/Editor/placeholder.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { DecorationSet, ViewUpdate } from '@codemirror/view'; -import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view'; - -class PlaceholderWidget extends WidgetType { - constructor(readonly name: string) { - super(); - } - eq(other: PlaceholderWidget) { - return this.name == other.name; - } - toDOM() { - const elt = document.createElement('span'); - elt.className = 'placeholder-widget'; - elt.textContent = this.name; - return elt; - } - ignoreEvent() { - return false; - } -} - -/** - * This is a custom MatchDecorator that will not decorate a match if the selection is inside it - */ -class BetterMatchDecorator extends MatchDecorator { - updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet { - if (!update.startState.selection.eq(update.state.selection)) { - return super.createDeco(update.view); - } else { - return super.updateDeco(update, deco); - } - } -} - -const placeholderMatcher = new BetterMatchDecorator({ - regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g, - decoration(match, view, matchStartPos) { - const matchEndPos = matchStartPos + match[0].length - 1; - - // Don't decorate if the cursor is inside the match - for (const r of view.state.selection.ranges) { - if (r.from > matchStartPos && r.to <= matchEndPos) { - return Decoration.replace({}); - } - } - - const groupMatch = match[1]; - if (groupMatch == null) { - // Should never happen, but make TS happy - console.warn('Group match was empty', match); - return Decoration.replace({}); - } - - return Decoration.replace({ - inclusive: true, - widget: new PlaceholderWidget(groupMatch), - }); - }, -}); - -export const placeholders = ViewPlugin.fromClass( - class { - placeholders: DecorationSet; - constructor(view: EditorView) { - this.placeholders = placeholderMatcher.createDeco(view); - } - update(update: ViewUpdate) { - this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders); - } - }, - { - decorations: (instance) => instance.placeholders, - provide: (plugin) => - EditorView.atomicRanges.of((view) => { - return view.plugin(plugin)?.placeholders || Decoration.none; - }), - }, -); diff --git a/src-web/components/core/Editor/twig/completion.ts b/src-web/components/core/Editor/twig/completion.ts index 4558674b..22659cd8 100644 --- a/src-web/components/core/Editor/twig/completion.ts +++ b/src-web/components/core/Editor/twig/completion.ts @@ -4,7 +4,7 @@ import { w } from '@tauri-apps/api/clipboard-79413165'; const openTag = '${[ '; const closeTag = ' ]}'; -interface TwigCompletionOption { +export interface TwigCompletionOption { name: string; } diff --git a/src-web/components/core/Editor/twig/extension.ts b/src-web/components/core/Editor/twig/extension.ts index a5b2fb13..254115fb 100644 --- a/src-web/components/core/Editor/twig/extension.ts +++ b/src-web/components/core/Editor/twig/extension.ts @@ -3,7 +3,7 @@ import { LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; import type { GenericCompletionConfig } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion'; -import { placeholders } from '../placeholder'; +import { placeholders } from './placeholder'; import { textLanguageName } from '../text/extension'; import { twigCompletion } from './completion'; import { parser as twigParser } from './twig'; @@ -29,7 +29,7 @@ export function twig( completion, completionBase, base.support, - placeholders, + placeholders(variables), ...additionalCompletion, ]; } diff --git a/src-web/components/core/Editor/twig/placeholder.ts b/src-web/components/core/Editor/twig/placeholder.ts new file mode 100644 index 00000000..a895e819 --- /dev/null +++ b/src-web/components/core/Editor/twig/placeholder.ts @@ -0,0 +1,86 @@ +import type { DecorationSet, ViewUpdate } from '@codemirror/view'; +import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view'; + +class PlaceholderWidget extends WidgetType { + constructor( + readonly name: string, + readonly isExistingVariable: boolean, + ) { + super(); + } + eq(other: PlaceholderWidget) { + return this.name == other.name; + } + toDOM() { + const elt = document.createElement('span'); + elt.className = `placeholder-widget ${!this.isExistingVariable ? 'placeholder-widget-error' : ''}`; + elt.textContent = this.name; + return elt; + } + ignoreEvent() { + return false; + } +} + +/** + * This is a custom MatchDecorator that will not decorate a match if the selection is inside it + */ +class BetterMatchDecorator extends MatchDecorator { + updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet { + if (!update.startState.selection.eq(update.state.selection)) { + return super.createDeco(update.view); + } else { + return super.updateDeco(update, deco); + } + } +} + +export const placeholders = function (variables: { name: string }[]) { + const placeholderMatcher = new BetterMatchDecorator({ + regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g, + decoration(match, view, matchStartPos) { + const matchEndPos = matchStartPos + match[0].length - 1; + + // Don't decorate if the cursor is inside the match + for (const r of view.state.selection.ranges) { + if (r.from > matchStartPos && r.to <= matchEndPos) { + return Decoration.replace({}); + } + } + + const groupMatch = match[1]; + if (groupMatch == null) { + // Should never happen, but make TS happy + console.warn('Group match was empty', match); + return Decoration.replace({}); + } + + return Decoration.replace({ + inclusive: true, + widget: new PlaceholderWidget( + groupMatch, + variables.some((v) => v.name === groupMatch), + ), + }); + }, + }); + + return ViewPlugin.fromClass( + class { + placeholders: DecorationSet; + constructor(view: EditorView) { + this.placeholders = placeholderMatcher.createDeco(view); + } + update(update: ViewUpdate) { + this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders); + } + }, + { + decorations: (instance) => instance.placeholders, + provide: (plugin) => + EditorView.atomicRanges.of((view) => { + return view.plugin(plugin)?.placeholders || Decoration.none; + }), + }, + ); +}; diff --git a/src-web/hooks/useCreateRequest.ts b/src-web/hooks/useCreateRequest.ts index 575fa8c5..b7545b9d 100644 --- a/src-web/hooks/useCreateRequest.ts +++ b/src-web/hooks/useCreateRequest.ts @@ -4,9 +4,11 @@ import type { HttpRequest } from '../lib/models'; import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useAppRoutes } from './useAppRoutes'; import { requestsQueryKey, useRequests } from './useRequests'; +import { useActiveEnvironmentId } from './useActiveEnvironmentId'; export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { const workspaceId = useActiveWorkspaceId(); + const activeEnvironmentId = useActiveEnvironmentId(); const routes = useAppRoutes(); const requests = useRequests(); const queryClient = useQueryClient(); @@ -26,7 +28,11 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) (requests) => [...(requests ?? []), request], ); if (navigateAfter) { - routes.navigate('request', { workspaceId: request.workspaceId, requestId: request.id }); + routes.navigate('request', { + workspaceId: request.workspaceId, + requestId: request.id, + environmentId: activeEnvironmentId ?? undefined, + }); } }, }); diff --git a/src-web/hooks/useDuplicateRequest.ts b/src-web/hooks/useDuplicateRequest.ts index 35bba1e0..45cf0381 100644 --- a/src-web/hooks/useDuplicateRequest.ts +++ b/src-web/hooks/useDuplicateRequest.ts @@ -4,6 +4,7 @@ import type { HttpRequest } from '../lib/models'; import { useActiveWorkspaceId } from './useActiveWorkspaceId'; import { useAppRoutes } from './useAppRoutes'; import { requestsQueryKey } from './useRequests'; +import { useActiveEnvironmentId } from './useActiveEnvironmentId'; export function useDuplicateRequest({ id, @@ -12,7 +13,8 @@ export function useDuplicateRequest({ id: string | null; navigateAfter: boolean; }) { - const workspaceId = useActiveWorkspaceId(); + const activeWorkspaceId = useActiveWorkspaceId(); + const activeEnvironmentId = useActiveEnvironmentId(); const routes = useAppRoutes(); const queryClient = useQueryClient(); return useMutation({ @@ -25,8 +27,12 @@ export function useDuplicateRequest({ requestsQueryKey({ workspaceId: request.workspaceId }), (requests) => [...(requests ?? []), request], ); - if (navigateAfter && workspaceId !== null) { - routes.navigate('request', { workspaceId, requestId: request.id }); + if (navigateAfter && activeWorkspaceId !== null) { + routes.navigate('request', { + workspaceId: activeWorkspaceId, + requestId: request.id, + environmentId: activeEnvironmentId ?? undefined, + }); } }, });