Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ jobs:
"types-endpoint-test.ts",
"server-endpoints/authentication-test.ts",
"server-endpoints/bot-registration-test.ts",
"server-endpoints/download-realm-test.ts",
"server-endpoints/index-responses-test.ts",
"server-endpoints/maintenance-endpoints-test.ts",
"server-endpoints/queue-status-test.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,24 @@ import Component from '@glimmer/component';
import FileCheck from '@cardstack/boxel-icons/file-check';
import FolderTree from '@cardstack/boxel-icons/folder-tree';

import { Button as BoxelButton } from '@cardstack/boxel-ui/components';
import { cn, not } from '@cardstack/boxel-ui/helpers';
import { Download } from '@cardstack/boxel-ui/icons';

import RealmDropdown from '@cardstack/host/components/realm-dropdown';

// These were inline but caused the template to have spurious Glint errors
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see the errors here. I tried for a while to come up with a better way, including splitting the template, moving the functions outside the class… this was the only way I found that worked.

import {
extractFilename,
fallbackDownloadName,
} from '@cardstack/host/lib/download-realm';

import RestoreScrollPosition from '@cardstack/host/modifiers/restore-scroll-position';

import type NetworkService from '@cardstack/host/services/network';
import type { FileView } from '@cardstack/host/services/operator-mode-state-service';
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
import type RealmService from '@cardstack/host/services/realm';
import type RecentFilesService from '@cardstack/host/services/recent-files-service';

import InnerContainer from './inner-container';
Expand All @@ -35,6 +47,8 @@ interface Signature {
export default class CodeSubmodeLeftPanelToggle extends Component<Signature> {
@service declare operatorModeStateService: OperatorModeStateService;
@service declare private recentFilesService: RecentFilesService;
@service declare private network: NetworkService;
@service declare private realm: RealmService;

private notifyFileBrowserIsVisible: (() => void) | undefined;

Expand Down Expand Up @@ -93,6 +107,45 @@ export default class CodeSubmodeLeftPanelToggle extends Component<Signature> {
this.switchRealm(realmItem.path);
};

private get downloadRealmURL() {
let downloadURL = new URL('/_download-realm', this.args.realmURL);
downloadURL.searchParams.set('realm', this.args.realmURL);
return downloadURL.href;
}

private triggerDownload(blob: Blob, filename: string) {
let blobUrl = URL.createObjectURL(blob);
let downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(blobUrl);
}

downloadRealm = async (event: Event) => {
event.preventDefault();
try {
let token = this.realm.token(this.args.realmURL);
let response = await this.network.authedFetch(this.downloadRealmURL, {
headers: token ? { Authorization: token } : {},
});
if (!response.ok) {
throw new Error(
`Failed to download realm: ${response.status} ${response.statusText}`,
);
}
let blob = await response.blob();
let filename =
extractFilename(response.headers.get('content-disposition')) ??
fallbackDownloadName(new URL(this.args.realmURL));
this.triggerDownload(blob, filename);
} catch (error) {
console.error('Error downloading realm:', error);
}
};

<template>
<InnerContainer
class={{cn 'left-panel' file-browser=this.isFileTreeShowing}}
Expand Down Expand Up @@ -122,15 +175,33 @@ export default class CodeSubmodeLeftPanelToggle extends Component<Signature> {
Inspector
</ToggleButton>
</header>

{{#if this.isFileTreeShowing}}

<RealmDropdown
@selectedRealmURL={{@realmURL}}
@onSelect={{this.handleRealmSelect}}
@selectedRealmPrefix='In'
@displayReadOnlyTag={{true}}
@contentClass='realm-dropdown-menu'
/>

<div class='realm-download'>
<BoxelButton
@kind='text-only'
@size='extra-small'
class='realm-download-button'
title='Download an archive of this workspace'
{{on 'click' this.downloadRealm}}
data-test-download-realm-button
>
<Download width='13' height='13' />
Download
</BoxelButton>
</div>

{{/if}}

<InnerContainerContent
class='content'
data-test-togglable-left-panel
Expand All @@ -148,6 +219,7 @@ export default class CodeSubmodeLeftPanelToggle extends Component<Signature> {
{{yield to='inspector'}}
{{/if}}
</InnerContainerContent>

</InnerContainer>

<style scoped>
Expand All @@ -170,8 +242,41 @@ export default class CodeSubmodeLeftPanelToggle extends Component<Signature> {
border: none;
border-bottom: 1px solid var(--boxel-400);
padding: var(--boxel-sp-xs);
padding-bottom: var(--boxel-sp);
height: fit-content;
}

.realm-download {
border-bottom: var(--boxel-border);
background-color: var(--boxel-light-100);
padding: var(--boxel-sp-xxs);
}

.realm-download-button {
--boxel-button-min-height: 1.5rem;
--boxel-button-padding: var(--boxel-sp-5xs) var(--boxel-sp-5xs)
var(--boxel-sp-5xs) var(--boxel-sp-xxxs);
--boxel-button-font: 600 var(--boxel-font-xs);

justify-content: flex-start;
gap: var(--boxel-sp-xxxs);
align-self: flex-start;

border: 0;
background: transparent;
border-radius: var(--boxel-radius);
cursor: pointer;
font: inherit;
width: 100%;
}

.realm-download-button :deep(svg) {
margin-bottom: var(--boxel-sp-6xs);
}

.realm-download-button:hover {
background-color: var(--boxel-light-200);
}
</style>
</template>
}
23 changes: 23 additions & 0 deletions packages/host/app/lib/download-realm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function extractFilename(
contentDisposition: string | null,
): string | null {
if (!contentDisposition) {
return null;
}
let utf8Match = contentDisposition.match(/filename\\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
let match = contentDisposition.match(/filename="?([^";]+)"?/i);
return match?.[1] ?? null;
}

export function fallbackDownloadName(realmURL: URL) {
let segments = realmURL.pathname.split('/').filter(Boolean);
let base =
segments.length >= 2
? segments.slice(-2).join('-')
: (segments[0] ?? realmURL.hostname);
base = base.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return base.length > 0 ? `${base}.zip` : 'realm.zip';
}
Loading
Loading