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",