diff --git a/changelog.md b/changelog.md index ab95406..c8286cc 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-12-31 - 3.18.0 - feat(filetree) +add filesystem watch support to WebContainer environment and auto-refresh file tree; improve icon handling and context menu behavior + +- Add IFileWatcher interface and watch(...) signature to IExecutionEnvironment. +- Implement watch(...) in WebContainerEnvironment using WebContainer's fs.watch and return a stop() handle. +- dees-editor-filetree: start/stop file watcher, debounce auto-refresh on FS changes, cleanup on disconnect, and track last execution environment. +- Add clipboard state (copy/cut) and related UI/menu enhancements for file operations (new file/folder, rename, delete, copy/paste). +- dees-icon: default to Lucide icons when no prefix is provided. +- dees-contextmenu: remove 'lucide:' prefix usage in templates and avoid awaiting windowLayer.destroy() to provide instant visual feedback. +- Menu item shape adjusted (use { divider: true } for dividers) and various menu icon name updates. + ## 2025-12-31 - 3.17.0 - feat(editor) add file explorer toolbar, empty-space context menu, editor auto-save, save-all, and keyboard save shortcuts diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index d11cbe6..1e8abab 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.17.0', + version: '3.18.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts index 23eeb54..396413a 100644 --- a/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts +++ b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts @@ -10,7 +10,7 @@ import { } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { themeDefaultStyles } from '../../00theme.js'; -import type { IExecutionEnvironment, IFileEntry } from '../../00group-runtime/index.js'; +import type { IExecutionEnvironment, IFileEntry, IFileWatcher } from '../../00group-runtime/index.js'; import '../../dees-icon/dees-icon.js'; import '../../dees-contextmenu/dees-contextmenu.js'; import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; @@ -60,6 +60,15 @@ export class DeesEditorFiletree extends DeesElement { private expandedPaths: Set = new Set(); private loadTreeStarted: boolean = false; + // Clipboard state for copy/paste operations + private clipboardPath: string | null = null; + private clipboardOperation: 'copy' | 'cut' | null = null; + + // File watcher for auto-refresh + private fileWatcher: IFileWatcher | null = null; + private refreshDebounceTimeout: ReturnType | null = null; + private lastExecutionEnvironment: IExecutionEnvironment | null = null; + public static styles = [ themeDefaultStyles, cssManager.defaultStyles, @@ -239,6 +248,7 @@ export class DeesEditorFiletree extends DeesElement { display: flex; align-items: center; justify-content: center; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')}; } .toolbar-button:hover { @@ -439,26 +449,58 @@ export class DeesEditorFiletree extends DeesElement { const menuItems = []; if (node.type === 'directory') { + // Directory-specific options menuItems.push( { name: 'New File', - iconName: 'lucide:filePlus', + iconName: 'filePlus', action: async () => this.createNewFile(node.path), }, { name: 'New Folder', - iconName: 'lucide:folderPlus', + iconName: 'folderPlus', action: async () => this.createNewFolder(node.path), }, - { name: 'divider' } + { divider: true } ); } - menuItems.push({ - name: 'Delete', - iconName: 'lucide:trash2', - action: async () => this.deleteItem(node), - }); + // Common options for both files and directories + menuItems.push( + { + name: 'Rename', + iconName: 'pencil', + action: async () => this.renameItem(node), + }, + { + name: 'Duplicate', + iconName: 'files', + action: async () => this.duplicateItem(node), + }, + { + name: 'Copy', + iconName: 'copy', + action: async () => this.copyItem(node), + } + ); + + // Paste option (only for directories and when clipboard has content) + if (node.type === 'directory' && this.clipboardPath) { + menuItems.push({ + name: 'Paste', + iconName: 'clipboard', + action: async () => this.pasteItem(node.path), + }); + } + + menuItems.push( + { divider: true }, + { + name: 'Delete', + iconName: 'trash2', + action: async () => this.deleteItem(node), + } + ); await DeesContextmenu.openContextMenuWithOptions(e, menuItems); } @@ -471,38 +513,48 @@ export class DeesEditorFiletree extends DeesElement { e.preventDefault(); e.stopPropagation(); - const menuItems = [ + const menuItems: any[] = [ { name: 'New File', - iconName: 'lucide:filePlus', + iconName: 'filePlus', action: async () => this.createNewFile('/'), }, { name: 'New Folder', - iconName: 'lucide:folderPlus', + iconName: 'folderPlus', action: async () => this.createNewFolder('/'), }, ]; + // Add Paste option if clipboard has content + if (this.clipboardPath) { + menuItems.push( + { divider: true }, + { + name: 'Paste', + iconName: 'clipboard', + action: async () => this.pasteItem('/'), + } + ); + } + await DeesContextmenu.openContextMenuWithOptions(e, menuItems); } private async showInputModal(options: { heading: string; label: string; + value?: string; + buttonName?: string; }): Promise { return new Promise(async (resolve) => { - let inputValue = ''; - const modal = await DeesModal.createAndShow({ heading: options.heading, width: 'small', content: html` { - inputValue = (e.target as DeesInputText).value; - }} + .value=${options.value || ''} > `, menuOptions: [ @@ -514,10 +566,15 @@ export class DeesEditorFiletree extends DeesElement { }, }, { - name: 'Create', + name: options.buttonName || 'Create', action: async (modalRef) => { + // Query the input element directly and read its value + const contentEl = modalRef.shadowRoot?.querySelector('.modal .content'); + const inputElement = contentEl?.querySelector('dees-input-text') as DeesInputText | null; + const inputValue = inputElement?.value?.trim() || ''; + await modalRef.destroy(); - resolve(inputValue.trim() || null); + resolve(inputValue || null); }, }, ], @@ -603,13 +660,225 @@ export class DeesEditorFiletree extends DeesElement { } } + /** + * Rename a file or folder + */ + private async renameItem(node: ITreeNode) { + if (!this.executionEnvironment) return; + + const newName = await this.showInputModal({ + heading: 'Rename', + label: 'New name', + value: node.name, + buttonName: 'Rename', + }); + if (!newName || newName === node.name) return; + + // Calculate new path + const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/'; + const newPath = parentPath === '/' ? `/${newName}` : `${parentPath}/${newName}`; + + try { + if (node.type === 'file') { + // For files: read content, write to new path, delete old + const content = await this.executionEnvironment.readFile(node.path); + await this.executionEnvironment.writeFile(newPath, content); + await this.executionEnvironment.rm(node.path); + } else { + // For directories: recursively copy contents then delete old + await this.copyDirectoryContents(node.path, newPath); + await this.executionEnvironment.rm(node.path, { recursive: true }); + } + await this.refresh(); + this.dispatchEvent( + new CustomEvent('item-renamed', { + detail: { oldPath: node.path, newPath, type: node.type }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error('Failed to rename item:', error); + } + } + + /** + * Duplicate a file or folder + */ + private async duplicateItem(node: ITreeNode) { + if (!this.executionEnvironment) return; + + const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/'; + let newName: string; + + if (node.type === 'file') { + // Add _copy before extension + const lastDot = node.name.lastIndexOf('.'); + if (lastDot > 0) { + const baseName = node.name.substring(0, lastDot); + const ext = node.name.substring(lastDot); + newName = `${baseName}_copy${ext}`; + } else { + newName = `${node.name}_copy`; + } + } else { + newName = `${node.name}_copy`; + } + + const newPath = parentPath === '/' ? `/${newName}` : `${parentPath}/${newName}`; + + try { + if (node.type === 'file') { + const content = await this.executionEnvironment.readFile(node.path); + await this.executionEnvironment.writeFile(newPath, content); + } else { + await this.copyDirectoryContents(node.path, newPath); + } + await this.refresh(); + this.dispatchEvent( + new CustomEvent('item-duplicated', { + detail: { sourcePath: node.path, newPath, type: node.type }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error('Failed to duplicate item:', error); + } + } + + /** + * Copy item path to clipboard + */ + private async copyItem(node: ITreeNode) { + this.clipboardPath = node.path; + this.clipboardOperation = 'copy'; + } + + /** + * Paste copied item to target directory + */ + private async pasteItem(targetPath: string) { + if (!this.executionEnvironment || !this.clipboardPath) return; + + // Get the name from clipboard path + const name = this.clipboardPath.split('/').pop() || 'pasted'; + const newPath = targetPath === '/' ? `/${name}` : `${targetPath}/${name}`; + + try { + // Check if source exists + if (!(await this.executionEnvironment.exists(this.clipboardPath))) { + console.error('Source file no longer exists'); + this.clipboardPath = null; + this.clipboardOperation = null; + return; + } + + // Check if it's a file or directory by trying to read as file + try { + const content = await this.executionEnvironment.readFile(this.clipboardPath); + await this.executionEnvironment.writeFile(newPath, content); + } catch { + // If reading fails, it's a directory + await this.copyDirectoryContents(this.clipboardPath, newPath); + } + + await this.refresh(); + this.dispatchEvent( + new CustomEvent('item-pasted', { + detail: { sourcePath: this.clipboardPath, targetPath: newPath }, + bubbles: true, + composed: true, + }) + ); + + // Clear clipboard after paste + this.clipboardPath = null; + this.clipboardOperation = null; + } catch (error) { + console.error('Failed to paste item:', error); + } + } + + /** + * Recursively copy directory contents to a new path + */ + private async copyDirectoryContents(sourcePath: string, destPath: string) { + if (!this.executionEnvironment) return; + + // Create destination directory + await this.executionEnvironment.mkdir(destPath); + + // Read source directory contents + const entries = await this.executionEnvironment.readDir(sourcePath); + + for (const entry of entries) { + const srcEntryPath = sourcePath === '/' ? `/${entry.name}` : `${sourcePath}/${entry.name}`; + const destEntryPath = destPath === '/' ? `/${entry.name}` : `${destPath}/${entry.name}`; + + if (entry.type === 'directory') { + await this.copyDirectoryContents(srcEntryPath, destEntryPath); + } else { + const content = await this.executionEnvironment.readFile(srcEntryPath); + await this.executionEnvironment.writeFile(destEntryPath, content); + } + } + } + public async firstUpdated() { await this.loadTree(); } public async updated(changedProperties: Map) { - if (changedProperties.has('executionEnvironment') && this.executionEnvironment) { - await this.loadTree(); + if (changedProperties.has('executionEnvironment')) { + // Stop watching the old environment + if (this.lastExecutionEnvironment !== this.executionEnvironment) { + this.stopFileWatcher(); + this.lastExecutionEnvironment = this.executionEnvironment; + } + + if (this.executionEnvironment) { + await this.loadTree(); + this.startFileWatcher(); + } + } + } + + public async disconnectedCallback() { + await super.disconnectedCallback(); + this.stopFileWatcher(); + if (this.refreshDebounceTimeout) { + clearTimeout(this.refreshDebounceTimeout); + this.refreshDebounceTimeout = null; + } + } + + private startFileWatcher() { + if (!this.executionEnvironment || this.fileWatcher) return; + + try { + this.fileWatcher = this.executionEnvironment.watch( + '/', + (_event, _filename) => { + // Debounce refresh to avoid excessive updates + if (this.refreshDebounceTimeout) { + clearTimeout(this.refreshDebounceTimeout); + } + this.refreshDebounceTimeout = setTimeout(() => { + this.refresh(); + }, 300); + }, + { recursive: true } + ); + } catch (error) { + console.warn('File watching not supported:', error); + } + } + + private stopFileWatcher() { + if (this.fileWatcher) { + this.fileWatcher.stop(); + this.fileWatcher = null; } } diff --git a/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts b/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts index c73e9c7..f914954 100644 --- a/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts +++ b/ts_web/elements/00group-runtime/environments/WebContainerEnvironment.ts @@ -1,5 +1,5 @@ import * as webcontainer from '@webcontainer/api'; -import type { IExecutionEnvironment, IFileEntry, IProcessHandle } from '../interfaces/IExecutionEnvironment.js'; +import type { IExecutionEnvironment, IFileEntry, IFileWatcher, IProcessHandle } from '../interfaces/IExecutionEnvironment.js'; /** * WebContainer-based execution environment. @@ -123,6 +123,22 @@ export class WebContainerEnvironment implements IExecutionEnvironment { } } + public watch( + path: string, + callback: (event: 'rename' | 'change', filename: string | null) => void, + options?: { recursive?: boolean } + ): IFileWatcher { + this.ensureReady(); + const watcher = this.container!.fs.watch( + path, + { recursive: options?.recursive ?? false }, + callback + ); + return { + stop: () => watcher.close(), + }; + } + // ============ Process Execution ============ public async spawn(command: string, args: string[] = []): Promise { diff --git a/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts b/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts index 7088b0e..6669e54 100644 --- a/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts +++ b/ts_web/elements/00group-runtime/interfaces/IExecutionEnvironment.ts @@ -7,6 +7,14 @@ export interface IFileEntry { path: string; } +/** + * Handle to a file system watcher + */ +export interface IFileWatcher { + /** Stop watching for changes */ + stop(): void; +} + /** * Handle to a spawned process with I/O streams */ @@ -68,6 +76,19 @@ export interface IExecutionEnvironment { */ exists(path: string): Promise; + /** + * Watch a file or directory for changes + * @param path - Absolute path to watch + * @param callback - Called when changes occur + * @param options - Optional: { recursive: true } to watch subdirectories + * @returns Watcher handle with stop() method + */ + watch( + path: string, + callback: (event: 'rename' | 'change', filename: string | null) => void, + options?: { recursive?: boolean } + ): IFileWatcher; + // ============ Process Execution ============ /** diff --git a/ts_web/elements/dees-contextmenu/dees-contextmenu.ts b/ts_web/elements/dees-contextmenu/dees-contextmenu.ts index e798870..1b198dc 100644 --- a/ts_web/elements/dees-contextmenu/dees-contextmenu.ts +++ b/ts_web/elements/dees-contextmenu/dees-contextmenu.ts @@ -246,7 +246,7 @@ export class DeesContextmenu extends DeesElement { @mouseleave=${() => this.handleMenuItemLeave()} > ${menuItem.iconName ? html` - + ` : ''} ${menuItem.name} ${menuItem.shortcut && !hasSubmenu ? html` @@ -436,8 +436,9 @@ export class DeesContextmenu extends DeesElement { } // Only destroy window layer if this is not a submenu + // Don't await - let cleanup happen in background for instant visual feedback if (this.windowLayer && !this.parentMenu) { - await this.windowLayer.destroy(); + this.windowLayer.destroy(); } this.style.opacity = '0'; diff --git a/ts_web/elements/dees-icon/dees-icon.ts b/ts_web/elements/dees-icon/dees-icon.ts index 9990720..b5a0695 100644 --- a/ts_web/elements/dees-icon/dees-icon.ts +++ b/ts_web/elements/dees-icon/dees-icon.ts @@ -268,9 +268,9 @@ export class DeesIcon extends DeesElement { name: iconStr.substring(7) // Remove 'lucide:' prefix }; } else { - // For backward compatibility, assume FontAwesome if no prefix + // Default to Lucide when no prefix is provided return { - type: 'fa', + type: 'lucide', name: iconStr }; }