Skip to content
Closed
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
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
2 changes: 2 additions & 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 Expand Up @@ -381,6 +382,7 @@ pub fn run() {
git::git_merge_remote,
git::git_push,
quit::quit_confirm,
quit::quit_cancel,
windows::open_note_window,
windows::window_bootstrap,
windows::close_note_windows,
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src-tauri/src/quit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ impl QuitState {
!self.lock().is_empty()
}

/// Call the quit off: the user chose to keep editing after a flush could
/// not preserve everything. No window owes a confirmation any more, so a
/// late `quit_confirm` from another window settles nothing and window
/// creation is allowed again.
pub fn cancel(&self) {
self.lock().clear();
}

/// Settle one window's obligation — its flush confirmed, or the window
/// was destroyed and can no longer confirm. True when it was the last
/// one owed; idempotent per label, so a confirm followed by a destroy
Expand All @@ -65,10 +73,26 @@ pub fn quit_confirm(window: tauri::WebviewWindow, app: AppHandle, state: State<'
}
}

/// Cancel a deferred quit: the user chose to keep editing.
#[tauri::command]
pub fn quit_cancel(state: State<'_, QuitState>) {
state.cancel();
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cancel_disarms_and_a_late_confirm_settles_nothing() {
let state = QuitState::default();
state.arm(["main".to_string(), "note-1".to_string()]);
assert!(state.armed());
state.cancel();
assert!(!state.armed());
assert!(!state.settle("main"), "nothing is owed after a cancel");
}

#[test]
fn arms_per_window_and_concludes_on_the_last_settle() {
let state = QuitState::default();
Expand Down
34 changes: 33 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,37 @@ 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
// Then preserve: under a parked conflict the write was a no-op, and a
// write that lands stale parks one on the way (its catch reconciles), so
// the check must run after the chain settles. Every exit path
// (navigation, quit, background) goes through here and waits for both.
return saveChain.then(preserve)
}

/**
* 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, marks
* the session errored, and rejects the flush so an exit path can ask the
* user before the buffer is destroyed.
*/
async function preserve(): Promise<void> {
if (conflict === null || !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()
throw cause
}
}

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
106 changes: 106 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,97 @@ 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 flush whose write lands stale parks the conflict and still archives', async () => {
// ⌘Q with a dirty buffer and no conflict yet: the external change is on
// disk but its watcher event has not arrived. The flush's write is
// rejected as stale, the catch parks the conflict, and the buffer must
// be archived before the flush resolves and the app quits.
const { session, writes, archived, snapshots, setDisk } = harness()
session.load()
await settled()
session.editorChanged('# Mine\n')
setDisk('# Theirs\n') // no externalChanged(): the watcher is behind

await session.flush()

expect(writes).toEqual([])
expect(snapshots.at(-1)?.conflict).toBe('# Theirs\n')
expect(archived).toEqual([{ path: 'notes/a.md', contents: '# Mine\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 expect(session.flush()).rejects.toThrow('disk full') // exit paths ask before discarding
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
2 changes: 1 addition & 1 deletion apps/desktop/src/editor/open-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe('open documents', () => {
const unregisterBad = registerOpenDocument({ session: failing })
const unregisterGood = registerOpenDocument({ session: fakeSession('notes/good.md', log) })
try {
await expect(flushOpenDocuments()).resolves.toBeUndefined()
await expect(flushOpenDocuments()).resolves.toEqual(['notes/bad.md']) // reported, not thrown
expect(log).toContain('flush:notes/good.md')
} finally {
unregisterBad()
Expand Down
Loading
Loading