Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions electron/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -3413,8 +3413,11 @@ function searchThreads(store, input) {
* Full thread detail for the renderer.
*
* Selecting a thread is visiting it: when markVisited is true (default), stamp
* lastVisitedAt = Date.now() and persist WITHOUT bumping updatedAt (visiting is
* not activity; bumping would re-unread the thread and re-sort the sidebar).
* lastVisitedAt = Date.now() WITHOUT bumping updatedAt (visiting is not
* activity; bumping would re-unread the thread and re-sort the sidebar).
* Do not store.save() here: a visit stamp must not schedule a whole-store
* rewrite (#636). markDirty lets the field ride the next real flush; the
* exit hook covers quit. Hard crash: unread-dot is a click stale.
*
* Callers (audit before changing stamp rules):
* - electron/ipc.js threads:get — user selection; markVisited true (default)
Expand All @@ -3436,7 +3439,7 @@ function getThreadDetail(store, threadId, workflow = null, opts) {
if (markVisited) {
// No touch: visiting must not bump updatedAt.
store.updateThread(threadId, { lastVisitedAt: Date.now() });
store.save();
store.markDirty();
}
const current = store.getThread(threadId) || thread;
return {
Expand Down
23 changes: 17 additions & 6 deletions electron/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,22 @@ class Store {
return cloneEmpty();
}

/**
* Remember in-memory mutations without scheduling a flush. The next
* save() coalesces them; the exit hook writes if we quit first. Use for
* cheap bookkeeping (lastVisitedAt) that must not rewrite the whole
* store on every call (#636). Not `touch`: that already means bump
* updatedAt on updateThread.
*/
markDirty() {
this._dirty = true;
// At most one exit hook no matter how often markDirty()/save() run.
if (!this._exitHookArmed) {
this._exitHookArmed = true;
process.once("exit", this._flushOnExit);
}
}

/**
* Mark dirty and coalesce writes: the whole store is one JSON blob, so a
* per-stream-event save would re-stringify everything every time. The
Expand All @@ -1130,13 +1146,8 @@ class Store {
* Callers that need the bytes on disk right now use saveNow().
*/
save() {
this._dirty = true;
this.markDirty();
this._scheduleFlush();
// At most one exit hook no matter how often save() is called.
if (!this._exitHookArmed) {
this._exitHookArmed = true;
process.once("exit", this._flushOnExit);
}
}

_scheduleFlush() {
Expand Down
27 changes: 27 additions & 0 deletions electron/test/last-visited.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,33 @@ describe("lastVisitedAt (round 43)", () => {
assert.equal(store.getThread(thread.id).lastVisitedAt, thread.createdAt);
});

it("getThreadDetail (threads:get) does not schedule a flush (#636)", () => {
const thread = services.createThread(store, {
projectId: project.id,
title: "Idle click",
});
store.updateThread(thread.id, { lastVisitedAt: 1 });
store.saveNow();
assert.equal(store._timer, null, "precondition: no flush armed");

const detail = services.getThreadDetail(store, thread.id);

assert.ok(
detail.thread.lastVisitedAt > 1,
"selection still stamps lastVisitedAt in memory",
);
assert.equal(
store._timer,
null,
"visit stamp must not arm a whole-store rewrite",
);
assert.equal(
store._dirty,
true,
"stamp must stay dirty so the exit hook / next real flush persist it",
);
});

it("getThreadDetail stamps lastVisitedAt and does NOT bump updatedAt", async () => {
const thread = services.createThread(store, {
projectId: project.id,
Expand Down
10 changes: 10 additions & 0 deletions electron/test/store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ describe("Store", () => {
assert.ok(SAVE_DEBOUNCE_MAX_MS >= SAVE_DEBOUNCE_MS * 2);
});

it("markDirty remembers mutations without scheduling a flush (#636)", () => {
const store = new Store(filePath);
store.setProjects([{ id: "p", slug: "a/b", name: "b", path: "/x" }]);
store.markDirty();
assert.equal(store._dirty, true);
assert.equal(store._timer, null, "must not arm the debounce timer");
store.saveNow();
assert.equal(new Store(filePath).getProjects()[0].id, "p");
});

it("saveNow() flushes a pending debounced save immediately", () => {
const store = new Store(filePath);
store.setProjects([{ id: "p", slug: "a/b", name: "b", path: "/x" }]);
Expand Down
Loading