diff --git a/app/guid-node/binderhub/-components/build-console/component.ts b/app/guid-node/binderhub/-components/build-console/component.ts
index 79c477651..bef27ebf0 100644
--- a/app/guid-node/binderhub/-components/build-console/component.ts
+++ b/app/guid-node/binderhub/-components/build-console/component.ts
@@ -32,6 +32,16 @@ export default class BuildConsole extends Component {
notAuthorized: boolean = false;
+ copyDefaultStorage?: boolean;
+
+ pathsYamlIsCustom?: boolean;
+
+ userHasWritePermission?: boolean;
+
+ toggleCopyDefaultStorage?: (event: Event) => void;
+
+ openPathsYamlResetConfirm?: () => void;
+
@requiredAction renewToken!: (binderhubUrl: string) => void;
@requiredAction requestBuild!: (
diff --git a/app/guid-node/binderhub/-components/build-console/styles.scss b/app/guid-node/binderhub/-components/build-console/styles.scss
index 791961aa4..9bff22da1 100644
--- a/app/guid-node/binderhub/-components/build-console/styles.scss
+++ b/app/guid-node/binderhub/-components/build-console/styles.scss
@@ -53,3 +53,23 @@
.build_launch_button {
margin-left: auto;
}
+
+.paths_toggle {
+ margin: 1.5em 0;
+}
+
+.paths_toggle__label {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ margin-bottom: 0;
+}
+
+.paths_toggle__label--disabled {
+ color: #999;
+ cursor: not-allowed;
+}
+
+.paths_toggle__hint {
+ margin: 0.5em 0;
+}
diff --git a/app/guid-node/binderhub/-components/build-console/template.hbs b/app/guid-node/binderhub/-components/build-console/template.hbs
index 18f2b1e1c..01b3a3f1c 100644
--- a/app/guid-node/binderhub/-components/build-console/template.hbs
+++ b/app/guid-node/binderhub/-components/build-console/template.hbs
@@ -1,7 +1,33 @@
{{t 'binderhub.deployment.header'}}
-{{t 'binderhub.deployment.description'}}
+
+
+ {{#if this.pathsYamlIsCustom}}
+
+ {{t 'binderhub.deployment.copy_default_storage_disabled_hint'}}
+
+ {{#if this.userHasWritePermission}}
+
+ {{/if}}
+ {{/if}}
+
this.loadCurrentFile(file, files));
await Promise.all(tasks);
+ await this.loadPathsYaml(files);
}
async saveCurrentFile(
@@ -1624,6 +1711,50 @@ export default class ProjectEditor extends Component {
);
}
+ @action
+ toggleCopyDefaultStorage(this: ProjectEditor, event: Event) {
+ const node = this.get('node');
+ if (!node || !node.userHasWritePermission || this.pathsYamlIsCustom) {
+ return;
+ }
+ const target = event.target as HTMLInputElement;
+ const previous = this.pathsYamlState;
+ const newState = target.checked ? PathsYamlState.DEFAULT : PathsYamlState.NO_COPY;
+ this.set('pathsYamlState', newState);
+ later(async () => {
+ try {
+ await this.persistPathsYamlPreference();
+ this.pageCleanser();
+ } catch (exception) {
+ this.set('pathsYamlState', previous);
+ this.onError(exception, this.intl.t('binderhub.error.modify_files_error'));
+ }
+ }, 0);
+ }
+
+ @action
+ openPathsYamlResetConfirm(this: ProjectEditor) {
+ this.set('showPathsYamlResetConfirm', true);
+ }
+
+ @action
+ cancelPathsYamlReset(this: ProjectEditor) {
+ this.set('showPathsYamlResetConfirm', false);
+ }
+
+ @action
+ resetPathsYaml(this: ProjectEditor) {
+ this.set('showPathsYamlResetConfirm', false);
+ later(async () => {
+ try {
+ await this.performPathsYamlReset();
+ this.pageCleanser();
+ } catch (exception) {
+ this.onError(exception, this.intl.t('binderhub.error.modify_files_error'));
+ }
+ }, 0);
+ }
+
@action
editDockerfileContent(this: ProjectEditor, event: { target: HTMLInputElement }) {
this.set('editingDockerfileContent', event.target.value);
@@ -1728,4 +1859,71 @@ export default class ProjectEditor extends Component {
modifyCustomBaseImageModelForLocale(model: CustomBaseImageModel) {
return this.modifyImageForLocale(imageFromCustomBaseImageModel(model));
}
+
+ private getHashedMinimalPathsYaml(): string {
+ const content = MINIMAL_PATHS_YAML_CONTENT;
+ const checksum = md5(content.trim());
+ return `# rdm-binderhub:hash:${checksum}\n${content}`;
+ }
+
+ private async loadPathsYaml(files: WaterButlerFile[] | null) {
+ const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
+ if (!pathsFile) {
+ this.set('pathsYamlModel', null);
+ this.set('pathsYaml', '');
+ this.set('pathsYamlState', PathsYamlState.DEFAULT);
+ return;
+ }
+ const content = (await pathsFile.getContents()).toString();
+ this.set('pathsYamlModel', pathsFile);
+ this.set('pathsYaml', content);
+
+ // Use computed property to check if manually changed (avoid duplicate hash verification)
+ if (this.pathsYamlManuallyChanged) {
+ this.set('pathsYamlState', PathsYamlState.CUSTOM);
+ return;
+ }
+
+ // Hash is valid - verify content structure is minimal paths.yaml
+ const parsed = this.pathsYamlContent;
+ if (!parsed) {
+ throw new EmberError('Invalid paths.yaml: hash is valid but failed to parse content');
+ }
+ if (!parsed.override || parsed.paths.length !== 0) {
+ throw new EmberError(
+ 'Invalid paths.yaml: hash is valid but content does not match minimal template',
+ );
+ }
+ this.set('pathsYamlState', PathsYamlState.NO_COPY);
+ }
+
+ private async persistPathsYamlPreference() {
+ if (!this.configFolder) {
+ throw new EmberError('Illegal config');
+ }
+ const files = await this.getRootFiles(true);
+ const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
+ if (this.pathsYamlState === PathsYamlState.DEFAULT) {
+ if (pathsFile) {
+ await pathsFile.delete();
+ }
+ } else {
+ const content = this.getHashedMinimalPathsYaml();
+ if (!pathsFile) {
+ await this.configFolder.createFile(PATHS_YAML_FILENAME, content);
+ } else {
+ await pathsFile.updateContents(content);
+ }
+ }
+ await this.loadPathsYaml(await this.getRootFiles(true));
+ }
+
+ private async performPathsYamlReset() {
+ const files = await this.getRootFiles(true);
+ const pathsFile = await this.getFile(PATHS_YAML_FILENAME, files);
+ if (pathsFile) {
+ await pathsFile.delete();
+ }
+ await this.loadPathsYaml(await this.getRootFiles(true));
+ }
}
diff --git a/app/guid-node/binderhub/-components/project-editor/template.hbs b/app/guid-node/binderhub/-components/project-editor/template.hbs
index fdb91b54c..e6fcd65fc 100644
--- a/app/guid-node/binderhub/-components/project-editor/template.hbs
+++ b/app/guid-node/binderhub/-components/project-editor/template.hbs
@@ -266,10 +266,42 @@
@beforeLaunch={{this.performDockerfileSave}}
@buildLog={{this.buildLog}}
@buildPhase={{this.buildPhase}}
+ @copyDefaultStorage={{this.copyDefaultStorage}}
+ @pathsYamlIsCustom={{this.pathsYamlIsCustom}}
+ @userHasWritePermission={{this.node.userHasWritePermission}}
+ @toggleCopyDefaultStorage={{action this.toggleCopyDefaultStorage}}
+ @openPathsYamlResetConfirm={{action this.openPathsYamlResetConfirm}}
/>
{{/if}}
+
+
+
+ {{t 'binderhub.deployment.reset_paths_yaml_confirm_title'}}
+
+
+
+ {{t 'binderhub.deployment.reset_paths_yaml_confirm_body'}}
+
+
+
+ {{t 'general.cancel'}}
+
+
+ {{t 'binderhub.deployment.reset_paths_yaml_confirm_ok'}}
+
+
+
+