diff --git a/src/components/editor/EditorPanel.tsx b/src/components/editor/EditorPanel.tsx
index 4c67008..b3320ef 100644
--- a/src/components/editor/EditorPanel.tsx
+++ b/src/components/editor/EditorPanel.tsx
@@ -1,4 +1,4 @@
-import { Show, createMemo, type JSX } from "solid-js";
+import { Show, createMemo, createEffect, type JSX } from "solid-js";
import { MultiBuffer } from "./MultiBuffer";
import { useEditor } from "@/context/EditorContext";
import { EditorSkeleton } from "./EditorSkeleton";
@@ -22,14 +22,19 @@ import { tokens } from "@/design-system/tokens";
*/
export function EditorPanel() {
const { state } = useEditor();
-
+
const hasOpenFiles = createMemo(() => state.openFiles.length > 0);
const showEditor = createMemo(() => !state.isOpening && hasOpenFiles());
+ // Diagnostic logging to trace editor loading flow
+ createEffect(() => {
+ console.warn("[EditorPanel] isOpening:", state.isOpening, "| hasOpenFiles:", hasOpenFiles(), "| showEditor:", showEditor(), "| openFiles count:", state.openFiles.length);
+ });
+
return (
-
diff --git a/src/components/editor/LazyEditor.tsx b/src/components/editor/LazyEditor.tsx
index 5d0685b..f44494f 100644
--- a/src/components/editor/LazyEditor.tsx
+++ b/src/components/editor/LazyEditor.tsx
@@ -4,17 +4,17 @@
* Background tabs have their Monaco model preserved but the editor DOM
* is unmounted to reduce memory pressure and DOM node count.
*
+ * Uses a direct import of CodeEditor instead of SolidJS lazy()+Suspense
+ * because the lazy chunk import can silently hang on WebKitGTK (Linux/Tauri),
+ * leaving the Suspense fallback showing forever with no timeout or error.
+ *
* Usage in EditorGroupPanel (MultiBuffer.tsx):
*
*/
-import { Show, createSignal, createEffect, onCleanup, lazy, Suspense } from "solid-js";
+import { Show, createSignal, createEffect, onCleanup } from "solid-js";
import type { OpenFile } from "@/context/EditorContext";
-import { EditorSkeleton } from "./EditorSkeleton";
-
-const CodeEditorLazy = lazy(() =>
- import("./CodeEditor").then((m) => ({ default: m.CodeEditor })),
-);
+import { CodeEditor } from "./CodeEditor";
export interface LazyEditorProps {
file: OpenFile;
@@ -27,10 +27,13 @@ const mountedModels = new Set();
export function LazyEditor(props: LazyEditorProps) {
const [wasEverActive, setWasEverActive] = createSignal(props.isActive);
+ console.warn("[LazyEditor] Created for file:", props.file.name, "| isActive:", props.isActive, "| wasEverActive:", wasEverActive());
+
createEffect(() => {
if (props.isActive) {
setWasEverActive(true);
mountedModels.add(props.file.id);
+ console.warn("[LazyEditor] File became active:", props.file.name, "| wasEverActive set to true");
}
});
@@ -51,9 +54,7 @@ export function LazyEditor(props: LazyEditorProps) {
data-active={props.isActive}
>
- }>
-
-
+
);
diff --git a/src/components/editor/MultiBuffer.tsx b/src/components/editor/MultiBuffer.tsx
index bafb3d2..9bcf8e2 100644
--- a/src/components/editor/MultiBuffer.tsx
+++ b/src/components/editor/MultiBuffer.tsx
@@ -1,6 +1,5 @@
import { Show, Suspense, createSignal, createMemo, createEffect, onMount, onCleanup, JSX, lazy, For } from "solid-js";
import { useEditor, SplitDirection, OpenFile, EditorGroup } from "@/context/EditorContext";
-import { CodeEditor } from "./CodeEditor";
import { LazyEditor } from "./LazyEditor";
import { TabBar } from "./TabBar";
import { ImageViewer, isImageFile, SVGPreview, isSVGFile } from "../viewers";
@@ -9,6 +8,11 @@ import { Card, Text } from "@/components/ui";
import { safeGetItem, safeSetItem } from "@/utils/safeStorage";
import "@/styles/animations.css";
+// Lazy load CodeEditor - avoids pulling all editor dependencies into the initial chunk
+const CodeEditorLazy = lazy(() =>
+ import("./CodeEditor").then((m) => ({ default: m.CodeEditor })),
+);
+
// Lazy load DiffEditor for better performance - only loaded when needed
const DiffEditorLazy = lazy(() => import("./DiffEditor"));
@@ -278,7 +282,9 @@ function FileViewer(props: FileViewerProps) {
when={isNonSvgImage()}
fallback={
-
+ Loading editor...
}>
+
+
}
>
@@ -466,13 +472,15 @@ export function DiffView(props: DiffViewProps) {
>
{props.leftFile.name} (Original)
-
+ Loading...}>
+
+
)}
second={() => (
-
{props.rightFile.name} (Modified)
-
+
Loading... }>
+
+
)}
/>
diff --git a/src/components/editor/core/EditorInstance.tsx b/src/components/editor/core/EditorInstance.tsx
index fbfa798..70bb7f3 100644
--- a/src/components/editor/core/EditorInstance.tsx
+++ b/src/components/editor/core/EditorInstance.tsx
@@ -56,7 +56,7 @@ export function getMonacoInstance(): typeof Monaco | null {
export interface CreateEditorInstanceResult {
editor: Accessor;
monaco: Accessor;
- containerRef: HTMLDivElement | undefined;
+ containerRef: Accessor;
setContainerRef: (el: HTMLDivElement) => void;
isLoading: Accessor;
activeFile: Accessor;
@@ -79,12 +79,15 @@ export function createEditorInstance(props: {
getEffectiveEditorSettings,
} = useSettings();
- let containerRef: HTMLDivElement | undefined;
+ const [containerRef, setContainerRef] = createSignal(undefined);
let editorRef: Monaco.editor.IStandaloneCodeEditor | null = null;
let isDisposed = false;
let currentFileId: string | null = null;
let currentFilePath: string | null = null;
let editorInitialized = false;
+ let monacoLoadAttempts = 0;
+ const MAX_MONACO_LOAD_ATTEMPTS = 2;
+ const MONACO_LOAD_TIMEOUT_MS = 15000;
const [isLoading, setIsLoading] = createSignal(true);
const [currentEditor, setCurrentEditor] =
@@ -104,14 +107,27 @@ export function createEditorInstance(props: {
onMount(async () => {
const file = activeFile();
+ console.warn("[EditorInstance] onMount fired | file:", file?.name, "| monacoLoaded:", monacoManager.isLoaded(), "| loadState:", monacoManager.getLoadState());
if (!file) {
+ console.warn("[EditorInstance] No file, setting isLoading=false");
setIsLoading(false);
return;
}
if (!monacoManager.isLoaded()) {
try {
- const monaco = await monacoManager.ensureLoaded();
+ monacoLoadAttempts++;
+ console.warn("[EditorInstance] Starting Monaco load, attempt:", monacoLoadAttempts);
+ // Add timeout to prevent permanent loading spinner
+ const loadPromise = monacoManager.ensureLoaded();
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("Monaco loading timed out")),
+ MONACO_LOAD_TIMEOUT_MS,
+ ),
+ );
+ const monaco = await Promise.race([loadPromise, timeoutPromise]);
+ console.warn("[EditorInstance] Monaco loaded successfully");
monacoInstance = monaco;
setCurrentMonaco(monaco);
@@ -122,15 +138,20 @@ export function createEditorInstance(props: {
});
}
} catch (error) {
- console.error("Failed to load Monaco editor:", error);
+ console.error("[EditorInstance] Failed to load Monaco:", error);
+ // Reset the MonacoManager state so retries start fresh instead of
+ // returning the same dead promise that timed out.
+ monacoManager.resetLoadState();
setIsLoading(false);
return;
}
} else {
+ console.warn("[EditorInstance] Monaco already loaded");
monacoInstance = monacoManager.getMonaco();
setCurrentMonaco(monacoInstance);
}
+ console.warn("[EditorInstance] Setting isLoading=false");
setIsLoading(false);
});
@@ -142,12 +163,28 @@ export function createEditorInstance(props: {
const fileId = file?.id || null;
const filePath = file?.path || null;
- if (!containerRef || isLoading()) return;
+ const container = containerRef();
+ console.warn("[EditorInstance] createEffect | file:", file?.name, "| container:", !!container, "| isLoading:", isLoading(), "| monacoLoaded:", monacoManager.isLoaded());
+ if (!container || isLoading()) return;
if (!monacoManager.isLoaded() && file) {
+ // Prevent infinite retry loops if Monaco repeatedly fails to load
+ if (monacoLoadAttempts >= MAX_MONACO_LOAD_ATTEMPTS) {
+ console.error("[CodeEditor] Monaco failed to load after max attempts, giving up");
+ return;
+ }
+ monacoLoadAttempts++;
setIsLoading(true);
- monacoManager
- .ensureLoaded()
+ // Use timeout on retry too — without this, a dead promise from a
+ // previous timed-out attempt would hang forever.
+ const retryLoad = monacoManager.ensureLoaded();
+ const retryTimeout = new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("Monaco retry timed out")),
+ MONACO_LOAD_TIMEOUT_MS,
+ ),
+ );
+ Promise.race([retryLoad, retryTimeout])
.then((monaco) => {
monacoInstance = monaco;
setCurrentMonaco(monaco);
@@ -155,6 +192,7 @@ export function createEditorInstance(props: {
})
.catch((err) => {
console.error("Failed to load Monaco editor:", err);
+ monacoManager.resetLoadState();
setIsLoading(false);
});
return;
@@ -385,7 +423,7 @@ export function createEditorInstance(props: {
);
} else {
editorRef = monacoInstance!.editor.create(
- containerRef,
+ container,
editorOptions,
);
editorInitialized = true;
@@ -511,8 +549,9 @@ export function createEditorInstance(props: {
setCurrentEditor(null);
}
- if (containerRef) {
- containerRef.innerHTML = "";
+ const el = containerRef();
+ if (el) {
+ el.innerHTML = "";
}
});
@@ -521,7 +560,7 @@ export function createEditorInstance(props: {
monaco: currentMonaco,
containerRef,
setContainerRef: (el: HTMLDivElement) => {
- containerRef = el;
+ setContainerRef(el);
},
isLoading,
activeFile,
diff --git a/src/context/editor/EditorProvider.tsx b/src/context/editor/EditorProvider.tsx
index 30d4b5d..06a3e5f 100644
--- a/src/context/editor/EditorProvider.tsx
+++ b/src/context/editor/EditorProvider.tsx
@@ -1,6 +1,7 @@
-import { createContext, useContext, ParentProps, createMemo, batch } from "solid-js";
+import { createContext, useContext, ParentProps, createMemo, batch, onMount } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { loadGridState } from "../../utils/gridSerializer";
+import { MonacoManager } from "../../utils/monacoManager";
import type { OpenFile, EditorGroup, EditorSplit, SplitDirection } from "../../types";
import type {
EditorState,
@@ -393,6 +394,18 @@ export function EditorProvider(props: ParentProps) {
saveFile: fileOps.saveFile,
});
+ // Preload Monaco at startup so it's ready when the first file is opened.
+ // This runs after the EditorProvider mounts (Tier 1), giving Monaco time
+ // to load in the background while the user navigates the file tree.
+ onMount(() => {
+ console.warn("[EditorProvider] Preloading Monaco editor in background...");
+ MonacoManager.getInstance().ensureLoaded().then(() => {
+ console.warn("[EditorProvider] Monaco preloaded successfully");
+ }).catch((err) => {
+ console.warn("[EditorProvider] Monaco preload failed (will retry on file open):", err);
+ });
+ });
+
return (
{
const perfStart = performance.now();
const targetGroupId = groupId || state.activeGroupId;
-
+ console.warn("[openFile] Called for path:", path, "| targetGroupId:", targetGroupId);
+
const existing = state.openFiles.find((f) => f.path === path);
if (existing) {
+ console.warn("[openFile] File already open, switching to existing:", existing.id);
batch(() => {
setState("activeFileId", existing.id);
setState("activeGroupId", targetGroupId);
@@ -36,10 +38,12 @@ export function createFileOperations(
}
setState("isOpening", true);
+ console.warn("[openFile] isOpening set to TRUE");
try {
const readStart = performance.now();
+ console.warn("[openFile] Calling fs_read_file IPC...");
const content = await invoke("fs_read_file", { path });
- console.debug(`[EditorContext] fs_read_file: ${(performance.now() - readStart).toFixed(1)}ms (${(content.length / 1024).toFixed(1)}KB)`);
+ console.warn(`[openFile] fs_read_file completed: ${(performance.now() - readStart).toFixed(1)}ms (${(content.length / 1024).toFixed(1)}KB)`);
const name = path.split(/[/\\]/).pop() || path;
const id = `file-${generateId()}`;
@@ -86,6 +90,7 @@ export function createFileOperations(
);
} finally {
setState("isOpening", false);
+ console.warn("[openFile] isOpening set to FALSE (finally block)");
}
};
diff --git a/src/utils/monacoManager.ts b/src/utils/monacoManager.ts
index 82d13af..8d90c71 100644
--- a/src/utils/monacoManager.ts
+++ b/src/utils/monacoManager.ts
@@ -187,6 +187,18 @@ class MonacoManager {
return this.loadState === "loaded" && this.monaco !== null;
}
+ /**
+ * Reset load state so a fresh load attempt can be made.
+ * Called when a previous load timed out or errored, leaving the manager
+ * stuck in "loading" state with a dead promise that will never resolve.
+ */
+ resetLoadState(): void {
+ if (this.loadState !== "loaded") {
+ this.loadState = "idle";
+ this.loadPromise = null;
+ }
+ }
+
/**
* Get the Monaco instance (throws if not loaded)
*/
@@ -210,31 +222,36 @@ class MonacoManager {
async ensureLoaded(): Promise {
// Already loaded
if (this.loadState === "loaded" && this.monaco) {
+ console.warn("[MonacoManager] ensureLoaded: already loaded");
return this.monaco;
}
// Loading in progress
if (this.loadState === "loading" && this.loadPromise) {
+ console.warn("[MonacoManager] ensureLoaded: load already in progress, waiting...");
return this.loadPromise;
}
// Start loading
+ console.warn("[MonacoManager] ensureLoaded: starting fresh load, current state:", this.loadState);
this.loadState = "loading";
this.config.onLoadStart();
this.loadPromise = this.loadMonaco();
-
+
try {
this.monaco = await this.loadPromise;
this.loadState = "loaded";
this.config.onLoadComplete();
-
+ console.warn("[MonacoManager] ensureLoaded: Monaco loaded successfully");
+
// Register theme and providers
this.registerTheme();
-
+
return this.monaco;
} catch (error) {
this.loadState = "error";
+ console.error("[MonacoManager] ensureLoaded: load failed:", error);
this.config.onLoadError(error instanceof Error ? error : new Error(String(error)));
throw error;
}
@@ -244,9 +261,60 @@ class MonacoManager {
* Load Monaco dynamically
*/
private async loadMonaco(): Promise {
- // Use direct ES module import instead of @monaco-editor/loader
- // This avoids CDN loading and lets Vite handle bundling
+ // Configure Monaco worker URLs before importing.
+ // In Vite, workers must be referenced via `new URL(..., import.meta.url)`
+ // so that Vite can resolve and bundle them correctly.
+ // Without this, Monaco fails to create web workers for tokenization
+ // and the editor may hang or never fully initialize.
+ //
+ // WebKitGTK (used by Tauri on Linux) may not support module workers
+ // (`{ type: "module" }`), so we try module first and fall back to classic.
+ const workerUrls: Record = {
+ json: new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url),
+ css: new URL("monaco-editor/esm/vs/language/css/css.worker.js", import.meta.url),
+ html: new URL("monaco-editor/esm/vs/language/html/html.worker.js", import.meta.url),
+ typescript: new URL("monaco-editor/esm/vs/language/typescript/ts.worker.js", import.meta.url),
+ editor: new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url),
+ };
+
+ function createWorkerWithFallback(url: URL): Worker {
+ try {
+ // Try module worker first (works in Chromium, Firefox)
+ return new Worker(url, { type: "module" });
+ } catch {
+ // Fallback for WebKitGTK/Safari where module workers may not be supported.
+ // Use a classic worker that imports the module via importScripts or blob.
+ console.warn("[Monaco] Module workers not supported, falling back to classic worker");
+ const blob = new Blob(
+ [`importScripts(${JSON.stringify(url.toString())});`],
+ { type: "application/javascript" },
+ );
+ return new Worker(URL.createObjectURL(blob));
+ }
+ }
+
+ self.MonacoEnvironment = {
+ getWorker(_workerId: string, label: string): Worker {
+ if (label === "json") {
+ return createWorkerWithFallback(workerUrls.json);
+ }
+ if (label === "css" || label === "scss" || label === "less") {
+ return createWorkerWithFallback(workerUrls.css);
+ }
+ if (label === "html" || label === "handlebars" || label === "razor") {
+ return createWorkerWithFallback(workerUrls.html);
+ }
+ if (label === "typescript" || label === "javascript") {
+ return createWorkerWithFallback(workerUrls.typescript);
+ }
+ // Default editor worker (handles tokenization, diff, etc.)
+ return createWorkerWithFallback(workerUrls.editor);
+ },
+ };
+
+ console.warn("[MonacoManager] loadMonaco: starting import('monaco-editor')...");
const monaco = await import("monaco-editor");
+ console.warn("[MonacoManager] loadMonaco: import('monaco-editor') resolved successfully");
return monaco;
}
diff --git a/vite.config.ts b/vite.config.ts
index 2cdc47f..c2c15ef 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -179,6 +179,9 @@ export default defineConfig(async (): Promise => ({
"@tauri-apps/plugin-os",
"marked",
"diff",
+ // Monaco must be pre-bundled; without this, Vite serves 500+ individual
+ // ESM files on first dynamic import, causing the editor to hang indefinitely.
+ "monaco-editor",
],
esbuildOptions: {
target: "es2021",