Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
40 changes: 37 additions & 3 deletions apps/desktop/src-tauri/src/conflict/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ const MAX_PER_NOTE: usize = 20;
/// Archived versions older than this are pruned regardless of count.
const MAX_AGE_MS: u64 = 90 * 24 * 60 * 60 * 1000;

/// Archive one version's full content before it is resolved away.
/// Archive one version's full content before it is resolved away. Returns
/// the archive file's path relative to the graph root.
pub fn archive_version(
root: &Path,
rel: &str,
device: Option<&str>,
modified_ms: u64,
bytes: &[u8],
) -> AppResult<()> {
) -> AppResult<String> {
let dir = note_archive_dir(root, rel)
.ok_or_else(|| AppError::io(format!("invalid archive path: {rel}")))?;
fs::create_dir_all(&dir)?;
Expand All @@ -40,7 +41,24 @@ pub fn archive_version(
target = dir.join(format!("{stem}-{attempt}.{ext}"));
}
crate::fs::atomic_write_bytes(root, &target, bytes)?;
Ok(())
let archived = target
.strip_prefix(root)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| target.to_string_lossy().into_owned());
Ok(archived)
}

/// Archive an open note's unsaved buffer: the user typed into a note while
/// an external change was parked against it, then closed the note before
/// choosing a side. Saves are paused behind a parked conflict, so without
/// this the buffer would die with the session (#1443). Stamped with the
/// current time and the `unsaved` tag, next to the sync versions.
pub fn archive_unsaved(root: &Path, rel: &str, bytes: &[u8]) -> AppResult<String> {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|dur| dur.as_millis() as u64)
.unwrap_or(0);
archive_version(root, rel, Some("unsaved"), now_ms, bytes)
}

/// Prune the archive: per note, keep the newest [`MAX_PER_NOTE`] versions and
Expand Down Expand Up @@ -158,6 +176,22 @@ mod tests {
.exists());
}

#[test]
fn archive_unsaved_keeps_the_buffer_under_the_unsaved_tag() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".reflect")).unwrap();
let archived = archive_unsaved(root.path(), "daily/2026-10-07.md", b"# typed\n").unwrap();
assert!(
archived.starts_with(".reflect/conflict-archive/daily/2026-10-07.md/"),
"{archived}"
);
assert!(archived.ends_with("-unsaved.md"), "{archived}");
assert_eq!(
fs::read_to_string(root.path().join(&archived)).unwrap(),
"# typed\n"
);
}

