Skip to content

Commit f09d048

Browse files
committed
add editor's file explorer
1 parent 9f2ca2e commit f09d048

9 files changed

Lines changed: 204 additions & 40 deletions

File tree

electron/main.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
app,
33
BrowserWindow,
44
Menu,
5+
dialog,
56
ipcMain,
67
type MenuItemConstructorOptions,
78
} from "electron";
@@ -30,6 +31,8 @@ let lspPort: number | null = null;
3031
let projectWatchers: Map<string, fs.FSWatcher> | null = null;
3132
let projectWatchRefreshTimer: NodeJS.Timeout | null = null;
3233
let projectWatchInitPromise: Promise<void> | null = null;
34+
const unsavedChangesByWebContents = new Map<number, boolean>();
35+
let allowWindowClose = false;
3336

3437
if (app.name !== APP_NAME) {
3538
app.setName(APP_NAME);
@@ -777,7 +780,31 @@ async function createWindow() {
777780
await mainWindow.loadFile(indexPath);
778781
}
779782

783+
allowWindowClose = false;
784+
const webContentsId = mainWindow.webContents.id;
785+
mainWindow.on("close", (event) => {
786+
if (allowWindowClose) return;
787+
const hasUnsaved = unsavedChangesByWebContents.get(webContentsId);
788+
if (!hasUnsaved) return;
789+
event.preventDefault();
790+
const choice = dialog.showMessageBoxSync(mainWindow as BrowserWindow, {
791+
type: "warning",
792+
buttons: ["Cancel", "Discard Changes"],
793+
defaultId: 0,
794+
cancelId: 0,
795+
title: "Unsaved Changes",
796+
message: "You have unsaved changes. Discard them and close?",
797+
detail: "Any unsaved edits will be lost.",
798+
});
799+
if (choice === 1) {
800+
allowWindowClose = true;
801+
unsavedChangesByWebContents.set(webContentsId, false);
802+
mainWindow?.destroy();
803+
}
804+
});
805+
780806
mainWindow.on("closed", () => {
807+
unsavedChangesByWebContents.delete(webContentsId);
781808
mainWindow = null;
782809
});
783810
}
@@ -1014,6 +1041,10 @@ function setupEditorIpc() {
10141041
return WORKSPACE_ROOT;
10151042
});
10161043

1044+
ipcMain.on("editor:setUnsavedChanges", (event, hasUnsaved: boolean) => {
1045+
unsavedChangesByWebContents.set(event.sender.id, Boolean(hasUnsaved));
1046+
});
1047+
10171048
ipcMain.handle("editor:watchProject", async () => {
10181049
await startProjectWatchers();
10191050
});

