From a41f751f2fbf801f74b3ac1cd024ccf7c77459ab Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Tue, 26 Mar 2019 20:40:51 +0800 Subject: [PATCH] update or notice file changed on disk (#796) * update or notice file changed on disk * update changelog * fix some typo and optimize some codes --- .github/CHANGELOG.md | 1 + src/main/actions/file.js | 22 ++-- src/main/preference.js | 2 +- src/main/watcher.js | 60 ++++++++-- src/main/window.js | 126 ++++++++++++-------- src/renderer/app.vue | 1 + src/renderer/mixins/index.js | 5 + src/renderer/services/notification/index.js | 23 +++- src/renderer/store/editor.js | 79 +++++++++++- src/renderer/store/help.js | 3 +- src/renderer/store/preferences.js | 2 +- 11 files changed, 251 insertions(+), 73 deletions(-) diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 40cbe70a..cf360a02 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -20,6 +20,7 @@ This update **fixes a XSS security vulnerability** when exporting a document. - Clicking a link should open it in the browser (#425) - Support maxOS `dark mode`, when you change `mode dark or light` in system, Mark Text will change its theme. - Add two new themes Ulysses Light and Graphite Light theme. +- Watch file changed in tabs and show a notice(autoSave is `false`) or update the file(autoSave is `true`) **:butterfly:Optimization** diff --git a/src/main/actions/file.js b/src/main/actions/file.js index b471e352..9b670e0e 100644 --- a/src/main/actions/file.js +++ b/src/main/actions/file.js @@ -5,7 +5,7 @@ import { promisify } from 'util' import { BrowserWindow, dialog, ipcMain, shell } from 'electron' import appWindow from '../window' import { EXTENSION_HASN, EXTENSIONS, PANDOC_EXTENSIONS, URL_REG } from '../config' -import { loadMarkdownFile, writeFile, writeMarkdownFile } from '../utils/filesystem' +import { writeFile, writeMarkdownFile } from '../utils/filesystem' import appMenu from '../menu' import { getPath, isMarkdownFile, isMarkdownFileOrLink, normalizeAndResolvePath, log, isFile, isDirectory, getRecommendTitle } from '../utils' import userPreference from '../preference' @@ -80,6 +80,10 @@ const handleResponseForSave = (e, { id, markdown, pathname, options }) => { return writeMarkdownFile(pathname, markdown, options, win) .then(() => { + if (!alreadyExistOnDisk) { + // it's a new created file, need watch + appWindow.watcher.watch(win, pathname, 'file') + } const filename = path.basename(pathname) win.webContents.send('AGANI::set-pathname', { id, pathname, filename }) return id @@ -175,6 +179,12 @@ ipcMain.on('AGANI::response-file-save-as', (e, { id, markdown, pathname, options if (filePath) { writeMarkdownFile(filePath, markdown, options, win) .then(() => { + // need watch file after `save as` + if (pathname !== filePath) { + appWindow.watcher.watch(win, filePath, 'file') + // unWatch the old file. + appWindow.watcher.unWatch(win, pathname, 'file') + } const filename = path.basename(filePath) win.webContents.send('AGANI::set-pathname', { id, pathname: filePath, filename }) }) @@ -333,13 +343,7 @@ export const openFileOrFolder = (win, pathname) => { if (openFilesInNewWindow) { appWindow.createWindow(resolvedPath) } else { - loadMarkdownFile(resolvedPath).then(rawDocument => { - newTab(win, rawDocument) - }).catch(err => { - // TODO: Handle error --> create a end-user error handler. - console.error('[ERROR] Cannot open file or directory.') - log(err) - }) + appWindow.newTab(win, pathname) } } else if (isDirectory(resolvedPath)) { appWindow.createWindow(resolvedPath) @@ -400,7 +404,7 @@ export const autoSave = (menuItem, browserWindow) => { const { checked } = menuItem userPreference.setItem('autoSave', checked) .then(() => { - for (const win of appWindow.windows.values()) { + for (const { win } of appWindow.windows.values()) { win.webContents.send('AGANI::user-preference', { autoSave: checked }) } }) diff --git a/src/main/preference.js b/src/main/preference.js index 5d27d38d..4c1d43e9 100644 --- a/src/main/preference.js +++ b/src/main/preference.js @@ -155,7 +155,7 @@ ipcMain.on('AGANI::set-user-preference', (e, pre) => { Object.keys(pre).map(key => { preference.setItem(key, pre[key]) .then(() => { - for (const win of appWindow.windows.values()) { + for (const { win } of appWindow.windows.values()) { win.webContents.send('AGANI::user-preference', { [key]: pre[key] }) } }) diff --git a/src/main/watcher.js b/src/main/watcher.js index 55d95a6b..eb2550cf 100644 --- a/src/main/watcher.js +++ b/src/main/watcher.js @@ -5,6 +5,11 @@ import chokidar from 'chokidar' import { getUniqueId, log, hasMarkdownExtension } from './utils' import { loadMarkdownFile } from './utils/filesystem' +const EVENT_NAME = { + dir: 'AGANI::update-object-tree', + file: 'AGANI::update-file' +} + const add = async (win, pathname) => { const stats = await promisify(fs.stat)(pathname) const birthTime = stats.birthtime @@ -28,15 +33,15 @@ const add = async (win, pathname) => { }) } -const unlink = (win, pathname) => { +const unlink = (win, pathname, type) => { const file = { pathname } - win.webContents.send('AGANI::update-object-tree', { + win.webContents.send(EVENT_NAME[type], { type: 'unlink', change: file }) } -const change = async (win, pathname) => { +const change = async (win, pathname, type) => { const isMarkdown = hasMarkdownExtension(pathname) if (isMarkdown) { @@ -45,7 +50,7 @@ const change = async (win, pathname) => { pathname, data } - win.webContents.send('AGANI::update-object-tree', { + win.webContents.send(EVENT_NAME[type], { type: 'change', change: file }) @@ -83,18 +88,18 @@ class Watcher { this.watchers = {} } // return a unwatch function - watch (win, dir) { + watch (win, watchPath, type = 'dir'/* file or dir */) { const id = getUniqueId() - const watcher = chokidar.watch(dir, { + const watcher = chokidar.watch(watchPath, { ignored: /node_modules|\.git/, - ignoreInitial: false, + ignoreInitial: type === 'file', persistent: true }) watcher .on('add', pathname => add(win, pathname)) - .on('change', pathname => change(win, pathname)) - .on('unlink', pathname => unlink(win, pathname)) + .on('change', pathname => change(win, pathname, type)) + .on('unlink', pathname => unlink(win, pathname, type)) .on('addDir', pathname => addDir(win, pathname)) .on('unlinkDir', pathname => unlinkDir(win, pathname)) .on('error', error => { @@ -103,7 +108,9 @@ class Watcher { this.watchers[id] = { win, - watcher + watcher, + pathname: watchPath, + type } // unwatcher function @@ -115,6 +122,39 @@ class Watcher { } } + // unWatch some single watch + 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 + } + } + } + + // unwatch for one window, (remove all the watchers in one window) + unWatchWin (win) { + const watchers = [] + const watchIds = [] + for (const id of Object.keys(this.watchers)) { + const w = this.watchers[id] + if (w.win === win) { + watchers.push(w.watcher) + watchIds.push(id) + } + } + if (watchers.length) { + watchIds.forEach(id => delete this.watchers[id]) + watchers.forEach(watcher => watcher.close()) + } + } + clear () { Object.keys(this.watchers).forEach(id => this.watchers[id].watcher.close()) } diff --git a/src/main/window.js b/src/main/window.js index 89e27b21..b7d4ad3c 100644 --- a/src/main/window.js +++ b/src/main/window.js @@ -1,4 +1,4 @@ -import { app, BrowserWindow, screen } from 'electron' +import { app, BrowserWindow, screen, ipcMain } from 'electron' import windowStateKeeper from 'electron-window-state' import { getOsLineEndingName, loadMarkdownFile, getDefaultTextDirection } from './utils/filesystem' import appMenu from './menu' @@ -6,12 +6,31 @@ import Watcher from './watcher' import { isMarkdownFile, isDirectory, normalizeAndResolvePath, log } from './utils' import { TITLE_BAR_HEIGHT, defaultWinOptions, isLinux } from './config' import userPreference from './preference' +import { newTab } from './actions/file' class AppWindow { constructor () { this.focusedWindowId = -1 this.windows = new Map() this.watcher = new Watcher() + this.listen() + } + + listen () { + // listen for file watch from renderer process eg + // 1. click file in folder. + // 2. new tab and save it. + // 3. close tab(s) need unwatch. + ipcMain.on('AGANI::file-watch', (e, { pathname, watch }) => { + const win = BrowserWindow.fromWebContents(e.sender) + if (watch) { + // listen for file `change` and `unlink` + this.watcher.watch(win, pathname, 'file') + } else { + // unlisten for file `change` and `unlink` + this.watcher.unWatch(win, pathname, 'file') + } + }) } ensureWindowPosition (mainWindowState) { @@ -79,10 +98,7 @@ class AppWindow { } const win = new BrowserWindow(winOpt) - windows.set(win.id, { - win, - watchers: [] - }) + windows.set(win.id, { win }) // create a menu for the current window appMenu.addWindowMenuWithListener(win) @@ -97,43 +113,11 @@ class AppWindow { // open single markdown file if (pathname && isMarkdownFile(pathname)) { appMenu.addRecentlyUsedDocument(pathname) - loadMarkdownFile(pathname) - .then(data => { - const { - markdown, - filename, - pathname, - isUtf8BomEncoded, - lineEnding, - adjustLineEndingOnSave, - isMixedLineEndings, - textDirection - } = data - - appMenu.updateLineEndingnMenu(lineEnding) - appMenu.updateTextDirectionMenu(textDirection) - win.webContents.send('AGANI::open-single-file', { - markdown, - filename, - pathname, - options: { - isUtf8BomEncoded, - lineEnding, - adjustLineEndingOnSave - } - }) - - // Notify user about mixed endings - if (isMixedLineEndings) { - win.webContents.send('AGANI::show-notification', { - title: 'Mixed Line Endings', - type: 'error', - message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, - time: 20000 - }) - } - }) - .catch(log) + try { + this.openFile(win, pathname) + } catch (err) { + log(err) + } // open directory / folder } else if (pathname && isDirectory(pathname)) { appMenu.addRecentlyUsedDocument(pathname) @@ -176,6 +160,7 @@ class AppWindow { // set renderer arguments const { codeFontFamily, codeFontSize, theme } = userPreference.getAll() + // wow, this can be accessesed in renderer process. win.stylePrefs = { codeFontFamily, codeFontSize, @@ -192,9 +177,57 @@ class AppWindow { return win } + openFile = async (win, filePath) => { + const data = await loadMarkdownFile(filePath) + const { + markdown, + filename, + pathname, + isUtf8BomEncoded, + lineEnding, + adjustLineEndingOnSave, + isMixedLineEndings, + textDirection + } = data + + appMenu.updateLineEndingnMenu(lineEnding) + appMenu.updateTextDirectionMenu(textDirection) + win.webContents.send('AGANI::open-single-file', { + markdown, + filename, + pathname, + options: { + isUtf8BomEncoded, + lineEnding, + adjustLineEndingOnSave + } + }) + // listen for file `change` and `unlink` + this.watcher.watch(win, filePath, 'file') + // Notify user about mixed endings + if (isMixedLineEndings) { + win.webContents.send('AGANI::show-notification', { + title: 'Mixed Line Endings', + type: 'error', + message: `The document has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, + time: 20000 + }) + } + } + + newTab (win, filePath) { + this.watcher.watch(win, filePath, 'file') + loadMarkdownFile(filePath).then(rawDocument => { + newTab(win, rawDocument) + }).catch(err => { + // TODO: Handle error --> create a end-user error handler. + console.error('[ERROR] Cannot open file or directory.') + log(err) + }) + } + openFolder (win, pathname) { - const unwatcher = this.watcher.watch(win, pathname) - this.windows.get(win.id).watchers.push(unwatcher) + this.watcher.watch(win, pathname, 'dir') try { win.webContents.send('AGANI::open-project', pathname) } catch (err) { @@ -206,10 +239,7 @@ class AppWindow { if (!win) return const { windows } = this if (windows.has(win.id)) { - const { watchers } = windows.get(win.id) - if (watchers && watchers.length) { - watchers.forEach(w => w()) - } + this.watcher.unWatchWin(win) windows.delete(win.id) } appMenu.removeWindowMenu(win.id) diff --git a/src/renderer/app.vue b/src/renderer/app.vue index ba80a83a..b406b522 100644 --- a/src/renderer/app.vue +++ b/src/renderer/app.vue @@ -145,6 +145,7 @@ dispatch('LINTEN_FOR_PRINT_SERVICE_CLEARUP') dispatch('LINTEN_FOR_EXPORT_SUCCESS') dispatch('LISTEN_FOR_SET_TEXT_DIRECTION') + dispatch('LISTEN_FOR_FILE_CHANGE') dispatch('LISTEN_FOR_TEXT_DIRECTION_MENU') // module: notification dispatch('LISTEN_FOR_NOTIFICATION') diff --git a/src/renderer/mixins/index.js b/src/renderer/mixins/index.js index 25e62a9a..7eb6931b 100644 --- a/src/renderer/mixins/index.js +++ b/src/renderer/mixins/index.js @@ -29,6 +29,11 @@ export const fileMixins = { const fileState = isOpened || getFileStateFromData(data) this.$store.dispatch('UPDATE_CURRENT_FILE', fileState) + // ask main process to watch this file changes + this.$store.dispatch('ASK_FILE_WATCH', { + pathname, + watch: true + }) ipcRenderer.send("AGANI::add-recently-used-document", pathname) diff --git a/src/renderer/services/notification/index.js b/src/renderer/services/notification/index.js index d24b2e9d..ad4c8650 100644 --- a/src/renderer/services/notification/index.js +++ b/src/renderer/services/notification/index.js @@ -1,4 +1,5 @@ import template from './index.html' +import { getUniqueId } from '../../util' import './index.css' const INON_HASH = { @@ -8,8 +9,22 @@ const INON_HASH = { info: 'icon-info' } +const COLOR_HASH = { + primary: 'var(--themeColor)', + error: 'var(--deleteColor)', + warning: 'var(--deleteColor)', + info: '#999999' +} + const notification = { name: 'notify', + noticeCache: {}, + // it's a dirty implement of clear, because not remove all the event listeners. need refactor. + clear () { + Object.keys(this.noticeCache).forEach(key => { + this.noticeCache[key].remove() + }) + }, notify ({ time = 10000, title = '', @@ -20,6 +35,7 @@ const notification = { let rs let rj let timer = null + const id = getUniqueId() const fragment = document.createElement('div') fragment.innerHTML = template @@ -40,7 +56,7 @@ const notification = { target = noticeContainer.querySelector('.confirm') } - bgNotice.style.backgroundColor = `var(--${type})` + bgNotice.style.backgroundColor = `${COLOR_HASH[type]}` fluent.style.height = offsetHeight * 2 + 'px' fluent.style.width = offsetHeight * 2 + 'px' @@ -117,9 +133,14 @@ const notification = { close.removeEventListener('click', closeHandler) noticeContainer.remove() rePositionNotices() + if (this.noticeCache[id]) { + delete this.noticeCache[id] + } }, 100) } + this.noticeCache[id] = { remove } + noticeContainer.addEventListener('mousemove', mousemoveHandler) noticeContainer.addEventListener('mouseleave', mouseleaveHandler) target.addEventListener('click', clickHandler) diff --git a/src/renderer/store/editor.js b/src/renderer/store/editor.js index ea6cfc49..a78062ce 100644 --- a/src/renderer/store/editor.js +++ b/src/renderer/store/editor.js @@ -50,6 +50,35 @@ const mutations = { } } }, + LOAD_CHANGE (state, change) { + const { tabs, currentFile } = state + const { data, pathname } = change + const { isMixedLineEndings, lineEnding, adjustLineEndingOnSave, isUtf8BomEncoded, markdown, textDirection, filename } = data + const options = { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave, textDirection } + const fileState = getSingleFileState({ markdown, filename, pathname, options }) + if (isMixedLineEndings) { + notice.notify({ + title: 'Line Ending', + message: `${filename} has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, + type: 'primary', + time: 20000, + showConfirm: false + }) + } + for (const tab of tabs) { + if (tab.pathname === pathname) { + Object.assign(tab, fileState) + break + } + } + state.tabs = tabs + if (pathname === currentFile.pathname) { + Object.assign(currentFile, fileState) + state.currentFile = currentFile + const { cursor, history } = currentFile + bus.$emit('file-changed', { markdown, cursor, renderCursor: true, history }) + } + }, SET_PATHNAME (state, file) { const { filename, pathname, id } = file if (id === state.currentFile.id && pathname) { @@ -112,6 +141,12 @@ const mutations = { CLOSE_TABS (state, arr) { arr.forEach(id => { const index = state.tabs.findIndex(f => f.id === id) + const { pathname } = state.tabs.find(f => f.id === id) + if (pathname) { + // close tab and unwatch this file + ipcRenderer.send('AGANI::file-watch', { pathname, watch: false }) + } + state.tabs.splice(index, 1) if (state.currentFile.id === id) { state.currentFile = {} @@ -178,8 +213,11 @@ const actions = { }) }, - REMOVE_FILE_IN_TABS ({ commit }, file) { + REMOVE_FILE_IN_TABS ({ commit, dispatch }, file) { commit('REMOVE_FILE_WITHIN_TABS', file) + // unwatch this file + const { pathname } = file + dispatch('ASK_FILE_WATCH', { pathname, watch: false }) }, // need update line ending when change between windows. @@ -416,7 +454,7 @@ const actions = { if (isMixedLineEndings) { const { filename, lineEnding } = markdownDocument - notice({ + notice.notify({ title: 'Line Ending', message: `${filename} has mixed line endings which are automatically normalized to ${lineEnding.toUpperCase()}.`, type: 'primary', @@ -593,6 +631,43 @@ const actions = { commit('SET_TEXT_DIRECTION', textDirection) } }) + }, + + LISTEN_FOR_FILE_CHANGE ({ commit, state, rootState }) { + ipcRenderer.on('AGANI::update-file', (e, { type, change }) => { + if (type === 'unlink') { + return notice.notify({ + title: 'File Removed on Disk', + message: `${change.pathname} has been removed or moved to other place`, + type: 'warning', + time: 0, + showConfirm: false + }) + } else { + const { autoSave } = rootState.preferences + const { windowActive } = rootState + const { filename } = change.data + if (windowActive) return + if (autoSave) { + commit('LOAD_CHANGE', change) + } else { + notice.clear() + notice.notify({ + title: 'File Changed on Disk', + message: `${filename} has been changed on disk, do you want to reload it?`, + showConfirm: true, + time: 0 + }) + .then(() => { + commit('LOAD_CHANGE', change) + }) + } + } + }) + }, + + ASK_FILE_WATCH ({ commit }, { pathname, watch }) { + ipcRenderer.send('AGANI::file-watch', { pathname, watch }) } } diff --git a/src/renderer/store/help.js b/src/renderer/store/help.js index b63a2697..942e881d 100644 --- a/src/renderer/store/help.js +++ b/src/renderer/store/help.js @@ -91,7 +91,7 @@ export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pat // TODO(refactor:renderer/editor): Replace this function with `createDocumentState`. const fileState = JSON.parse(JSON.stringify(defaultFileState)) - const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave } = options + const { isUtf8BomEncoded, lineEnding, adjustLineEndingOnSave, textDirection = 'ltr' } = options assertLineEnding(adjustLineEndingOnSave, lineEnding) @@ -102,6 +102,7 @@ export const getSingleFileState = ({ id = getUniqueId(), markdown, filename, pat pathname, isUtf8BomEncoded, lineEnding, + textDirection, adjustLineEndingOnSave }) } diff --git a/src/renderer/store/preferences.js b/src/renderer/store/preferences.js index 2fba19b9..480f4bbd 100644 --- a/src/renderer/store/preferences.js +++ b/src/renderer/store/preferences.js @@ -52,7 +52,7 @@ const actions = { if (autoSave) { const { pathname, markdown } = state const options = getOptionsFromState(rootState.editor) - if (autoSave && pathname) { + if (pathname) { commit('SET_SAVE_STATUS', true) ipcRenderer.send('AGANI::response-file-save', { pathname, markdown, options }) }