update or notice file changed on disk (#796)

* update or notice file changed on disk

* update changelog

* fix some typo and optimize some codes
This commit is contained in:
Ran Luo 2019-03-26 20:40:51 +08:00 committed by Felix Häusler
parent 499a3cd36b
commit a41f751f2f
11 changed files with 251 additions and 73 deletions

View File

@ -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**

View File

@ -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 })
}
})

View File

@ -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] })
}
})

View File

@ -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())
}

View File

@ -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
try {
this.openFile(win, pathname)
} catch (err) {
log(err)
}
})
// 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)
// 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)

View File

@ -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')

View File

@ -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)

View File

@ -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)

View File

@ -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 })
}
}

View File

@ -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
})
}

View File

@ -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 })
}