feat: add note moving functionality with UI controls

- Added new API endpoint /api/vault/notes/move for moving markdown files between folders
- Implemented setupMoveNoteEndpoint with path validation, error handling, and event broadcasting
- Added move note UI component to note header with folder selection
- Updated note viewer to handle note path changes after moving
- Added moveNoteToFolder method to VaultService for client-side integration
- Modified note header layout to include move trigger
This commit is contained in:
Bruno Charest 2025-10-25 21:39:31 -04:00
parent b1da9b111d
commit 0ae9cae1eb
10 changed files with 988 additions and 7 deletions

View File

@ -18,6 +18,7 @@ import path from 'path';
// ============================================================================
// ENDPOINT X: /api/files/rename - Rename a markdown file within the same folder
// ============================================================================
export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
app.put('/api/files/rename', express.json(), (req, res) => {
try {
@ -84,6 +85,84 @@ export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, meta
});
}
// ============================================================================
// ENDPOINT X+1: /api/vault/notes/move - Move a markdown file to another folder
// ============================================================================
export function setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
app.post('/api/vault/notes/move', express.json(), (req, res) => {
try {
const { notePath, newFolderPath } = req.body || {};
if (!notePath || typeof notePath !== 'string') {
return res.status(400).json({ error: 'Missing or invalid notePath' });
}
const sanitizePath = (value = '') => String(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
const sanitizedNotePath = sanitizePath(notePath.endsWith('.md') ? notePath : `${notePath}.md`);
if (!sanitizedNotePath || sanitizedNotePath.includes('..')) {
return res.status(400).json({ error: 'Invalid notePath' });
}
const sourceAbs = path.join(vaultDir, sanitizedNotePath);
if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) {
return res.status(404).json({ error: 'Source note not found' });
}
const sanitizedFolder = sanitizePath(typeof newFolderPath === 'string' ? newFolderPath : '');
if (sanitizedFolder.includes('..')) {
return res.status(400).json({ error: 'Invalid destination folder' });
}
if (sanitizedFolder.startsWith('__builtin__') || sanitizedFolder.startsWith('.trash')) {
return res.status(400).json({ error: 'Destination folder is not allowed' });
}
const destinationDir = sanitizedFolder ? path.join(vaultDir, sanitizedFolder) : vaultDir;
try {
fs.mkdirSync(destinationDir, { recursive: true });
} catch (mkErr) {
console.error('[POST /api/vault/notes/move] Failed to ensure destination directory:', mkErr);
return res.status(500).json({ error: 'Failed to prepare destination folder' });
}
const fileName = path.basename(sourceAbs);
const destinationAbs = path.join(destinationDir, fileName);
if (sourceAbs === destinationAbs) {
return res.status(400).json({ error: 'Destination is same as source' });
}
if (fs.existsSync(destinationAbs)) {
return res.status(409).json({ error: 'A note with this name already exists in the destination folder' });
}
try {
fs.renameSync(sourceAbs, destinationAbs);
} catch (renameErr) {
console.error('[POST /api/vault/notes/move] Move operation failed:', renameErr);
return res.status(500).json({ error: 'Failed to move note' });
}
const newRelPath = path.relative(vaultDir, destinationAbs).replace(/\\/g, '/');
try { metadataCache?.clear?.(); } catch {}
try {
broadcastVaultEvent?.({
event: 'file-move',
oldPath: sanitizedNotePath,
newPath: newRelPath,
timestamp: Date.now()
});
} catch (evtErr) {
console.warn('[POST /api/vault/notes/move] Failed to broadcast event:', evtErr);
}
return res.json({ success: true, oldPath: sanitizedNotePath, newPath: newRelPath });
} catch (error) {
console.error('[POST /api/vault/notes/move] Unexpected error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});
}
// ============================================================================
// ENDPOINT 5: /api/folders/rename - Rename folder with validation
// ============================================================================

View File

@ -36,7 +36,8 @@ import {
setupRenameFolderEndpoint,
setupDeleteFolderEndpoint,
setupCreateFolderEndpoint,
setupRenameFileEndpoint
setupRenameFileEndpoint,
setupMoveNoteEndpoint
} from './index-phase3-patch.mjs';
const __filename = fileURLToPath(import.meta.url);
@ -1540,6 +1541,9 @@ setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
// Setup rename file endpoint (must be before catch-all)
setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
// Setup move note endpoint (must be before catch-all)
setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
// Setup delete folder endpoint (must be before catch-all)
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);

View File

@ -0,0 +1,352 @@
import { CommonModule } from '@angular/common';
import {
Component,
DestroyRef,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild,
computed,
effect,
inject,
signal
} from '@angular/core';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
import { ToastService } from '../../../../shared/toast/toast.service';
import { VaultService } from '../../../../../services/vault.service';
import { VaultNode } from '../../../../../types';
interface FolderEntry {
name: string;
path: string;
children: FolderEntry[];
}
interface FlattenedFolder {
name: string;
path: string;
breadcrumb: string[];
}
@Component({
selector: 'app-move-note-to-folder',
standalone: true,
imports: [CommonModule],
templateUrl: './move-note-to-folder.component.html'
})
export class MoveNoteToFolderComponent {
@Input()
set currentPath(value: string | null | undefined) {
this.currentFolderPath.set(this.normalizeFolderPath(value));
}
@Input() notePath!: string;
@Output() noteMoved = new EventEmitter<string>();
@Output() openFolderRequested = new EventEmitter<void>();
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
readonly showMenu = signal(false);
readonly searchQuery = signal('');
readonly breadcrumb = signal<FolderEntry[]>([]);
readonly loading = signal(true);
readonly processingPath = signal<string | null>(null);
readonly currentFolderPath = signal('');
readonly rootFolders = signal<FolderEntry[]>([]);
readonly currentLevelFolders = signal<FolderEntry[]>([]);
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
readonly isSearching = computed(() => this.searchQuery().trim().length > 0);
readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders');
readonly activeFolderPath = computed(() => {
const crumbs = this.breadcrumb();
return crumbs.length ? crumbs[crumbs.length - 1].path : '';
});
readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath());
readonly searchResults = computed<FlattenedFolder[]>(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return [];
}
const results = this.flattenedFolders().filter(entry => {
const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase();
return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query);
});
if ('all my folders'.includes(query)) {
results.unshift({ name: 'All my folders', path: '', breadcrumb: [] });
}
return results;
});
private readonly vault = inject(VaultService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(ElementRef<HTMLElement>);
private listenersAttached = false;
constructor() {
effect(
() => {
const nodes = this.vault.fastFileTree() as VaultNode[];
this.buildFolderSources(nodes ?? []);
},
{ allowSignalWrites: true }
);
effect(
() => {
const path = this.currentFolderPath();
const roots = this.rootFolders();
this.setActiveLevel(path, roots);
},
{ allowSignalWrites: true }
);
this.destroyRef.onDestroy(() => this.detachGlobalListeners());
}
toggleMenu(): void {
if (this.showMenu()) {
this.closeMenu();
return;
}
this.searchQuery.set('');
this.processingPath.set(null);
this.showMenu.set(true);
this.setActiveLevel(this.currentFolderPath(), this.rootFolders());
this.attachGlobalListeners();
queueMicrotask(() => this.focusSearchField());
}
openFolderInSidebar(): void {
this.openFolderRequested.emit();
this.closeMenu();
}
goToRoot(): void {
this.breadcrumb.set([]);
this.currentLevelFolders.set(this.rootFolders());
}
navigateInto(folder: FolderEntry, event: Event): void {
event.stopPropagation();
this.breadcrumb.update(prev => [...prev, folder]);
this.currentLevelFolders.set(folder.children ?? []);
}
navigateTo(level: number): void {
if (level <= 0) {
this.goToRoot();
return;
}
const trail = this.breadcrumb().slice(0, level);
this.breadcrumb.set(trail);
const last = trail[trail.length - 1];
this.currentLevelFolders.set(last?.children ?? []);
}
onSearchChange(value: string): void {
this.searchQuery.set(value);
}
clearSearch(): void {
this.searchQuery.set('');
queueMicrotask(() => this.focusSearchField());
}
async onFolderSelected(folder: FolderEntry, event?: Event): Promise<void> {
event?.stopPropagation();
await this.performMove(folder.path);
}
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
await this.performMove(result.path);
}
moveToCurrentFolder(): Promise<void> {
return this.performMove(this.activeFolderPath());
}
onEscapeKey(): void {
this.closeMenu();
}
private async performMove(destinationPath: string): Promise<void> {
if (!this.notePath) {
return;
}
const target = this.normalizeFolderPath(destinationPath);
const current = this.currentFolderPath();
if (target === current || this.processingPath()) {
this.closeMenu();
return;
}
this.processingPath.set(target || '__root__');
try {
const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target);
this.toast.success('Note moved successfully');
const { prefix } = splitPathKeepFilename(newPath);
this.currentFolderPath.set(this.normalizeFolderPath(prefix));
this.noteMoved.emit(newPath);
this.searchQuery.set('');
this.closeMenu();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to move note';
this.toast.error(message);
} finally {
this.processingPath.set(null);
}
}
private buildFolderSources(nodes: VaultNode[]): void {
const folders = this.mapFolders(nodes);
this.rootFolders.set(folders);
const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }];
this.collectFolders(folders, [], flattened);
this.flattenedFolders.set(flattened);
this.loading.set(false);
}
private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] {
if (!nodes?.length) {
return [];
}
const folders: FolderEntry[] = [];
for (const node of nodes) {
if (node.type !== 'folder') continue;
folders.push({
name: node.name,
path: this.normalizeFolderPath(node.path),
children: this.mapFolders(node.children)
});
}
return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void {
for (const node of nodes) {
const breadcrumb = [...trail, node.name];
acc.push({ name: node.name, path: node.path, breadcrumb });
if (node.children?.length) {
this.collectFolders(node.children, breadcrumb, acc);
}
}
}
private setActiveLevel(path: string, roots: FolderEntry[]): void {
if (!roots.length) {
return;
}
if (!path) {
this.breadcrumb.set([]);
this.currentLevelFolders.set(roots);
return;
}
const trail = this.findTrail(roots, path);
if (!trail) {
this.breadcrumb.set([]);
this.currentLevelFolders.set(roots);
return;
}
this.breadcrumb.set(trail);
const last = trail[trail.length - 1];
this.currentLevelFolders.set(last?.children ?? []);
}
private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null {
for (const node of nodes) {
const nextTrail = [...trail, node];
if (node.path === target) {
return nextTrail;
}
if (node.children?.length) {
const found = this.findTrail(node.children, target, nextTrail);
if (found) {
return found;
}
}
}
return null;
}
private closeMenu(): void {
this.showMenu.set(false);
this.detachGlobalListeners();
this.searchQuery.set('');
this.processingPath.set(null);
}
private focusSearchField(): void {
const el = this.searchField?.nativeElement;
if (el) {
el.focus();
el.select();
}
}
private attachGlobalListeners(): void {
if (typeof document === 'undefined' || this.listenersAttached) {
return;
}
document.addEventListener('pointerdown', this.handleOutsidePointer, true);
document.addEventListener('keydown', this.handleEscape, true);
this.listenersAttached = true;
}
private detachGlobalListeners(): void {
if (typeof document === 'undefined' || !this.listenersAttached) {
return;
}
document.removeEventListener('pointerdown', this.handleOutsidePointer, true);
document.removeEventListener('keydown', this.handleEscape, true);
this.listenersAttached = false;
}
private handleOutsidePointer = (event: PointerEvent) => {
if (!this.showMenu()) {
return;
}
if (!this.host.nativeElement.contains(event.target as Node)) {
this.closeMenu();
}
};
private handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && this.showMenu()) {
this.closeMenu();
}
};
private normalizeFolderPath(value: string | null | undefined): string {
return (value ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/g, '')
.replace(/\/+$/, '')
.trim();
}
}

