Skip to content

Commit 632838e

Browse files
committed
feat: Allow downloading packages
https://harperdb.atlassian.net/browse/STUDIO-536
1 parent 85609e6 commit 632838e

File tree

5 files changed

+181
-2
lines changed

5 files changed

+181
-2
lines changed

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions';
99
import { useEmitToListeners } from '@/lib/events/listener';
1010
import { useSetWatchedValue } from '@/lib/events/watcher';
1111
import {
12+
DownloadIcon,
1213
FileIcon,
1314
FolderIcon,
1415
PackageIcon,
@@ -34,6 +35,7 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
3435

3536
const onAddFileClick = useSetWatchedValue('ShowAddDirectoryOrFileModalType', 'file');
3637
const onAddDirectoryClick = useSetWatchedValue('ShowAddDirectoryOrFileModalType', 'directory');
38+
const onDownloadApplicationClick = useSetWatchedValue('ShowDownloadApplicationModal', true);
3739
const onRenameClick = useSetWatchedValue('ShowRenameFileModal', true);
3840
const onDeleteClick = useSetWatchedValue('ShowDeleteDirectoryOrFileModal', true);
3941
const onRedeployClick = useSetWatchedValue('ShowRedeployApplicationModal', true);
@@ -86,7 +88,10 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
8688
onClick={onAddFileClick}
8789
>
8890
<FileIcon />
89-
<span className="hidden lg:inline-block"><u>N</u>ew File</span>
91+
<span>
92+
<u>N</u>ew
93+
<span className="hidden mlg:inline-block">&nbsp;File</span>
94+
</span>
9095
</Button>}
9196

