/
githubmirror
/
marktext
Обзор
Документация
Войти
/
githubmirror
/
marktext
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
packages/desktop/src/main/windows/editor.ts
645 строк
20 KB
Ran Luo
refactor(desktop): remove remaining no-explicit-any suppressions (#4548)
17 июн 2026, 13:24
Не верифицирован
17 июн 2026, 13:24
2482e00
Код
Авторство
О чём код?
import path from 'path' import { BrowserWindow, dialog, ipcMain } from 'electron' import type { BrowserWindowConstructorOptions } from 'electron' import log from 'electron-log' import windowStateKeeper from 'electron-window-state' import { isChildOfDirectory, isSamePathSync } from 'common/filesystem/paths' import BaseWindow, { WindowLifecycle, WindowType } from './base' import type Accessor from '../app/accessor' import { ensureWindowPosition, zoomIn, zoomOut } from './utils' import { TITLE_BAR_HEIGHT, editorWinOptions, isLinux, isOsx } from '../config' import { showEditorContextMenu } from '../contextMenu/editor' import { loadMarkdownFile } from '../filesystem/markdown' import { switchLanguage } from '../spellchecker' import fs from 'fs' type RawMarkdownDocument = Awaited<ReturnType<typeof loadMarkdownFile>> // The deferred file/markdown to open before the window finishes loading. interface PendingFile { doc: RawMarkdownDocument options: Record<string, unknown> selected: boolean } interface BufferStoreInfo { id: string filePath: string | null } interface CandidateScore { id: number | null score: number } interface RestoredTab { pathname: string filename?: string markdown?: string isSaved?: boolean [key: string]: unknown } interface RestoredBufferState { tabs: RestoredTab[] restoreWarnings?: unknown[] project?: { rootDirectory?: string } [key: string]: unknown } class EditorWindow extends BaseWindow { // Root directory and file list to open when the window is ready. private _directoryToOpen: string | null private _filesToOpen: PendingFile[] | null private _markdownToOpen: string[] | null // Root directory and file list that are currently opened. These lists are // used to find the best window to open new files in. private _openedRootDirectory: string | null private _openedFiles: string[] | null public bufferStoreInfo: BufferStoreInfo | null /** * @param accessor The application accessor for application instances. */ constructor(accessor: Accessor) { super(accessor) this.type = WindowType.EDITOR // Root directory and file list to open when the window is ready. this._directoryToOpen = null this._filesToOpen = [] // {doc: IMarkdownDocumentRaw, options: any, selected: boolean} this._markdownToOpen = [] // List of markdown strings or an empty string will open a new untitled tab // Root directory and file list that are currently opened. These lists are // used to find the best window to open new files in. this._openedRootDirectory = '' this._openedFiles = [] this.bufferStoreInfo = null } /** * Creates a new editor window. */ createWindow( rootDirectory: string | null = null, fileList: string[] = [], markdownList: string[] = [], options: Partial<BrowserWindowConstructorOptions> = {}, bufferStoreInfo: BufferStoreInfo | null = null ): BrowserWindow { const { menu: appMenu, env, preferences, editorBufferStore } = this._accessor const addBlankTab = !bufferStoreInfo && !rootDirectory && fileList.length === 0 && markdownList.length === 0 const mainWindowState = windowStateKeeper({ defaultWidth: 1200, defaultHeight: 800 }) const { x, y, width, height } = ensureWindowPosition(mainWindowState) const winOptions: BrowserWindowConstructorOptions = Object.assign( { x, y, width, height }, editorWinOptions, options ) if (isLinux) { winOptions.icon = path.join(process.cwd(), 'static', 'logo-96px.png') } const { titleBarStyle, theme, sideBarVisibility, restoreLayoutState, tabBarVisibility, sourceCodeModeEnabled, spellcheckerEnabled, spellcheckerLanguage } = preferences.getAll() const resolvedSideBarVisibility = restoreLayoutState ? !!sideBarVisibility : false // Enable native or custom/frameless window and titlebar if (!isOsx) { winOptions.titleBarStyle = 'default' if (titleBarStyle === 'native') { winOptions.frame = true } } winOptions.backgroundColor = this._getPreferredBackgroundColor(theme) if (env.disableSpellcheck) { // winOptions.webPreferences is set by editorWinOptions spread above ;(winOptions.webPreferences as { spellcheck: boolean }).spellcheck = false } let win: BrowserWindow | null = (this.browserWindow = new BrowserWindow(winOptions)) // Give every editor window a stable id for session buffer persistence. // We cant use win.id as it might collide with same IDs from closed windows this.bufferStoreInfo = { id: bufferStoreInfo ? bufferStoreInfo.id : editorBufferStore.getUnUsedBufferUUID(), filePath: bufferStoreInfo ? bufferStoreInfo.filePath : null } ;(win as unknown as { restoreBufferId: string }).restoreBufferId = this.bufferStoreInfo.id this.id = win.id if (spellcheckerEnabled && !isOsx) { try { switchLanguage(win, spellcheckerLanguage as string) } catch (error) { log.error('Unable to set spell checker language on startup:', error) } } // Create a menu for the current window appMenu.addEditorMenu(win, { sourceCodeModeEnabled: sourceCodeModeEnabled as boolean }) win.webContents.on('context-menu', (event, params) => { showEditorContextMenu(win!, event, params, preferences.getItem('spellcheckerEnabled')) }) win.webContents.once('did-finish-load', () => { this.lifecycle = WindowLifecycle.READY this.emit('window-ready') // Restore and focus window this.bringToFront() const lineEnding = preferences.getPreferredEol() appMenu.updateLineEndingMenu(this.id!, lineEnding) win!.webContents.send('mt::bootstrap-editor', { addBlankTab, markdownList: this.bufferStoreInfo!.filePath ? [] : this._markdownToOpen, lineEnding, sideBarVisibility: resolvedSideBarVisibility, tabBarVisibility, sourceCodeModeEnabled }) if (this.bufferStoreInfo!.filePath) { this._restoreAllState() } else { this._doOpenFilesToOpen() this._markdownToOpen!.length = 0 } // Listen on default system mouse zoom event (e.g. Ctrl+MouseWheel on Linux/Windows). win!.webContents.on('zoom-changed', (_event, zoomDirection) => { if (zoomDirection === 'in') { zoomIn(win!) } else if (zoomDirection === 'out') { zoomOut(win!) } }) }) win.webContents.once('did-fail-load', (_event, errorCode, errorDescription, url) => { log.error( `The window failed to load or was cancelled: ${errorCode}; ${errorDescription}; @ ${url}` ) }) win.webContents.once('render-process-gone', async(_event, { reason }) => { if (reason === 'clean-exit') { return } const msg = `The renderer process has crashed unexpected or is killed (${reason}).` log.error(msg) if (reason === 'abnormal-exit') { return } const { response } = await dialog.showMessageBox(win!, { type: 'warning', buttons: ['Close', 'Reload', 'Keep It Open'], message: 'MarkText has crashed', detail: msg }) if (win!.id) { switch (response) { case 0: return this.destroy() case 1: return this.reload() } } }) win.on('focus', () => { this.emit('window-focus') win!.webContents.send('mt::window-active-status', { status: true }) }) // Lost focus win.on('blur', () => { this.emit('window-blur') win!.webContents.send('mt::window-active-status', { status: false }) }) ;(['maximize', 'unmaximize', 'enter-full-screen', 'leave-full-screen'] as const).forEach( (channel) => { // Electron's BrowserWindow.on() is heavily overloaded — the union of // event names can't be satisfied by a single overload, so we widen. ;(win! as { on(event: string, listener: () => void): void }).on(channel, () => { win!.webContents.send(`mt::window-${channel}`) }) } ) // Before closed. We cancel the action and ask the editor further instructions. win.on('close', (event) => { this.emit('window-close') event.preventDefault() win!.webContents.send('mt::ask-for-close') // TODO: Close all watchers etc. Should we do this manually or listen to 'quit' event? }) // The window is now destroyed. win.on('closed', () => { this.lifecycle = WindowLifecycle.QUITTED this.emit('window-closed') // Free window reference win = null }) this.lifecycle = WindowLifecycle.LOADING win.loadURL(this._buildUrlString(this.id, env, preferences)) win.setSheetOffset(TITLE_BAR_HEIGHT) mainWindowState.manage(win) // Disable application menu shortcuts because we want to handle key bindings ourself. win.webContents.setIgnoreMenuShortcuts(true) // Delay load files and directories after the current control flow. setTimeout(() => { if (rootDirectory) { this.openFolder(rootDirectory) } if (fileList.length) { this.openTabsFromPaths(fileList) } }, 0) return win } /** * Open a new tab from a markdown file. */ openTab(filePath: string, options: Record<string, unknown> = {}, selected: boolean = true): void { // TODO: Don't allow new files if quitting. if (this.lifecycle === WindowLifecycle.QUITTED) return this.openTabs([{ filePath, options, selected }]) } /** * Open new tabs from the given file paths. */ openTabsFromPaths(filePaths: string[]): void { if (!filePaths || filePaths.length === 0) return const fileList = filePaths.map((p) => ({ filePath: p, options: {}, selected: false })) fileList[0].selected = true this.openTabs(fileList) } /** * Open new tabs from markdown files with options for editor window. */ openTabs( fileList: { filePath: string; selected: boolean; options: Record<string, unknown> }[] ): void { // TODO: Don't allow new files if quitting. if (this.lifecycle === WindowLifecycle.QUITTED) return const { browserWindow } = this const { preferences } = this._accessor const eol = preferences.getPreferredEol() const { autoGuessEncoding, trimTrailingNewline, autoNormalizeLineEndings } = preferences.getAll() for (const { filePath, options, selected } of fileList) { if (this._openedFiles!.includes(filePath)) { // File is already opened - avoid opening it again so we dont have duplicate watchers browserWindow!.webContents.send('mt::switch-tab-by-file_path', filePath) continue } loadMarkdownFile( filePath, eol, autoGuessEncoding, trimTrailingNewline, autoNormalizeLineEndings ) .then((rawDocument) => { if (this.lifecycle === WindowLifecycle.READY) { this._doOpenTab(rawDocument, options, selected) } else { this._filesToOpen!.push({ doc: rawDocument, options, selected }) } }) .catch((err: Error) => { const { message, stack } = err log.error(`[ERROR] Cannot open file or directory: ${message}\n\n${stack}`) browserWindow!.webContents.send('mt::show-notification', { title: 'Cannot open tab', type: 'error', message: err.message }) }) } } /** * Open a new untitled tab optional with a markdown string. */ openUntitledTab(selected: boolean = true, markdown: string = ''): void { // TODO: Don't allow new files if quitting. if (this.lifecycle === WindowLifecycle.QUITTED) return if (this.lifecycle === WindowLifecycle.READY) { const { browserWindow } = this browserWindow!.webContents.send('mt::new-untitled-tab', selected, markdown) } else { this._markdownToOpen!.push(markdown) } } /** * Open a (new) directory and replaces the old one. */ openFolder(pathname: string): void { // TODO: Don't allow new files if quitting. if ( !pathname || this.lifecycle === WindowLifecycle.QUITTED || isSamePathSync(pathname, this._openedRootDirectory ?? '') ) { return } if (this.lifecycle === WindowLifecycle.READY) { const { browserWindow } = this const { menu: appMenu, preferences } = this._accessor if (this._openedRootDirectory) { ipcMain.emit('watcher-unwatch-directory', browserWindow, this._openedRootDirectory) } preferences.setItems({ lastOpenedFolder: pathname }) appMenu.addRecentlyUsedDocument(pathname) this._openedRootDirectory = pathname ipcMain.emit('watcher-watch-directory', browserWindow, pathname) browserWindow!.webContents.send('mt::open-directory', pathname) } else { this._directoryToOpen = pathname } } /** * Add a new path to the file list and watch the given path. */ addToOpenedFiles(filePath: string): void { const { _openedFiles, browserWindow } = this _openedFiles!.push(filePath) ipcMain.emit('watcher-watch-file', browserWindow, filePath) } /** * Change a path in the opened file list and update the watcher. */ changeOpenedFilePath(pathname: string, oldPathname: string): void { const { _openedFiles, browserWindow } = this const index = _openedFiles!.findIndex((p) => p === oldPathname) if (index === -1) { // The old path was not found but add the new one. _openedFiles!.push(pathname) } else { _openedFiles![index] = pathname } ipcMain.emit('watcher-unwatch-file', browserWindow, oldPathname) ipcMain.emit('watcher-watch-file', browserWindow, pathname) } /** * Remove a path from the opened file list and stop watching the path. */ removeFromOpenedFiles(pathname: string): void { const { _openedFiles, browserWindow } = this const index = _openedFiles!.findIndex((p) => p === pathname) if (index !== -1) { _openedFiles!.splice(index, 1) } ipcMain.emit('watcher-unwatch-file', browserWindow, pathname) } /** * Returns a score list for a given file list. */ getCandidateScores(fileList: string[]): CandidateScore[] { const { _openedFiles, _openedRootDirectory, id } = this const buf: CandidateScore[] = [] for (const pathname of fileList) { let score = 0 if (_openedFiles!.some((p) => p === pathname)) { score = -1 } else { if (isChildOfDirectory(_openedRootDirectory ?? '', pathname)) { score += 5 } for (const item of _openedFiles!) { if (isChildOfDirectory(path.dirname(item), pathname)) { score += 1 } } } buf.push({ id, score }) } return buf } override reload(): void { const { id, browserWindow } = this // Close watchers ipcMain.emit('watcher-unwatch-all-by-id', id) // Reset saved state this._directoryToOpen = '' this._filesToOpen = [] this._markdownToOpen = [] this._openedRootDirectory = '' this._openedFiles = [] browserWindow!.webContents.once('did-finish-load', () => { this.lifecycle = WindowLifecycle.READY const { preferences } = this._accessor const { sideBarVisibility, restoreLayoutState, tabBarVisibility, sourceCodeModeEnabled } = preferences.getAll() const resolvedSideBarVisibility = restoreLayoutState ? !!sideBarVisibility : false const lineEnding = preferences.getPreferredEol() browserWindow!.webContents.send('mt::bootstrap-editor', { addBlankTab: true, markdownList: [], lineEnding, sideBarVisibility: resolvedSideBarVisibility, tabBarVisibility, sourceCodeModeEnabled }) }) this.lifecycle = WindowLifecycle.LOADING super.reload() } override destroy(): void { super.destroy() // Watchers are freed from WindowManager. this._directoryToOpen = null this._filesToOpen = null this._markdownToOpen = null this._openedRootDirectory = null this._openedFiles = null } get openedRootDirectory(): string | null { return this._openedRootDirectory } // --- private --------------------------------- /** * Open a new new tab from the markdown document. */ private _doOpenTab( rawDocument: RawMarkdownDocument, options: Record<string, unknown>, selected: boolean ): void { const { _accessor, _openedFiles, browserWindow } = this const { menu: appMenu } = _accessor const { pathname } = rawDocument // Listen for file changed. ipcMain.emit('watcher-watch-file', browserWindow, pathname) appMenu.addRecentlyUsedDocument(pathname) _openedFiles!.push(pathname) browserWindow!.webContents.send('mt::open-new-tab', rawDocument, options, selected) } private _doOpenFilesToOpen(): void { if (this.lifecycle !== WindowLifecycle.READY) { throw new Error('Invalid state.') } if (this._directoryToOpen) { this.openFolder(this._directoryToOpen) } this._directoryToOpen = null for (const { doc, options, selected } of this._filesToOpen!) { this._doOpenTab(doc, options, selected) } this._filesToOpen!.length = 0 } private _restoreAllState(): void { if (this.lifecycle !== WindowLifecycle.READY) { throw new Error('Invalid state.') } const { browserWindow, bufferStoreInfo, _accessor } = this const { menu: appMenu, preferences } = _accessor try { const bufferState = JSON.parse( fs.readFileSync(bufferStoreInfo!.filePath!, 'utf-8') ) as RestoredBufferState if (!bufferState || !Array.isArray(bufferState.tabs)) { throw new Error('Invalid editor buffer state.') } if (!Array.isArray(bufferState.restoreWarnings)) { bufferState.restoreWarnings = [] } const rootDirectory = bufferState.project?.rootDirectory if (rootDirectory) { this.openFolder(rootDirectory) } // We still need to load the files of all opened tabs and check for errors/changed files const eol = preferences.getPreferredEol() const { autoGuessEncoding, trimTrailingNewline, autoNormalizeLineEndings } = preferences.getAll() const fileOpenRequests: Promise<void>[] = [] for (const tab of bufferState.tabs) { if (!tab.pathname) { continue } fileOpenRequests.push( loadMarkdownFile( tab.pathname, eol, autoGuessEncoding, trimTrailingNewline, autoNormalizeLineEndings ) .then((rawDocument) => { if (rawDocument.markdown !== tab.markdown) { // File has changed since it was last opened, if it is not saved, we should NOT override the buffer if (tab.isSaved) { tab.markdown = rawDocument.markdown } } if (!this._openedFiles!.includes(tab.pathname)) { this.addToOpenedFiles(tab.pathname) appMenu.addRecentlyUsedDocument(tab.pathname) } }) .catch((err: Error) => { const { message, stack } = err tab.isSaved = false // Set to false as base file could not be found, needs saving log.error(`[ERROR] Cannot open file: ${message}\n\n${stack}`) browserWindow!.webContents.send('mt::show-notification', { title: `Could not find file ${tab.filename} on disk, please save your work.`, type: 'error', message: err.message }) }) ) } Promise.all(fileOpenRequests) .then(() => { // After all files are loaded, we can send the state to the renderer and open the tabs browserWindow!.webContents.send('mt::load-state', bufferState) }) .catch((err: Error) => { log.error('Failed to load files for restoring editor state:', err) browserWindow!.webContents.send('mt::show-notification', { title: 'Failed to restore buffered state', type: 'error', message: err.message }) }) } catch (e) { log.error('Failed to restore editor state:', e) } } } export default EditorWindow