diff --git a/src/dashboardWebView/DashboardMessage.ts b/src/dashboardWebView/DashboardMessage.ts index 4fd51a9d..53201438 100644 --- a/src/dashboardWebView/DashboardMessage.ts +++ b/src/dashboardWebView/DashboardMessage.ts @@ -23,6 +23,7 @@ export enum DashboardMessage { createContent = 'createContent', createByContentType = 'createByContentType', createByTemplate = 'createByTemplate', + createContentInFolder = 'createContentInFolder', refreshPages = 'refreshPages', searchPages = 'searchPages', openFile = 'openFile', @@ -31,6 +32,7 @@ export enum DashboardMessage { pinItem = 'pinItem', unpinItem = 'unpinItem', rename = 'rename', + moveFile = 'moveFile', // Media Dashboard getMedia = 'getMedia', diff --git a/src/dashboardWebView/components/Contents/ContentActions.tsx b/src/dashboardWebView/components/Contents/ContentActions.tsx index e22d8715..06b19fc8 100644 --- a/src/dashboardWebView/components/Contents/ContentActions.tsx +++ b/src/dashboardWebView/components/Contents/ContentActions.tsx @@ -1,5 +1,12 @@ import { messageHandler } from '@estruyf/vscode/dist/client'; -import { EyeIcon, GlobeEuropeAfricaIcon, TrashIcon, LanguageIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'; +import { + EyeIcon, + GlobeEuropeAfricaIcon, + TrashIcon, + LanguageIcon, + EllipsisHorizontalIcon, + ArrowRightCircleIcon +} from '@heroicons/react/24/outline'; import * as React from 'react'; import { CustomScript, I18nConfig } from '../../../models'; import { DashboardMessage } from '../../DashboardMessage'; @@ -58,6 +65,11 @@ export const ContentActions: React.FunctionComponent = ({ setSelectedItemAction({ path, action: 'delete' }); }, [path]); + const onMove = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedItemAction({ path, action: 'move' }); + }, [path]); + const onRename = React.useCallback((e: React.MouseEvent) => { e.stopPropagation(); messageHandler.send(DashboardMessage.rename, path); @@ -122,6 +134,11 @@ export const ContentActions: React.FunctionComponent = ({ {l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemView)} + + + Move to folder + + {l10n.t(LocalizationKey.commonRename)} diff --git a/src/dashboardWebView/components/Contents/Contents.tsx b/src/dashboardWebView/components/Contents/Contents.tsx index 10c8bf65..a3f5d309 100644 --- a/src/dashboardWebView/components/Contents/Contents.tsx +++ b/src/dashboardWebView/components/Contents/Contents.tsx @@ -14,6 +14,7 @@ import { GeneralCommands } from '../../../constants'; import { PageLayout } from '../Layout/PageLayout'; import { FilesProvider } from '../../providers/FilesProvider'; import { Alert } from '../Modals/Alert'; +import { MoveFileDialog } from '../Modals/MoveFileDialog'; import { LocalizationKey } from '../../../localization'; import { deletePage } from '../../utils'; @@ -28,12 +29,14 @@ export const Contents: React.FunctionComponent = ({ const settings = useRecoilValue(SettingsSelector); const { pageItems } = usePages(pages); const [showDeletionAlert, setShowDeletionAlert] = React.useState(false); + const [showMoveDialog, setShowMoveDialog] = React.useState(false); const [page, setPage] = useState(undefined); const [selectedItemAction, setSelectedItemAction] = useRecoilState(SelectedItemActionAtom); const pageFolders = [...new Set(pageItems.map((page) => page.fmFolder))]; const onDismiss = useCallback(() => { + setShowMoveDialog(false); setShowDeletionAlert(false); setSelectedItemAction(undefined); }, []); @@ -46,13 +49,29 @@ export const Contents: React.FunctionComponent = ({ setSelectedItemAction(undefined); }, [page]); - useEffect(() => { - if (selectedItemAction && selectedItemAction.path && selectedItemAction.action === 'delete') { - const page = pageItems.find((p) => p.fmFilePath === selectedItemAction.path); + const onMoveConfirm = useCallback((destinationFolder: string) => { + if (page) { + Messenger.send(DashboardMessage.moveFile, { + filePath: page.fmFilePath, + destinationFolder + }); + } + setShowMoveDialog(false); + setSelectedItemAction(undefined); + }, [page]); - if (page) { - setPage(page); - setShowDeletionAlert(true); + useEffect(() => { + if (selectedItemAction && selectedItemAction.path) { + const pageItem = pageItems.find((p) => p.fmFilePath === selectedItemAction.path); + + if (pageItem) { + setPage(pageItem); + + if (selectedItemAction.action === 'delete') { + setShowDeletionAlert(true); + } else if (selectedItemAction.action === 'move') { + setShowMoveDialog(true); + } } setSelectedItemAction(undefined); @@ -85,6 +104,15 @@ export const Contents: React.FunctionComponent = ({ Content metrics + {showMoveDialog && page && ( + + )} + {showDeletionAlert && page && ( = ({ pages }: React.PropsWithChildren) => { + const [selectedFolder, setSelectedFolder] = useRecoilState(SelectedStructureFolderAtom); + const settings = useRecoilValue(SettingsSelector); + const folderTree = useMemo(() => { const root: FolderNode = { name: '', @@ -31,9 +40,8 @@ export const StructureView: React.FunctionComponent = ({ const folderMap = new Map(); folderMap.set('', root); - // Helper to compute the normalized folder path for a page. - // It ensures the page's folder starts with the `fmFolder` segment and - // preserves any subpaths after that segment (so subfolders are created). + // Helper to compute the normalized workspace-relative folder path for a page. + // This returns the actual folder path relative to the workspace, not just titles. const computeNormalizedFolderPath = (page: Page): string => { if (!page.fmFolder) { return ''; @@ -41,31 +49,16 @@ export const StructureView: React.FunctionComponent = ({ const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); - // If we have a file path, use its directory (exclude the filename) to compute - // the relative path. This avoids treating filenames as folder segments. - const filePath = page.fmFilePath ? parseWinPath(page.fmFilePath).replace(/^\/+|\/+$/g, '') : ''; - const fileDir = filePath && filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : ''; - - if (fileDir) { - // If the content folder is known, and the file directory starts with it, - // replace that root with the fmFolder (preserving subfolders after it). - if (page.fmPageFolder?.path) { - const contentFolderPath = parseWinPath(page.fmPageFolder.path).replace(/^\/+|\/+$/g, ''); - if (fileDir.startsWith(contentFolderPath)) { - const rel = fileDir.substring(contentFolderPath.length).replace(/^\/+|\/+$/g, ''); - return rel ? `${fmFolder}/${rel}` : fmFolder; - } - } - - // Otherwise try to find fmFolder as a directory segment in the fileDir - const segments = fileDir.split('/').filter(Boolean); - const fmIndex = segments.indexOf(fmFolder); - if (fmIndex >= 0) { - return segments.slice(fmIndex).join('/'); + // Use fmRelFilePath which is already workspace-relative + if (page.fmRelFilePath) { + const relPath = parseWinPath(page.fmRelFilePath).replace(/^\/+|\/+$/g, ''); + const relDir = relPath.includes('/') ? relPath.substring(0, relPath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : ''; + if (relDir) { + return relDir; } } - // Fallback: just use the fmFolder name + // Fallback: use fmFolder title if we can't determine the path return fmFolder; }; @@ -127,6 +120,57 @@ export const StructureView: React.FunctionComponent = ({ return root; }, [pages]); + // Filter the folder tree based on the selected folder + const displayedNode = useMemo(() => { + if (!selectedFolder) { + return folderTree; + } + + // Find the selected folder node in the tree + const findNode = (node: FolderNode, path: string): FolderNode | null => { + if (node.path === path) { + return node; + } + for (const child of node.children) { + const found = findNode(child, path); + if (found) { + return found; + } + } + return null; + }; + + const foundNode = findNode(folderTree, selectedFolder); + return foundNode || folderTree; + }, [folderTree, selectedFolder]); + + const handleFolderClick = (folderPath: string) => { + setSelectedFolder(folderPath); + }; + + const handleBackClick = () => { + if (!selectedFolder) { + return; + } + + // Navigate to parent folder + const parts = selectedFolder.split('/'); + if (parts.length > 1) { + const parentPath = parts.slice(0, -1).join('/'); + setSelectedFolder(parentPath); + } else { + setSelectedFolder(null); + } + }; + + const handleHomeClick = () => { + setSelectedFolder(null); + }; + + const handleCreateContent = () => { + Messenger.send(DashboardMessage.createContentInFolder, { folderPath: selectedFolder }); + }; + const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => { const hasContent = node.pages.length > 0 || node.children.length > 0; @@ -168,24 +212,32 @@ export const StructureView: React.FunctionComponent = ({ {({ open }) => ( <> - - - - - {node.name} - {node.pages.length > 0 && ( - - ({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'}) - - )} - - +
+ + + + + {node.name} + {node.pages.length > 0 && ( + + ({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'}) + + )} + + + +
{/* Child folders */} @@ -211,7 +263,61 @@ export const StructureView: React.FunctionComponent = ({ return (
- {renderFolderNode(folderTree)} + {/* Toolbar */} +
+ {/* Breadcrumb navigation */} + {selectedFolder && ( +
+
+ + + + / {selectedFolder.split('/').join(' / ')} + +
+
+ )} + + {/* Create content button */} +
+ + {selectedFolder && ( + + in {selectedFolder} + + )} +
+
+ + {/* Folder tree */} + {renderFolderNode(displayedNode)}
); }; \ No newline at end of file diff --git a/src/dashboardWebView/components/Modals/MoveFileDialog.tsx b/src/dashboardWebView/components/Modals/MoveFileDialog.tsx new file mode 100644 index 00000000..883f40e8 --- /dev/null +++ b/src/dashboardWebView/components/Modals/MoveFileDialog.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import { useState, useMemo, useEffect } from 'react'; +import { FolderIcon, ChevronRightIcon } from '@heroicons/react/24/solid'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../../../localization'; +import { parseWinPath } from '../../../helpers/parseWinPath'; +import { Page } from '../../models'; + +export interface IMoveFileDialogProps { + page: Page; + availableFolders: string[]; + dismiss: () => void; + trigger: (destinationFolder: string) => void; +} + +interface FolderNode { + name: string; + path: string; + children: FolderNode[]; + level: number; +} + +export const MoveFileDialog: React.FunctionComponent = ({ + page, + availableFolders, + dismiss, + trigger +}: React.PropsWithChildren) => { + const [selectedFolder, setSelectedFolder] = useState(''); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + // Build folder tree structure + const folderTree = useMemo(() => { + const root: FolderNode[] = []; + const folderMap = new Map(); + + for (const folderPath of availableFolders) { + const normalized = parseWinPath(folderPath).replace(/^\/+|\/+$/g, ''); + const parts = normalized.split('/').filter(Boolean); + + let currentPath = ''; + let currentLevel: FolderNode[] = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const fullPath = currentPath ? `${currentPath}/${part}` : part; + + if (!folderMap.has(fullPath)) { + const newNode: FolderNode = { + name: part, + path: fullPath, + children: [], + level: i + }; + folderMap.set(fullPath, newNode); + currentLevel.push(newNode); + } + + const node = folderMap.get(fullPath); + if (node) { + currentLevel = node.children; + } + currentPath = fullPath; + } + } + + return root; + }, [availableFolders]); + + const toggleFolder = (folderPath: string) => { + const newExpanded = new Set(expandedFolders); + if (newExpanded.has(folderPath)) { + newExpanded.delete(folderPath); + } else { + newExpanded.add(folderPath); + } + setExpandedFolders(newExpanded); + }; + + const renderFolderNode = (node: FolderNode): React.ReactNode => { + const isExpanded = expandedFolders.has(node.path); + const isSelected = selectedFolder === node.path; + const hasChildren = node.children.length > 0; + const paddingLeft = node.level * 20; + + return ( +
+
setSelectedFolder(node.path)} + > + {hasChildren && ( + + )} + {!hasChildren && } + + {node.name} +
+ {hasChildren && isExpanded && ( +
+ {node.children.map((child) => renderFolderNode(child))} +
+ )} +
+ ); + }; + + const handleMove = () => { + if (selectedFolder) { + trigger(selectedFolder); + } + }; + + // Auto-expand folders by default (first level) + useEffect(() => { + const firstLevelFolders = folderTree.map(node => node.path); + setExpandedFolders(new Set(firstLevelFolders)); + }, [folderTree]); + + return ( +
+
+
+

+ Move File +

+

+ Move {page.title} to a different folder +

+
+ +
+
+ {folderTree.length > 0 ? ( + folderTree.map((node) => renderFolderNode(node)) + ) : ( +

+ No folders available +

+ )} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/dashboardWebView/state/atom/SelectedItemActionAtom.ts b/src/dashboardWebView/state/atom/SelectedItemActionAtom.ts index 39526d72..e6728a6b 100644 --- a/src/dashboardWebView/state/atom/SelectedItemActionAtom.ts +++ b/src/dashboardWebView/state/atom/SelectedItemActionAtom.ts @@ -3,7 +3,7 @@ import { atom } from 'recoil'; export const SelectedItemActionAtom = atom< | { path: string; - action: 'view' | 'edit' | 'delete'; + action: 'view' | 'edit' | 'delete' | 'move'; } | undefined >({ diff --git a/src/dashboardWebView/state/atom/SelectedStructureFolderAtom.ts b/src/dashboardWebView/state/atom/SelectedStructureFolderAtom.ts new file mode 100644 index 00000000..95522c89 --- /dev/null +++ b/src/dashboardWebView/state/atom/SelectedStructureFolderAtom.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const SelectedStructureFolderAtom = atom({ + key: 'SelectedStructureFolderAtom', + default: null +}); diff --git a/src/dashboardWebView/state/atom/index.ts b/src/dashboardWebView/state/atom/index.ts index c4dc162d..e35ccd3d 100644 --- a/src/dashboardWebView/state/atom/index.ts +++ b/src/dashboardWebView/state/atom/index.ts @@ -22,6 +22,7 @@ export * from './SearchAtom'; export * from './SearchReadyAtom'; export * from './SelectedItemActionAtom'; export * from './SelectedMediaFolderAtom'; +export * from './SelectedStructureFolderAtom'; export * from './SettingsAtom'; export * from './SortingAtom'; export * from './TabAtom'; diff --git a/src/dashboardWebView/state/selectors/SelectedStructureFolderSelector.ts b/src/dashboardWebView/state/selectors/SelectedStructureFolderSelector.ts new file mode 100644 index 00000000..976b0545 --- /dev/null +++ b/src/dashboardWebView/state/selectors/SelectedStructureFolderSelector.ts @@ -0,0 +1,9 @@ +import { selector } from 'recoil'; +import { SelectedStructureFolderAtom } from '..'; + +export const SelectedStructureFolderSelector = selector({ + key: 'SelectedStructureFolderSelector', + get: ({ get }) => { + return get(SelectedStructureFolderAtom); + } +}); diff --git a/src/dashboardWebView/state/selectors/index.ts b/src/dashboardWebView/state/selectors/index.ts index 0b4cf203..70082e9d 100644 --- a/src/dashboardWebView/state/selectors/index.ts +++ b/src/dashboardWebView/state/selectors/index.ts @@ -8,6 +8,7 @@ export * from './MediaTotalSelector'; export * from './PageSelector'; export * from './SearchSelector'; export * from './SelectedMediaFolderSelector'; +export * from './SelectedStructureFolderSelector'; export * from './SettingsSelector'; export * from './SortingSelector'; export * from './TabSelector'; diff --git a/src/listeners/dashboard/PagesListener.ts b/src/listeners/dashboard/PagesListener.ts index 407490ca..77f4c587 100644 --- a/src/listeners/dashboard/PagesListener.ts +++ b/src/listeners/dashboard/PagesListener.ts @@ -1,5 +1,5 @@ import { PostMessageData } from './../../models/PostMessageData'; -import { basename } from 'path'; +import { basename, join } from 'path'; import { commands, FileSystemWatcher, RelativePattern, TextDocument, Uri, workspace } from 'vscode'; import { Dashboard } from '../../commands/Dashboard'; import { Folders } from '../../commands/Folders'; @@ -12,13 +12,15 @@ import { import { DashboardCommand } from '../../dashboardWebView/DashboardCommand'; import { DashboardMessage } from '../../dashboardWebView/DashboardMessage'; import { Page } from '../../dashboardWebView/models'; -import { ArticleHelper, Extension, Logger, parseWinPath, Settings } from '../../helpers'; +import { ArticleHelper, Extension, Logger, parseWinPath, Settings, ContentType } from '../../helpers'; import { BaseListener } from './BaseListener'; import { DataListener } from '../panel'; import Fuse from 'fuse.js'; import { PagesParser } from '../../services/PagesParser'; import { unlinkAsync, rmdirAsync } from '../../utils'; import { LoadingType } from '../../models'; +import { Questions } from '../../helpers/Questions'; +import { Template } from '../../commands/Template'; export class PagesListener extends BaseListener { private static watchers: { [path: string]: FileSystemWatcher } = {}; @@ -45,6 +47,9 @@ export class PagesListener extends BaseListener { case DashboardMessage.createByTemplate: await commands.executeCommand(COMMAND_NAME.createByTemplate); break; + case DashboardMessage.createContentInFolder: + await this.createContentInFolder(msg.payload); + break; case DashboardMessage.refreshPages: this.getPagesData(true); break; @@ -57,6 +62,9 @@ export class PagesListener extends BaseListener { case DashboardMessage.rename: ArticleHelper.rename(msg.payload); break; + case DashboardMessage.moveFile: + await this.moveFile(msg.payload); + break; } } @@ -306,6 +314,208 @@ export class PagesListener extends BaseListener { this.sendMsg(DashboardCommand.searchPages, pageResults); } + /** + * Move a file to a different folder + * @param payload + */ + private static async moveFile(payload: { filePath: string; destinationFolder: string }) { + if (!payload || !payload.filePath || !payload.destinationFolder) { + return; + } + + const { filePath, destinationFolder } = payload; + + try { + const wsFolder = Folders.getWorkspaceFolder(); + if (!wsFolder) { + Logger.error('Workspace folder not found'); + return; + } + + // Get all content folders + const folders = await Folders.get(); + if (!folders || folders.length === 0) { + Logger.error('No content folders found'); + return; + } + + // Find the destination folder + let targetFolderPath = ''; + for (const folder of folders) { + const absoluteFolderPath = Folders.getFolderPath(Uri.file(folder.path)); + const relativeFolderPath = parseWinPath(absoluteFolderPath) + .replace(parseWinPath(wsFolder.fsPath), '') + .replace(/^\/+|\/+$/g, ''); + + if ( + destinationFolder === relativeFolderPath || + destinationFolder.startsWith(relativeFolderPath + '/') + ) { + targetFolderPath = absoluteFolderPath; + // Add subfolder if any + if (destinationFolder !== relativeFolderPath) { + const subPath = destinationFolder + .substring(relativeFolderPath.length) + .replace(/^\/+|\/+$/g, ''); + targetFolderPath = join(targetFolderPath, subPath); + } + break; + } + } + + if (!targetFolderPath) { + Logger.error('Target folder not found'); + return; + } + + // Get the file name + const fileName = basename(filePath); + const newFilePath = join(targetFolderPath, fileName); + + // Check if target already exists + try { + await workspace.fs.stat(Uri.file(newFilePath)); + Logger.error(`File already exists at destination: ${newFilePath}`); + return; + } catch { + // File doesn't exist, which is good + } + + // Check if it's a page bundle + const article = await ArticleHelper.getFrontMatterByPath(filePath); + if (article) { + const contentType = await ArticleHelper.getContentType(article); + + if (contentType.pageBundle) { + // Move the entire folder + const sourceFolder = parseWinPath(filePath).substring( + 0, + parseWinPath(filePath).lastIndexOf('/') + ); + const folderName = basename(sourceFolder); + const newFolderPath = join(targetFolderPath, folderName); + + // Move the folder + await workspace.fs.rename(Uri.file(sourceFolder), Uri.file(newFolderPath), { + overwrite: false + }); + + Logger.info(`Moved page bundle from ${sourceFolder} to ${newFolderPath}`); + } else { + // Move just the file + await workspace.fs.rename(Uri.file(filePath), Uri.file(newFilePath), { + overwrite: false + }); + + Logger.info(`Moved file from ${filePath} to ${newFilePath}`); + } + } else { + // Move just the file + await workspace.fs.rename(Uri.file(filePath), Uri.file(newFilePath), { + overwrite: false + }); + + Logger.info(`Moved file from ${filePath} to ${newFilePath}`); + } + + // Refresh the pages data + this.getPagesData(true); + } catch (error) { + Logger.error(`Error moving file: ${(error as Error).message}`); + } + } + + /** + * Create content in a specific folder + * @param payload + */ + private static async createContentInFolder(payload: { folderPath: string | null }) { + if (!payload) { + // Fall back to regular content creation + await commands.executeCommand(COMMAND_NAME.createContent); + return; + } + + const { folderPath } = payload; + + // Get all content folders + let folders = await Folders.get(); + folders = folders.filter((f) => !f.disableCreation); + + if (!folders || folders.length === 0) { + await commands.executeCommand(COMMAND_NAME.createContent); + return; + } + + let targetFolder = null; + let subPath = ''; + + if (folderPath) { + // The folderPath is a relative path like "content/posts" or "blog/en" + // We need to find the matching content folder and determine the subfolder + for (const folder of folders) { + const wsFolder = Folders.getWorkspaceFolder(); + if (!wsFolder) { + continue; + } + + const absoluteFolderPath = Folders.getFolderPath(Uri.file(folder.path)); + const relativeFolderPath = parseWinPath(absoluteFolderPath).replace(parseWinPath(wsFolder.fsPath), '').replace(/^\/+|\/+$/g, ''); + + // Check if the folderPath starts with this content folder + if (folderPath === relativeFolderPath || folderPath.startsWith(relativeFolderPath + '/')) { + targetFolder = folder; + // Extract the subfolder part + if (folderPath !== relativeFolderPath) { + subPath = folderPath.substring(relativeFolderPath.length).replace(/^\/+|\/+$/g, ''); + } + break; + } + } + } + + if (!targetFolder) { + // If no folder matches, let the user select one + const selectedFolder = await Questions.SelectContentFolder(); + if (!selectedFolder) { + return; + } + targetFolder = folders.find((f) => f.path === selectedFolder.path); + } + + if (!targetFolder) { + return; + } + + // Get the folder path + let absoluteFolderPath = Folders.getFolderPath(Uri.file(targetFolder.path)); + + // Add the subfolder if any + if (subPath) { + absoluteFolderPath = join(absoluteFolderPath, subPath); + } + + // Check if templates are enabled + const templatesEnabled = Settings.get('dashboardState.contents.templatesEnabled'); + + if (templatesEnabled) { + // Use the template creation flow + await Template.create(absoluteFolderPath); + } else { + // Use the content type creation flow + const selectedContentType = await Questions.SelectContentType(targetFolder.contentTypes || []); + if (!selectedContentType) { + return; + } + + const contentTypes = ContentType.getAll(); + const contentType = contentTypes?.find((ct) => ct.name === selectedContentType); + if (contentType) { + ContentType['create'](contentType, absoluteFolderPath); + } + } + } + /** * Get fresh page data */