View File

@ -0,0 +1,104 @@
<div class="relative" (keydown.escape.stop)="onEscapeKey()">
<button
#trigger
type="button"
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
(click)="toggleMenu()"
>
<span class="truncate" [title]="currentPath">
{{ currentFolderLabel() }}
</span>
<span class="text-xs opacity-70"></span>
</button>
@if (showMenu()) {
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
</div>
<div class="p-3 space-y-3">
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
<input
#searchField
type="text"
class="bg-transparent text-sm flex-1 outline-none"
placeholder="Search folders"
[value]="searchQuery()"
(input)="onSearchChange($any($event.target).value)"
/>
@if (searchQuery()) {
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()"></button>
}
</div>
@if (isSearching() && searchResults().length === 0) {
<div class="text-xs text-muted px-1">No matching folders</div>
}
@if (!isSearching()) {
<div class="text-xs text-muted px-1">All my folders</div>
<div class="flex items-center gap-1 text-xs text-muted/80">
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
<span></span>
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
}
</div>
<div class="max-h-64 overflow-y-auto space-y-1">
@if (loading()) {
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
} @else {
@for (folder of currentLevelFolders(); track folder.path) {
<button
type="button"
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
(click)="onFolderSelected(folder, $event)"
>
<span class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"></path>
</svg>
<span class="truncate">{{ folder.name }}</span>
</span>
@if (folder.children?.length) {
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
</button>
}
</button>
}
}
</div>
}
@if (isSearching()) {
<div class="max-h-64 overflow-y-auto space-y-1">
@for (result of searchResults(); track result.path) {
<button
type="button"
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
(click)="onSearchResultSelected(result)"
>
<span class="font-medium">{{ result.name }}</span>
<span class="text-xs text-muted">{{ result.breadcrumb.join(' / ') }}</span>
</button>
}
</div>
}
<div class="flex items-center justify-between pt-2 border-t border-border/60">
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
</div>
</div>
</div>
}
</div>

