Skip to content

Commit 49d3062

Browse files
mikalcallahanAndrewKushnir
authored andcommitted
docs(docs-infra): allow file renaming in code editor (angular#54989)
Any filename but the main.ts is now editable. PR Close angular#54989
1 parent 9160a21 commit 49d3062

7 files changed

+136
-7
lines changed

adev/src/app/editor/code-editor/code-editor.component.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,29 @@
1010
@for (file of files(); track file) {
1111
<mat-tab #tab>
1212
<ng-template mat-tab-label>
13+
@if (tab.isActive && isRenamingFile()) {
14+
<form
15+
(submit)="renameFile($event, file.filename)"
16+
(docsClickOutside)="closeRenameFile()">
17+
<input
18+
name="rename-file"
19+
class="adev-rename-file-input"
20+
#renameFileInput
21+
(keydown)="$event.stopPropagation()"
22+
/>
23+
</form>
24+
} @else {
1325
{{ file.filename.replace('src/', '') }}
26+
}
27+
@if (tab.isActive && canRenameFile(file.filename)) {
28+
<button
29+
class="docs-rename-file"
30+
aria-label="rename file"
31+
(click)="onRenameButtonClick()"
32+
>
33+
<docs-icon>edit</docs-icon>
34+
</button>
35+
}
1436
@if (tab.isActive && canDeleteFile(file.filename)) {
1537
<button
1638
class="docs-delete-file"

adev/src/app/editor/code-editor/code-editor.component.scss

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@
7979
}
8080

8181
.adev-add-file,
82-
.docs-delete-file {
82+
.docs-delete-file,
83+
.docs-rename-file {
8384
docs-icon {
8485
color: var(--gray-400);
8586
transition: color 0.3s ease;
@@ -92,7 +93,8 @@
9293
}
9394
}
9495

95-
.docs-delete-file {
96+
.docs-delete-file,
97+
.docs-rename-file {
9698
padding-inline-start: 0.1rem;
9799
padding-inline-end: 0;
98100
margin-block-start: 0.2rem;
@@ -101,7 +103,8 @@
101103
}
102104
}
103105

104-
.adev-new-file-input {
106+
.adev-new-file-input,
107+
.adev-rename-file-input {
105108
color: var(--primary-contrast);
106109
border: none;
107110
border-radius: 0;

adev/src/app/editor/code-editor/code-editor.component.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('CodeEditor', () => {
107107
const tabs = await matTabGroup.getTabs();
108108
const expectedLabels = files.map((file, index) => {
109109
const label = file.filename.replace('src/', '');
110-
if (index === 0) return `${label} delete`;
110+
if (index === 0) return `${label} editdelete`;
111111
return label;
112112
});
113113

adev/src/app/editor/code-editor/code-editor.component.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,21 @@ import {EmbeddedTutorialManager} from '../embedded-tutorial-manager.service';
2828
import {CodeMirrorEditor} from './code-mirror-editor.service';
2929
import {DiagnosticWithLocation, DiagnosticsState} from './services/diagnostics-state.service';
3030
import {DownloadManager} from '../download-manager.service';
31-
import {IconComponent} from '@angular/docs';
31+
import {ClickOutside, IconComponent} from '@angular/docs';
3232

33-
export const REQUIRED_FILES = new Set(['src/main.ts', 'src/index.html']);
33+
export const REQUIRED_FILES = new Set([
34+
'src/main.ts',
35+
'src/index.html',
36+
'src/app/app.component.ts',
37+
]);
3438

3539
@Component({
3640
selector: 'docs-tutorial-code-editor',
3741
standalone: true,
3842
templateUrl: './code-editor.component.html',
3943
styleUrls: ['./code-editor.component.scss'],
4044
changeDetection: ChangeDetectionStrategy.OnPush,
41-
imports: [NgIf, NgFor, MatTabsModule, IconComponent],
45+
imports: [NgIf, NgFor, MatTabsModule, IconComponent, ClickOutside],
4246
})
4347
export class CodeEditor implements AfterViewInit, OnDestroy {
4448
@ViewChild('codeEditorWrapper') private codeEditorWrapperRef!: ElementRef<HTMLDivElement>;
@@ -54,6 +58,16 @@ export class CodeEditor implements AfterViewInit, OnDestroy {
5458
}
5559
}
5660

61+
private renameFileInputRef?: ElementRef<HTMLInputElement>;
62+
@ViewChild('renameFileInput') protected set setRenameFileInputRef(
63+
element: ElementRef<HTMLInputElement>,
64+
) {
65+
if (element) {
66+
element.nativeElement.focus();
67+
this.renameFileInputRef = element;
68+
}
69+
}
70+
5771
private readonly destroyRef = inject(DestroyRef);
5872

5973
private readonly codeMirrorEditor = inject(CodeMirrorEditor);
@@ -82,6 +96,7 @@ export class CodeEditor implements AfterViewInit, OnDestroy {
8296
readonly errors = signal<DiagnosticWithLocation[]>([]);
8397
readonly files = this.codeMirrorEditor.openFiles;
8498
readonly isCreatingFile = signal<boolean>(false);
99+
readonly isRenamingFile = signal<boolean>(false);
85100

86101
ngAfterViewInit() {
87102
this.codeMirrorEditor.init(this.codeEditorWrapperRef.nativeElement);
@@ -104,6 +119,12 @@ export class CodeEditor implements AfterViewInit, OnDestroy {
104119
this.displayErrorsBox.set(false);
105120
}
106121

122+
closeRenameFile(): void {
123+
this.isRenamingFile.set(false);
124+
}
125+
126+
canRenameFile = (filename: string) => this.canDeleteFile(filename);
127+
107128
canDeleteFile(filename: string) {
108129
return !REQUIRED_FILES.has(filename);
109130
}
@@ -118,6 +139,37 @@ export class CodeEditor implements AfterViewInit, OnDestroy {
118139
this.matTabGroup.selectedIndex = this.files().length;
119140
}
120141

142+
onRenameButtonClick() {
143+
this.isRenamingFile.set(true);
144+
}
145+
146+
async renameFile(event: SubmitEvent, oldPath: string) {
147+
if (!this.renameFileInputRef) return;
148+
149+
event.preventDefault();
150+
151+
const renameFileInputValue = this.renameFileInputRef.nativeElement.value;
152+
153+
if (renameFileInputValue) {
154+
if (renameFileInputValue.includes('..')) {
155+
alert('File name can not contain ".."');
156+
return;
157+
}
158+
159+
// src is hidden from users, here we manually add it to the new filename
160+
const newFile = 'src/' + renameFileInputValue;
161+
162+
if (this.files().find(({filename}) => filename.includes(newFile))) {
163+
alert('File name already exists');
164+
return;
165+
}
166+
167+
await this.codeMirrorEditor.renameFile(oldPath, newFile);
168+
}
169+
170+
this.isRenamingFile.set(false);
171+
}
172+
121173
async createFile(event: SubmitEvent) {
122174
if (!this.createFileInputRef) return;
123175

adev/src/app/editor/code-editor/code-mirror-editor.service.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,34 @@ export class CodeMirrorEditor {
340340
this.changeCurrentFile(filename);
341341
}
342342

343+
async renameFile(oldPath: string, newPath: string) {
344+
const content = await this.nodeRuntimeSandbox.readFile(oldPath).catch((error) => {
345+
// empty content if file does not exist
346+
if (error.message.includes('ENOENT')) return '';
347+
else throw error;
348+
});
349+
350+
await this.nodeRuntimeSandbox.renameFile(oldPath, newPath).catch((error) => {
351+
throw error;
352+
});
353+
354+
this.embeddedTutorialManager.tutorialFiles.update((files) => {
355+
delete files[oldPath];
356+
files[newPath] = content;
357+
return files;
358+
});
359+
360+
this.embeddedTutorialManager.openFiles.update((files) => [
361+
...files.filter((file) => file !== oldPath),
362+
newPath,
363+
]);
364+
365+
this.setProjectFiles();
366+
this.updateVfsEnv();
367+
this.saveLibrariesTypes();
368+
this.changeCurrentFile(newPath);
369+
}
370+
343371
async deleteFile(deletedFile: string): Promise<void> {
344372
await this.nodeRuntimeSandbox.deleteFile(deletedFile);
345373

adev/src/app/editor/node-runtime-sandbox.service.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,20 @@ describe('NodeRuntimeSandbox', () => {
177177
expect(writeFileSpy).toHaveBeenCalledOnceWith(path, content);
178178
});
179179

180+
it('should call renameFile with proper parameters', async () => {
181+
setValuesToInitializeProject();
182+
183+
const fakeWebContainer = new FakeWebContainer();
184+
service['webContainerPromise'] = Promise.resolve(fakeWebContainer);
185+
const renameFileSpy = spyOn(fakeWebContainer.fs, 'rename');
186+
187+
const oldPath = 'oldPath';
188+
const newPath = 'newPath';
189+
190+
await service.renameFile(oldPath, newPath);
191+
expect(renameFileSpy).toHaveBeenCalledOnceWith(oldPath, newPath);
192+
});
193+
180194
it('should initialize the Angular CLI based on the tutorial config', async () => {
181195
setValuesToInitializeAngularCLI();
182196

adev/src/app/editor/node-runtime-sandbox.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,16 @@ export class NodeRuntimeSandbox {
270270
}
271271
}
272272

273+
async renameFile(oldPath: string, newPath: string): Promise<void> {
274+
const webContainer = await this.webContainerPromise!;
275+
276+
try {
277+
await webContainer.fs.rename(oldPath, newPath);
278+
} catch (err: any) {
279+
throw err;
280+
}
281+
}
282+
273283
async readFile(filePath: string): Promise<string> {
274284
const webContainer = await this.webContainerPromise!;
275285

0 commit comments

Comments
 (0)