From a20d9ff1388fdeee53f75eedab688529e72224b0 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 1 Jan 2026 11:32:01 +0000 Subject: [PATCH] feat(workspace): add external file change detection, conflict resolution UI, and diff editor --- changelog.md | 9 + ts_web/00_commitinfo_data.ts | 2 +- .../dees-workspace-diff-editor.ts | 359 ++++++++++++++++++ .../dees-workspace-diff-editor/index.ts | 1 + .../dees-workspace-monaco.ts | 49 +++ .../dees-workspace/dees-workspace.ts | 229 ++++++++++- ts_web/elements/00group-workspace/index.ts | 1 + 7 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 ts_web/elements/00group-workspace/dees-workspace-diff-editor/dees-workspace-diff-editor.ts create mode 100644 ts_web/elements/00group-workspace/dees-workspace-diff-editor/index.ts diff --git a/changelog.md b/changelog.md index 1a40d53..daeeeae 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-01-01 - 3.26.0 - feat(workspace) +add external file change detection, conflict resolution UI, and diff editor + +- Watch open files for external changes with debounced file watchers (startWatchingFile/stopWatchingFile/stopAllFileWatchers). +- Prompt the user when disk changes conflict with unsaved local edits via dees-actionbar (actions: Load from Disk, Save Local, Compare). +- Introduce dees-workspace-diff-editor component and export it; support comparing and resolving diffs (diff-resolved / diff-closed events). +- Add setContentExternal in dees-workspace-monaco to update editor content from external sources while optionally preserving cursor, selections and scroll position. +- Start/stop file watchers when files are opened/closed and integrate diff view and actionbar into the workspace UI for seamless conflict handling. + ## 2026-01-01 - 3.25.0 - feat(dees-actionbar) add action bar component and improve workspace package update handling diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index b37b75c..db77fcb 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.25.0', + version: '3.26.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-workspace/dees-workspace-diff-editor/dees-workspace-diff-editor.ts b/ts_web/elements/00group-workspace/dees-workspace-diff-editor/dees-workspace-diff-editor.ts new file mode 100644 index 0000000..2cbd170 --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-diff-editor/dees-workspace-diff-editor.ts @@ -0,0 +1,359 @@ +import { + DeesElement, + property, + html, + customElement, + type TemplateResult, + css, + cssManager, +} from '@design.estate/dees-element'; +import * as domtools from '@design.estate/dees-domtools'; +import { MONACO_VERSION } from '../dees-workspace-monaco/version.js'; +import { themeDefaultStyles } from '../../00theme.js'; +import '../../00group-button/dees-button/dees-button.js'; + +import type * as monaco from 'monaco-editor'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-workspace-diff-editor': DeesWorkspaceDiffEditor; + } +} + +@customElement('dees-workspace-diff-editor') +export class DeesWorkspaceDiffEditor extends DeesElement { + // DEMO + public static demo = () => html` + + `; + + // INSTANCE + public diffEditorDeferred = domtools.plugins.smartpromise.defer(); + + @property({ type: String }) + accessor originalContent: string = ''; + + @property({ type: String }) + accessor modifiedContent: string = ''; + + @property({ type: String }) + accessor originalLabel: string = 'Disk Version'; + + @property({ type: String }) + accessor modifiedLabel: string = 'Local Version'; + + @property({ type: String }) + accessor language: string = 'typescript'; + + @property({ type: String }) + accessor filePath: string = ''; + + private diffEditor: monaco.editor.IStandaloneDiffEditor | null = null; + private monacoThemeSubscription: domtools.plugins.smartrx.rxjs.Subscription | null = null; + private originalModel: monaco.editor.ITextModel | null = null; + private modifiedModel: monaco.editor.ITextModel | null = null; + + constructor() { + super(); + domtools.DomTools.setupDomTools(); + } + + public static styles = [ + themeDefaultStyles, + cssManager.defaultStyles, + css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + * { + box-sizing: border-box; + } + + .diff-wrapper { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + } + + .diff-toolbar { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 12%)')}; + border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 20%)')}; + flex-shrink: 0; + } + + .diff-info { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')}; + } + + .diff-filename { + font-weight: 600; + color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')}; + } + + .diff-labels { + font-size: 12px; + color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; + } + + .diff-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .diff-container { + flex: 1; + min-height: 0; + width: 100%; + } + + .nav-buttons { + display: flex; + gap: 4px; + } + + .action-buttons { + display: flex; + gap: 8px; + margin-left: 16px; + } + `, + ]; + + public render(): TemplateResult { + const fileName = this.filePath.split('/').pop() || 'file'; + + return html` +
+
+
+ ${fileName} + ${this.originalLabel} ↔ ${this.modifiedLabel} +
+
+ +
+ Use Local + Use Disk + Close +
+
+
+
+
+ `; + } + + public async firstUpdated(): Promise { + await super.firstUpdated(new Map()); + await this.initDiffEditor(); + } + + private async initDiffEditor(): Promise { + const container = this.shadowRoot?.querySelector('.diff-container') as HTMLElement; + if (!container) return; + + const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`; + + // Wait for Monaco to be loaded (should already be loaded by dees-workspace-monaco) + let monacoInstance = (window as any).monaco as typeof monaco; + + if (!monacoInstance) { + // Monaco not loaded yet, wait for it + await new Promise((resolve) => { + const checkMonaco = setInterval(() => { + if ((window as any).monaco) { + clearInterval(checkMonaco); + resolve(); + } + }, 100); + }); + monacoInstance = (window as any).monaco as typeof monaco; + } + + // Get current theme from domtools + const domtoolsInstance = await this.domtoolsPromise; + const isBright = domtoolsInstance.themeManager.goBrightBoolean; + const initialTheme = isBright ? 'vs' : 'vs-dark'; + + // Create unique URIs for models + const timestamp = Date.now(); + const originalUri = monacoInstance.Uri.parse(`diff://original/${timestamp}${this.filePath}`); + const modifiedUri = monacoInstance.Uri.parse(`diff://modified/${timestamp}${this.filePath}`); + + // Create models + this.originalModel = monacoInstance.editor.createModel( + this.originalContent, + this.language, + originalUri + ); + this.modifiedModel = monacoInstance.editor.createModel( + this.modifiedContent, + this.language, + modifiedUri + ); + + // Create diff editor + this.diffEditor = monacoInstance.editor.createDiffEditor(container, { + automaticLayout: true, + readOnly: false, // Allow editing the modified (local) side + originalEditable: false, // Disk version is read-only + renderSideBySide: true, + ignoreTrimWhitespace: false, + fontSize: 14, + minimap: { + enabled: false, + }, + }); + + // Set the theme + monacoInstance.editor.setTheme(initialTheme); + + this.diffEditor.setModel({ + original: this.originalModel, + modified: this.modifiedModel, + }); + + // Subscribe to theme changes + this.monacoThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe( + (goBright: boolean) => { + const newTheme = goBright ? 'vs' : 'vs-dark'; + monacoInstance.editor.setTheme(newTheme); + } + ); + + // Inject Monaco CSS if not already present + const cssId = 'monaco-diff-editor-css'; + if (!this.shadowRoot?.getElementById(cssId)) { + const cssResponse = await fetch(`${monacoCdnBase}/min/vs/editor/editor.main.css`); + const cssText = await cssResponse.text(); + const styleElement = document.createElement('style'); + styleElement.id = cssId; + styleElement.textContent = cssText; + this.shadowRoot?.append(styleElement); + } + + // Navigate to first diff after a short delay + setTimeout(() => { + try { + this.diffEditor?.revealFirstDiff(); + } catch { + // Ignore if no diffs + } + }, 100); + + this.diffEditorDeferred.resolve(this.diffEditor); + } + + public goToNextDiff(): void { + try { + this.diffEditor?.goToDiff('next'); + } catch { + // Ignore if no more diffs + } + } + + public goToPreviousDiff(): void { + try { + this.diffEditor?.goToDiff('previous'); + } catch { + // Ignore if no more diffs + } + } + + public acceptLocal(): void { + // User wants to keep local version (potentially with edits made in diff view) + const modifiedContent = this.diffEditor?.getModifiedEditor().getValue() || this.modifiedContent; + this.dispatchEvent( + new CustomEvent('diff-resolved', { + detail: { action: 'use-local', content: modifiedContent }, + bubbles: true, + composed: true, + }) + ); + } + + public acceptDisk(): void { + // User wants disk version + this.dispatchEvent( + new CustomEvent('diff-resolved', { + detail: { action: 'use-disk', content: this.originalContent }, + bubbles: true, + composed: true, + }) + ); + } + + public close(): void { + this.dispatchEvent( + new CustomEvent('diff-closed', { + bubbles: true, + composed: true, + }) + ); + } + + public async disconnectedCallback(): Promise { + await super.disconnectedCallback(); + + if (this.monacoThemeSubscription) { + this.monacoThemeSubscription.unsubscribe(); + this.monacoThemeSubscription = null; + } + + // Dispose models + if (this.originalModel) { + this.originalModel.dispose(); + this.originalModel = null; + } + if (this.modifiedModel) { + this.modifiedModel.dispose(); + this.modifiedModel = null; + } + + // Dispose editor + if (this.diffEditor) { + this.diffEditor.dispose(); + this.diffEditor = null; + } + } +} diff --git a/ts_web/elements/00group-workspace/dees-workspace-diff-editor/index.ts b/ts_web/elements/00group-workspace/dees-workspace-diff-editor/index.ts new file mode 100644 index 0000000..0cfff69 --- /dev/null +++ b/ts_web/elements/00group-workspace/dees-workspace-diff-editor/index.ts @@ -0,0 +1 @@ +export * from './dees-workspace-diff-editor.js'; diff --git a/ts_web/elements/00group-workspace/dees-workspace-monaco/dees-workspace-monaco.ts b/ts_web/elements/00group-workspace/dees-workspace-monaco/dees-workspace-monaco.ts index bb92967..083ff68 100644 --- a/ts_web/elements/00group-workspace/dees-workspace-monaco/dees-workspace-monaco.ts +++ b/ts_web/elements/00group-workspace/dees-workspace-monaco/dees-workspace-monaco.ts @@ -242,4 +242,53 @@ export class DeesWorkspaceMonaco extends DeesElement { this.monacoThemeSubscription = null; } } + + /** + * Update content from external source with optional cursor preservation. + * Use this when the file content changes externally (e.g., file changed on disk). + * @param newContent The new content to set + * @param preserveCursor Whether to preserve cursor/scroll position (default: true) + */ + public async setContentExternal( + newContent: string, + preserveCursor: boolean = true + ): Promise { + const editor = await this.editorDeferred.promise; + const currentValue = editor.getValue(); + + if (currentValue === newContent) return; + + // Save cursor state if preserving + const position = preserveCursor ? editor.getPosition() : null; + const selections = preserveCursor ? editor.getSelections() : null; + const scrollTop = preserveCursor ? editor.getScrollTop() : 0; + const scrollLeft = preserveCursor ? editor.getScrollLeft() : 0; + + // Update content + this.isUpdatingFromExternal = true; + editor.setValue(newContent); + this.isUpdatingFromExternal = false; + + // Restore cursor state if preserving + if (preserveCursor) { + if (position) { + // Clamp position to valid range + const model = editor.getModel(); + const lineCount = model?.getLineCount() || 1; + const clampedLine = Math.min(position.lineNumber, lineCount); + const lineLength = model?.getLineMaxColumn(clampedLine) || 1; + const clampedColumn = Math.min(position.column, lineLength); + editor.setPosition({ lineNumber: clampedLine, column: clampedColumn }); + } + if (selections && selections.length > 0) { + // Selections may be invalid after content change, wrap in try-catch + try { + editor.setSelections(selections); + } catch { + // Ignore invalid selections + } + } + editor.setScrollPosition({ scrollTop, scrollLeft }); + } + } } diff --git a/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts b/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts index 68913aa..c6a255b 100644 --- a/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts +++ b/ts_web/elements/00group-workspace/dees-workspace/dees-workspace.ts @@ -26,6 +26,9 @@ import { DeesWorkspaceMonaco } from '../dees-workspace-monaco/dees-workspace-mon import { TypeScriptIntelliSenseManager } from './typescript-intellisense.js'; import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; import '@design.estate/dees-wcctools/demotools'; +import '../../dees-actionbar/dees-actionbar.js'; +import type { DeesActionbar } from '../../dees-actionbar/dees-actionbar.js'; +import '../dees-workspace-diff-editor/dees-workspace-diff-editor.js'; declare global { interface HTMLElementTagNameMap { @@ -254,6 +257,11 @@ testSmartPromise(); private nodeModulesDebounceTimeout: ReturnType | null = null; private intelliSenseDebounceTimeout: ReturnType | null = null; + // Open file watchers for external change detection + private openFileWatchers: Map = new Map(); + private fileChangeDebounce: Map> = new Map(); + private actionbarElement: DeesActionbar | null = null; + // Auto-save functionality @state() accessor autoSave: boolean = false; @@ -279,6 +287,18 @@ testSmartPromise(); @state() accessor isDraggingTerminal: boolean = false; + // Diff view state + @state() + accessor showDiffView: boolean = false; + + @state() + accessor diffViewConfig: { + filePath: string; + originalContent: string; + modifiedContent: string; + language: string; + } | null = null; + // Keyboard shortcut handler (bound for proper cleanup) private keydownHandler = (e: KeyboardEvent) => { // Cmd+S (Mac) or Ctrl+S (Windows/Linux) - Save @@ -914,7 +934,16 @@ testSmartPromise();
- ${this.openFiles.length === 0 ? html` + ${this.showDiffView && this.diffViewConfig ? html` + { this.showDiffView = false; this.diffViewConfig = null; }} + > + ` : this.openFiles.length === 0 ? html`
Select a file to edit @@ -992,6 +1021,9 @@ testSmartPromise(); .executionEnvironment=${this.executionEnvironment} @run-process=${this.handleRunProcess} > + + +
`; } @@ -1016,6 +1048,7 @@ testSmartPromise(); this.autoSaveInterval = null; } this.stopNodeModulesWatcher(); + this.stopAllFileWatchers(); } public async firstUpdated() { @@ -1023,6 +1056,9 @@ testSmartPromise(); this.currentFileTreeWidth = this.fileTreeWidth; this.currentTerminalHeight = this.terminalHeight; + // Get actionbar reference for file change notifications + this.actionbarElement = this.shadowRoot?.querySelector('dees-actionbar') as DeesActionbar; + if (this.executionEnvironment) { await this.initializeWorkspace(); } @@ -1186,6 +1222,191 @@ testSmartPromise(); } } + // ========== Open File Watching for External Changes ========== + + /** + * Start watching an open file for external changes + */ + private startWatchingFile(path: string): void { + if (!this.executionEnvironment || this.openFileWatchers.has(path)) return; + + try { + const watcher = this.executionEnvironment.watch( + path, + (_event, _filename) => { + // Debounce to avoid multiple rapid triggers + const existingTimeout = this.fileChangeDebounce.get(path); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + const timeout = setTimeout(() => { + this.handleExternalFileChange(path); + this.fileChangeDebounce.delete(path); + }, 300); + this.fileChangeDebounce.set(path, timeout); + } + ); + this.openFileWatchers.set(path, watcher); + } catch (error) { + console.warn(`Could not watch file ${path}:`, error); + } + } + + /** + * Stop watching a file when it's closed + */ + private stopWatchingFile(path: string): void { + const watcher = this.openFileWatchers.get(path); + if (watcher) { + watcher.stop(); + this.openFileWatchers.delete(path); + } + const timeout = this.fileChangeDebounce.get(path); + if (timeout) { + clearTimeout(timeout); + this.fileChangeDebounce.delete(path); + } + } + + /** + * Stop all file watchers + */ + private stopAllFileWatchers(): void { + for (const watcher of this.openFileWatchers.values()) { + watcher.stop(); + } + this.openFileWatchers.clear(); + + for (const timeout of this.fileChangeDebounce.values()) { + clearTimeout(timeout); + } + this.fileChangeDebounce.clear(); + } + + /** + * Handle external file change - show actionbar if file has local changes, + * otherwise silently update with cursor preservation + */ + private async handleExternalFileChange(path: string): Promise { + const file = this.openFiles.find(f => f.path === path); + if (!file || !this.executionEnvironment) return; + + try { + // Read the new content from disk + const newContent = await this.executionEnvironment.readFile(path); + + // If content is same as what we have, no action needed + if (newContent === file.content) return; + + if (file.modified) { + // File has unsaved local changes AND disk changed - conflict! + const result = await this.actionbarElement?.show({ + message: `"${file.name}" changed on disk. What do you want to do?`, + type: 'question', + icon: 'lucide:gitMerge', + actions: [ + { id: 'load-disk', label: 'Load from Disk', primary: true }, + { id: 'save-local', label: 'Save Local to Disk' }, + { id: 'compare', label: 'Compare' }, + ], + timeout: { duration: 15000, defaultActionId: 'load-disk' }, + dismissible: true, + }); + + if (result?.actionId === 'load-disk') { + // Discard local changes, load disk version + await this.updateFileContent(path, newContent, false); + } else if (result?.actionId === 'save-local') { + // Keep local changes and save to disk (overwrite external) + await this.executionEnvironment.writeFile(path, file.content); + // Mark as saved + this.openFiles = this.openFiles.map(f => + f.path === path ? { ...f, modified: false } : f + ); + } else if (result?.actionId === 'compare') { + // Open diff view + this.openDiffView(path, file.content, newContent); + } + // If dismissed, do nothing - user can manually resolve later + } else { + // No local changes - silently update with cursor preservation + await this.updateFileContent(path, newContent, true); + } + } catch (error) { + console.warn(`Failed to handle external change for ${path}:`, error); + } + } + + /** + * Update file content in state and optionally in the editor + */ + private async updateFileContent( + path: string, + newContent: string, + preserveCursor: boolean + ): Promise { + // Update internal state + this.openFiles = this.openFiles.map(f => + f.path === path ? { ...f, content: newContent, modified: false } : f + ); + + // If this is the active file, update Monaco editor + if (path === this.activeFilePath) { + const editor = this.shadowRoot?.querySelector('dees-workspace-monaco') as DeesWorkspaceMonaco; + if (editor) { + await editor.setContentExternal(newContent, preserveCursor); + } + } + } + + /** + * Open the diff view to compare local and disk versions + */ + private openDiffView(path: string, localContent: string, diskContent: string): void { + this.diffViewConfig = { + filePath: path, + originalContent: diskContent, + modifiedContent: localContent, + language: this.getLanguageFromPath(path), + }; + this.showDiffView = true; + } + + /** + * Handle diff view resolution + */ + private async handleDiffResolved(e: CustomEvent): Promise { + const { action, content } = e.detail; + const path = this.diffViewConfig?.filePath; + + if (!path || !this.executionEnvironment) { + this.showDiffView = false; + this.diffViewConfig = null; + return; + } + + if (action === 'use-local') { + // Save local content to disk + await this.executionEnvironment.writeFile(path, content); + this.openFiles = this.openFiles.map(f => + f.path === path ? { ...f, content, modified: false } : f + ); + // Update editor if active + if (path === this.activeFilePath) { + const editor = this.shadowRoot?.querySelector('dees-workspace-monaco') as DeesWorkspaceMonaco; + if (editor) { + await editor.setContentExternal(content, false); + } + } + } else if (action === 'use-disk') { + // Update editor with disk content + await this.updateFileContent(path, content, false); + } + + this.showDiffView = false; + this.diffViewConfig = null; + } + private async handleFileSelect(e: CustomEvent<{ path: string; name: string }>) { const { path, name } = e.detail; await this.openFile(path, name); @@ -1210,6 +1431,9 @@ testSmartPromise(); ]; this.activeFilePath = path; + // Start watching for external changes + this.startWatchingFile(path); + // Initialize IntelliSense lazily after first file opens (Monaco loads on demand) if (!this.intelliSenseInitialized) { // Wait for Monaco editor to mount and load Monaco from CDN @@ -1246,6 +1470,9 @@ testSmartPromise(); if (!confirmed) return; } + // Stop watching this file + this.stopWatchingFile(path); + this.openFiles = this.openFiles.filter(f => f.path !== path); // If closing the active file, activate another one diff --git a/ts_web/elements/00group-workspace/index.ts b/ts_web/elements/00group-workspace/index.ts index 5b217f5..261227a 100644 --- a/ts_web/elements/00group-workspace/index.ts +++ b/ts_web/elements/00group-workspace/index.ts @@ -7,3 +7,4 @@ export * from './dees-workspace-terminal-preview/index.js'; export * from './dees-workspace-markdown/index.js'; export * from './dees-workspace-markdownoutlet/index.js'; export * from './dees-workspace-bottombar/index.js'; +export * from './dees-workspace-diff-editor/index.js';