View File

@ -0,0 +1,369 @@
import { CommonModule } from '@angular/common';
import {
Component,
DestroyRef,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild,
computed,
effect,
inject,
signal
} from '@angular/core';
import { splitPathKeepFilename } from '../../../../shared/utils/path';
import { ToastService } from '../../../../shared/toast/toast.service';
import { VaultService } from '../../../../../services/vault.service';
import { VaultNode } from '../../../../../types';
interface FolderEntry {
name: string;
path: string;
children: FolderEntry[];
}
interface FlattenedFolder {
name: string;
path: string;
breadcrumb: string[];
}
@Component({
selector: 'app-move-note-to-folder',
standalone: true,
imports: [CommonModule],
templateUrl: './move-note-to-folder.component.html'
})
export class MoveNoteToFolderComponent {
@Input()
set currentPath(value: string | null | undefined) {
this.currentFolderPath.set(this.normalizeFolderPath(value));
}
@Input() notePath!: string;
@Output() noteMoved = new EventEmitter<string>();
@Output() openFolderRequested = new EventEmitter<void>();
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
@ViewChild('trigger') trigger?: ElementRef<HTMLElement>;
readonly showMenu = signal(false);
readonly searchQuery = signal('');
readonly breadcrumb = signal<FolderEntry[]>([]);
readonly loading = signal(true);
readonly processingPath = signal<string | null>(null);
readonly currentFolderPath = signal('');
readonly rootFolders = signal<FolderEntry[]>([]);
readonly currentLevelFolders = signal<FolderEntry[]>([]);
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
readonly menuTop = signal(0);
readonly menuLeft = signal(0);
readonly isSearching = computed(() => this.searchQuery().trim().length > 0);
readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders');
readonly activeFolderPath = computed(() => {
const crumbs = this.breadcrumb();
return crumbs.length ? crumbs[crumbs.length - 1].path : '';
});
readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath());
readonly searchResults = computed<FlattenedFolder[]>(() => {
const query = this.searchQuery().trim().toLowerCase();
if (!query) {
return [];
}
const results = this.flattenedFolders().filter(entry => {
const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase();
return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query);
});
if ('all my folders'.includes(query)) {
results.unshift({ name: 'All my folders', path: '', breadcrumb: [] });
}
return results;
});
private readonly vault = inject(VaultService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(ElementRef<HTMLElement>);
private listenersAttached = false;
constructor() {
effect(
() => {
const nodes = this.vault.fastFileTree() as VaultNode[];
this.buildFolderSources(nodes ?? []);
},
{ allowSignalWrites: true }
);
effect(
() => {
const path = this.currentFolderPath();
const roots = this.rootFolders();
this.setActiveLevel(path, roots);
},
{ allowSignalWrites: true }
);
this.destroyRef.onDestroy(() => this.detachGlobalListeners());
}
toggleMenu(): void {
if (this.showMenu()) {
this.closeMenu();
return;
}
this.searchQuery.set('');
this.processingPath.set(null);
this.showMenu.set(true);
this.setActiveLevel(this.currentFolderPath(), this.rootFolders());
this.attachGlobalListeners();
queueMicrotask(() => {
this.computeMenuPosition();
this.focusSearchField();
});
}
openFolderInSidebar(): void {
this.openFolderRequested.emit();
this.closeMenu();
}
goToRoot(): void {
this.breadcrumb.set([]);
this.currentLevelFolders.set(this.rootFolders());
}
navigateInto(folder: FolderEntry, event: Event): void {
event.stopPropagation();
this.breadcrumb.update(prev => [...prev, folder]);
this.currentLevelFolders.set(folder.children ?? []);
}
navigateTo(level: number): void {
if (level <= 0) {
this.goToRoot();
return;
}
const trail = this.breadcrumb().slice(0, level);
this.breadcrumb.set(trail);
const last = trail[trail.length - 1];
this.currentLevelFolders.set(last?.children ?? []);
}
onSearchChange(value: string): void {
this.searchQuery.set(value);
}
clearSearch(): void {
this.searchQuery.set('');
queueMicrotask(() => this.focusSearchField());
}
async onFolderSelected(folder: FolderEntry, event?: Event): Promise<void> {
event?.stopPropagation();
await this.performMove(folder.path);
}
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
await this.performMove(result.path);
}
moveToCurrentFolder(): Promise<void> {
return this.performMove(this.activeFolderPath());
}
onEscapeKey(): void {
this.closeMenu();
}
private async performMove(destinationPath: string): Promise<void> {
if (!this.notePath) {
return;
}
const target = this.normalizeFolderPath(destinationPath);
const current = this.currentFolderPath();
if (target === current || this.processingPath()) {
this.closeMenu();
return;
}
this.processingPath.set(target || '__root__');
try {
const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target);
this.toast.success('Note moved successfully');
const { prefix } = splitPathKeepFilename(newPath);
this.currentFolderPath.set(this.normalizeFolderPath(prefix));
this.noteMoved.emit(newPath);
this.searchQuery.set('');
this.closeMenu();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to move note';
this.toast.error(message);
} finally {
this.processingPath.set(null);
}
}
private buildFolderSources(nodes: VaultNode[]): void {
const folders = this.mapFolders(nodes);
this.rootFolders.set(folders);
const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }];
this.collectFolders(folders, [], flattened);
this.flattenedFolders.set(flattened);
this.loading.set(false);
}
private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] {
if (!nodes?.length) {
return [];
}
const folders: FolderEntry[] = [];
for (const node of nodes) {
if (node.type !== 'folder') continue;
folders.push({
name: node.name,
path: this.normalizeFolderPath(node.path),
children: this.mapFolders(node.children)
});
}
return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void {
for (const node of nodes) {
const breadcrumb = [...trail, node.name];
acc.push({ name: node.name, path: node.path, breadcrumb });
if (node.children?.length) {
this.collectFolders(node.children, breadcrumb, acc);
}
}
}
private setActiveLevel(path: string, roots: FolderEntry[]): void {
if (!roots.length) {
return;
}
if (!path) {
this.breadcrumb.set([]);
this.currentLevelFolders.set(roots);
return;
}
const trail = this.findTrail(roots, path);
if (!trail) {
this.breadcrumb.set([]);
this.currentLevelFolders.set(roots);
return;
}
this.breadcrumb.set(trail);
const last = trail[trail.length - 1];
this.currentLevelFolders.set(last?.children ?? []);
}
private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null {
for (const node of nodes) {
const nextTrail = [...trail, node];
if (node.path === target) {
return nextTrail;
}
if (node.children?.length) {
const found = this.findTrail(node.children, target, nextTrail);
if (found) {
return found;
}
}
}
return null;
}
private closeMenu(): void {
this.showMenu.set(false);
this.detachGlobalListeners();
this.searchQuery.set('');
this.processingPath.set(null);
}
private focusSearchField(): void {
const el = this.searchField?.nativeElement;
if (el) {
el.focus();
el.select();
}
}
private computeMenuPosition(): void {
const t = this.trigger?.nativeElement;
if (!t) return;
const rect = t.getBoundingClientRect();
// Place menu just under the trigger, with small margin
this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8));
// Try to align left edges; ensure not off-screen (simple clamp)
const left = Math.round(rect.left + window.scrollX);
this.menuLeft.set(Math.max(8, left));
}
private attachGlobalListeners(): void {
if (typeof document === 'undefined' || this.listenersAttached) {
return;
}
document.addEventListener('pointerdown', this.handleOutsidePointer, true);
document.addEventListener('keydown', this.handleEscape, true);
this.listenersAttached = true;
}
private detachGlobalListeners(): void {
if (typeof document === 'undefined' || !this.listenersAttached) {
return;
}
document.removeEventListener('pointerdown', this.handleOutsidePointer, true);
document.removeEventListener('keydown', this.handleEscape, true);
this.listenersAttached = false;
}
private handleOutsidePointer = (event: PointerEvent) => {
if (!this.showMenu()) {
return;
}
if (!this.host.nativeElement.contains(event.target as Node)) {
this.closeMenu();
}
};
private handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && this.showMenu()) {
this.closeMenu();
}
};
private normalizeFolderPath(value: string | null | undefined): string {
return (value ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/g, '')
.replace(/\/+$/, '')
.trim();
}
}