9297
{!openedEntry.package && canManageBrowseInstance && <Button
@@ -95,7 +100,22 @@ export function ContentActions({ toggledSidebar, toggleSidebar }: {
95100
onClick={onAddDirectoryClick}
96101
>
97102
<FolderIcon />
98-
<span className="hidden lg:inline-block"><u>A</u>dd Directory</span>
103+
<span>
104+
<u>A</u>dd
105+
<span className="hidden xl:inline-block">&nbsp;Directory</span>
106+
</span>
107+
</Button>}
108+
109+
{openedEntry.project && <Button
110+
variant="ghost"
111+
className="rounded-none"
112+
onClick={onDownloadApplicationClick}
113+
>
114+
<DownloadIcon />
115+
<span className="hidden lg:inline-block">
116+
Download
117+
<span className="hidden xl:inline-block">&nbsp;Application</span>
118+
</span>
99119
</Button>}
100120

101121
{!!openedEntry.package && canManageBrowseInstance && !restrictPackageModification &&

src/features/instance/applications/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ContentActions } from '@/features/instance/applications/components/Cont
22
import { ContentViewer } from '@/features/instance/applications/components/ContentViewer';
33
import { AddDirectoryOrFileModal } from '@/features/instance/applications/modals/AddDirectoryOrFileModal';
44
import { DeleteDirectoryOrFileModal } from '@/features/instance/applications/modals/DeleteDirectoryOrFileModal';
5+
import { DownloadApplicationModal } from '@/features/instance/applications/modals/DownloadApplicationModal';
56
import { RedeployApplicationModal } from '@/features/instance/applications/modals/RedeployApplicationModal';
67
import { RenameFileModal } from '@/features/instance/applications/modals/RenameFileModal';
78
import { useSessionToggler } from '@/hooks/useSessionToggler';
@@ -33,6 +34,7 @@ export function ApplicationsEditor() {
3334

3435
<AddDirectoryOrFileModal />
3536
<DeleteDirectoryOrFileModal />
37+
<DownloadApplicationModal />
3638
<RedeployApplicationModal />
3739
<RenameFileModal />
3840
</EditorViewProvider>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
3+
import { Input } from '@/components/ui/input';
4+
import { Label } from '@/components/ui/label';
5+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
6+
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
7+
import { usePackageComponentMutation } from '@/features/instance/operations/mutations/packageComponent';
8+
import { setWatchedValue, useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher';
9+
import { DownloadIcon } from 'lucide-react';
10+
import { ChangeEvent, MouseEvent, useCallback, useState } from 'react';
11+
import { toast } from 'sonner';
12+
13+
export function DownloadApplicationModal() {
14+
const isModalOpen = useWatchedValue('ShowDownloadApplicationModal', false);
15+
16+
const instanceParams = useInstanceClientIdParams();
17+
const { openedEntry } = useEditorView();
18+
const { mutate: packageComponent, isPending, isSuccess } = usePackageComponentMutation();
19+
const actionStatus = isSuccess ? `Downloaded` : isPending ? `Downloading` : 'Download';
20+
21+
const [skipNodeModules, setSkipNodeModules] = useState(true);
22+
const [skipSymlinks, setSkipSymlinks] = useState(true);
23+
24+
const skipNodeModulesChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
25+
setSkipNodeModules(e.target.checked);
26+
}, []);
27+
const skipSymlinksChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
28+
setSkipSymlinks(e.target.checked);
29+
}, []);
30+
31+
const closeModal = useSetWatchedValue('ShowDownloadApplicationModal', false);
32+
const onClickYes = useCallback((e: MouseEvent) => {
33+
e.preventDefault();
34+
if (!openedEntry) {
35+
return;
36+
}
37+
setWatchedValue('ShowDownloadApplicationModal', false);
38+
const toastId = toast.loading('Packaging...');
39+
packageComponent(
40+
{
41+
packageName: openedEntry.package,
42+
project: openedEntry.project,
43+
skipNodeModules,
44+
skipSymlinks,
45+
...instanceParams,
46+
},
47+
{
48+
onSuccess: (response) => {
49+
toast.success('Download ready!', { id: toastId });
50+
51+
const element = document.createElement('a');
52+
element.setAttribute('class', 'invisible absolute top-0');
53+
const bytes = Uint8Array.from(atob(response.payload), c => c.charCodeAt(0));
54+
const file = new Blob([bytes], { type: 'application/gzip' });
55+
element.href = URL.createObjectURL(file);
56+
element.download = `${openedEntry.project}.gz`;
57+
document.body.appendChild(element);
58+
element.click();
59+
document.body.removeChild(element);
60+
},
61+
},
62+
);
63+
}, [openedEntry, packageComponent, skipNodeModules, skipSymlinks, instanceParams]);
64+
65+
66+
return (
67+
<Dialog onOpenChange={closeModal} open={isModalOpen}>
68+
<DialogContent aria-describedby={undefined} className="text-white">
69+
<DialogHeader>
70+
<DialogTitle>Download Application</DialogTitle>
71+
<DialogDescription>
72+
This will package up the contents of <strong>{openedEntry?.project}</strong> into a gzipped file.
73+
</DialogDescription>
74+
</DialogHeader>
75+
76+
<Label className="flex">
77+
<Input
78+
type="checkbox"
79+
className="w-6"
80+
disabled={isPending}
81+
checked={skipNodeModules}
82+
onChange={skipNodeModulesChanged}
83+
/>
84+
<span className="pl-4 pr-8 flex-1 py-2.5">Skip Node Modules</span>
85+
</Label>
86+
87+
<Label className="flex">
88+
<Input
89+
type="checkbox"
90+
className="w-6"
91+
disabled={isPending}
92+
checked={skipSymlinks}
93+
onChange={skipSymlinksChanged}
94+
/>
95+
<span className="pl-4 pr-8 flex-1 py-2.5">Skip Symlinks</span>
96+
</Label>
97+
98+
<div className="flex w-full gap-4">
99+
<Button variant="ghostOutline" className="w-full rounded-full" onClick={closeModal}>
100+
Cancel
101+
</Button>
102+
<Button
103+
variant="positive"
104+
type="button"
105+
className="w-full rounded-full"
106+
disabled={isPending}
107+
autoFocus={true}
108+
onClick={onClickYes}
109+
>
110+
<DownloadIcon /> {actionStatus}{isPending ? '...' : ''}
111+
</Button>
112+
</div>
113+
</DialogContent>
114+
</Dialog>
115+
);
116+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { InstanceClientConfig } from '@/config/instanceClientConfig';
2+
import { useMutation } from '@tanstack/react-query';
3+
4+
interface PackageComponentParams {
5+
packageName?: string;
6+
project: string;
7+
skipNodeModules?: boolean;
8+
skipSymlinks?: boolean;
9+
}
10+
11+
interface PackageResponse {
12+
payload: string;
13+
}
14+
15+
async function packageComponent({
16+
packageName,
17+
project,
18+
skipNodeModules,
19+
skipSymlinks,
20+
instanceClient,
21+
}: PackageComponentParams & InstanceClientConfig): Promise<PackageResponse> {
22+
const { data } = await instanceClient.post(
23+
'/',
24+
{
25+
operation: 'package_component',
26+
package: packageName,
27+
project,
28+
skip_node_modules: skipNodeModules,
29+
skip_symlinks: skipSymlinks,
30+
},
31+
{ timeout: 300_000 },
32+
);
33+
return data;
34+
}
35+
36+
export function usePackageComponentMutation() {
37+
return useMutation({
38+
mutationFn: packageComponent,
39+
});
40+
}

src/lib/storage/watchedValueKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface WatchedValuesTypeMap {
33
SaveFile: true;
44
ShowAddDirectoryOrFileModalType: 'file' | 'directory' | false;
55
ShowDeleteDirectoryOrFileModal: boolean;
6+
ShowDownloadApplicationModal: boolean;
67
ShowRedeployApplicationModal: boolean;
78
ShowRenameFileModal: boolean;
89
ShowDeleteDatabase: boolean;

0 commit comments

Comments
 (0)