Skip to content
Open
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 localtypings/pxteditor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,7 @@ declare namespace pxt.editor {
setSideMarkdown(md: string): void;
setSideDocCollapsed(shouldCollapse?: boolean): void;
removeFile(fn: IFile, skipConfirm?: boolean): void;
renameFile(fn: IFile, newName: string): Promise<void>;
updateFileAsync(name: string, content: string, open?: boolean): Promise<void>;

openHome(): void;
Expand Down
5 changes: 5 additions & 0 deletions theme/common.less
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ pre {
flex-grow: 1;
}

.ui.button.label,
.ui.button {
margin-left: 0.25rem !important;
}

.button:focus-visible {
outline: @editorFocusBorderSize solid var(--pxt-focus-border);
outline-offset: 2px;
Expand Down
34 changes: 34 additions & 0 deletions webapp/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,40 @@ export class ProjectView
})
}

renameFile(fn: pkg.File, newName: string): Promise<void> {
const isCurrent = this.state.currFile?.name === fn.name;
const main = pkg.mainEditorPkg();
const oldHistoryName = "this/" + fn.name;
const newHistoryName = "this/" + newName;

return main.renameFileAsync(fn.name, newName)
.then(() => main.saveFilesAsync())
.then(() => {
const monacoEditor = (window as any).monaco?.editor;
if (monacoEditor) {
const oldProto = "pkg:" + fn.getName();
const oldModel = monacoEditor.getModels?.()?.find((m: any) => m.uri?.toString() === oldProto);
if (oldModel) oldModel.dispose();
}
if (this.settings?.fileHistory) {
this.settings.fileHistory.forEach(e => {
if (e.id === this.state.header?.id && e.name === oldHistoryName) {
e.name = newHistoryName;
}
});
}
return this.reloadHeaderAsync();
})
.then(() => {
if (isCurrent) {
const newF = main.lookupFile(newHistoryName);
if (newF) {
this.setFile(newF);
}
}
});
}