View File

@ -30,11 +30,14 @@
</svg>
</button>
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis cursor-pointer" (click)="onPathClick()" [title]="fullPath">
{{ pathParts.prefix }}
</span>
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
<div class="path-wrap flex items-center gap-2 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
<app-move-note-to-folder
class="move-note-trigger shrink-0"
[currentPath]="pathParts.prefix"
[notePath]="fullPath"
(noteMoved)="onNoteMoved($event)"
(openFolderRequested)="onPathClick()"
></app-move-note-to-folder>
<ng-container *ngIf="!isRenaming; else renameTpl">
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">

View File

@ -10,11 +10,12 @@ import { FrontmatterPropertiesService } from '../../shared/frontmatter-propertie
import { VaultService } from '../../../../../services/vault.service';
import { ToastService } from '../../../../shared/toast/toast.service';
import { UrlStateService } from '../../../../services/url-state.service';
import { MoveNoteToFolderComponent } from '../move-note-to-folder/move-note-to-folder.component';
@Component({
selector: 'app-note-header',
standalone: true,
imports: [CommonModule, TagManagerComponent],
imports: [CommonModule, TagManagerComponent, MoveNoteToFolderComponent],
templateUrl: './note-header.component.html',
styleUrls: ['./note-header.component.scss']
})
@ -27,6 +28,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
@Output() copyRequested = new EventEmitter<void>();
@Output() tagsChange = new EventEmitter<string[]>();
@Output() tagSelected = new EventEmitter<string>();
@Output() noteMoved = new EventEmitter<string>();
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
@ -61,6 +63,22 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
}
}
onNoteMoved(newPath: string): void {
if (!newPath) {
return;
}
this.fullPath = newPath;
this.pathParts = splitPathKeepFilename(newPath);
this.noteMoved.emit(newPath);
this.urlState.openNote(newPath, { force: true });
queueMicrotask(() => {
this.applyProgressiveCollapse();
this.fitPath();
});
}
ngAfterViewInit(): void {
this.pathParts = splitPathKeepFilename(this.fullPath);

View File

@ -60,6 +60,7 @@ export interface WikiLinkActivation {
[tags]="note.tags ?? []"
(copyRequested)="copyPath()"
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
(noteMoved)="onNoteMoved($event)"
(tagsChange)="onTagsChange($event)"
(tagSelected)="tagClicked.emit($event)"
></app-note-header>
@ -479,6 +480,21 @@ export class NoteViewerComponent implements OnDestroy {
// Pas besoin de sauvegarder ici, c'est déjà fait par TagsEditorComponent
}
onNoteMoved(newPath: string): void {
const currentNote = this.note();
if (!currentNote || !newPath) {
return;
}
currentNote.filePath = newPath;
currentNote.originalPath = newPath.replace(/\\/g, '/').replace(/\.md$/i, '');
const newFolder = this.getDirectoryFromPath(newPath);
if (newFolder) {
this.directoryClicked.emit(newFolder);
}
}
async copyPath(): Promise<void> {
const path = this.note()?.filePath ?? '';
try {

View File

@ -230,6 +230,42 @@ export class VaultService implements OnDestroy {
return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') };
}
/** Move a markdown file to a different folder within the vault. */
async moveNoteToFolder(notePath: string, newFolderPath: string): Promise<{ oldPath: string; newPath: string }> {
const payload = {
notePath,
newFolderPath
};
const res = await fetch('/api/vault/notes/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
let reason = res.statusText;
try {
const errorBody = await res.json();
reason = errorBody?.error || reason;
} catch {
// ignore parsing error, keep default status text
}
throw new Error(`Failed to move note: ${reason}`);
}
const data = await res.json();
// Refresh local caches to reflect the new structure
this.refresh();
this.loadFastFileTree(true);
const oldPath = String(data.oldPath ?? notePath ?? '');
const newPath = String(data.newPath ?? notePath ?? '');
return { oldPath, newPath };
}
getNoteById(id: string): Note | undefined {
return this.notesMap().get(id);
}