Skip to content

Commit e5e5be3

Browse files
committed
feat: Add global shortcut keys and modal focus restoration
https://harperdb.atlassian.net/browse/STUDIO-545
1 parent c2c6d74 commit e5e5be3

24 files changed

+358
-132
lines changed

src/components/RestartButton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ export function RestartButton({
3939
onClick={targetNoun === 'Cluster' && operation === 'restart' ? onRestartClusterClick : onRestartClick}
4040
disabled={disabled || isRestartPending || isRestartClusterPending}
4141
>
42-
<RotateCcwIcon />
43-
{hideText !== true && <span className="hidden md:inline-block">Restart {targetNoun}</span>}
42+
<RotateCcwIcon className="pointer-events-none" />
43+
{hideText !== true && <span className="hidden md:inline-block pointer-events-none">Restart {targetNoun}</span>}
4444
</Button>
4545
</TooltipTrigger>
46-
<TooltipContent>
46+
<TooltipContent side="bottom">
4747
{tooltip
4848
? tooltip
4949
: operation === 'restart_service'

src/features/instance/applications/components/ApplicationsSidebar/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DirectoryEntry } from '@/features/instance/applications/context/di
22
import type { FileEntry } from '@/features/instance/applications/context/fileEntry';
33
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
44
import { useRenameFiles } from '@/features/instance/applications/hooks/useRenameFiles';
5+
import { useGlobalShortcutKeys } from '@/features/instance/applications/shortcuts';
56
import { extractFileNameFromPath } from '@/lib/string/paths/extractFileNameFromPath';
67
import { joinPath } from '@/lib/string/paths/joinPath';
78
import { renameFileInPath } from '@/lib/string/paths/renameFileInPath';
@@ -28,6 +29,8 @@ export function ApplicationsSidebar() {
2829
} = useEditorView();
2930
const { items, rootId } = useMemo(() => buildItems(rootEntries), [rootEntries]);
3031

32+
useGlobalShortcutKeys();
33+
3134
useEffect(function setOpenedEntryFromFocusedItem() {
3235
if (openedEntry?.path !== focusedItem && focusedItem) {
3336
const item = items[focusedItem];
@@ -59,6 +62,7 @@ export function ApplicationsSidebar() {
5962
break;
6063
}
6164
}, [items, renameFiles]);
65+
6266
const onRenameItem = useCallback((item: TreeItem<FileEntry | DirectoryEntry | undefined>, name: string) => {
6367
if (item.data) {
6468
return renameFiles([
@@ -78,6 +82,7 @@ export function ApplicationsSidebar() {
7882
canDropOnNonFolder={false}
7983
canReorderItems={false}
8084
canSearch={true}
85+
canRename={false}
8186
getItemTitle={getItemTitle}
8287
items={items}
8388
onDrop={onDrop}

src/features/instance/applications/components/ContentActions.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
9292
onClick={onSaveClick}
9393
disabled={fileIsClean || isSavingFile}
9494
>
95-
<SaveIcon />
96-
<span className="hidden lg:inline-block"><u>S</u>ave</span>
95+
<SaveIcon className="pointer-events-none" />
96+
<span className="hidden lg:inline-block pointer-events-none"><u>S</u>ave</span>
9797
</Button>}
9898

9999
{!isDirectory(openedEntry) && !openedEntry.package && canManageBrowseInstance && <Button
@@ -102,17 +102,17 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
102102
onClick={onRenameClick}
103103
disabled={!fileIsClean || isSavingFile}
104104
>
105-
<PencilIcon />
106-
<span className="hidden lg:inline-block"><u>R</u>ename</span>
105+
<PencilIcon className="pointer-events-none" />
106+
<span className="hidden lg:inline-block pointer-events-none"><u>R</u>ename</span>
107107
</Button>}
108108

109109
{!openedEntry.package && canManageBrowseInstance && <Button
110110
variant="ghost"
111111
className="rounded-none"
112112
onClick={onAddFileClick}
113113
>
114-
<FileIcon />
115-
<span>
114+
<FileIcon className="pointer-events-none" />
115+
<span className="pointer-events-none">
116116
<u>N</u>ew
117117
<span className="hidden mlg:inline-block">&nbsp;File</span>
118118
</span>
@@ -123,8 +123,8 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
123123
className="rounded-none"
124124
onClick={onAddDirectoryClick}
125125
>
126-
<FolderIcon />
127-
<span>
126+
<FolderIcon className="pointer-events-none" />
127+
<span className="pointer-events-none">
128128
<u>A</u>dd
129129
<span className="hidden xl:inline-block">&nbsp;Directory</span>
130130
</span>
@@ -135,8 +135,8 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
135135
className="rounded-none"
136136
onClick={onDownloadApplicationClick}
137137
>
138-
<DownloadIcon />
139-
<span className="hidden lg:inline-block">
138+
<DownloadIcon className="pointer-events-none" />
139+
<span className="hidden lg:inline-block pointer-events-none">
140140
Download
141141
<span className="hidden xl:inline-block">&nbsp;Application</span>
142142
</span>
@@ -148,8 +148,8 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
148148
className="rounded-none"
149149
onClick={onRedeployClick}
150150
>
151-
<PackageIcon />
152-
<span>Redeploy <u>P</u>ackage</span>
151+
<PackageIcon className="pointer-events-none" />
152+
<span className="pointer-events-none">Redeploy <u>P</u>ackage</span>
153153
</Button>}
154154

155155
<div className="grow"></div>
@@ -169,17 +169,17 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
169169
onClick={onRevertChangesClicked}
170170
disabled={fileIsClean || isSavingFile}
171171
>
172-
<Undo2Icon />
173-
<span className="hidden xl:inline-block">Revert Changes</span>
172+
<Undo2Icon className="pointer-events-none" />
173+
<span className="hidden xl:inline-block pointer-events-none">Revert Changes</span>
174174
</Button>}
175175

176176
{!restrictPackageModification && canManageBrowseInstance && <Button
177177
variant="destructiveGhost"
178178
className="rounded-none"
179179
onClick={onDeleteClick}
180180
>
181-
<TrashIcon />
182-
<span className="hidden xl:inline-block"><u>D</u>elete</span>
181+
<TrashIcon className="pointer-events-none" />
182+
<span className="hidden xl:inline-block pointer-events-none"><u>D</u>elete</span>
183183
</Button>}
184184

185185
</>)}

src/features/instance/applications/components/TextEditorView/index.tsx

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
22
import { useEditorFileContent } from '@/features/instance/applications/context/editorFileContent';
33
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
4+
import { registerWithEditor } from '@/features/instance/applications/shortcuts';
45
import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions';
5-
import { curryEmitToListeners, useListener } from '@/lib/events/listener';
6-
import { currySetWatchedValue } from '@/lib/events/watcher';
6+
import { useListener } from '@/lib/events/listener';
77
import { parseFileExtension } from '@/lib/string/parseFileExtension';
88
import { Editor, EditorProps, OnMount } from '@monaco-editor/react';
99
import { useCallback, useEffect, useState } from 'react';
@@ -47,51 +47,9 @@ export function TextEditorView() {
4747
}, []);
4848

4949
useEffect(() => {
50-
if (!mounted || !canManageBrowseInstance || !!openedEntry?.package || restrictPackageModification) {
51-
return;
50+
if (mounted && canManageBrowseInstance && !openedEntry?.package && !restrictPackageModification) {
51+
return registerWithEditor(mounted);
5252
}
53-
const [editor, monaco] = mounted;
54-
const disposables = [
55-
editor.addAction({
56-
id: 'new-file',
57-
label: 'New File',
58-
keybindings: [monaco.KeyMod.WinCtrl | monaco.KeyMod.Alt | monaco.KeyCode.KeyN],
59-
run: currySetWatchedValue('ShowAddDirectoryOrFileModalType', 'file'),
60-
}),
61-
editor.addAction({
62-
id: 'rename-file',
63-
label: 'Rename File',
64-
keybindings: [monaco.KeyMod.WinCtrl | monaco.KeyMod.Alt | monaco.KeyCode.KeyR],
65-
run: currySetWatchedValue('ShowRenameFileModal', true),
66-
}),
67-
editor.addAction({
68-
id: 'new-directory',
69-
label: 'New Directory',
70-
keybindings: [monaco.KeyMod.WinCtrl | monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyN],
71-
run: currySetWatchedValue('ShowAddDirectoryOrFileModalType', 'directory'),
72-
}),
73-
editor.addAction({
74-
id: 'save-file',
75-
label: 'Save Changes',
76-
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
77-
run: curryEmitToListeners('SaveFile', true),
78-
}),
79-
editor.addAction({
80-
id: 'revert-file',
81-
label: 'Revert File',
82-
run: curryEmitToListeners('RevertChanges', true),
83-
}),
84-
editor.addAction({
85-
id: 'delete-file',
86-
label: 'Delete File',
87-
run: currySetWatchedValue('ShowDeleteDirectoryOrFileModal', true),
88-
}),
89-
];
90-
return () => {
91-
for (const disposable of disposables) {
92-
disposable?.dispose();
93-
}
94-
};
9553
}, [mounted, canManageBrowseInstance, openedEntry, restrictPackageModification]);
9654

9755
useListener(

src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,21 @@ import { isDirectory } from '@/features/instance/applications/context/isDirector
1919
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
2020
import { useSetComponentFile } from '@/integrations/api/instance/applications/setComponentFile';
2121
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
22-
import { useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher';
22+
import { attemptToRestoreFocus } from '@/lib/attemptToRestoreFocus';
23+
import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
2324
import { zodResolver } from '@hookform/resolvers/zod';
2425
import { Plus } from 'lucide-react';
2526
import { useCallback } from 'react';
2627
import { useForm } from 'react-hook-form';
2728
import z from 'zod';
2829

2930
export function AddDirectoryOrFileModal() {
30-
const type = useWatchedValue('ShowAddDirectoryOrFileModalType', false);
31-
const hideModal = useSetWatchedValue('ShowAddDirectoryOrFileModalType', false);
31+
const { value: type, trigger } = useWatchedValue('ShowAddDirectoryOrFileModalType', false);
32+
33+
const closeModal = useCallback(() => {
34+
setWatchedValue('ShowAddDirectoryOrFileModalType', false);
35+
attemptToRestoreFocus(trigger);
36+
}, [trigger]);
3237

3338
const { openedEntry, reloadRootEntries, setFocusedItem, setSelectedItems, setExpandedItems } = useEditorView();
3439
const instanceParams = useInstanceClientIdParams();
@@ -71,7 +76,7 @@ export function AddDirectoryOrFileModal() {
7176
{
7277
onSuccess: () => {
7378
void reloadRootEntries();
74-
hideModal();
79+
closeModal();
7580
form.reset();
7681
const treeId = [openedEntry.project, filePath, data.name].filter(excludeFalsy).join('/');
7782
setFocusedItem(treeId);
@@ -82,15 +87,15 @@ export function AddDirectoryOrFileModal() {
8287
},
8388
},
8489
);
85-
}, [addFolderFile, form, hideModal, instanceParams, openedEntry, reloadRootEntries, setExpandedItems, setFocusedItem, setSelectedItems, type]);
90+
}, [addFolderFile, form, closeModal, instanceParams, openedEntry, reloadRootEntries, setExpandedItems, setFocusedItem, setSelectedItems, type]);
8691

8792
const onCancelClick = useCallback(() => {
88-
hideModal();
93+
closeModal();
8994
form.reset();
90-
}, [hideModal, form]);
95+
}, [closeModal, form]);
9196

9297
return (
93-
<Dialog onOpenChange={hideModal} open={!!type}>
98+
<Dialog onOpenChange={closeModal} open={!!type}>
9499
<DialogContent aria-describedby={undefined} className="text-white">
95100
<Form {...form}>
96101
<form onSubmit={form.handleSubmit(submitForm)}>

src/features/instance/applications/modals/DeleteDirectoryOrFileModal.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,35 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient';
44
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
55
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
66
import { useDropComponent } from '@/integrations/api/instance/applications/dropComponent';
7-
import { setWatchedValue, useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher';
7+
import { attemptToRestoreFocus } from '@/lib/attemptToRestoreFocus';
8+
import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
89
import { Trash } from 'lucide-react';
910
import { MouseEvent, useCallback } from 'react';
1011

1112
export function DeleteDirectoryOrFileModal() {
12-
const isModalOpen = useWatchedValue('ShowDeleteDirectoryOrFileModal', false);
13+
const { value: isModalOpen, trigger } = useWatchedValue('ShowDeleteDirectoryOrFileModal', false);
1314

1415
const instanceParams = useInstanceClientIdParams();
1516
const { openedEntry, reloadRootEntries, setFocusedItem, setSelectedItems } = useEditorView();
1617
const isDirectorySelected = isDirectory(openedEntry);
18+
const isApplicationSelected = isDirectorySelected && openedEntry.path === openedEntry.project;
1719
const isPackageSelected = !!openedEntry?.package;
1820
const action = isPackageSelected ? 'Remove' : 'Delete';
19-
const thing = isPackageSelected ? 'Imported Application' : isDirectorySelected ? 'Directory' : 'File';
21+
const thing = isPackageSelected
22+
? 'Imported Application'
23+
: isApplicationSelected
24+
? 'Application'
25+
: isDirectorySelected
26+
? 'Directory'
27+
: 'File';
2028
const { mutate: deleteFolderFile, isPending, isSuccess } = useDropComponent();
2129
const actionStatus = isSuccess ? `${action}d` : isPending ? `${action.slice(0, -1)}ing` : action;
2230

31+
const closeModal = useCallback(() => {
32+
setWatchedValue('ShowDeleteDirectoryOrFileModal', false);
33+
attemptToRestoreFocus(trigger);
34+
}, [trigger]);
35+
2336
const handleDeleteFolderOrFile = useCallback(() => {
2437
if (!openedEntry) {
2538
return;
@@ -34,23 +47,21 @@ export function DeleteDirectoryOrFileModal() {
3447
},
3548
{
3649
onSuccess: () => {
37-
setWatchedValue('ShowDeleteDirectoryOrFileModal', false);
50+
closeModal();
3851
const itemToFocus = !openedEntry.package && openedEntry.path.split('/').slice(0, -1).join('/');
3952
setFocusedItem(itemToFocus || undefined);
4053
setSelectedItems(itemToFocus ? [itemToFocus] : []);
4154
void reloadRootEntries();
4255
},
4356
},
4457
);
45-
}, [deleteFolderFile, instanceParams, openedEntry, reloadRootEntries, setFocusedItem, setSelectedItems]);
58+
}, [deleteFolderFile, instanceParams, openedEntry, reloadRootEntries, setFocusedItem, setSelectedItems, closeModal]);
4659

4760
const onClickYes = useCallback((e: MouseEvent) => {
4861
e.preventDefault();
4962
handleDeleteFolderOrFile();
5063
}, [handleDeleteFolderOrFile]);
5164

52-
const closeModal = useSetWatchedValue('ShowDeleteDirectoryOrFileModal', false);
53-
5465
return (
5566
<Dialog onOpenChange={closeModal} open={isModalOpen}>
5667
<DialogContent aria-describedby={undefined} className="text-white">

src/features/instance/applications/modals/DownloadApplicationModal.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { Label } from '@/components/ui/label';
55
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
66
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
77
import { usePackageComponentMutation } from '@/integrations/api/instance/applications/packageComponent';
8-
import { setWatchedValue, useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher';
8+
import { attemptToRestoreFocus } from '@/lib/attemptToRestoreFocus';
9+
import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
910
import { DownloadIcon } from 'lucide-react';
1011
import { ChangeEvent, MouseEvent, useCallback, useState } from 'react';
1112
import { toast } from 'sonner';
1213

1314
export function DownloadApplicationModal() {
14-
const isModalOpen = useWatchedValue('ShowDownloadApplicationModal', false);
15+
const { value: isModalOpen, trigger } = useWatchedValue('ShowDownloadApplicationModal', false);
1516

1617
const instanceParams = useInstanceClientIdParams();
1718
const { openedEntry } = useEditorView();
@@ -24,13 +25,16 @@ export function DownloadApplicationModal() {
2425
setIncludeNodeModules(e.target.checked);
2526
}, []);
2627

27-
const closeModal = useSetWatchedValue('ShowDownloadApplicationModal', false);
28+
const closeModal = useCallback(() => {
29+
setWatchedValue('ShowDownloadApplicationModal', false);
30+
attemptToRestoreFocus(trigger);
31+
}, [trigger]);
2832
const onClickYes = useCallback((e: MouseEvent) => {
2933
e.preventDefault();
3034
if (!openedEntry) {
3135
return;
3236
}
33-
setWatchedValue('ShowDownloadApplicationModal', false);
37+
closeModal();
3438
const toastId = toast.loading('Packaging...');
3539
packageComponent(
3640
{
@@ -55,7 +59,7 @@ export function DownloadApplicationModal() {
5559
},
5660
},
5761
);
58-
}, [openedEntry, packageComponent, includeNodeModules, instanceParams]);
62+
}, [openedEntry, packageComponent, includeNodeModules, instanceParams, closeModal]);
5963

6064

6165
return (

0 commit comments

Comments
 (0)