updateFileAsync(name: string, content: string, open?: boolean): Promise<void> {
const p = pkg.mainEditorPkg();
return p.setContentAsync(name, content)
Expand Down
119 changes: 116 additions & 3 deletions webapp/src/filelist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ enum MyEnum {
/**
* ${lf("Custom blocks")}
*/
//% weight=100 color=#0fbc11 icon="\uf0c3"
//% block="Custom" weight=100 color=#0fbc11 icon="\uf0c3"
namespace custom {
/**
* TODO: ${lf("describe your function here")}
Expand Down Expand Up @@ -70,6 +70,7 @@ export class FileList extends data.Component<ISettingsProps, FileListState> {
this.handleButtonKeydown = this.handleButtonKeydown.bind(this);
this.setFile = this.setFile.bind(this);
this.removeFile = this.removeFile.bind(this);
this.renameFile = this.renameFile.bind(this);
this.addLocalizedFile = this.addLocalizedFile.bind(this);
this.removePkg = this.removePkg.bind(this);
this.updatePkg = this.updatePkg.bind(this);
Expand Down Expand Up @@ -114,6 +115,89 @@ export class FileList extends data.Component<ISettingsProps, FileListState> {
this.props.parent.removeFile(f);
}

private renameFile(f: pkg.File) {
const validRx = /^[\w][\/\w\-\.]*$/;
const mainPkg = pkg.mainEditorPkg();
const originalExt = f.getExtension();

const lastSlash = f.name.lastIndexOf("/");
const dir = lastSlash >= 0 ? f.name.slice(0, lastSlash + 1) : "";
const baseFileName = lastSlash >= 0 ? f.name.slice(lastSlash + 1) : f.name;

core.promptAsync({
header: lf("Rename {0}", baseFileName),
body: lf("Please provide a new name for your file."),
agreeLbl: lf("Rename"),
initialValue: baseFileName,
hasCloseIcon: true,
onInputValidation: (v) => {
if (!v) return lf("Filename is empty.");
v = v.trim();
if (!validRx.test(v)) return lf("Don't use spaces or special characters.");
return undefined;
}
}).then(str => {
if (!str) return Promise.resolve();

str = str.replace(/\s+/g, "");
if (str === baseFileName) return Promise.resolve();

const indLastPeriod = str.lastIndexOf(".");
let name = str;
let ext = originalExt;

if (indLastPeriod !== -1) {
name = str.slice(0, indLastPeriod);
const givenExt = str.slice(indLastPeriod + 1).toLowerCase();
const allowedExts = ["ts", "js", "py", "md", "json", "jres"];
if (allowedExts.indexOf(givenExt) >= 0 || givenExt === originalExt) {
ext = givenExt;
}
}

if (!name) return Promise.resolve();

if (!validRx.test(name)) {
core.warningNotification(lf("Invalid file name"));
return Promise.resolve();
}

const newFileName = `${dir}${ext ? `${name}.${ext}` : name}`;
if (newFileName === f.name) return Promise.resolve();

const isSpecial = newFileName === pxt.CONFIG_NAME
|| newFileName === pxt.MAIN_TS
|| newFileName === pxt.MAIN_BLOCKS
|| newFileName === pxt.MAIN_PY
|| newFileName === pxt.HISTORY_FILE;

if (isSpecial) {
core.warningNotification(lf("Cannot use this file name"));
return Promise.resolve();
}

const pkgCfg = pkg.mainPkg && pkg.mainPkg.config;
const languageRestriction = pkgCfg && pkgCfg.languageRestriction;
if (languageRestriction === pxt.editor.LanguageRestriction.JavaScriptOnly && ext === "py") {
core.warningNotification(lf("Python files are not allowed in this project"));
return Promise.resolve();
} else if (languageRestriction === pxt.editor.LanguageRestriction.PythonOnly && (ext === "ts" || ext === "js")) {
core.warningNotification(lf("TypeScript files are not allowed in this project"));
return Promise.resolve();
} else if (languageRestriction === pxt.editor.LanguageRestriction.BlocksOnly && (ext === "py" || ext === "ts" || ext === "js")) {
core.warningNotification(lf("Text files are not allowed in this project"));
return Promise.resolve();
}

if (mainPkg.sortedFiles().some(file => file.name === newFileName)) {
core.warningNotification(lf("File already exists"));
return Promise.resolve();
}

return this.props.parent.renameFile(f, newFileName);
});
}

private addLocalizedFile(f: pkg.File, localizedF: string) {
return this.props.parent.updateFileAsync(localizedF, f.content, true);
}
Expand Down Expand Up @@ -194,6 +278,12 @@ export class FileList extends data.Component<ISettingsProps, FileListState> {
&& isTutorialMd
&& pxt.Util.userLanguage() !== (pxt.appTarget.appTheme.defaultLocale || "en")
&& !files.some(f => f.name == localized);
const isSpecialFile = file.name === pxt.CONFIG_NAME
|| file.name === pxt.MAIN_TS
|| file.name === pxt.MAIN_BLOCKS
|| file.name === pxt.MAIN_PY
|| file.name === pxt.HISTORY_FILE;
const hasRename = topPkg && !shellReadonly && !isSpecialFile && !file.isReadonly();
const hasDelete = deleteFiles
&& (
file.name != pxt.CONFIG_NAME
Expand All @@ -209,10 +299,12 @@ export class FileList extends data.Component<ISettingsProps, FileListState> {
meta={meta}
onItemClick={this.setFile}
onItemRemove={this.removeFile}
onItemRename={this.renameFile}
onErrorClick={this.navigateToError}
onItemLocalize={this.addLocalizedFile}
isActive={currentFile == file}
hasDelete={hasDelete}
hasRename={hasRename}
previewUrl={previewUrl}
shareUrl={shareUrl}
addLocalizedFile={addLocale && localized}
Expand Down Expand Up @@ -295,7 +387,7 @@ export class FileList extends data.Component<ISettingsProps, FileListState> {
hasCloseIcon: true,
onInputValidation: (v) => {
if (!v)
return lf("filename is empty.");
return lf("Filename is empty.");
v = v.trim();
if (!validRx.test(v))
return lf("Don't use spaces or special characters.");
Expand Down Expand Up @@ -482,10 +574,12 @@ interface FileTreeItemProps {
meta: pkg.FileMeta;
onItemClick: (fn: pkg.File) => void;
onItemRemove: (fn: pkg.File) => void;
onItemRename?: (fn: pkg.File) => void;
onItemLocalize: (fn: pkg.File, localizedf: string) => void;
onErrorClick?: (meta: pkg.FileMeta) => void;
isActive: boolean;
hasDelete?: boolean;
hasRename?: boolean;
previewUrl?: string;
shareUrl?: string;
addLocalizedFile?: string;
Expand All @@ -499,6 +593,7 @@ class FileTreeItem extends sui.StatelessUIElement<FileTreeItemProps> {

this.handleClick = this.handleClick.bind(this);
this.handleRemove = this.handleRemove.bind(this);
this.handleRename = this.handleRename.bind(this);
this.handleButtonKeydown = this.handleButtonKeydown.bind(this);
this.handlePreview = this.handlePreview.bind(this);
this.handleShare = this.handleShare.bind(this);
Expand All @@ -524,6 +619,14 @@ class FileTreeItem extends sui.StatelessUIElement<FileTreeItemProps> {
this.props.onItemRemove(this.props.file);
}

handleRename(e: React.MouseEvent<HTMLElement>) {
pxt.tickEvent("explorer.file.rename");
e.preventDefault();
e.stopPropagation();
if (this.props.onItemRename)
this.props.onItemRename(this.props.file);
}

handlePreview(e: React.MouseEvent<HTMLElement>) {
pxt.tickEvent("explorer.file.preview");
e.preventDefault();
Expand Down Expand Up @@ -556,7 +659,7 @@ class FileTreeItem extends sui.StatelessUIElement<FileTreeItemProps> {
}

renderCore() {
const { isActive, hasDelete, file, meta, previewUrl, shareUrl, addLocalizedFile, className, locked } = this.props;
const { isActive, hasDelete, hasRename, file, meta, previewUrl, shareUrl, addLocalizedFile, className, locked } = this.props;

return (
<a
Expand Down Expand Up @@ -607,6 +710,16 @@ class FileTreeItem extends sui.StatelessUIElement<FileTreeItemProps> {
{meta.numErrors}
</div> : undefined
}
{hasRename &&
<sui.Button
className="neutral label"
icon="pencil"
title={lf("Rename file {0}", file.name)}
ariaLabel={lf("Rename file {0}", file.name)}
onClick={this.handleRename}
onKeyDown={fireClickOnEnter}
/>
}
{hasDelete &&
<sui.Button
className="neutral label"
Expand Down
28 changes: 28 additions & 0 deletions webapp/src/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,34 @@ export class EditorPackage {
})
}

async renameFileAsync(oldName: string, newName: string): Promise<void> {
Util.assert(!!oldName && !!newName, "missing file name");
const f = this.files[oldName];
if (!f) return Promise.resolve();

delete this.files[oldName];
f.name = newName;
this.files[newName] = f;
data.invalidate("open-meta:");

await this.updateConfigAsync(cfg => {
if (cfg.files) {
const idx = cfg.files.indexOf(oldName);
if (idx >= 0) {
cfg.files[idx] = newName;
} else if (cfg.files.indexOf(newName) < 0) {
cfg.files.push(newName);
}
}
if (cfg.testFiles) {
const idx = cfg.testFiles.indexOf(oldName);
if (idx >= 0) {
cfg.testFiles[idx] = newName;
}
}
});
}

async setContentAsync(n: string, v: string): Promise<void> {
Util.assert(!!n, "missing file name");
let f = this.files[n];
Expand Down