feat(filetree): add filesystem watch support to WebContainer environment and auto-refresh file tree; improve icon handling and context menu behavior

This commit is contained in:
2025-12-31 08:53:01 +00:00
parent 9e229543eb
commit e193e28fe9
7 changed files with 345 additions and 27 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # 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) ## 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 add file explorer toolbar, empty-space context menu, editor auto-save, save-all, and keyboard save shortcuts

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', 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.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

View File

@@ -10,7 +10,7 @@ import {
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import { themeDefaultStyles } from '../../00theme.js'; 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-icon/dees-icon.js';
import '../../dees-contextmenu/dees-contextmenu.js'; import '../../dees-contextmenu/dees-contextmenu.js';
import { DeesContextmenu } from '../../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<string> = new Set(); private expandedPaths: Set<string> = new Set();
private loadTreeStarted: boolean = false; 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<typeof setTimeout> | null = null;
private lastExecutionEnvironment: IExecutionEnvironment | null = null;
public static styles = [ public static styles = [
themeDefaultStyles, themeDefaultStyles,
cssManager.defaultStyles, cssManager.defaultStyles,
@@ -239,6 +248,7 @@ export class DeesEditorFiletree extends DeesElement {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
} }
.toolbar-button:hover { .toolbar-button:hover {
@@ -439,26 +449,58 @@ export class DeesEditorFiletree extends DeesElement {
const menuItems = []; const menuItems = [];
if (node.type === 'directory') { if (node.type === 'directory') {
// Directory-specific options
menuItems.push( menuItems.push(
{ {
name: 'New File', name: 'New File',
iconName: 'lucide:filePlus', iconName: 'filePlus',
action: async () => this.createNewFile(node.path), action: async () => this.createNewFile(node.path),
}, },
{ {
name: 'New Folder', name: 'New Folder',
iconName: 'lucide:folderPlus', iconName: 'folderPlus',
action: async () => this.createNewFolder(node.path), action: async () => this.createNewFolder(node.path),
}, },
{ name: 'divider' } { divider: true }
); );
} }
menuItems.push({ // Common options for both files and directories
name: 'Delete', menuItems.push(
iconName: 'lucide:trash2', {
action: async () => this.deleteItem(node), 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); await DeesContextmenu.openContextMenuWithOptions(e, menuItems);
} }
@@ -471,38 +513,48 @@ export class DeesEditorFiletree extends DeesElement {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const menuItems = [ const menuItems: any[] = [
{ {
name: 'New File', name: 'New File',
iconName: 'lucide:filePlus', iconName: 'filePlus',
action: async () => this.createNewFile('/'), action: async () => this.createNewFile('/'),
}, },
{ {
name: 'New Folder', name: 'New Folder',
iconName: 'lucide:folderPlus', iconName: 'folderPlus',
action: async () => this.createNewFolder('/'), 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); await DeesContextmenu.openContextMenuWithOptions(e, menuItems);
} }
private async showInputModal(options: { private async showInputModal(options: {
heading: string; heading: string;
label: string; label: string;
value?: string;
buttonName?: string;
}): Promise<string | null> { }): Promise<string | null> {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
let inputValue = '';
const modal = await DeesModal.createAndShow({ const modal = await DeesModal.createAndShow({
heading: options.heading, heading: options.heading,
width: 'small', width: 'small',
content: html` content: html`
<dees-input-text <dees-input-text
.label=${options.label} .label=${options.label}
@changeSubject=${(e: CustomEvent) => { .value=${options.value || ''}
inputValue = (e.target as DeesInputText).value;
}}
></dees-input-text> ></dees-input-text>
`, `,
menuOptions: [ menuOptions: [
@@ -514,10 +566,15 @@ export class DeesEditorFiletree extends DeesElement {
}, },
}, },
{ {
name: 'Create', name: options.buttonName || 'Create',
action: async (modalRef) => { 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(); 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() { public async firstUpdated() {
await this.loadTree(); await this.loadTree();
} }
public async updated(changedProperties: Map<string, any>) { public async updated(changedProperties: Map<string, any>) {
if (changedProperties.has('executionEnvironment') && this.executionEnvironment) { if (changedProperties.has('executionEnvironment')) {
await this.loadTree(); // 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;
} }
} }

View File

@@ -1,5 +1,5 @@
import * as webcontainer from '@webcontainer/api'; 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. * 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 ============ // ============ Process Execution ============
public async spawn(command: string, args: string[] = []): Promise<IProcessHandle> { public async spawn(command: string, args: string[] = []): Promise<IProcessHandle> {

View File

@@ -7,6 +7,14 @@ export interface IFileEntry {
path: string; 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 * Handle to a spawned process with I/O streams
*/ */
@@ -68,6 +76,19 @@ export interface IExecutionEnvironment {
*/ */
exists(path: string): Promise<boolean>; exists(path: string): Promise<boolean>;
/**
* 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 ============ // ============ Process Execution ============
/** /**

View File

@@ -246,7 +246,7 @@ export class DeesContextmenu extends DeesElement {
@mouseleave=${() => this.handleMenuItemLeave()} @mouseleave=${() => this.handleMenuItemLeave()}
> >
${menuItem.iconName ? html` ${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon> <dees-icon .icon="${menuItem.iconName}"></dees-icon>
` : ''} ` : ''}
<span class="menuitem-text">${menuItem.name}</span> <span class="menuitem-text">${menuItem.name}</span>
${menuItem.shortcut && !hasSubmenu ? html` ${menuItem.shortcut && !hasSubmenu ? html`
@@ -436,8 +436,9 @@ export class DeesContextmenu extends DeesElement {
} }
// Only destroy window layer if this is not a submenu // 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) { if (this.windowLayer && !this.parentMenu) {
await this.windowLayer.destroy(); this.windowLayer.destroy();
} }
this.style.opacity = '0'; this.style.opacity = '0';

View File

@@ -268,9 +268,9 @@ export class DeesIcon extends DeesElement {
name: iconStr.substring(7) // Remove 'lucide:' prefix name: iconStr.substring(7) // Remove 'lucide:' prefix
}; };
} else { } else {
// For backward compatibility, assume FontAwesome if no prefix // Default to Lucide when no prefix is provided
return { return {
type: 'fa', type: 'lucide',
name: iconStr name: iconStr
}; };
} }