mirror of
https://github.com/marktext/marktext.git
synced 2025-05-02 11:20:25 +08:00

* Upgrade to Electron 7 * Fix E2E test issue with Electron 7 * Fix native theme event * Remove runtime native theme detection * Update Electron to v7.0.1 * Fix keytar exception
346 lines
9.9 KiB
JavaScript
346 lines
9.9 KiB
JavaScript
import path from 'path'
|
|
import fs from 'fs-extra'
|
|
import log from 'electron-log'
|
|
import chokidar from 'chokidar'
|
|
import { exists } from 'common/filesystem'
|
|
import { hasMarkdownExtension } from 'common/filesystem/paths'
|
|
import { getUniqueId } from '../utils'
|
|
import { loadMarkdownFile } from '../filesystem/markdown'
|
|
import { isLinux, isOsx } from '../config'
|
|
|
|
// TODO(refactor): Please see GH#1035.
|
|
|
|
export const WATCHER_STABILITY_THRESHOLD = 1000
|
|
export const WATCHER_STABILITY_POLL_INTERVAL = 150
|
|
|
|
const EVENT_NAME = {
|
|
dir: 'AGANI::update-object-tree',
|
|
file: 'AGANI::update-file'
|
|
}
|
|
|
|
const add = async (win, pathname, type, endOfLine, autoGuessEncoding) => {
|
|
const stats = await fs.stat(pathname)
|
|
const birthTime = stats.birthtime
|
|
const isMarkdown = hasMarkdownExtension(pathname)
|
|
const file = {
|
|
pathname,
|
|
name: path.basename(pathname),
|
|
isFile: true,
|
|
isDirectory: false,
|
|
birthTime,
|
|
isMarkdown
|
|
}
|
|
if (isMarkdown) {
|
|
// HACK: But this should be removed completely in #1034/#1035.
|
|
try {
|
|
const data = await loadMarkdownFile(pathname, endOfLine, autoGuessEncoding)
|
|
file.data = data
|
|
} catch (err) {
|
|
// Only notify user about opened files.
|
|
if (type === 'file') {
|
|
win.webContents.send('AGANI::show-notification', {
|
|
title: 'Watcher I/O error',
|
|
type: 'error',
|
|
message: err.message
|
|
})
|
|
return
|
|
}
|
|
}
|
|
win.webContents.send(EVENT_NAME[type], {
|
|
type: 'add',
|
|
change: file
|
|
})
|
|
}
|
|
}
|
|
|
|
const unlink = (win, pathname, type) => {
|
|
const file = { pathname }
|
|
win.webContents.send(EVENT_NAME[type], {
|
|
type: 'unlink',
|
|
change: file
|
|
})
|
|
}
|
|
|
|
const change = async (win, pathname, type, endOfLine, autoGuessEncoding) => {
|
|
// No need to update the tree view if the file content has changed.
|
|
if (type === 'dir') return
|
|
|
|
const isMarkdown = hasMarkdownExtension(pathname)
|
|
if (isMarkdown) {
|
|
// HACK: Markdown data should be removed completely in #1034/#1035 and
|
|
// should be only loaded after user interaction.
|
|
try {
|
|
const data = await loadMarkdownFile(pathname, endOfLine, autoGuessEncoding)
|
|
const file = {
|
|
pathname,
|
|
data
|
|
}
|
|
win.webContents.send('AGANI::update-file', {
|
|
type: 'change',
|
|
change: file
|
|
})
|
|
} catch (err) {
|
|
// Only notify user about opened files.
|
|
if (type === 'file') {
|
|
win.webContents.send('AGANI::show-notification', {
|
|
title: 'Watcher I/O error',
|
|
type: 'error',
|
|
message: err.message
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const addDir = (win, pathname, type) => {
|
|
if (type === 'file') return
|
|
|
|
const directory = {
|
|
pathname,
|
|
name: path.basename(pathname),
|
|
isCollapsed: true,
|
|
isDirectory: true,
|
|
isFile: false,
|
|
isMarkdown: false,
|
|
folders: [],
|
|
files: []
|
|
}
|
|
|
|
win.webContents.send('AGANI::update-object-tree', {
|
|
type: 'addDir',
|
|
change: directory
|
|
})
|
|
}
|
|
|
|
const unlinkDir = (win, pathname, type) => {
|
|
if (type === 'file') return
|
|
|
|
const directory = { pathname }
|
|
win.webContents.send('AGANI::update-object-tree', {
|
|
type: 'unlinkDir',
|
|
change: directory
|
|
})
|
|
}
|
|
|
|
class Watcher {
|
|
/**
|
|
* @param {Preference} preferences The preference instance.
|
|
*/
|
|
constructor (preferences) {
|
|
this._preferences = preferences
|
|
this._ignoreChangeEvents = []
|
|
this.watchers = {}
|
|
}
|
|
|
|
// Watch a file or directory and return a unwatch function.
|
|
watch (win, watchPath, type = 'dir'/* file or dir */) {
|
|
// TODO: Is it needed to set `watcherUsePolling` ? because macOS need to set to true.
|
|
const usePolling = isOsx ? true : this._preferences.getItem('watcherUsePolling')
|
|
|
|
const id = getUniqueId()
|
|
const watcher = chokidar.watch(watchPath, {
|
|
ignored: (pathname, fileInfo) => {
|
|
// This function is called twice, once with a single argument (the path),
|
|
// second time with two arguments (the path and the "fs.Stats" object of that path).
|
|
if (!fileInfo) {
|
|
return /(^|[/\\])(\..|node_modules)/.test(pathname)
|
|
}
|
|
|
|
if (/(^|[/\\])(\..|node_modules)/.test(pathname)) {
|
|
return true
|
|
}
|
|
if (fileInfo.isDirectory()) {
|
|
return false
|
|
}
|
|
return !hasMarkdownExtension(pathname)
|
|
},
|
|
ignoreInitial: type === 'file',
|
|
persistent: true,
|
|
ignorePermissionErrors: true,
|
|
|
|
// Just to be sure when a file is replaced with a directory don't watch recursively.
|
|
depth: type === 'file'
|
|
? (isOsx ? 1 : 0) : undefined,
|
|
|
|
// Please see GH#1043
|
|
awaitWriteFinish: {
|
|
stabilityThreshold: WATCHER_STABILITY_THRESHOLD,
|
|
pollInterval: WATCHER_STABILITY_POLL_INTERVAL
|
|
},
|
|
|
|
// Settings options
|
|
usePolling
|
|
})
|
|
|
|
let disposed = false
|
|
let enospcReached = false
|
|
let renameTimer = null
|
|
|
|
watcher
|
|
.on('add', pathname => {
|
|
if (!this._shouldIgnoreEvent(win.id, pathname, type)) {
|
|
const eol = this._preferences.getPreferedEol()
|
|
const autoGuessEncoding = this._preferences.getItem('autoGuessEncoding')
|
|
add(win, pathname, type, eol, autoGuessEncoding)
|
|
}
|
|
})
|
|
.on('change', pathname => {
|
|
if (!this._shouldIgnoreEvent(win.id, pathname, type)) {
|
|
const eol = this._preferences.getPreferedEol()
|
|
const autoGuessEncoding = this._preferences.getItem('autoGuessEncoding')
|
|
change(win, pathname, type, eol, autoGuessEncoding)
|
|
}
|
|
})
|
|
.on('unlink', pathname => unlink(win, pathname, type))
|
|
.on('addDir', pathname => addDir(win, pathname, type))
|
|
.on('unlinkDir', pathname => unlinkDir(win, pathname, type))
|
|
.on('raw', (event, subpath, details) => {
|
|
if (global.MARKTEXT_DEBUG_VERBOSE >= 3) {
|
|
console.log('watcher: ', event, subpath, details)
|
|
}
|
|
|
|
// Fix atomic rename on Linux (chokidar#591).
|
|
// TODO: This should also apply to macOS.
|
|
// TODO: Do we need to rewatch when the watched directory was renamed?
|
|
if (isLinux && type === 'file' && event === 'rename') {
|
|
if (renameTimer) {
|
|
clearTimeout(renameTimer)
|
|
}
|
|
renameTimer = setTimeout(async () => {
|
|
renameTimer = null
|
|
if (disposed) {
|
|
return
|
|
}
|
|
|
|
const fileExists = await exists(watchPath)
|
|
if (fileExists) {
|
|
// File still exists but we need to rewatch the file because the inode has changed.
|
|
watcher.unwatch(watchPath)
|
|
watcher.add(watchPath)
|
|
}
|
|
}, 150)
|
|
}
|
|
})
|
|
.on('error', error => {
|
|
// Check if too many file descriptors are opened and notify the user about this issue.
|
|
if (error.code === 'ENOSPC') {
|
|
if (!enospcReached) {
|
|
enospcReached = true
|
|
log.warn('inotify limit reached: Too many file descriptors are opened.')
|
|
|
|
win.webContents.send('AGANI::show-notification', {
|
|
title: 'inotify limit reached',
|
|
type: 'warning',
|
|
message: 'Cannot watch all files and file changes because too many file descriptors are opened.'
|
|
})
|
|
}
|
|
} else {
|
|
log.error('Error while watching files:', error)
|
|
}
|
|
})
|
|
|
|
const closeFn = () => {
|
|
disposed = true
|
|
if (this.watchers[id]) {
|
|
delete this.watchers[id]
|
|
}
|
|
if (renameTimer) {
|
|
clearTimeout(renameTimer)
|
|
renameTimer = null
|
|
}
|
|
watcher.close()
|
|
}
|
|
|
|
this.watchers[id] = {
|
|
win,
|
|
watcher,
|
|
pathname: watchPath,
|
|
type,
|
|
|
|
close: closeFn
|
|
}
|
|
|
|
// unwatcher function
|
|
return closeFn
|
|
}
|
|
|
|
// Remove a single watcher.
|
|
unwatch (win, watchPath, type = 'dir') {
|
|
for (const id of Object.keys(this.watchers)) {
|
|
const w = this.watchers[id]
|
|
if (
|
|
w.win === win &&
|
|
w.pathname === watchPath &&
|
|
w.type === type
|
|
) {
|
|
w.watcher.close()
|
|
delete this.watchers[id]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove all watchers from the given window id.
|
|
unwatchByWindowId (windowId) {
|
|
const watchers = []
|
|
const watchIds = []
|
|
for (const id of Object.keys(this.watchers)) {
|
|
const w = this.watchers[id]
|
|
if (w.win.id === windowId) {
|
|
watchers.push(w.watcher)
|
|
watchIds.push(id)
|
|
}
|
|
}
|
|
if (watchers.length) {
|
|
watchIds.forEach(id => delete this.watchers[id])
|
|
watchers.forEach(watcher => watcher.close())
|
|
}
|
|
}
|
|
|
|
close () {
|
|
Object.keys(this.watchers).forEach(id => this.watchers[id].close())
|
|
this.watchers = {}
|
|
this._ignoreChangeEvents = []
|
|
}
|
|
|
|
/**
|
|
* Ignore the next changed event within a certain time for the current file and window.
|
|
*
|
|
* NOTE: Only valid for files and "add"/"change" event!
|
|
*
|
|
* @param {number} windowId The window id.
|
|
* @param {string} pathname The path to ignore.
|
|
* @param {number} [duration] The duration in ms to ignore the changed event.
|
|
*/
|
|
ignoreChangedEvent (windowId, pathname, duration = WATCHER_STABILITY_THRESHOLD + WATCHER_STABILITY_POLL_INTERVAL + 1000) {
|
|
this._ignoreChangeEvents.push({ windowId, pathname, duration, start: new Date() })
|
|
}
|
|
|
|
/**
|
|
* Check whether we should ignore the current event because the file may be changed from Mark Text itself.
|
|
*
|
|
* @param {number} winId
|
|
* @param {string} pathname
|
|
* @param {string} type
|
|
*/
|
|
_shouldIgnoreEvent (winId, pathname, type) {
|
|
if (type === 'file') {
|
|
const { _ignoreChangeEvents } = this
|
|
const currentTime = new Date()
|
|
for (let i = 0; i < _ignoreChangeEvents.length; ++i) {
|
|
const { windowId, pathname: pathToIgnore, start, duration } = _ignoreChangeEvents[i]
|
|
if (windowId === winId && pathToIgnore === pathname) {
|
|
_ignoreChangeEvents.splice(i, 1)
|
|
--i
|
|
if (currentTime - start < duration) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
export default Watcher
|