latex-stickies 1.3.8 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +3 -2
- package/src/main.js +31 -5
- package/src/preload.js +2 -1
- package/src/renderer/note.css +35 -0
- package/src/renderer/note.html +7 -0
- package/src/renderer/note.js +92 -6
- package/src/store.js +127 -24
package/README.md
CHANGED
|
@@ -29,7 +29,8 @@ keeps a copy of the runtime carrying this app's name and icon in
|
|
|
29
29
|
`~/Library/Application Support/latex-stickies/` -- on APFS that is a
|
|
30
30
|
copy-on-write clone, so it costs almost no disk. On a Mac you can
|
|
31
31
|
download a `.dmg` from [Releases](https://github.com/kev-nj/latex-stickies/releases)
|
|
32
|
-
instead and skip Node entirely.
|
|
32
|
+
instead and skip Node entirely. It is not signed by Apple, so the first launch
|
|
33
|
+
needs a right-click on the app and **Open** rather than a double-click.
|
|
33
34
|
|
|
34
35
|
## Writing in a note
|
|
35
36
|
|
|
@@ -91,7 +92,7 @@ Use `Ctrl` in place of `Cmd` on Windows and Linux.
|
|
|
91
92
|
| | |
|
|
92
93
|
|---|---|
|
|
93
94
|
| `Cmd+N` | New note |
|
|
94
|
-
| `Cmd+W` | Close note (it stays closed until you reopen it) |
|
|
95
|
+
| `Cmd+W` | Close note (it stays closed until you reopen it; an empty one is discarded) |
|
|
95
96
|
| `Cmd+Shift+Backspace` | Delete note, permanently |
|
|
96
97
|
| `Cmd+B` / `Cmd+I` / `Cmd+E` | Bold / italic / code |
|
|
97
98
|
| `Cmd+K` | Link |
|
|
@@ -162,6 +163,7 @@ node scripts/smoke.js --lifecycle # closing every note behaves per platform
|
|
|
162
163
|
node scripts/render-check.js # the first-run note renders correctly
|
|
163
164
|
node scripts/ghost-check.js # autocomplete suggests, and Tab accepts
|
|
164
165
|
node scripts/snapshot-check.js # a long note is captured whole
|
|
166
|
+
node scripts/conflict-check.js # the changed-on-disk banner behaves
|
|
165
167
|
node scripts/verify-install.js # the install path, end to end (macOS)
|
|
166
168
|
```
|
|
167
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "latex-stickies",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Sticky notes for your desktop that render LaTeX and Markdown.",
|
|
5
5
|
"main": "src/main.js",
|
|
6
6
|
"scripts": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"zip"
|
|
31
31
|
],
|
|
32
32
|
"icon": "build/icon.icns"
|
|
33
|
-
}
|
|
33
|
+
},
|
|
34
|
+
"afterPack": "scripts/after-pack.js"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@codemirror/commands": "^6.11.0",
|
package/src/main.js
CHANGED
|
@@ -68,7 +68,13 @@ async function deleteFocusedNote() {
|
|
|
68
68
|
|
|
69
69
|
const note = store.get(id);
|
|
70
70
|
if (note && note.body.trim()) {
|
|
71
|
-
|
|
71
|
+
// Deliberately not attached to the note's window. A sheet hangs off a
|
|
72
|
+
// title bar, and these windows are frameless, transparent and often on
|
|
73
|
+
// top -- so the prompt rendered invisibly and the app sat waiting for an
|
|
74
|
+
// answer nobody could give. Delete Note looked like it did nothing at all.
|
|
75
|
+
// The same trap took window.confirm() out of use here.
|
|
76
|
+
win.show();
|
|
77
|
+
const { response } = await dialog.showMessageBox({
|
|
72
78
|
type: 'warning',
|
|
73
79
|
buttons: ['Delete', 'Cancel'],
|
|
74
80
|
defaultId: 1,
|
|
@@ -163,7 +169,22 @@ function openNote(note) {
|
|
|
163
169
|
// Closing a note is a decision that should survive a restart. Quitting is
|
|
164
170
|
// not: an app that shuts down closes every window, and treating that as
|
|
165
171
|
// "the user closed them all" would greet them with an empty desk.
|
|
166
|
-
|
|
172
|
+
// Nothing to record if the note is already gone: this fires during a
|
|
173
|
+
// delete too, and writing "open: false" for an id the store no longer
|
|
174
|
+
// knows created a fresh empty note in its place -- the note came straight
|
|
175
|
+
// back as "Untitled note".
|
|
176
|
+
if (!quitting && store.get(note.id)) {
|
|
177
|
+
const closed = store.get(note.id);
|
|
178
|
+
if (closed && !(closed.body || '').trim()) {
|
|
179
|
+
// An emptied note closed is a note thrown away, as in Stickies.
|
|
180
|
+
// Otherwise clearing a note's text leaves an "Untitled note" in the
|
|
181
|
+
// list for ever, with no way to be rid of it short of opening it
|
|
182
|
+
// again to delete it -- which is not where anyone looks.
|
|
183
|
+
store.remove(note.id);
|
|
184
|
+
} else {
|
|
185
|
+
store.upsert({ id: note.id, open: false });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
167
188
|
|
|
168
189
|
// macOS apps outlive their windows; the Dock icon is the way back.
|
|
169
190
|
// Elsewhere there is no way back, so an app with no windows is just an
|
|
@@ -493,11 +514,16 @@ function prepareToExit() {
|
|
|
493
514
|
}
|
|
494
515
|
|
|
495
516
|
function watchNotesFolder() {
|
|
496
|
-
stopWatchingNotes = store.watch((
|
|
497
|
-
for (const note of
|
|
517
|
+
stopWatchingNotes = store.watch((changes) => {
|
|
518
|
+
for (const { note, body, conflict } of changes) {
|
|
498
519
|
const win = windows.get(note.id);
|
|
499
|
-
if (win
|
|
520
|
+
if (!win || win.isDestroyed()) continue;
|
|
521
|
+
// A note with unsaved edits is never overwritten from disk. The window
|
|
522
|
+
// is told there is a conflict and offers the choice, because only the
|
|
523
|
+
// person typing knows which copy they want.
|
|
524
|
+
win.webContents.send(conflict ? 'note-conflict' : 'note-changed', body);
|
|
500
525
|
}
|
|
526
|
+
refreshNoteTitles();
|
|
501
527
|
});
|
|
502
528
|
}
|
|
503
529
|
|
package/src/preload.js
CHANGED
|
@@ -43,7 +43,8 @@ contextBridge.exposeInMainWorld('sticky', {
|
|
|
43
43
|
on: (channel, fn) => {
|
|
44
44
|
const allowed = [
|
|
45
45
|
'font-size', 'always-on-top-changed',
|
|
46
|
-
'copy-note-image', 'ai-settings-changed', 'note-changed', '
|
|
46
|
+
'copy-note-image', 'ai-settings-changed', 'note-changed', 'note-conflict',
|
|
47
|
+
'find-in-note', 'describe-latex',
|
|
47
48
|
];
|
|
48
49
|
if (!allowed.includes(channel)) return;
|
|
49
50
|
ipcRenderer.on(channel, (_e, payload) => fn(payload));
|
package/src/renderer/note.css
CHANGED
|
@@ -362,3 +362,38 @@ body.capturing-note #preview [data-math].capturing {
|
|
|
362
362
|
|
|
363
363
|
::-webkit-scrollbar { width: 8px; }
|
|
364
364
|
::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.16); border-radius: 4px; }
|
|
365
|
+
|
|
366
|
+
/* The file changed underneath unsaved edits. Sits over the note rather than
|
|
367
|
+
interrupting it: the text stays editable while the choice is on screen. */
|
|
368
|
+
#conflict {
|
|
369
|
+
/* Fixed to the window, not the scrolling content: the choice has to stay
|
|
370
|
+
visible while you scroll the note it is about. */
|
|
371
|
+
position: fixed;
|
|
372
|
+
left: 8px;
|
|
373
|
+
right: 8px;
|
|
374
|
+
bottom: 8px;
|
|
375
|
+
display: flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
gap: 8px;
|
|
378
|
+
padding: 7px 10px;
|
|
379
|
+
border-radius: 7px;
|
|
380
|
+
background: rgba(0, 0, 0, 0.82);
|
|
381
|
+
color: #fff;
|
|
382
|
+
font-size: 12px;
|
|
383
|
+
z-index: 5;
|
|
384
|
+
}
|
|
385
|
+
/* An author display rule beats the [hidden] attribute, which only sets
|
|
386
|
+
display:none in the browser's own stylesheet -- so without this the banner
|
|
387
|
+
is on screen from launch and cannot be dismissed. */
|
|
388
|
+
#conflict[hidden] { display: none; }
|
|
389
|
+
#conflict span { flex: 1; }
|
|
390
|
+
#conflict button {
|
|
391
|
+
font: inherit;
|
|
392
|
+
color: inherit;
|
|
393
|
+
padding: 3px 9px;
|
|
394
|
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
395
|
+
border-radius: 5px;
|
|
396
|
+
background: transparent;
|
|
397
|
+
cursor: pointer;
|
|
398
|
+
}
|
|
399
|
+
#conflict button:hover { background: rgba(255, 255, 255, 0.16); }
|
package/src/renderer/note.html
CHANGED
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
</header>
|
|
22
22
|
<main id="surface">
|
|
23
23
|
<div id="host"></div>
|
|
24
|
+
<!-- Shown only when the file changed underneath unsaved edits. Not a
|
|
25
|
+
modal: the note stays editable while the choice is on screen. -->
|
|
26
|
+
<div id="conflict" hidden>
|
|
27
|
+
<span>Changed on disk.</span>
|
|
28
|
+
<button class="reload" type="button">Use theirs</button>
|
|
29
|
+
<button class="mine" type="button">Keep mine</button>
|
|
30
|
+
</div>
|
|
24
31
|
</main>
|
|
25
32
|
<script src="vendor/katex.min.js"></script>
|
|
26
33
|
<script src="vendor/codemirror.js"></script>
|
package/src/renderer/note.js
CHANGED
|
@@ -14,12 +14,41 @@ let view = null;
|
|
|
14
14
|
|
|
15
15
|
/* ---------- state ---------- */
|
|
16
16
|
|
|
17
|
+
// Trailing debounce, but with a ceiling. A debounce that resets on every
|
|
18
|
+
// keystroke never fires while you are typing, so the file could trail the
|
|
19
|
+
// screen by as long as the burst lasted -- and anything that then read the
|
|
20
|
+
// file got text seconds out of date. VS Code's autosave delay is 1s; this
|
|
21
|
+
// keeps the quick 250ms settle and caps the wait at 1s.
|
|
22
|
+
const SAVE_SETTLE_MS = 250;
|
|
23
|
+
const SAVE_MAX_MS = 1000;
|
|
24
|
+
|
|
17
25
|
let saveTimer = null;
|
|
26
|
+
let pendingSince = 0;
|
|
27
|
+
|
|
28
|
+
function saveNow() {
|
|
29
|
+
clearTimeout(saveTimer);
|
|
30
|
+
saveTimer = null;
|
|
31
|
+
pendingSince = 0;
|
|
32
|
+
if (note) window.sticky.update({ body: note.body });
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
function scheduleSave() {
|
|
36
|
+
if (!pendingSince) pendingSince = Date.now();
|
|
37
|
+
if (Date.now() - pendingSince >= SAVE_MAX_MS) {
|
|
38
|
+
saveNow();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
19
41
|
clearTimeout(saveTimer);
|
|
20
|
-
saveTimer = setTimeout(
|
|
42
|
+
saveTimer = setTimeout(saveNow, SAVE_SETTLE_MS);
|
|
21
43
|
}
|
|
22
44
|
|
|
45
|
+
// Losing focus is the moment to be certain: the file is what another editor,
|
|
46
|
+
// or a sync engine, is about to read. VS Code ships the same behaviour as
|
|
47
|
+
// files.autoSave onFocusChange.
|
|
48
|
+
window.addEventListener('blur', () => {
|
|
49
|
+
if (saveTimer) saveNow();
|
|
50
|
+
});
|
|
51
|
+
|
|
23
52
|
// Typing is saved on a debounce, which Cmd+W would otherwise outrun: the window
|
|
24
53
|
// is closed from the main process, so the pending timer dies with the renderer
|
|
25
54
|
// and the last few words typed are lost. beforeunload is the last point the
|
|
@@ -114,16 +143,73 @@ window.sticky.on('describe-latex', async () => {
|
|
|
114
143
|
// The file changed underneath us -- another editor, Dropbox, a git checkout.
|
|
115
144
|
// Replace the text but keep the caret where it was, so a sync landing while
|
|
116
145
|
// you are typing does not throw you back to the top of the note.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Applies an edit that happened outside this window.
|
|
148
|
+
*
|
|
149
|
+
* As a splice of the part that actually differs, not a replacement of the
|
|
150
|
+
* whole document. Replacing everything maps every position to the end of the
|
|
151
|
+
* insertion, so the caret jumps, the selection is lost and the undo history
|
|
152
|
+
* becomes one opaque blob -- CodeMirror maps positions through a change set
|
|
153
|
+
* for you, but only if the change describes what really changed.
|
|
154
|
+
*
|
|
155
|
+
* Common prefix and suffix is enough here: an outside edit is nearly always
|
|
156
|
+
* one contiguous region, and it costs no dependency.
|
|
157
|
+
*/
|
|
158
|
+
function applyExternal(body) {
|
|
159
|
+
const doc = view.state.doc.toString();
|
|
160
|
+
if (body === doc) return;
|
|
161
|
+
|
|
162
|
+
let start = 0;
|
|
163
|
+
const max = Math.min(doc.length, body.length);
|
|
164
|
+
while (start < max && doc[start] === body[start]) start += 1;
|
|
165
|
+
|
|
166
|
+
let end = 0;
|
|
167
|
+
while (
|
|
168
|
+
end < max - start
|
|
169
|
+
&& doc[doc.length - 1 - end] === body[body.length - 1 - end]
|
|
170
|
+
) end += 1;
|
|
171
|
+
|
|
120
172
|
note.body = body;
|
|
121
173
|
view.dispatch({
|
|
122
|
-
changes: { from:
|
|
123
|
-
selection: { anchor: caret },
|
|
174
|
+
changes: { from: start, to: doc.length - end, insert: body.slice(start, body.length - end) },
|
|
124
175
|
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
window.sticky.on('note-changed', (body) => {
|
|
179
|
+
if (!view) return;
|
|
180
|
+
applyExternal(body);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The file changed while this note had edits that were not saved yet.
|
|
185
|
+
*
|
|
186
|
+
* Nothing is overwritten: the two versions are offered as a choice, because
|
|
187
|
+
* only the person typing knows which one they want. Silently reloading over
|
|
188
|
+
* unsaved input is the one thing every editor of this kind refuses to do.
|
|
189
|
+
*/
|
|
190
|
+
window.sticky.on('note-conflict', (body) => {
|
|
191
|
+
if (!view) return;
|
|
192
|
+
showConflict(body);
|
|
125
193
|
});
|
|
126
194
|
|
|
195
|
+
const conflict = document.getElementById('conflict');
|
|
196
|
+
|
|
197
|
+
function showConflict(body) {
|
|
198
|
+
conflict.hidden = false;
|
|
199
|
+
conflict.querySelector('.reload').onclick = () => {
|
|
200
|
+
applyExternal(body);
|
|
201
|
+
saveNow();
|
|
202
|
+
conflict.hidden = true;
|
|
203
|
+
};
|
|
204
|
+
conflict.querySelector('.mine').onclick = () => {
|
|
205
|
+
// Writing our copy over theirs is what makes this stick: the next save
|
|
206
|
+
// carries it, and the file stops disagreeing.
|
|
207
|
+
note.body = view.state.doc.toString();
|
|
208
|
+
saveNow();
|
|
209
|
+
conflict.hidden = true;
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
127
213
|
// Right-click a rendered equation to copy it as an image or as LaTeX.
|
|
128
214
|
host.addEventListener('contextmenu', async (e) => {
|
|
129
215
|
const slot = e.target.closest('[data-tex]');
|
package/src/store.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* the previous file intact rather than a truncated one.
|
|
18
18
|
*/
|
|
19
19
|
const { app } = require('electron');
|
|
20
|
+
const crypto = require('crypto');
|
|
20
21
|
const fs = require('fs');
|
|
21
22
|
const path = require('path');
|
|
22
23
|
|
|
@@ -98,6 +99,7 @@ function load() {
|
|
|
98
99
|
try {
|
|
99
100
|
body = fs.readFileSync(path.join(DIR, file), 'utf8');
|
|
100
101
|
} catch (_) { /* unreadable file: show it empty rather than vanish */ }
|
|
102
|
+
seen.set(file, digest(body));
|
|
101
103
|
return {
|
|
102
104
|
...DEFAULTS,
|
|
103
105
|
...saved,
|
|
@@ -154,7 +156,6 @@ function migrateLegacy() {
|
|
|
154
156
|
/* ---------- writing ---------- */
|
|
155
157
|
|
|
156
158
|
function writeFileAtomic(target, contents) {
|
|
157
|
-
justWrote.set(path.basename(target), Date.now());
|
|
158
159
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
159
160
|
const fd = fs.openSync(tmp, 'w');
|
|
160
161
|
try {
|
|
@@ -164,6 +165,7 @@ function writeFileAtomic(target, contents) {
|
|
|
164
165
|
fs.closeSync(fd);
|
|
165
166
|
}
|
|
166
167
|
fs.renameSync(tmp, target);
|
|
168
|
+
seen.set(path.basename(target), digest(contents));
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
// A kill between the temp write and the rename strands a .tmp file.
|
|
@@ -204,6 +206,22 @@ function flushIndex() {
|
|
|
204
206
|
function flush() {
|
|
205
207
|
timer = null;
|
|
206
208
|
for (const note of all()) {
|
|
209
|
+
// Only the notes that actually changed. Rewriting every note on every
|
|
210
|
+
// keystroke multiplied the mtimes, the events and the chances of
|
|
211
|
+
// misattributing one -- four notes turned one save into twenty events --
|
|
212
|
+
// and churned any sync engine watching the folder.
|
|
213
|
+
if (!isDirty(note)) continue;
|
|
214
|
+
|
|
215
|
+
// Never write over a file that moved underneath us. The watcher usually
|
|
216
|
+
// gets there first, but a save already pending can land in between --
|
|
217
|
+
// and overwriting then is silent loss, with no event left to notice it.
|
|
218
|
+
// Skipping leaves the file alone; the watcher reports the conflict, and
|
|
219
|
+
// if it somehow missed the event, the next save checks again.
|
|
220
|
+
try {
|
|
221
|
+
const onDisk = fs.readFileSync(path.join(DIR, note.file), 'utf8');
|
|
222
|
+
if (digest(onDisk) !== seen.get(note.file)) continue;
|
|
223
|
+
} catch (_) { /* not there yet: writing it is exactly right */ }
|
|
224
|
+
|
|
207
225
|
try {
|
|
208
226
|
writeFileAtomic(path.join(DIR, note.file), note.body || '');
|
|
209
227
|
} catch (err) {
|
|
@@ -213,6 +231,11 @@ function flush() {
|
|
|
213
231
|
flushIndex();
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
/** Has this note been edited since it was last written to disk? */
|
|
235
|
+
function isDirty(note) {
|
|
236
|
+
return digest(note.body || '') !== seen.get(note.file);
|
|
237
|
+
}
|
|
238
|
+
|
|
216
239
|
// Writes are frequent (every keystroke, every window drag), so coalesce them.
|
|
217
240
|
function save() {
|
|
218
241
|
if (timer) return;
|
|
@@ -235,6 +258,11 @@ function upsert(note) {
|
|
|
235
258
|
const i = list.findIndex((n) => n.id === note.id);
|
|
236
259
|
|
|
237
260
|
if (i === -1) {
|
|
261
|
+
// A patch for an id nobody has, carrying no text, is not a new note: it is
|
|
262
|
+
// a stray message about one that has gone -- a window closing after its
|
|
263
|
+
// note was deleted, or bounds arriving as it goes. Creating a note from it
|
|
264
|
+
// resurrects what the user just deleted, empty.
|
|
265
|
+
if (note.body === undefined) return;
|
|
238
266
|
const taken = new Set(list.map((n) => n.file));
|
|
239
267
|
list.push({ ...DEFAULTS, ...note, file: note.file || slugFor(note.body, taken) });
|
|
240
268
|
save();
|
|
@@ -254,11 +282,8 @@ function upsert(note) {
|
|
|
254
282
|
const next = slugFor(list[i].body, taken, path.extname(before.file) || '.md');
|
|
255
283
|
try {
|
|
256
284
|
const from = path.join(DIR, before.file);
|
|
257
|
-
// Both names count as our own writing, or the rename looks like an
|
|
258
|
-
// outside edit and the watcher reloads over the top of it.
|
|
259
|
-
justWrote.set(before.file, Date.now());
|
|
260
|
-
justWrote.set(next, Date.now());
|
|
261
285
|
if (fs.existsSync(from)) fs.renameSync(from, path.join(DIR, next));
|
|
286
|
+
seen.delete(before.file);
|
|
262
287
|
list[i].file = next;
|
|
263
288
|
// Write this note's body now too. The rename is immediate but the body
|
|
264
289
|
// is on the 400ms timer, so a reload in between would read the new file
|
|
@@ -284,6 +309,11 @@ function remove(id) {
|
|
|
284
309
|
try {
|
|
285
310
|
fs.unlinkSync(path.join(DIR, note.file));
|
|
286
311
|
} catch (_) { /* already gone */ }
|
|
312
|
+
// Forget what that filename held. Otherwise a later note that takes the
|
|
313
|
+
// same name -- "note.md" is handed out again the moment an untitled note
|
|
314
|
+
// is deleted -- is compared against the dead file's content, matches, and
|
|
315
|
+
// is judged already saved. It then never reaches disk.
|
|
316
|
+
seen.delete(note.file);
|
|
287
317
|
}
|
|
288
318
|
notes = all().filter((n) => n.id !== id);
|
|
289
319
|
save();
|
|
@@ -296,34 +326,106 @@ function saveNow() {
|
|
|
296
326
|
|
|
297
327
|
/* ---------- watching the folder ---------- */
|
|
298
328
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
329
|
+
/**
|
|
330
|
+
* The content we last wrote or read, per file, as a hash.
|
|
331
|
+
*
|
|
332
|
+
* This is how an event is attributed. A file whose bytes hash to what we last
|
|
333
|
+
* put there is our own save coming back, whenever it arrives; anything else is
|
|
334
|
+
* somebody else's edit. What it replaces was a 1200ms window after each write,
|
|
335
|
+
* which is not what mature editors do and was wrong in both directions: a save
|
|
336
|
+
* echoed back late -- measured at 2.5s when the main process stalls -- looked
|
|
337
|
+
* like an outside edit and reverted the note being typed in.
|
|
338
|
+
*
|
|
339
|
+
* VS Code uses mtime+size for the same job and has an open issue about content
|
|
340
|
+
* changing without the length changing; notes are small enough to just hash.
|
|
341
|
+
*/
|
|
342
|
+
const seen = new Map();
|
|
343
|
+
const digest = (text) => crypto.createHash('sha256').update(text || '').digest('hex');
|
|
302
344
|
|
|
303
345
|
/**
|
|
304
346
|
* Calls back when a note file changes underneath us.
|
|
305
347
|
*
|
|
306
348
|
* This is the point of keeping notes as files: edit one in Vim, or let Dropbox
|
|
307
349
|
* bring down a change from another machine, and the open note follows along.
|
|
308
|
-
*
|
|
350
|
+
*
|
|
351
|
+
* Two rules, both taken from how VS Code and Zed handle this:
|
|
352
|
+
*
|
|
353
|
+
* - Only the file the event names is touched. Fanning one event out into a
|
|
354
|
+
* reload of every note is what let a change to one note revert the text
|
|
355
|
+
* being typed into another.
|
|
356
|
+
* - A note with unsaved edits is never overwritten. It is reported as a
|
|
357
|
+
* conflict instead, for the window to offer as a choice. "Do not resolve a
|
|
358
|
+
* model that is dirty" is the invariant every editor of this kind keeps.
|
|
309
359
|
*/
|
|
310
360
|
function watch(onChanged) {
|
|
311
|
-
|
|
361
|
+
const timers = new Map();
|
|
312
362
|
let watcher = null;
|
|
363
|
+
|
|
364
|
+
const handle = (filename) => {
|
|
365
|
+
timers.delete(filename);
|
|
366
|
+
const full = path.join(DIR, filename);
|
|
367
|
+
|
|
368
|
+
let body;
|
|
369
|
+
try {
|
|
370
|
+
body = fs.readFileSync(full, 'utf8');
|
|
371
|
+
} catch (_) {
|
|
372
|
+
// Deleted or moved away. The window keeps what it has: a file vanishing
|
|
373
|
+
// underneath an open note is not a reason to blank it.
|
|
374
|
+
seen.delete(filename);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const before = seen.get(filename);
|
|
379
|
+
const now = digest(body);
|
|
380
|
+
|
|
381
|
+
// Set LATEX_STICKIES_DEBUG_WATCH=1 to see why an event was attributed the
|
|
382
|
+
// way it was. Guessing at this from the outside cost a day: the question
|
|
383
|
+
// is always whether the bytes on disk are the bytes we last wrote.
|
|
384
|
+
if (process.env.LATEX_STICKIES_DEBUG_WATCH) {
|
|
385
|
+
const held = all().find((n) => n.file === filename);
|
|
386
|
+
console.log(
|
|
387
|
+
`WATCH ${filename} disk=${now.slice(0, 8)} lastWrote=${String(before).slice(0, 8)}`
|
|
388
|
+
+ ` memory=${digest(held ? held.body : '').slice(0, 8)}`
|
|
389
|
+
+ ` ours=${now === before} diskLen=${body.length}`
|
|
390
|
+
+ ` memLen=${held ? (held.body || '').length : -1}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (now === before) return; // our own save, however late it arrives
|
|
395
|
+
|
|
396
|
+
const note = all().find((n) => n.file === filename);
|
|
397
|
+
if (!note) {
|
|
398
|
+
// A file that appeared from outside. Added on its own rather than by
|
|
399
|
+
// reloading the folder: a reload drops every note's unsaved edits on the
|
|
400
|
+
// floor, so one new file would cost the words being typed in another.
|
|
401
|
+
const meta = readIndex()[filename] || {};
|
|
402
|
+
const added = {
|
|
403
|
+
...DEFAULTS, ...meta, id: meta.id || filename, file: filename, body,
|
|
404
|
+
};
|
|
405
|
+
all().push(added);
|
|
406
|
+
seen.set(filename, now);
|
|
407
|
+
onChanged([{ note: added, body, conflict: false }]);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Whether the note was edited since we last wrote it has to be judged
|
|
412
|
+
// against the hash from before this event, which is what our copy came
|
|
413
|
+
// from -- not against the disk we have just read.
|
|
414
|
+
const dirty = digest(note.body || '') !== before;
|
|
415
|
+
seen.set(filename, now); // seen it now, so a repeat event says nothing new
|
|
416
|
+
|
|
417
|
+
if (!dirty) note.body = body;
|
|
418
|
+
onChanged([{ note, body, conflict: dirty }]);
|
|
419
|
+
};
|
|
420
|
+
|
|
313
421
|
try {
|
|
314
422
|
watcher = fs.watch(DIR, (_event, filename) => {
|
|
315
423
|
if (!filename || filename.startsWith('.') || filename.endsWith('.tmp')) return;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
timer = setTimeout(() => {
|
|
322
|
-
const before = new Map(all().map((n) => [n.file, n.body]));
|
|
323
|
-
const after = reload();
|
|
324
|
-
const changed = after.filter((n) => before.get(n.file) !== n.body);
|
|
325
|
-
if (changed.length) onChanged(changed);
|
|
326
|
-
}, 150);
|
|
424
|
+
if (!/\.(md|tex|txt)$/i.test(filename)) return;
|
|
425
|
+
|
|
426
|
+
// Editors save in bursts; wait for the dust to settle, per file.
|
|
427
|
+
clearTimeout(timers.get(filename));
|
|
428
|
+
timers.set(filename, setTimeout(() => handle(filename), 150));
|
|
327
429
|
});
|
|
328
430
|
} catch (err) {
|
|
329
431
|
console.error('could not watch the notes folder', err);
|
|
@@ -333,8 +435,8 @@ function watch(onChanged) {
|
|
|
333
435
|
// watcher left open is enough to stop the process exiting after its last
|
|
334
436
|
// window closes -- which on Windows leaves the app running invisibly.
|
|
335
437
|
return () => {
|
|
336
|
-
clearTimeout(timer);
|
|
337
|
-
|
|
438
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
439
|
+
timers.clear();
|
|
338
440
|
if (watcher) watcher.close();
|
|
339
441
|
watcher = null;
|
|
340
442
|
};
|
|
@@ -371,5 +473,6 @@ function saveSettings(patch) {
|
|
|
371
473
|
}
|
|
372
474
|
|
|
373
475
|
module.exports = {
|
|
374
|
-
all, get, upsert, remove, saveNow, reload, watch,
|
|
476
|
+
all, get, upsert, remove, saveNow, reload, watch, isDirty,
|
|
477
|
+
settings, saveSettings, DIR,
|
|
375
478
|
};
|