electron/preload.cts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("editorAPI", {
1515
findClipLabel: (label: string) => ipcRenderer.invoke("editor:findClipLabel", label),
1616
getLspPort: () => ipcRenderer.invoke("editor:getLspPort"),
1717
getProjectRoot: () => ipcRenderer.invoke("editor:getProjectRoot"),
18+
setUnsavedChanges: (hasUnsaved: boolean) => ipcRenderer.send("editor:setUnsavedChanges", hasUnsaved),
1819
watchProject: () => ipcRenderer.invoke("editor:watchProject"),
1920
unwatchProject: () => ipcRenderer.invoke("editor:unwatchProject"),
2021
onProjectFilesChanged: (handler: (payload: { type: string; path: string }) => void) => {

src/StudioApp.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,11 +304,17 @@ export const StudioApp = () => {
304304
</div>
305305
</div>
306306

307-
{isEditorVisible ? (
308-
<div style={{ width: editorWidth, minWidth: editorMinWidth, height: "100%", minHeight: 0, display: "flex" }}>
309-
<CodeEditor width={editorWidth} onWidthChange={clampEditorWidth} />
310-
</div>
311-
) : null}
307+
<div
308+
style={{
309+
width: editorWidth,
310+
minWidth: editorMinWidth,
311+
height: "100%",
312+
minHeight: 0,
313+
display: isEditorVisible ? "flex" : "none",
314+
}}
315+
>
316+
<CodeEditor width={editorWidth} onWidthChange={clampEditorWidth} />
317+
</div>
312318
</div>
313319
</div>
314320
</WithCurrentFrame>

src/ui/clip-visibility.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export const ClipVisibilityContent = () => {
102102
gap: 8,
103103
height: "100%",
104104
minHeight: 0,
105+
width: "100%",
106+
minWidth: 0,
105107
color: "#e5e7eb",
106108
}}
107109
>
@@ -172,9 +174,11 @@ export const ClipVisibilityContent = () => {
172174
}}
173175
style={{
174176
flex: "1 1 auto",
175-
whiteSpace: "nowrap",
176-
overflow: "hidden",
177-
textOverflow: "ellipsis",
177+
display: "flex",
178+
alignItems: "center",
179+
gap: 8,
180+
width: "100%",
181+
minWidth: 0,
178182
background: "transparent",
179183
border: "none",
180184
padding: 0,
@@ -185,7 +189,26 @@ export const ClipVisibilityContent = () => {
185189
fontSize: "inherit",
186190
}}
187191
>
188-
{label}
192+
<span
193+
style={{
194+
flex: "1 1 auto",
195+
whiteSpace: "nowrap",
196+
overflow: "hidden",
197+
textOverflow: "ellipsis",
198+
}}
199+
>
200+
{label}
201+
</span>
202+
<span
203+
aria-hidden="true"
204+
style={{
205+
color: canJump ? "#94a3b8" : "#475569",
206+
fontSize: 12,
207+
paddingRight: 6,
208+
}}
209+
>
210+
{canJump ? ">" : ""}
211+
</span>
189212
</button>
190213
</label>
191214
)

src/ui/code-editor.tsx

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ type JumpTarget = {
4646
line: number;
4747
column?: number;
4848
};
49+
type BufferEntry = {
50+
content: string;
51+
isDirty: boolean;
52+
};
4953

5054
const toFilePath = (filePath: string) => {
5155
if (!filePath.startsWith("file:")) return filePath;
@@ -160,10 +164,12 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
160164
const lspClientRef = useRef<MonacoLanguageClient | null>(null);
161165
const lspStartingRef = useRef(false);
162166
const resizerRef = useRef<HTMLDivElement>(null);
163-
const { registerEditor } = useEditor();
167+
const { registerEditor, setCurrentFile: setCurrentFileContext } = useEditor();
164168
const loadIdRef = useRef(0);
165169
const pendingJumpRef = useRef<JumpTarget | null>(null);
166170
const currentFileRef = useRef<string>(currentFile);
171+
const bufferCacheRef = useRef<Map<string, BufferEntry>>(new Map());
172+
const isApplyingRef = useRef(false);
167173
const openFileRef = useRef<
168174
((filePath: string, line?: number, column?: number) => Promise<void>) | null
169175
>(null);
@@ -175,6 +181,39 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
175181
currentFileRef.current = currentFile;
176182
}, [currentFile]);
177183

184+
useEffect(() => {
185+
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
186+
const hasDirty = Array.from(bufferCacheRef.current.values()).some((entry) => entry.isDirty);
187+
if (!hasDirty) return;
188+
event.preventDefault();
189+
event.returnValue = "";
190+
};
191+
window.addEventListener("beforeunload", handleBeforeUnload);
192+
return () => {
193+
window.removeEventListener("beforeunload", handleBeforeUnload);
194+
};
195+
}, []);
196+
197+
const notifyUnsavedChanges = useCallback(() => {
198+
if (!window.editorAPI?.setUnsavedChanges) return;
199+
const hasDirty = Array.from(bufferCacheRef.current.values()).some((entry) => entry.isDirty);
200+
window.editorAPI.setUnsavedChanges(hasDirty);
201+
}, []);
202+
203+
const applyBuffer = useCallback((fileUri: string, content: string, dirty: boolean) => {
204+
isApplyingRef.current = true;
205+
setCode(content);
206+
setCurrentFile(fileUri);
207+
setIsDirty(dirty);
208+
currentFileRef.current = fileUri;
209+
bufferCacheRef.current.set(fileUri, { content, isDirty: dirty });
210+
notifyUnsavedChanges();
211+
setCurrentFileContext(fileUri);
212+
queueMicrotask(() => {
213+
isApplyingRef.current = false;
214+
});
215+
}, [notifyUnsavedChanges, setCurrentFileContext]);
216+
178217
const readFile = useCallback(async (filePath: string) => {
179218
const rawPath = toFilePath(filePath);
180219
if (window.editorAPI?.readFileOptional) {
@@ -217,19 +256,21 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
217256
const { content, path } = payload;
218257
if (loadId !== loadIdRef.current) return;
219258
const fileUri = toFileUri(path);
220-
setCode(content);
221-
setCurrentFile(fileUri);
222-
setIsDirty(false);
259+
const cached = bufferCacheRef.current.get(fileUri);
260+
const shouldUseCache = cached?.isDirty === true;
261+
const nextContent = shouldUseCache && cached ? cached.content : content;
262+
const nextDirty = shouldUseCache;
263+
applyBuffer(fileUri, nextContent, nextDirty);
223264
const editor = editorRef.current;
224265
const monacoApi = monacoRef.current;
225266
if (editor && monacoApi) {
226267
const uri = monacoApi.Uri.parse(fileUri);
227268
let model = monacoApi.editor.getModel(uri);
228269
const languageId = getLanguageId(fileUri);
229270
if (!model) {
230-
model = monacoApi.editor.createModel(content, languageId, uri);
231-
} else if (model.getValue() !== content) {
232-
model.setValue(content);
271+
model = monacoApi.editor.createModel(nextContent, languageId, uri);
272+
} else if (model.getValue() !== nextContent) {
273+
model.setValue(nextContent);
233274
}
234275
if (editor.getModel()?.uri.toString() !== uri.toString()) {
235276
editor.setModel(model);
@@ -244,7 +285,7 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
244285
setIsLoading(false);
245286
}
246287
}
247-
}, [readFile]);
288+
}, [applyBuffer, readFile]);
248289

