feat: add move file functionality and create content in folder dialog

This commit is contained in:
Elio Struyf
2026-03-06 16:51:41 +01:00
parent 2e8472dd75
commit 7ea0fbad05
11 changed files with 612 additions and 55 deletions

View File

@@ -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',

View File

@@ -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<IContentActionsProps> = ({
setSelectedItemAction({ path, action: 'delete' });
}, [path]);
const onMove = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
setSelectedItemAction({ path, action: 'move' });
}, [path]);
const onRename = React.useCallback((e: React.MouseEvent<HTMLButtonElement | HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
messageHandler.send(DashboardMessage.rename, path);
@@ -122,6 +134,11 @@ export const ContentActions: React.FunctionComponent<IContentActionsProps> = ({
<span>{l10n.t(LocalizationKey.dashboardContentsContentActionsMenuItemView)}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onMove}>
<ArrowRightCircleIcon className={`mr-2 h-4 w-4`} aria-hidden={true} />
<span>Move to folder</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onRename}>
<RenameIcon className={`mr-2 h-4 w-4`} aria-hidden={true} />
<span>{l10n.t(LocalizationKey.commonRename)}</span>

View File

@@ -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<IContentsProps> = ({
const settings = useRecoilValue(SettingsSelector);
const { pageItems } = usePages(pages);
const [showDeletionAlert, setShowDeletionAlert] = React.useState(false);
const [showMoveDialog, setShowMoveDialog] = React.useState(false);
const [page, setPage] = useState<Page | undefined>(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<IContentsProps> = ({
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<IContentsProps> = ({
<img className='hidden' src="https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Ffrontmatter.codes%2Fmetrics%2Fdashboards&slug=content" alt="Content metrics" />
{showMoveDialog && page && (
<MoveFileDialog
page={page}
availableFolders={pageFolders}
dismiss={onDismiss}
trigger={onMoveConfirm}
/>
)}
{showDeletionAlert && page && (
<Alert
title={l10n.t(LocalizationKey.dashboardContentsContentActionsAlertTitle, page.title)}

View File

@@ -1,10 +1,16 @@
import { Disclosure } from '@headlessui/react';
import { ChevronRightIcon, FolderIcon } from '@heroicons/react/24/solid';
import { ChevronRightIcon, FolderIcon, PlusIcon, HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/solid';
import * as React from 'react';
import { useMemo } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Page } from '../../models';
import { StructureItem } from './StructureItem';
import { parseWinPath } from '../../../helpers/parseWinPath';
import { SelectedStructureFolderAtom, SettingsSelector } from '../../state';
import { Messenger } from '@estruyf/vscode/dist/client';
import { DashboardMessage } from '../../DashboardMessage';
import * as l10n from '@vscode/l10n';
import { LocalizationKey } from '../../../localization';
export interface IStructureViewProps {
pages: Page[];
@@ -20,6 +26,9 @@ interface FolderNode {
export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
pages
}: React.PropsWithChildren<IStructureViewProps>) => {
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<IStructureViewProps> = ({
const folderMap = new Map<string, FolderNode>();
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<IStructureViewProps> = ({
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<IStructureViewProps> = ({
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<IStructureViewProps> = ({
<Disclosure defaultOpen={depth <= 1}>
{({ open }) => (
<>
<Disclosure.Button
className="flex items-center w-full text-left"
style={{ paddingLeft: `${paddingLeft}px` }}
>
<ChevronRightIcon
className={`w-4 h-4 mr-2 transform transition-transform ${open ? 'rotate-90' : ''
}`}
/>
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="font-medium text-[var(--vscode-editor-foreground)]">
{node.name}
{node.pages.length > 0 && (
<span className="ml-2 text-sm text-[var(--vscode-descriptionForeground)]">
({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'})
</span>
)}
</span>
</Disclosure.Button>
<div className="flex items-center w-full" style={{ paddingLeft: `${paddingLeft}px` }}>
<Disclosure.Button
className="flex items-center flex-1 text-left hover:bg-[var(--vscode-list-hoverBackground)] rounded px-2 py-1"
>
<ChevronRightIcon
className={`w-4 h-4 mr-2 transform transition-transform ${open ? 'rotate-90' : ''
}`}
/>
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="font-medium text-[var(--vscode-editor-foreground)]">
{node.name}
{node.pages.length > 0 && (
<span className="ml-2 text-sm text-[var(--vscode-descriptionForeground)]">
({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'})
</span>
)}
</span>
</Disclosure.Button>
<button
onClick={() => handleFolderClick(node.path)}
className="p-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded"
title={l10n.t(LocalizationKey.commonOpen)}
>
<ChevronRightIcon className="w-4 h-4 text-[var(--vscode-descriptionForeground)]" />
</button>
</div>
<Disclosure.Panel className="mt-2">
{/* Child folders */}
@@ -211,7 +263,61 @@ export const StructureView: React.FunctionComponent<IStructureViewProps> = ({
return (
<div className="structure-view">
{renderFolderNode(folderTree)}
{/* Toolbar */}
<div className="mb-4 pb-3 border-b border-[var(--frontmatter-border)]">
{/* Breadcrumb navigation */}
{selectedFolder && (
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<button
onClick={handleHomeClick}
className="p-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded"
title="Home"
>
<HomeIcon className="w-4 h-4 text-[var(--vscode-descriptionForeground)]" />
</button>
<button
onClick={handleBackClick}
className="flex items-center space-x-1 px-2 py-1 hover:bg-[var(--vscode-list-hoverBackground)] rounded text-sm"
title={l10n.t(LocalizationKey.commonBack) || 'Back'}
>
<ArrowLeftIcon className="w-3 h-3" />
<span>{l10n.t(LocalizationKey.commonBack) || 'Back'}</span>
</button>
<span className="text-sm text-[var(--vscode-descriptionForeground)]">
/ {selectedFolder.split('/').join(' / ')}
</span>
</div>
</div>
)}
{/* Create content button */}
<div className="flex items-center space-x-2">
<button
onClick={handleCreateContent}
disabled={!settings?.initialized}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs leading-4 font-medium focus:outline-none rounded text-[var(--vscode-button-foreground)] bg-[var(--frontmatter-button-background)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50"
title={selectedFolder
? l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent) + ` in ${selectedFolder}`
: l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)}
>
<PlusIcon className="w-4 h-4 mr-1" />
<span>
{selectedFolder
? `${l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)} here`
: l10n.t(LocalizationKey.dashboardHeaderHeaderCreateContent)}
</span>
</button>
{selectedFolder && (
<span className="text-xs text-[var(--vscode-descriptionForeground)]">
in {selectedFolder}
</span>
)}
</div>
</div>
{/* Folder tree */}
{renderFolderNode(displayedNode)}
</div>
);
};

View File

@@ -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<IMoveFileDialogProps> = ({
page,
availableFolders,
dismiss,
trigger
}: React.PropsWithChildren<IMoveFileDialogProps>) => {
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Build folder tree structure
const folderTree = useMemo(() => {
const root: FolderNode[] = [];
const folderMap = new Map<string, FolderNode>();
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 (
<div key={node.path}>
<div
className={`flex items-center py-1 px-2 cursor-pointer hover:bg-[var(--vscode-list-hoverBackground)] rounded ${
isSelected ? 'bg-[var(--vscode-list-activeSelectionBackground)]' : ''
}`}
style={{ paddingLeft: `${paddingLeft}px` }}
onClick={() => setSelectedFolder(node.path)}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleFolder(node.path);
}}
className="mr-1"
>
<ChevronRightIcon
className={`w-3 h-3 transform transition-transform ${
isExpanded ? 'rotate-90' : ''
}`}
/>
</button>
)}
{!hasChildren && <span className="w-3 mr-1"></span>}
<FolderIcon className="w-4 h-4 mr-2 text-[var(--vscode-symbolIcon-folderForeground)]" />
<span className="text-sm text-[var(--vscode-editor-foreground)]">{node.name}</span>
</div>
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => renderFolderNode(child))}
</div>
)}
</div>
);
};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div className="bg-[var(--vscode-editor-background)] border border-[var(--frontmatter-border)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-[var(--frontmatter-border)]">
<h2 className="text-xl font-bold text-[var(--vscode-editor-foreground)]">
Move File
</h2>
<p className="mt-2 text-sm text-[var(--vscode-descriptionForeground)]">
Move <span className="font-medium">{page.title}</span> to a different folder
</p>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-1">
{folderTree.length > 0 ? (
folderTree.map((node) => renderFolderNode(node))
) : (
<p className="text-sm text-[var(--vscode-descriptionForeground)]">
No folders available
</p>
)}
</div>
</div>
<div className="p-6 border-t border-[var(--frontmatter-border)] flex justify-end space-x-2">
<button
onClick={dismiss}
className="px-4 py-2 text-sm font-medium rounded text-[var(--vscode-button-foreground)] bg-[var(--vscode-button-secondaryBackground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
>
{l10n.t(LocalizationKey.commonCancel)}
</button>
<button
onClick={handleMove}
disabled={!selectedFolder}
className="px-4 py-2 text-sm font-medium rounded text-[var(--vscode-button-foreground)] bg-[var(--frontmatter-button-background)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50 disabled:cursor-not-allowed"
>
Move
</button>
</div>
</div>
</div>
);
};

View File

@@ -3,7 +3,7 @@ import { atom } from 'recoil';
export const SelectedItemActionAtom = atom<
| {
path: string;
action: 'view' | 'edit' | 'delete';
action: 'view' | 'edit' | 'delete' | 'move';
}
| undefined
>({

View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const SelectedStructureFolderAtom = atom<string | null>({
key: 'SelectedStructureFolderAtom',
default: null
});

View File

@@ -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';

View File

@@ -0,0 +1,9 @@
import { selector } from 'recoil';
import { SelectedStructureFolderAtom } from '..';
export const SelectedStructureFolderSelector = selector({
key: 'SelectedStructureFolderSelector',
get: ({ get }) => {
return get(SelectedStructureFolderAtom);
}
});

View File

@@ -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';

View File

@@ -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
*/