From c4ee4cfc094818aa85b5da07b66412f4d2b3dfb5 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 30 Mar 2026 10:46:05 +0200 Subject: [PATCH 1/2] feat: add smart rename to sync filename with front matter Add a 'Smart Rename' feature that regenerates the filename from current front matter values (title and publish date) using the same logic as content creation. This helps users keep filenames in sync when they change the title or publish date after file creation. Changes: - Add ArticleHelper.smartRename() core logic with page bundle support - Add SmartRenameAction button to the panel webview Actions section - Add smart rename option to dashboard content actions dropdown - Add CommandToCode.smartRename and DashboardMessage.smartRename - Add PanelAction type support for disabling the action - Add localization keys for all UI strings Closes #545 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- l10n/bundle.l10n.json | 9 ++ src/dashboardWebView/DashboardMessage.ts | 1 + .../components/Contents/ContentActions.tsx | 13 +- src/helpers/ArticleHelper.ts | 140 ++++++++++++++++++ src/listeners/dashboard/PagesListener.ts | 3 + src/listeners/panel/ArticleListener.ts | 3 + src/localization/localization.enum.ts | 24 +++ src/models/PanelSettings.ts | 1 + src/panelWebView/CommandToCode.ts | 3 +- src/panelWebView/components/Actions.tsx | 5 + .../components/SmartRenameAction.tsx | 25 ++++ 11 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/panelWebView/components/SmartRenameAction.tsx diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 90499486..76f21207 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -524,6 +524,8 @@ "panel.slugAction.title": "Optimize slug", + "panel.smartRenameAction.title": "Smart rename", + "panel.spinner.loading": "Loading...", "panel.startServerbutton.start": "Start server", @@ -550,6 +552,13 @@ "commands.article.rename.fileName.title": "Rename: {0}", "commands.article.rename.fileName.prompt": "File name", + "commands.article.smartRename.alreadyInSync": "The filename is already in sync with the front matter", + "commands.article.smartRename.success": "File renamed from \"{0}\" to \"{1}\"", + "commands.article.smartRename.unableToGenerate": "Unable to generate a new filename from the front matter", + "commands.article.smartRename.fileExists.error": "A file with the name \"{0}\" already exists", + + "dashboard.contents.contentActions.menuItem.smartRename": "Smart rename", + "commands.cache.cleared": "Cache cleared", "commands.chatbot.title": "Ask me anything", diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index 53201438..8db1af4a 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -32,6 +32,7 @@ export enum DashboardMessage { pinItem = 'pinItem', unpinItem = 'unpinItem', rename = 'rename', + smartRename = 'smartRename', moveFile = 'moveFile', // Media Dashboard diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 06b19fc8..7fee588a 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -5,7 +5,8 @@ import { TrashIcon, LanguageIcon, EllipsisHorizontalIcon, - ArrowRightCircleIcon + ArrowRightCircleIcon, + ArrowPathIcon } from '@heroicons/react/24/outline'; import * as React from 'react'; import { CustomScript, I18nConfig } from '../../../models'; @@ -75,6 +76,11 @@ export const ContentActions: React.FunctionComponent = ({ messageHandler.send(DashboardMessage.rename, path); }, [path]) + const onSmartRename = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + messageHandler.send(DashboardMessage.smartRename, path); + }, [path]) + const onOpenWebsite = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); openOnWebsite(settings?.websiteUrl, path); @@ -144,6 +150,11 @@ export const ContentActions: React.FunctionComponent = ({ {l10n.t(LocalizationKey.commonRename)} + + + {l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemSmartRename)} + + { settings?.websiteUrl && ( diff --git a/src/helpers/ArticleHelper.ts b/src/helpers/ArticleHelper.ts index 0c668202..d8050020 100644 --- a/src/helpers/ArticleHelper.ts +++ b/src/helpers/ArticleHelper.ts @@ -236,6 +236,146 @@ export class ArticleHelper { }); } + /** + * Smart rename a file based on its front matter title and publish date. + * Regenerates the expected filename using the same logic as content creation. + * @param filePath - The path of the file to be renamed. + */ + public static async smartRename(filePath?: string) { + if (!filePath) { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + filePath = editor.document.uri.fsPath; + } + + filePath = parseWinPath(filePath); + const fileUri = Uri.file(filePath); + + const article = await ArticleHelper.getFrontMatterByPath(filePath); + if (!article || !article.data) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleRenameFileNotExistsError) + ); + return; + } + + const titleField = getTitleField(); + const title: string = article.data[titleField]; + if (!title) { + Notifications.warning( + l10n.t(LocalizationKey.commandsArticleSmartRenameUnableToGenerate) + ); + return; + } + + const contentType = await ArticleHelper.getContentType(article); + const articleDate = await ArticleHelper.getDate(article); + + let filePrefix = Settings.get(SETTING_TEMPLATES_PREFIX); + filePrefix = await ArticleHelper.getFilePrefix( + filePrefix, + filePath, + contentType, + title, + articleDate + ); + + const sanitizedName = ArticleHelper.sanitize(title); + const parsed = parseFile(filePath); + const folderPath = dirname(fileUri.fsPath); + + let newFileName: string; + if (contentType?.pageBundle) { + // For page bundles, the folder name should be updated + if (filePrefix && typeof filePrefix === 'string') { + if (filePrefix.endsWith('/')) { + newFileName = `${filePrefix}${sanitizedName}`; + } else { + newFileName = `${filePrefix}-${sanitizedName}`; + } + } else { + newFileName = sanitizedName; + } + + const parentFolder = dirname(folderPath); + const currentFolderName = parseFile(folderPath).base; + + if (currentFolderName === newFileName) { + Notifications.info( + l10n.t(LocalizationKey.commandsArticleSmartRenameAlreadyInSync) + ); + return; + } + + const newFolderPath = join(parentFolder, newFileName); + if (await existsAsync(newFolderPath)) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleSmartRenameFileExistsError, newFileName) + ); + return; + } + + await workspace.fs.rename(Uri.file(folderPath), Uri.file(newFolderPath), { + overwrite: false + }); + + Notifications.info( + l10n.t( + LocalizationKey.commandsArticleSmartRenameSuccess, + currentFolderName, + newFileName + ) + ); + } else { + // For regular files, rename the file + let newFileBase = `${sanitizedName}${parsed.ext}`; + if (filePrefix && typeof filePrefix === 'string') { + if (filePrefix.endsWith('/')) { + newFileBase = `${filePrefix}${newFileBase}`; + } else { + newFileBase = `${filePrefix}-${newFileBase}`; + } + } + + if (parsed.base === newFileBase) { + Notifications.info( + l10n.t(LocalizationKey.commandsArticleSmartRenameAlreadyInSync) + ); + return; + } + + const newFileUri = Uri.joinPath(Uri.file(folderPath), newFileBase); + if (await existsAsync(newFileUri.fsPath)) { + Notifications.error( + l10n.t(LocalizationKey.commandsArticleSmartRenameFileExistsError, newFileBase) + ); + return; + } + + // Close the document if it's open in an editor before renaming + const openEditors = vscode.window.visibleTextEditors.filter( + (e) => parseWinPath(e.document.uri.fsPath) === filePath + ); + for (const editor of openEditors) { + await editor.document.save(); + } + + await workspace.fs.rename(fileUri, newFileUri, { + overwrite: false + }); + + Notifications.info( + l10n.t( + LocalizationKey.commandsArticleSmartRenameSuccess, + parsed.base, + newFileBase + ) + ); + } + } + /** * Generate the update to be applied to the article. * @param article diff --git a/src/listeners/dashboard/PagesListener.ts b/src/listeners/dashboard/PagesListener.ts index da955a25..ff7479f9 100644 --- a/src/listeners/dashboard/PagesListener.ts +++ b/src/listeners/dashboard/PagesListener.ts @@ -74,6 +74,9 @@ export class PagesListener extends BaseListener { case DashboardMessage.moveFile: await this.moveFile(msg.payload); break; + case DashboardMessage.smartRename: + ArticleHelper.smartRename(msg.payload); + break; } } diff --git a/src/listeners/panel/ArticleListener.ts b/src/listeners/panel/ArticleListener.ts index 3bd09dca..9fce64f0 100644 --- a/src/listeners/panel/ArticleListener.ts +++ b/src/listeners/panel/ArticleListener.ts @@ -25,6 +25,9 @@ export class ArticleListener extends BaseListener { case CommandToCode.publish: Article.toggleDraft(); break; + case CommandToCode.smartRename: + ArticleHelper.smartRename(); + break; } } diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 312eda56..46145bc6 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -1696,6 +1696,10 @@ export enum LocalizationKey { * Optimize slug */ panelSlugActionTitle = 'panel.slugAction.title', + /** + * Smart rename + */ + panelSmartRenameActionTitle = 'panel.smartRenameAction.title', /** * Loading... */ @@ -1772,6 +1776,26 @@ export enum LocalizationKey { * File name */ commandsArticleRenameFileNamePrompt = 'commands.article.rename.fileName.prompt', + /** + * The filename is already in sync with the front matter + */ + commandsArticleSmartRenameAlreadyInSync = 'commands.article.smartRename.alreadyInSync', + /** + * File renamed from "{0}" to "{1}" + */ + commandsArticleSmartRenameSuccess = 'commands.article.smartRename.success', + /** + * Unable to generate a new filename from the front matter + */ + commandsArticleSmartRenameUnableToGenerate = 'commands.article.smartRename.unableToGenerate', + /** + * A file with the name "{0}" already exists + */ + commandsArticleSmartRenameFileExistsError = 'commands.article.smartRename.fileExists.error', + /** + * Smart rename + */ + dashboardContentsContentActionsMenuItemSmartRename = 'dashboard.contents.contentActions.menuItem.smartRename', /** * Cache cleared */ diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 30a1495f..c567751c 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -38,6 +38,7 @@ export type PanelAction = | 'openDashboard' | 'createContent' | 'optimizeSlug' + | 'smartRename' | 'preview' | 'openOnWebsite' | 'startStopServer' diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 1063c61b..b5307bdb 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -48,5 +48,6 @@ export enum CommandToCode { searchByType = 'search-by-type', processMediaData = 'process-media-data', isServerStarted = 'is-server-started', - runFieldAction = 'run-field-action' + runFieldAction = 'run-field-action', + smartRename = 'smart-rename' } diff --git a/src/panelWebView/components/Actions.tsx b/src/panelWebView/components/Actions.tsx index 8bf30669..2bf055bc 100644 --- a/src/panelWebView/components/Actions.tsx +++ b/src/panelWebView/components/Actions.tsx @@ -4,6 +4,7 @@ import { Collapsible } from './Collapsible'; import { CustomScript } from './CustomScript'; import { Preview } from './Preview'; import { SlugAction } from './SlugAction'; +import { SmartRenameAction } from './SmartRenameAction'; import { StartServerButton } from './StartServerButton'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../localization'; @@ -52,6 +53,10 @@ const Actions: React.FunctionComponent = ({ allActions.push(); } + if (metadata?.title && !disableActions.includes(`smartRename`)) { + allActions.push(); + } + if (settings?.preview?.host && !disableActions.includes(`preview`)) { if ((metadata && typeof metadata.slug !== "undefined") || !metadata) { allActions.push(); diff --git a/src/panelWebView/components/SmartRenameAction.tsx b/src/panelWebView/components/SmartRenameAction.tsx new file mode 100644 index 00000000..6f178d0f --- /dev/null +++ b/src/panelWebView/components/SmartRenameAction.tsx @@ -0,0 +1,25 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import * as React from 'react'; +import { CommandToCode } from '../CommandToCode'; +import { ActionButton } from './ActionButton'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../../localization'; + +export interface ISmartRenameActionProps { } + +const SmartRenameAction: React.FunctionComponent< + ISmartRenameActionProps +> = () => { + const smartRename = () => { + Messenger.send(CommandToCode.smartRename); + }; + + return ( + + {l10n.t(LocalizationKey.panelSmartRenameActionTitle)} + + ); +}; + +SmartRenameAction.displayName = 'SmartRenameAction'; +export { SmartRenameAction }; From ac60d5c3d079a41e4e6f644910ff2be8f2d16050 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Mon, 30 Mar 2026 14:11:47 +0200 Subject: [PATCH 2/2] fix: update changelog and improve code formatting in ContentActions component --- CHANGELOG.md | 1 + package.json | 2 +- src/dashboardWebView/components/Contents/ContentActions.tsx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cbf7995..ba19b915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### 🎨 Enhancements +- [#545](https://github.com/estruyf/vscode-front-matter/issues/545): Add smart rename action to sync filename with front matter data - [#937](https://github.com/estruyf/vscode-front-matter/issues/937): Dashboard "Structure" view for documentation sites *WIP* - [#965](https://github.com/estruyf/vscode-front-matter/issues/965): Added SEO support for the keyword in the first paragraph - [#973](https://github.com/estruyf/vscode-front-matter/issues/973): Support for number fields in the snippets diff --git a/package.json b/package.json index 6bc35600..05f7cbb7 100644 --- a/package.json +++ b/package.json @@ -2856,7 +2856,7 @@ ] }, "scripts": { - "dev:ext": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", + "dev": "npm run clean && npm run localization:generate && npm-run-all --parallel watch:*", "vscode:prepublish": "npm run clean && npm run localization:generate && npm-run-all --parallel prod:*", "build:ext": "npm run clean && npm-run-all --parallel dev:build:*", "watch:ext": "webpack --mode development --watch --config ./webpack/extension.config.js", diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index 7fee588a..3a0b83de 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -74,12 +74,12 @@ export const ContentActions: React.FunctionComponent = ({ const onRename = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); messageHandler.send(DashboardMessage.rename, path); - }, [path]) + }, [path]); const onSmartRename = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); messageHandler.send(DashboardMessage.smartRename, path); - }, [path]) + }, [path]); const onOpenWebsite = React.useCallback((e: React.MouseEvent) => { e.stopPropagation();