Skip to content

Commit 0542d1a

Browse files
authored
feat: Added file watcher & auto update modified opened files in Dev Tab (#1802)
* fix dev tab scroll bug & added file watcher functionalities for opened files in dev tab
1 parent 5309b94 commit 0542d1a

File tree

7 files changed

+632
-65
lines changed

7 files changed

+632
-65
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { subscribe, type AsyncSubscription } from '@parcel/watcher';
2+
import { MainChannels } from '@onlook/models/constants';
3+
import * as pathModule from 'path';
4+
import fs from 'fs';
5+
import { mainWindow } from '../index';
6+
import { readFile } from './files';
7+
import { debounce } from 'lodash';
8+
9+
export class FileWatcher {
10+
private subscriptions: Map<string, AsyncSubscription> = new Map();
11+
private selfModified: Set<string> = new Set();
12+
private fileContents: Map<string, string> = new Map();
13+
14+
async watchFile(filePath: string) {
15+
if (!fs.existsSync(filePath)) {
16+
console.error(`File does not exist: ${filePath}`);
17+
return false;
18+
}
19+
20+
// If already watching this file, no need to create a new subscription
21+
if (this.subscriptions.has(filePath)) {
22+
return true;
23+
}
24+
25+
try {
26+
// Caches the initial file content
27+
const initialContent = await readFile(filePath);
28+
if (initialContent !== null) {
29+
this.fileContents.set(filePath, initialContent);
30+
}
31+
32+
// Watch the directory containing the file
33+
const dirPath = pathModule.dirname(filePath);
34+
35+
const normalizedPath = pathModule.normalize(filePath);
36+
37+
const subscription = await subscribe(
38+
dirPath,
39+
(err, events) => {
40+
if (err) {
41+
console.error(`File watcher error: ${err}`);
42+
return;
43+
}
44+
45+
if (events.length > 0) {
46+
for (const event of events) {
47+
const eventPath = pathModule.normalize(event.path);
48+
49+
// Skip if this change was made by our application
50+
if (this.selfModified.has(eventPath)) {
51+
this.selfModified.delete(eventPath);
52+
continue;
53+
}
54+
55+
// If the watched file was updated
56+
if (
57+
eventPath === normalizedPath &&
58+
(event.type === 'update' || event.type === 'create')
59+
) {
60+
this.debouncedNotifyFileChanged(filePath);
61+
}
62+
}
63+
}
64+
},
65+
{
66+
ignore: ['**/node_modules/**', '**/.git/**'],
67+
},
68+
);
69+
70+
this.subscriptions.set(filePath, subscription);
71+
return true;
72+
} catch (error) {
73+
console.error('Error setting up file watcher:', error);
74+
return false;
75+
}
76+
}
77+
78+
// This prevent multiple notifications for a single save event
79+
private debouncedNotifyFileChanged = debounce(async (filePath: string) => {
80+
await this.notifyFileChanged(filePath);
81+
}, 300);
82+
83+
private async notifyFileChanged(filePath: string) {
84+
try {
85+
if (!fs.existsSync(filePath)) {
86+
console.warn(`Cannot read changed file that no longer exists: ${filePath}`);
87+
return;
88+
}
89+
90+
// Read the new content of the file
91+
const content = await readFile(filePath);
92+
if (content === null) {
93+
console.warn(`Failed to read content for file: ${filePath}`);
94+
return;
95+
}
96+
97+
// Compare with cached content to see if it actually changed
98+
const cachedContent = this.fileContents.get(filePath);
99+
if (cachedContent === content) {
100+
return;
101+
}
102+
103+
// Update cache
104+
this.fileContents.set(filePath, content);
105+
106+
// Notifies the UI about the file change
107+
if (mainWindow?.webContents && !mainWindow.isDestroyed()) {
108+
mainWindow.webContents.send(MainChannels.FILE_CHANGED, {
109+
path: filePath,
110+
content,
111+
});
112+
}
113+
} catch (error) {
114+
console.error('Error reading changed file:', error);
115+
}
116+
}
117+
118+
markFileAsModified(filePath: string) {
119+
const normalizedPath = pathModule.normalize(filePath);
120+
this.selfModified.add(normalizedPath);
121+
122+
// When we mark a file as modified, we also update our content cache
123+
// to avoid unnecessary notifications
124+
setTimeout(async () => {
125+
try {
126+
if (fs.existsSync(filePath)) {
127+
const content = await readFile(filePath);
128+
if (content !== null) {
129+
this.fileContents.set(filePath, content);
130+
}
131+
}
132+
} catch (error) {
133+
console.error('Error updating cached content after modification:', error);
134+
}
135+
}, 500);
136+
}
137+
138+
unwatchFile(filePath: string) {
139+
const subscription = this.subscriptions.get(filePath);
140+
if (subscription) {
141+
subscription.unsubscribe().catch((err) => {
142+
console.error('Error unsubscribing from file watcher:', err);
143+
});
144+
this.subscriptions.delete(filePath);
145+
this.fileContents.delete(filePath);
146+
}
147+
}
148+
149+
async clearAllSubscriptions() {
150+
for (const [filePath, subscription] of this.subscriptions.entries()) {
151+
try {
152+
await subscription.unsubscribe();
153+
} catch (error) {
154+
console.error(`Error unsubscribing from watcher for ${filePath}:`, error);
155+
}
156+
}
157+
this.subscriptions.clear();
158+
this.fileContents.clear();
159+
}
160+
}
161+
162+
export const fileWatcher = new FileWatcher();

apps/studio/electron/main/events/code.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { extractComponentsFromDirectory } from '../code/components';
2222
import { getCodeDiffs } from '../code/diff';
2323
import { isChildTextEditable } from '../code/diff/text';
2424
import { readFile } from '../code/files';
25+
import { fileWatcher } from '../code/fileWatcher';
2526
import { getTemplateNodeProps } from '../code/props';
2627
import { getTemplateNodeChild } from '../code/templateNode';
2728
import runManager from '../run';
@@ -195,4 +196,21 @@ export function listenForCodeMessages() {
195196
const { projectRoot } = args;
196197
return fontFileWatcher.watch(projectRoot);
197198
});
199+
200+
ipcMain.handle(MainChannels.WATCH_FILE, async (e: Electron.IpcMainInvokeEvent, args) => {
201+
const { filePath } = args as { filePath: string };
202+
return fileWatcher.watchFile(filePath);
203+
});
204+
205+
ipcMain.handle(MainChannels.UNWATCH_FILE, (e: Electron.IpcMainInvokeEvent, args) => {
206+
const { filePath } = args as { filePath: string };
207+
fileWatcher.unwatchFile(filePath);
208+
return true;
209+
});
210+
211+
ipcMain.handle(MainChannels.MARK_FILE_MODIFIED, (e: Electron.IpcMainInvokeEvent, args) => {
212+
const { filePath } = args as { filePath: string };
213+
fileWatcher.markFileAsModified(filePath);
214+
return true;
215+
});
198216
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from 'react';
2+
import { Button } from '@onlook/ui/button';
3+
import { Icons } from '@onlook/ui/icons';
4+
5+
interface FileConflictAlertProps {
6+
filename: string;
7+
onUseExternalChanges: () => void;
8+
onKeepLocalChanges: () => void;
9+
}
10+
11+
export const FileConflictAlert: React.FC<FileConflictAlertProps> = ({
12+
filename,
13+
onUseExternalChanges,
14+
onKeepLocalChanges,
15+
}) => {
16+
return (
17+
<div className="bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800 px-4 py-3 flex items-center justify-between">
18+
<div className="flex items-center space-x-2">
19+
<Icons.File className="text-amber-500 h-5 w-5" />
20+
<span className="text-sm">
21+
<strong>{filename}</strong> has been modified outside the editor.
22+
</span>
23+
</div>
24+
<div className="flex items-center space-x-2">
25+
<Button variant="outline" size="sm" onClick={onKeepLocalChanges}>
26+
Keep my changes
27+
</Button>
28+
<Button variant="default" size="sm" onClick={onUseExternalChanges}>
29+
Use external changes
30+
</Button>
31+
</div>
32+
</div>
33+
);
34+
};

0 commit comments

Comments
 (0)