#[test]
fn traversal_shapes_are_refused() {
let root = tempdir().unwrap();
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src-tauri/src/fs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,25 @@ pub async fn note_read(
crate::blocking::run_blocking(move || Ok(io::read_note_no_follow(&root, &abs)?)).await
}

/// Archive an open note's unsaved buffer under `.reflect/conflict-archive/`
/// (see [`crate::conflict::archive::archive_unsaved`]). The editor calls
/// this when a session is torn down while an external change is parked
/// against unsaved edits: the buffer must outlive the session. Returns the
/// graph-relative archive path for the notice.
#[tauri::command]
pub async fn note_archive_unsaved(
path: String,
contents: String,
generation: u64,
state: State<'_, GraphState>,
) -> AppResult<String> {
let root = root_for_generation(&state, generation)?;
crate::blocking::run_blocking(move || {
crate::conflict::archive::archive_unsaved(&root, &path, contents.as_bytes())
})
.await
}

/// How a [`note_read_local`] request found the note on disk.
#[derive(Debug, Serialize)]
#[serde(
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ pub fn run() {
fs::note_read_local,
fs::note_create,
fs::note_write,
fs::note_archive_unsaved,
fs::asset_write,
fs::asset_read,
fs::asset_read_binary,
Expand Down
31 changes: 30 additions & 1 deletion apps/desktop/src/editor/note-session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
* conflict when the user kept typing during the save.
*/
let inFlightWrite: string | null = null
/** The last buffer handed to `io.archiveUnsaved` — the same bytes are archived once. */
let archivedBuffer: string | null = null
/** True while we push external content into the editor via `applyContent`. */
let applyingContent = false
/** True while the initial `load()` read is in flight. */
Expand Down Expand Up @@ -166,7 +168,34 @@ export function createNoteSession(options: NoteSessionOptions): NoteSession {
save()
// save() extended the chain synchronously (or left it settled when there
// was nothing to do) — the chain as of now is exactly this flush's write.
return saveChain
// Under a parked conflict that write is a no-op, so the buffer is
// preserved instead; every exit path (navigation, quit, background) goes
// through here and waits for it.
return conflict === null ? saveChain : preserve()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by GPT-6. Reviewed SHA: 9e2b52e.

[P1] Check for a parked conflict after the save chain settles.

The choice here happens before the queued write runs. Start with a dirty buffer and no conflict, let an external writer change the file before its watcher notification, then press Cmd-Q. flush selects saveChain. note_write rejects the stale expectedContents; save's catch awaits reconcileFromDisk, which now parks the external content. The selected promise then resolves without ever calling preserve, and installQuitFlush confirms quit. The local buffer was neither saved nor archived, although the archive directory is writable.

Await the pending save/reconcile work, then re-check whether a dirty parked buffer needs archival, and await that archival before resolving flush. Add a regression where the exit-facing flush starts without a conflict and its CAS failure creates one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that path was missed: a stale write parks the conflict from inside the save chain, after flush() had already chosen the chain. In c486530 flush() returns saveChain.then(preserve), so the parked-buffer check always runs after the chain settles, whichever way the conflict appeared, and the exit flush waits for the archive. Regression: "a flush whose write lands stale parks the conflict and still archives" (dirty buffer, disk changed, no watcher event, flush() alone).

}

/**
* Keep a parked conflict's unsaved buffer recoverable (#1443). Saves are
* paused while the conflict waits on the user, so this is the only copy
* that survives teardown. The same buffer is archived once, however many
* flushes see it; a failed archive keeps the buffer in the session and the
* next flush retries.
*/
async function preserve(): Promise<void> {
if (!dirty || io.archiveUnsaved === undefined) {
return
}
const content = header + buffer
if (content === archivedBuffer) {
return
}
try {
await io.archiveUnsaved(path, content)
archivedBuffer = content
} catch (cause) {
error = errorMessage(cause)
emit()
}
}

function editorChanged(markdown: string): void {
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/editor/note-session-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export interface NoteSessionIo {
write:
| ((path: string, contents: string, expectedContents?: string | null) => Promise<void>)
| null
/**
* Keep an unsaved buffer somewhere recoverable. Called by `flush()` while
* an external change is parked against unsaved edits: saves are paused in
* that state, so without this the buffer would be lost on navigation,
* quit, or backgrounding. Optional — a host without a graph generation
* cannot archive.
*/
archiveUnsaved?: ((path: string, contents: string) => Promise<void>) | undefined
}

/** Why {@link NoteSessionOptions.onContent} fired. */
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/editor/note-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ interface Harness {
snapshots: NoteSessionSnapshot[]
expectedContents: (string | null | undefined)[]
writes: Array<{ path: string; contents: string }>
archived: Array<{ path: string; contents: string }>
applied: string[]
contents: Array<{ content: string; origin: string }>
/** `null` deletes the file: subsequent reads throw the notFound AppError. */
setDisk: (contents: string | null) => void
/** While set, writes reject with this message (the save-failure seam). */
failWrites: (message: string | null) => void
/** While set, archiveUnsaved rejects with this message. */
failArchive: (message: string | null) => void
session: ReturnType<typeof createNoteSession>
}

Expand All @@ -49,10 +52,12 @@ function harness(options?: {
const snapshots: NoteSessionSnapshot[] = []
const expectedContents: (string | null | undefined)[] = []
const writes: Array<{ path: string; contents: string }> = []
const archived: Array<{ path: string; contents: string }> = []
const applied: string[] = []
const contents: Array<{ content: string; origin: string }> = []
let disk = options?.disk === undefined ? '# Hello\n' : options.disk
let writeFailure: string | null = null
let archiveFailure: string | null = null
const session = createNoteSession({
path: 'notes/a.md',
io: {
Expand All @@ -77,6 +82,12 @@ function harness(options?: {
writes.push({ path, contents })
disk = contents
},
archiveUnsaved: async (path, contents) => {
if (archiveFailure !== null) {
throw new Error(archiveFailure)
}
archived.push({ path, contents })
},
},
classify: options?.classify ?? (() => 'exact'),
onSnapshot: (snapshot) => {
Expand All @@ -99,6 +110,7 @@ function harness(options?: {
snapshots,
expectedContents,
writes,
archived,
applied,
contents,
setDisk: (contents) => {
Expand All @@ -107,6 +119,9 @@ function harness(options?: {
failWrites: (message) => {
writeFailure = message
},
failArchive: (message) => {
archiveFailure = message
},
session,
}
}
Expand Down Expand Up @@ -213,6 +228,79 @@ describe('createNoteSession', () => {
expect(snapshots.length).toBe(afterLoad + 1) // one dirty transition, not two
})

it('dispose archives the buffer when a conflict is parked', async () => {
// #1443: a parked conflict pauses saves, so leaving the note would drop
// everything typed since the external change arrived.
const { session, writes, archived, snapshots, setDisk } = harness()
session.load()
await settled()

session.editorChanged('# Mine\n')
setDisk('# Theirs\n')
session.externalChanged()
await settled()
expect(snapshots.at(-1)?.conflict).toBe('# Theirs\n')

session.dispose()
await settled()

expect(writes).toEqual([]) // the conflict still blocks the write
expect(archived).toEqual([{ path: 'notes/a.md', contents: '# Mine\n' }])
})

it('flush archives a parked buffer once, without dispose (the quit path)', async () => {
// ⌘Q, window close, and iOS backgrounding await flush() on every open
// session but never dispose it; a blur-triggered flush can run many times.
const { session, archived, setDisk } = harness()
session.load()
await settled()
session.editorChanged('# Mine\n')
setDisk('# Theirs\n')
session.externalChanged()
await settled()

await session.flush()
await session.flush()
expect(archived).toEqual([{ path: 'notes/a.md', contents: '# Mine\n' }])

session.editorChanged('# Mine, more\n')
await session.flush()
expect(archived.map((entry) => entry.contents)).toEqual(['# Mine\n', '# Mine, more\n'])
})

it('a failed archive keeps the buffer and the next flush retries', async () => {
const { session, archived, snapshots, setDisk, failArchive } = harness()
session.load()
await settled()
session.editorChanged('# Mine\n')
setDisk('# Theirs\n')
session.externalChanged()
await settled()

failArchive('disk full')
await session.flush()
expect(archived).toEqual([])
expect(snapshots.at(-1)?.error).toBe('disk full')
expect(session.content()).toBe('# Mine\n') // still owned by the session

failArchive(null)
await session.flush()
expect(archived).toEqual([{ path: 'notes/a.md', contents: '# Mine\n' }])
})

it('dispose does not archive without a parked conflict', async () => {
const { session, writes, archived } = harness()
session.load()
await settled()

session.editorChanged('# Mine\n')
session.dispose()
await settled()

expect(writes).toEqual([{ path: 'notes/a.md', contents: '# Mine\n' }])
expect(archived).toEqual([])
})

it('keepMine rewrites the file even when the conflict content equals the buffer', async () => {
const { session, writes, snapshots, setDisk } = harness()
session.load()
Expand Down
34 changes: 33 additions & 1 deletion apps/desktop/src/editor/use-note-document.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { readNote, writeNote, type FileChange } from '@reflect/core'
import {
archiveUnsavedNote,
errorMessage,
readNote,
writeNote,
type FileChange,
} from '@reflect/core'
import { startOperation } from '@/lib/operations.ts'
import { useFileChanges } from '@/lib/use-file-changes.ts'
import { createDocumentBinding, type DocumentBinding } from './document-binding.ts'
import type { NoteEditorHandle } from './note-editor.tsx'
Expand Down Expand Up @@ -128,6 +135,31 @@ export function useNoteDocument(
return writeNote(forPath, contents, current, expectedContents)
}
: null,
archiveUnsaved: canWrite
? async (forPath, contents) => {
const current = generationRef.current
if (current === null) {
return
}
try {
const archived = await archiveUnsavedNote(forPath, contents, current)
startOperation('Unsaved edits kept').warn(
`${forPath} changed on disk while you were editing. Your version is at ${archived}.`,
)
} catch (cause) {
// The buffer stays in the session (the next flush retries);
// meanwhile the user can take it out by hand.
startOperation('Unsaved edits could not be archived', {
persistent: true,
action: {
label: 'Copy edits',
run: () => navigator.clipboard.writeText(contents),
},
}).fail(`${forPath}: ${errorMessage(cause)}`)
throw cause
}
}
: undefined,
},
classify: checkRoundTrip,
onSnapshot: (next) => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/exports/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export {
readNoteLocal,
type LocalNoteRead,
writeNote,
archiveUnsavedNote,
createNoteIfAbsent,
writeAsset,
readAsset,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/graph/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ export async function writeNote(
echoLocalWrite({ path, kind: 'upsert', modifiedMs: modifiedMs ?? Date.now() })
}

/**
* Archive an open note's unsaved buffer under `.reflect/conflict-archive/`.
* The note session calls this when it is torn down while an external change
* is parked against unsaved edits: saves are paused behind a parked
* conflict, so the buffer would otherwise die with the session. Returns the
* graph-relative archive path.
*/
export async function archiveUnsavedNote(
path: string,
contents: string,
generation: number,
): Promise<string> {
return await call('note_archive_unsaved', { path, contents, generation }, z.string())
}

/**
* Atomically create a note only if `path` is still unoccupied. A collision is
* returned as data and never overwrites the winner, closing the race between a
Expand Down
Loading