249290
const revealLine = useCallback((line: number) => {
250291
const editor = editorRef.current;
@@ -488,24 +529,32 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
488529
const textModel = model?.textEditorModel ?? null;
489530
const target = textModel?.uri?.toString();
490531
if (target) {
532+
const cached = bufferCacheRef.current.get(target);
533+
const cachedDirty = cached?.isDirty === true;
534+
const cachedContent = cached?.content;
491535
const selection = extractSelection(options);
492536
if (!selection && options) {
493537
logNavigationIssue("Missing selection for editor navigation", options);
494538
}
495539
const editor = editorRef.current;
496540
if (textModel && editor) {
497-
editor.setModel(textModel as unknown as MonacoEditor.editor.ITextModel);
498-
const content = textModel.getValue();
499-
setCode(content);
500-
setCurrentFile(target);
501-
setIsDirty(false);
502-
currentFileRef.current = target;
541+
if (cachedDirty && cachedContent != null) {
542+
applyBuffer(target, cachedContent, true);
543+
editor.setModel(textModel as unknown as MonacoEditor.editor.ITextModel);
544+
if (textModel.getValue() !== cachedContent) {
545+
textModel.setValue(cachedContent);
546+
}
547+
} else {
548+
const content = textModel.getValue();
549+
applyBuffer(target, content, false);
550+
editor.setModel(textModel as unknown as MonacoEditor.editor.ITextModel);
551+
}
503552
if (selection) {
504553
revealPosition(selection.line, selection.column);
505554
}
506555
return editor as unknown as MonacoEditor.editor.ICodeEditor;
507556
}
508-
const content = textModel?.getValue();
557+
const content = cachedDirty && cachedContent != null ? cachedContent : textModel?.getValue();
509558
if (content != null) {
510559
await openFileWithContentRef.current?.(target, content, selection?.line, selection?.column);
511560
} else {
@@ -546,7 +595,7 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
546595
throw error;
547596
});
548597
return vscodeInitRef.current;
549-
}, [ensureFileSystemProvider]);
598+
}, [applyBuffer, ensureFileSystemProvider, revealPosition]);
550599

551600
useEffect(() => {
552601
let cancelled = false;
@@ -770,6 +819,10 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
770819
}
771820
}
772821
setIsDirty(false);
822+
if (currentFileRef.current) {
823+
bufferCacheRef.current.set(currentFileRef.current, { content: code, isDirty: false });
824+
}
825+
notifyUnsavedChanges();
773826

774827
// Trigger hot reload
775828
if (import.meta.hot) {
@@ -920,8 +973,14 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
920973
language={languageId}
921974
value={code}
922975
onChange={(value) => {
923-
setCode(value || "");
976+
if (isApplyingRef.current) return;
977+
const nextValue = value || "";
978+
setCode(nextValue);
924979
setIsDirty(true);
980+
if (currentFileRef.current) {
981+
bufferCacheRef.current.set(currentFileRef.current, { content: nextValue, isDirty: true });
982+
}
983+
notifyUnsavedChanges();
925984
}}
926985
beforeMount={configureMonaco}
927986
onMount={handleEditorDidMount}

src/ui/editor-context.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ interface EditorContextValue {
1111
jumpToLine: (line: number) => void;
1212
jumpToMatch: (needle: string) => void;
1313
registerEditor: (api: EditorApi) => void;
14+
currentFile: string | null;
15+
setCurrentFile: (filePath: string | null) => void;
1416
}
1517

1618
const EditorContext = createContext<EditorContextValue | null>(null);
1719

1820
export const EditorProvider = ({ children }: { children: ReactNode }) => {
1921
const [editorApi, setEditorApi] = useState<EditorApi | null>(null);
22+
const [currentFile, setCurrentFile] = useState<string | null>(null);
2023

2124
const openFile = useCallback((filePath: string, line?: number, column?: number) => {
2225
editorApi?.openFile(filePath, line, column);
@@ -35,7 +38,7 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => {
3538
}, []);
3639

3740
return (
38-
<EditorContext.Provider value={{ openFile, jumpToLine, jumpToMatch, registerEditor }}>
41+
<EditorContext.Provider value={{ openFile, jumpToLine, jumpToMatch, registerEditor, currentFile, setCurrentFile }}>
3942
{children}
4043
</EditorContext.Provider>
4144
);

0 commit comments

Comments
 (0)