diff --git a/linux/src/app.rs b/linux/src/app.rs index 2832aba..4ec9b5b 100644 --- a/linux/src/app.rs +++ b/linux/src/app.rs @@ -15,8 +15,10 @@ use acord_viewport::{ viewport_set_line_indicator, viewport_set_gutter_rainbow, viewport_set_auto_pair_flags, viewport_send_command, viewport_free_string, + viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes, ViewportHandle, }; +use acord_viewport::sidecar; use acord_viewport::browser::{self, BrowserHandle}; use acord_viewport::handle as viewport_handle; use acord_viewport::editor::ShellAction; @@ -207,23 +209,99 @@ impl App { fn drain_browser_open(&mut self) { let Some(handle) = self.browser_handle.as_mut() else { return }; let Some(path) = browser::handle::take_pending_open(handle) else { return }; - if let Ok(text) = std::fs::read_to_string(&path) { - let c = CString::new(text).unwrap_or_default(); - viewport_set_text(self.handle, c.as_ptr()); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); - let c_ext = CString::new(ext).unwrap(); - viewport_set_lang(self.handle, c_ext.as_ptr()); - if let Some(w) = &self.window { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); - w.set_title(&format!("{name} - Acord")); - w.focus_window(); - } - self.current_file = Some(path); - self.last_autosaved_hash = None; + if let Ok(bytes) = std::fs::read(&path) { + self.load_file_bytes(&path, bytes); } self.close_browser(); } + /// pushes file bytes into the viewport, splitting the binary archive when the file qualifies. + fn load_file_bytes(&mut self, path: &std::path::Path, bytes: Vec) { + let (text_bytes, archive) = if self.is_acord_note(path) { + sidecar::extract_from_md(&bytes) + } else { + (bytes, self.read_external_sidecar(path)) + }; + let text = String::from_utf8_lossy(&text_bytes).into_owned(); + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + if let Some(zip) = archive { + viewport_apply_sidecar_bytes(self.handle, zip.as_ptr(), zip.len()); + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + w.focus_window(); + } + self.current_file = Some(path.to_path_buf()); + self.last_autosaved_hash = None; + } + + /// true when path is an .md inside the configured notes library (recursive). + fn is_acord_note(&self, path: &std::path::Path) -> bool { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !ext.eq_ignore_ascii_case("md") { + return false; + } + let notes_dir = self.config.notes_dir(); + let canon_dir = std::fs::canonicalize(¬es_dir).unwrap_or(notes_dir); + let canon_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + canon_path.starts_with(&canon_dir) + } + + /// path under /.external/ for storing an external file's archive companion. + fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf { + let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()); + let s = canon.to_string_lossy(); + let trimmed = s.trim_start_matches('/').trim_start_matches('\\'); + let encoded: String = trimmed + .chars() + .map(|c| match c { + '/' | '\\' | ':' => '.', + _ => c, + }) + .collect(); + self.config + .notes_dir() + .join(".external") + .join(format!("{encoded}.acord")) + } + + fn read_external_sidecar(&self, original: &std::path::Path) -> Option> { + let path = self.external_sidecar_path(original); + std::fs::read(path).ok() + } + + fn write_external_sidecar(&self, original: &std::path::Path, archive: Option<&[u8]>) { + let path = self.external_sidecar_path(original); + match archive { + Some(bytes) if !bytes.is_empty() => { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, bytes); + } + _ => { + let _ = std::fs::remove_file(&path); + } + } + } + + /// drains the current archive zip from the viewport. + fn take_archive_bytes(&self) -> Option> { + let mut len: usize = 0; + let ptr = viewport_take_sidecar_bytes(self.handle, &mut len as *mut usize); + if ptr.is_null() || len == 0 { + return None; + } + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + viewport_free_bytes(ptr, len); + Some(bytes) + } + fn handle_browser_event(&mut self, event: WindowEvent) { let Some(handle) = self.browser_handle.as_mut() else { return }; @@ -322,18 +400,8 @@ impl App { .add_filter("Markdown", &["md", "markdown"]) .add_filter("All Files", &["*"]); if let Some(path) = dialog.pick_file() { - if let Ok(text) = std::fs::read_to_string(&path) { - let c = CString::new(text).unwrap_or_default(); - viewport_set_text(self.handle, c.as_ptr()); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); - let c_ext = CString::new(ext).unwrap(); - viewport_set_lang(self.handle, c_ext.as_ptr()); - if let Some(w) = &self.window { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); - w.set_title(&format!("{name} - Acord")); - } - self.current_file = Some(path); - self.last_autosaved_hash = None; + if let Ok(bytes) = std::fs::read(&path) { + self.load_file_bytes(&path, bytes); } } } @@ -371,9 +439,19 @@ impl App { .to_string_lossy() .into_owned(); viewport_free_string(text_ptr); - if std::fs::write(path, &text).is_ok() { + + let archive = self.take_archive_bytes(); + let in_library = self.is_acord_note(path); + let file_bytes: Vec = match (&archive, in_library) { + (Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc), + _ => text.as_bytes().to_vec(), + }; + if std::fs::write(path, &file_bytes).is_ok() { self.last_autosaved_hash = Some(text_hash(&text)); } + if !in_library { + self.write_external_sidecar(path, archive.as_deref()); + } } fn derive_default_filename(&self) -> String { @@ -424,15 +502,29 @@ impl App { let hash = text_hash(&text); if Some(hash) == self.last_autosaved_hash { return; } + // skip the launch stub so it can't overwrite last session's Untitled.md. + if self.current_file.is_none() && is_effectively_blank(&text) { + return; + } + let path = self.current_file.clone().unwrap_or_else(|| { self.config.notes_dir().join("Untitled.md") }); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - if std::fs::write(&path, &text).is_ok() { + let archive = self.take_archive_bytes(); + let in_library = self.is_acord_note(&path); + let file_bytes: Vec = match (&archive, in_library) { + (Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc), + _ => text.as_bytes().to_vec(), + }; + if std::fs::write(&path, &file_bytes).is_ok() { self.last_autosaved_hash = Some(hash); } + if !in_library { + self.write_external_sidecar(&path, archive.as_deref()); + } } fn winit_button(button: MouseButton) -> u8 { @@ -624,6 +716,15 @@ fn text_hash(s: &str) -> u64 { h.finish() } +/// true when the buffer is empty or just leading heading markers. +fn is_effectively_blank(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return true; + } + trimmed.trim_start_matches('#').trim().is_empty() +} + /// Translates winit logical keys into iced keyboard keys for direct iced /// event push (used by the second browser window, which speaks iced /// directly rather than through the C bridge). diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index 0885744..09e5ae1 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -829,6 +829,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { } self.lastAutosavedHash = text.hashValue } + appState.takeArchiveBytesFromViewport = { [weak self] in + self?.viewport?.takeSidecarBytes() + } + appState.applyArchiveBytesToViewport = { [weak self] data in + self?.viewport?.applySidecarBytes(data) + } } private func syncTextFromViewport() { diff --git a/macos/src/AppState.swift b/macos/src/AppState.swift index 677a274..c80ff53 100644 --- a/macos/src/AppState.swift +++ b/macos/src/AppState.swift @@ -134,6 +134,10 @@ class AppState: ObservableObject { private let autoSaveQueue = DispatchQueue(label: "com.acord.autosave") /// fires synchronously after a load/new note swap so the host shell can push the new text into the viewport before the autosave timer reads stale viewport state. var onLoadedTextChanged: ((String) -> Void)? + /// drains the document's archive zip from the viewport for embed-on-save. + var takeArchiveBytesFromViewport: (() -> Data?)? + /// applies an archive zip's metadata back into the viewport. + var applyArchiveBytesToViewport: ((Data) -> Void)? /// Per-note autosave file path, established on the first write and never /// changed for the rest of the session. Stops the title-derived filename /// from re-deriving on every keystroke and littering the notes directory @@ -166,9 +170,21 @@ class AppState: ObservableObject { let text = documentText let noteID = currentNoteID let url = resolveAutoSaveURL(noteID: noteID, text: text) + let archive = takeArchiveBytesFromViewport?() + let inLibrary = isAcordNote(url) + let payload: Data = { + let textData = Data(text.utf8) + if inLibrary, let archive = archive { + return AppState.embedArchiveInMd(text: textData, archive: archive) + } + return textData + }() autoSaveQueue.async { [weak self] in - Self.writeAutoSaveFile(at: url, text: text) + try? payload.write(to: url, options: .atomic) + if !inLibrary { + self?.writeExternalSidecar(for: url, archive: archive) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } self.autoSaveCoolingDown = false @@ -287,6 +303,82 @@ class AppState: ObservableObject { try? text.write(to: url, atomically: true, encoding: .utf8) } + /// magic separating the markdown body from the appended raw zip; surrounding + /// NULs trip text editors into binary mode so the archive shows as garbage. + static let binarySentinel: Data = { + var d = Data() + d.append(0x0A) // \n + d.append(0x00) + d.append(contentsOf: "ACORD-ARCHIVE".utf8) + d.append(0x00) + d.append(0x0A) // \n + return d + }() + + /// splits raw file bytes on the binary sentinel into (text, optional archive zip). + static func splitFileBytes(_ data: Data) -> (Data, Data?) { + let sentinel = binarySentinel + guard let range = data.range(of: sentinel, options: .backwards) else { + return (data, nil) + } + let text = data.subdata(in: 0.. Data { + var out = Data(capacity: text.count + binarySentinel.count + archive.count) + out.append(text) + if !text.isEmpty && text.last != 0x0A { + out.append(0x0A) + } + out.append(binarySentinel) + out.append(archive) + return out + } + + /// true when url is an .md inside the configured notes library (recursive). + func isAcordNote(_ url: URL) -> Bool { + let format = FileFormat.from(filename: url.lastPathComponent) + guard format.isMarkdown else { return false } + let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + .standardizedFileURL.resolvingSymlinksInPath() + let standardized = url.standardizedFileURL.resolvingSymlinksInPath() + return standardized.path.hasPrefix(dir.path + "/") + || standardized.path == dir.path + } + + /// path under /.external/ for an external file's archive companion. + func externalSidecarPath(for original: URL) -> URL { + let trimmed = original.standardizedFileURL.path + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let encoded = trimmed.map { ch -> Character in + (ch == "/" || ch == "\\" || ch == ":") ? "." : ch + } + let name = String(encoded) + ".acord" + return URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) + .appendingPathComponent(".external", isDirectory: true) + .appendingPathComponent(name) + } + + /// reads the companion archive zip for an external file, if it exists. + func readExternalSidecar(for original: URL) -> Data? { + try? Data(contentsOf: externalSidecarPath(for: original)) + } + + /// writes (or clears) the companion archive for an external file. + func writeExternalSidecar(for original: URL, archive: Data?) { + let path = externalSidecarPath(for: original) + if let archive = archive, !archive.isEmpty { + let parent = path.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try? archive.write(to: path, options: .atomic) + } else { + try? FileManager.default.removeItem(at: path) + } + } + /// Strip the `` sidecar comment from `text`. /// The markdown body before the comment is the user's actual content; /// non-markdown destinations (.rs, .json, .csv-source, etc.) must not @@ -361,7 +453,7 @@ class AppState: ObservableObject { bridge.setText(currentNoteID, text: documentText) if let url = currentFileURL { let textToSave = textForExternalSave(format: currentFileFormat) - try? textToSave.write(to: url, atomically: true, encoding: .utf8) + writeNoteFile(text: textToSave, to: url) } let _ = bridge.cacheSave(currentNoteID) modified = false @@ -371,7 +463,7 @@ class AppState: ObservableObject { func saveNoteToFile(_ url: URL) { let format = FileFormat.from(filename: url.lastPathComponent) let textToSave = textForExternalSave(format: format) - try? textToSave.write(to: url, atomically: true, encoding: .utf8) + writeNoteFile(text: textToSave, to: url) currentFileURL = url currentFileFormat = format // An explicit save-to-disk locks the autosave path to the same file @@ -383,6 +475,24 @@ class AppState: ObservableObject { modified = false } + /// embeds the binary archive into in-library .md files; for everything else + /// writes plain text and stashes the archive at /.external/. + private func writeNoteFile(text: String, to url: URL) { + let archive = takeArchiveBytesFromViewport?() + let inLibrary = isAcordNote(url) + let textData = Data(text.utf8) + let payload: Data + if inLibrary, let archive = archive { + payload = AppState.embedArchiveInMd(text: textData, archive: archive) + } else { + payload = textData + } + try? payload.write(to: url, options: .atomic) + if !inLibrary { + writeExternalSidecar(for: url, archive: archive) + } + } + /// Project the in-memory `documentText` onto the right shape for an /// external file format. CSV gets converted from the markdown table, /// non-markdown formats get the sidecar archive comment stripped (the @@ -395,29 +505,35 @@ class AppState: ObservableObject { func loadNoteFromFile(_ url: URL) { let format = FileFormat.from(filename: url.lastPathComponent) - if let (id, text) = bridge.loadNote(path: url.path) { - // pin the autosave path before touching documentText so the didSet - // autosave path resolution lands on the actual file rather than a - // title-derived sibling. - let dir = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) - .standardizedFileURL - let parent = url.deletingLastPathComponent().standardizedFileURL - if format.isMarkdown && parent == dir { - autoSavePaths[id] = url - } - currentNoteID = id - currentFileURL = url - currentFileFormat = format - if format.isCSV { - documentText = csvToMarkdownTable(text) - } else { - documentText = text - } - modified = false - let _ = bridge.cacheSave(id) - evaluate() - refreshNoteList() - onLoadedTextChanged?(documentText) + guard let bytes = try? Data(contentsOf: url) else { return } + + let inLibrary = isAcordNote(url) + let (textBytes, embeddedArchive): (Data, Data?) = inLibrary + ? AppState.splitFileBytes(bytes) + : (bytes, nil) + let archive = embeddedArchive ?? (inLibrary ? nil : readExternalSidecar(for: url)) + let text = String(data: textBytes, encoding: .utf8) ?? "" + + guard let id = bridge.installDocument(text: text) else { return } + + if format.isMarkdown { + autoSavePaths[id] = url + } + currentNoteID = id + currentFileURL = url + currentFileFormat = format + if format.isCSV { + documentText = csvToMarkdownTable(text) + } else { + documentText = text + } + modified = false + let _ = bridge.cacheSave(id) + evaluate() + refreshNoteList() + onLoadedTextChanged?(documentText) + if let archive = archive { + applyArchiveBytesToViewport?(archive) } } @@ -573,8 +689,20 @@ class AppState: ObservableObject { func writeAutosavedCopy(text: String) { let noteID = currentNoteID let url = resolveAutoSaveURL(noteID: noteID, text: text) - autoSaveQueue.async { - Self.writeAutoSaveFile(at: url, text: text) + let archive = takeArchiveBytesFromViewport?() + let inLibrary = isAcordNote(url) + let payload: Data = { + let textData = Data(text.utf8) + if inLibrary, let archive = archive { + return AppState.embedArchiveInMd(text: textData, archive: archive) + } + return textData + }() + autoSaveQueue.async { [weak self] in + try? payload.write(to: url, options: .atomic) + if !inLibrary { + self?.writeExternalSidecar(for: url, archive: archive) + } } } diff --git a/macos/src/IcedViewportView.swift b/macos/src/IcedViewportView.swift index 94c2c99..32b76dc 100644 --- a/macos/src/IcedViewportView.swift +++ b/macos/src/IcedViewportView.swift @@ -241,6 +241,28 @@ class IcedViewportView: NSView { return result } + /// drains the document's archive zip bytes for embed-on-save. + func takeSidecarBytes() -> Data? { + if isTornDown { return nil } + guard let h = viewportHandle else { return nil } + var len: UInt = 0 + guard let ptr = viewport_take_sidecar_bytes(h, &len), len > 0 else { return nil } + let data = Data(bytes: ptr, count: Int(len)) + viewport_free_bytes(ptr, len) + return data + } + + /// applies an archive zip's metadata back into the document. + func applySidecarBytes(_ data: Data) { + if isTornDown { return } + guard let h = viewportHandle, !data.isEmpty else { return } + data.withUnsafeBytes { raw in + if let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) { + viewport_apply_sidecar_bytes(h, base, UInt(data.count)) + } + } + } + func sendCommand(_ command: UInt32) { guard let h = viewportHandle else { return } viewport_send_command(h, command) diff --git a/macos/src/RustBridge.swift b/macos/src/RustBridge.swift index 544b1bf..79e1580 100644 --- a/macos/src/RustBridge.swift +++ b/macos/src/RustBridge.swift @@ -90,6 +90,23 @@ class RustBridge { return (id, text) } + /// installs a doc from already-decoded text — used when the shell read raw bytes + /// itself (so it could split off the binary archive trailer) and just needs a UUID. + func installDocument(text: String) -> UUID? { + guard let ptr = acord_doc_new() else { return nil } + text.withCString { cstr in + acord_doc_set_text(ptr, cstr) + } + let uuidStr = cacheSaveRaw(ptr) + guard let id = UUID(uuidString: uuidStr) else { + acord_doc_free(ptr) + return nil + } + if let old = docs[id] { acord_doc_free(old) } + docs[id] = ptr + return id + } + func cacheSave(_ id: UUID) -> Bool { guard let ptr = docs[id] else { return false } guard let cstr = acord_cache_save(ptr) else { return false } diff --git a/viewport/include/acord.h b/viewport/include/acord.h index ececc08..971c552 100644 --- a/viewport/include/acord.h +++ b/viewport/include/acord.h @@ -84,6 +84,23 @@ char *viewport_get_text(struct ViewportHandle *handle); void viewport_free_string(char *s); +/** + * returns the archive zip bytes (or null when empty); writes the length to len_out. + */ +uint8_t *viewport_take_sidecar_bytes(struct ViewportHandle *handle, uintptr_t *len_out); + +/** + * applies archive zip bytes back into the document. + */ +void viewport_apply_sidecar_bytes(struct ViewportHandle *handle, + const uint8_t *bytes, + uintptr_t len); + +/** + * frees byte buffers returned by viewport_take_sidecar_bytes. + */ +void viewport_free_bytes(uint8_t *ptr, uintptr_t len); + void viewport_set_theme(struct ViewportHandle *handle, const char *name); void viewport_set_line_indicator(struct ViewportHandle *handle, const char *mode); diff --git a/viewport/src/browser/handle.rs b/viewport/src/browser/handle.rs index 409fd69..a570db9 100644 --- a/viewport/src/browser/handle.rs +++ b/viewport/src/browser/handle.rs @@ -61,8 +61,9 @@ pub fn create( let backends = wgpu::Backends::METAL; #[cfg(target_os = "windows")] let backends = wgpu::Backends::DX12; + // accept GL alongside Vulkan so distros without a Vulkan ICD still get a browser surface. #[cfg(not(any(target_os = "macos", target_os = "windows")))] - let backends = wgpu::Backends::VULKAN; + let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL; let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends, @@ -74,23 +75,46 @@ pub fn create( raw_window_handle: raw_window, }; - let surface = unsafe { instance.create_surface_unsafe(target).ok()? }; + let surface = match unsafe { instance.create_surface_unsafe(target) } { + Ok(s) => s, + Err(e) => { + eprintln!("acord: browser surface creation failed: {e}"); + return None; + } + }; - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, compatible_surface: Some(&surface), force_fallback_adapter: false, - })) - .ok()?; + })) { + Ok(a) => a, + Err(e) => { + eprintln!("acord: browser adapter request failed: {e}"); + return None; + } + }; let (device, queue) = - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) { + Ok(dq) => dq, + Err(e) => { + eprintln!("acord: browser device request failed: {e}"); + return None; + } + }; let phys_w = (width * scale) as u32; let phys_h = (height * scale) as u32; let caps = surface.get_capabilities(&adapter); - let format = caps.formats.first().copied()?; + let format = match caps.formats.first().copied() { + Some(f) => f, + None => { + eprintln!("acord: browser surface has no compatible formats"); + return None; + } + }; surface.configure( &device, diff --git a/viewport/src/browser/model.rs b/viewport/src/browser/model.rs index 004f30c..ee6a268 100644 --- a/viewport/src/browser/model.rs +++ b/viewport/src/browser/model.rs @@ -81,7 +81,10 @@ pub fn scan_directory(dir: &Path) -> Vec { } pub fn file_preview(path: &Path) -> String { - let Ok(text) = std::fs::read_to_string(path) else { return String::new() }; + // bytes-first so the binary archive trailer doesn't trip the utf-8 decode. + let Ok(bytes) = std::fs::read(path) else { return String::new() }; + let (text_bytes, _) = crate::sidecar::extract_from_md(&bytes); + let text = String::from_utf8_lossy(&text_bytes); let body = strip_sidecar_archive(&text); if body_looks_blank(body) { return "(empty note)".to_string(); diff --git a/viewport/src/editor.rs b/viewport/src/editor.rs index 946e1e9..dec08b0 100644 --- a/viewport/src/editor.rs +++ b/viewport/src/editor.rs @@ -1102,13 +1102,24 @@ impl EditorState { } } - /// saves the document to file bytes, embedding the sidecar archive + /// returns the clean markdown body; the archive lives in a separate channel. pub fn save_doc(&mut self) -> String { - let body = self.get_clean_text(); + self.get_clean_text() + } + + /// returns the archive zip bytes the shell should embed for in-library .md files. + pub fn save_sidecar_bytes(&mut self) -> Option> { self.rebuild_modules(); let sidecar = self.build_sidecar(); let block_files = self.build_block_files(); - sidecar::embed_archive(&body, &sidecar, &block_files) + sidecar::build_archive_bytes(&sidecar, &block_files) + } + + /// applies an archive zip's metadata back into the document. + pub fn apply_sidecar_bytes(&mut self, bytes: &[u8]) { + if let Some(sc) = sidecar::extract_archive_bytes(bytes) { + self.apply_sidecar(&sc); + } } /// builds the per-block `.cord` source files for the sidecar archive diff --git a/viewport/src/lib.rs b/viewport/src/lib.rs index 622919a..1466e94 100644 --- a/viewport/src/lib.rs +++ b/viewport/src/lib.rs @@ -242,8 +242,6 @@ pub extern "C" fn viewport_get_text(handle: *mut ViewportHandle) -> *mut c_char Some(h) => h, None => return std::ptr::null_mut(), }; - // Goes through `save_doc` so any tables with persistent metadata get - // their data round-tripped via the embedded sidecar archive comment. let text = h.state.save_doc(); CString::new(text).unwrap_or_default().into_raw() } @@ -254,6 +252,57 @@ pub extern "C" fn viewport_free_string(s: *mut c_char) { unsafe { drop(CString::from_raw(s)); } } +/// returns the archive zip bytes (or null when empty); writes the length to len_out. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_take_sidecar_bytes( + handle: *mut ViewportHandle, + len_out: *mut usize, +) -> *mut u8 { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => { + if !len_out.is_null() { unsafe { *len_out = 0; } } + return std::ptr::null_mut(); + } + }; + let bytes = match h.state.save_sidecar_bytes() { + Some(b) => b, + None => { + if !len_out.is_null() { unsafe { *len_out = 0; } } + return std::ptr::null_mut(); + } + }; + let mut boxed = bytes.into_boxed_slice(); + if !len_out.is_null() { unsafe { *len_out = boxed.len(); } } + let ptr = boxed.as_mut_ptr(); + std::mem::forget(boxed); + ptr +} + +/// applies archive zip bytes back into the document. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_apply_sidecar_bytes( + handle: *mut ViewportHandle, + bytes: *const u8, + len: usize, +) { + let h = match unsafe { handle.as_mut() } { + Some(h) => h, + None => return, + }; + if bytes.is_null() || len == 0 { return; } + let slice = unsafe { std::slice::from_raw_parts(bytes, len) }; + h.state.apply_sidecar_bytes(slice); + h.needs_redraw = true; +} + +/// frees byte buffers returned by viewport_take_sidecar_bytes. +#[unsafe(no_mangle)] +pub extern "C" fn viewport_free_bytes(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { return; } + unsafe { drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); } +} + #[unsafe(no_mangle)] pub extern "C" fn viewport_set_theme(handle: *mut ViewportHandle, name: *const c_char) { let s = if name.is_null() { diff --git a/viewport/src/sidecar.rs b/viewport/src/sidecar.rs index 8e92edf..946e3ca 100644 --- a/viewport/src/sidecar.rs +++ b/viewport/src/sidecar.rs @@ -218,27 +218,63 @@ pub struct BlockFile { pub content: String, } -/// Append an archive comment to the markdown body. If there's nothing to store -/// (no block files AND no sidecar entries), returns the body unchanged. -pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String { +/// builds archive zip bytes from sidecar metadata and block files, None when both empty. +pub fn build_archive_bytes(sidecar: &Sidecar, block_files: &[BlockFile]) -> Option> { if sidecar.tables.is_empty() && block_files.is_empty() { - return markdown.to_string(); + return None; } + let toml_text = toml::to_string_pretty(sidecar).ok()?; + write_zip(&toml_text, block_files).ok() +} - let toml_text = match toml::to_string_pretty(sidecar) { - Ok(t) => t, - Err(_) => return markdown.to_string(), +/// parses zip bytes back into a Sidecar. +pub fn extract_archive_bytes(bytes: &[u8]) -> Option { + let toml_text = read_zip(bytes)?; + toml::from_str::(&toml_text).ok() +} + +/// magic separating the markdown body from the appended raw zip; the surrounding NULs +/// trip text editors into "binary mode" so the archive shows up as garbage, not as +/// readable base64. +pub const BINARY_SENTINEL: &[u8] = b"\n\x00ACORD-ARCHIVE\x00\n"; + +/// appends raw zip bytes after the markdown body, separated by BINARY_SENTINEL. +pub fn embed_in_md(markdown: &[u8], archive: &[u8]) -> Vec { + let mut out = Vec::with_capacity(markdown.len() + BINARY_SENTINEL.len() + archive.len()); + out.extend_from_slice(markdown); + if !markdown.ends_with(b"\n") { + out.push(b'\n'); + } + out.extend_from_slice(BINARY_SENTINEL); + out.extend_from_slice(archive); + out +} + +/// splits raw file bytes on BINARY_SENTINEL, returning (text_bytes, optional zip bytes). +pub fn extract_from_md(bytes: &[u8]) -> (Vec, Option>) { + if let Some(idx) = rfind_subslice(bytes, BINARY_SENTINEL) { + let text = bytes[..idx].to_vec(); + let archive_start = idx + BINARY_SENTINEL.len(); + let archive = bytes[archive_start..].to_vec(); + return (text, Some(archive)); + } + (bytes.to_vec(), None) +} + +fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack.windows(needle.len()).rposition(|w| w == needle) +} + +/// legacy embed format: base64-encoded zip inside an HTML comment. Kept for the +/// round-trip tests and the load-side back-compat path; new saves go through embed_in_md. +pub fn embed_archive(markdown: &str, sidecar: &Sidecar, block_files: &[BlockFile]) -> String { + let Some(zip_bytes) = build_archive_bytes(sidecar, block_files) else { + return markdown.to_string(); }; - - let zip_bytes = match write_zip(&toml_text, block_files) { - Ok(b) => b, - Err(_) => return markdown.to_string(), - }; - let encoded = B64.encode(&zip_bytes); - - // Wrap base64 to ~76 cols so the comment doesn't blow out git diffs and - // terminal viewers. The decoder ignores whitespace. let wrapped = wrap_base64(&encoded, 76); let mut out = markdown.trim_end_matches('\n').to_string(); diff --git a/viewport/src/syntax.rs b/viewport/src/syntax.rs index 94b4a7e..bf0e8a4 100644 --- a/viewport/src/syntax.rs +++ b/viewport/src/syntax.rs @@ -111,6 +111,8 @@ pub struct SyntaxHighlighter { current_line: usize, line_decors: Vec, user_idents: HashMap, + /// per-line tree-sitter spans for fenced code body lines, by absolute line index. + code_block_spans: HashMap, SyntaxHighlight)>>, } impl SyntaxHighlighter { @@ -153,6 +155,82 @@ impl SyntaxHighlighter { self.current_line = 0; self.scan_user_idents(source); + self.scan_fenced_code_blocks(source); + } + + /// runs each language-tagged fenced block through tree-sitter and stashes per-line spans. + fn scan_fenced_code_blocks(&mut self, source: &str) { + self.code_block_spans.clear(); + let lines: Vec<&str> = source.split('\n').collect(); + let mut i = 0; + while i < lines.len() { + let is_md = i < self.line_kinds.len() + && self.line_kinds[i] == LineKind::Markdown; + if !is_md { + i += 1; + continue; + } + let trimmed = lines[i].trim_start(); + if !trimmed.starts_with("```") { + i += 1; + continue; + } + let lang_label = trimmed.strip_prefix("```").unwrap_or("").trim(); + + let mut j = i + 1; + while j < lines.len() { + let jis_md = j < self.line_kinds.len() + && self.line_kinds[j] == LineKind::Markdown; + if jis_md && lines[j].trim_start().starts_with("```") { + break; + } + j += 1; + } + + let body_start_line = i + 1; + let body_end_line = j; // exclusive + if body_end_line > body_start_line { + if let Some(canonical) = canonical_code_lang(lang_label) { + let body_start_byte = self.line_offsets[body_start_line]; + let body_end_byte = if body_end_line < self.line_offsets.len() { + self.line_offsets[body_end_line].saturating_sub(1) + } else { + source.len() + }; + let body_end_byte = body_end_byte.min(source.len()); + if body_end_byte > body_start_byte { + let body = &source[body_start_byte..body_end_byte]; + let spans = highlight_source(body, &canonical); + for span in spans { + let abs_start = body_start_byte + span.start; + let abs_end = body_start_byte + span.end; + for line_idx in body_start_line..body_end_line { + let line_byte_start = self.line_offsets[line_idx]; + let line_byte_end = if line_idx + 1 < self.line_offsets.len() { + self.line_offsets[line_idx + 1].saturating_sub(1) + } else { + source.len() + }; + if abs_start >= line_byte_end { continue; } + if abs_end <= line_byte_start { break; } + let local_start = abs_start.saturating_sub(line_byte_start); + let local_end = abs_end.min(line_byte_end) - line_byte_start; + if local_end > local_start { + self.code_block_spans + .entry(line_idx) + .or_default() + .push(( + local_start..local_end, + SyntaxHighlight { kind: span.kind }, + )); + } + } + } + } + } + } + i = j + 1; // skip past closing fence (or break out if unclosed) + } } /// Walk the source, find every identifier introduction site (let, fn, @@ -234,6 +312,29 @@ impl SyntaxHighlighter { } } + /// byte ranges of inline format markers on a non-fenced markdown line, empty otherwise. + pub fn line_marker_ranges(&self, line_idx: usize, line_text: &str) -> Vec> { + if line_idx >= self.line_kinds.len() { + return Vec::new(); + } + if self.line_kinds[line_idx] != LineKind::Markdown { + return Vec::new(); + } + if line_idx < self.line_decors.len() { + match self.line_decors[line_idx] { + LineDecor::CodeBlock + | LineDecor::FenceMarker + | LineDecor::HorizontalRule => return Vec::new(), + _ => {} + } + } + parse_inline(line_text, 0) + .into_iter() + .filter(|(_, h)| h.kind == MD_FORMAT_MARKER) + .map(|(r, _)| r) + .collect() + } + fn highlight_markdown(&self, line: &str) -> Vec<(Range, SyntaxHighlight)> { let trimmed = line.trim_start(); let leading = line.len() - trimmed.len(); @@ -824,6 +925,7 @@ impl highlighter::Highlighter for SyntaxHighlighter { current_line: 0, line_decors: Vec::new(), user_idents: HashMap::new(), + code_block_spans: HashMap::new(), }; h.rebuild(&settings.source); h @@ -863,6 +965,9 @@ impl highlighter::Highlighter for SyntaxHighlighter { } if self.in_fenced_code { + if let Some(spans) = self.code_block_spans.get(&ln) { + return spans.clone().into_iter(); + } return vec![(0..line.len(), SyntaxHighlight { kind: MD_CODE_BLOCK })].into_iter(); } @@ -1016,6 +1121,49 @@ pub fn highlight_font(kind: u8) -> Option { } } +/// maps a fenced-code label to a tree-sitter language id, recursing on the trailing extension for dotted labels. +fn canonical_code_lang(label: &str) -> Option { + let label = label.trim().to_ascii_lowercase(); + if label.is_empty() { + return None; + } + let direct = match label.as_str() { + "rust" | "rs" => Some("rust"), + "py" | "python" => Some("python"), + "js" | "javascript" | "mjs" | "cjs" => Some("javascript"), + "ts" | "typescript" => Some("typescript"), + "tsx" => Some("tsx"), + "jsx" => Some("javascript"), + "c" | "h" => Some("c"), + "cpp" | "c++" | "cc" | "cxx" | "hpp" => Some("cpp"), + "go" => Some("go"), + "rb" | "ruby" => Some("ruby"), + "sh" | "bash" | "shell" | "zsh" => Some("bash"), + "java" => Some("java"), + "html" | "htm" => Some("html"), + "css" => Some("css"), + "scss" => Some("scss"), + "json" => Some("json"), + "lua" => Some("lua"), + "php" => Some("php"), + "toml" => Some("toml"), + "yaml" | "yml" => Some("yaml"), + "swift" => Some("swift"), + "zig" => Some("zig"), + "sql" => Some("sql"), + "make" | "makefile" => Some("make"), + "md" | "markdown" => Some("markdown"), + _ => None, + }; + if let Some(d) = direct { + return Some(d.to_string()); + } + if let Some(idx) = label.rfind('.') { + return canonical_code_lang(&label[idx + 1..]); + } + None +} + pub fn compute_line_decors(source: &str) -> Vec { let classified = classify_document(source); let line_kinds: Vec = classified.into_iter().map(|cl| cl.kind).collect(); diff --git a/viewport/src/text_widget.rs b/viewport/src/text_widget.rs index cbc888d..d46e414 100644 --- a/viewport/src/text_widget.rs +++ b/viewport/src/text_widget.rs @@ -134,48 +134,137 @@ fn gutter_fade_t(distance: usize) -> f32 { (distance as f32 / max_d).min(1.0) } -/// Build iced Spans from a LayoutRun's glyphs, grouping consecutive glyphs by color. -/// `font_size_px` drives perceptual brightness compensation against the -/// dark-theme background — see `oklab::lighten_for_size`. +/// builds iced Spans from cosmic glyphs, grouping by (color, weight, style) and skipping marker ranges. fn build_color_spans<'a>( text: &'a str, glyphs: &[cosmic_text::LayoutGlyph], + attrs_list: &cosmic_text::AttrsList, + base_font: Font, font_size_px: f32, + marker_ranges: &[std::ops::Range], ) -> Vec> { + use iced_wgpu::core::font::{Style as IcedStyle, Weight as IcedWeight}; + fn cosmic_to_iced(c: cosmic_text::Color, font_size_px: f32) -> Color { let raw = Color::from_rgba8(c.r(), c.g(), c.b(), c.a() as f32 / 255.0); crate::oklab::lighten_for_size(raw, font_size_px) } - if glyphs.is_empty() { - return vec![Span::new(text)]; - } - - let mut spans = Vec::new(); - let mut seg_start = 0usize; - let mut cur_color: Option = glyphs.first().and_then(|g| g.color_opt); - - for glyph in glyphs { - if glyph.color_opt != cur_color { - let end = glyph.start.min(text.len()); - if end > seg_start { - let mut span = Span::new(&text[seg_start..end]); - if let Some(c) = cur_color { - span = span.color(cosmic_to_iced(c, font_size_px)); - } - spans.push(span); - } - seg_start = end; - cur_color = glyph.color_opt; + fn cosmic_to_iced_weight(w: cosmic_text::Weight) -> IcedWeight { + match w.0 { + 0..=149 => IcedWeight::Thin, + 150..=249 => IcedWeight::ExtraLight, + 250..=349 => IcedWeight::Light, + 350..=449 => IcedWeight::Normal, + 450..=549 => IcedWeight::Medium, + 550..=649 => IcedWeight::Semibold, + 650..=749 => IcedWeight::Bold, + 750..=849 => IcedWeight::ExtraBold, + _ => IcedWeight::Black, } } - if seg_start < text.len() { - let mut span = Span::new(&text[seg_start..]); - if let Some(c) = cur_color { + fn cosmic_to_iced_style(s: cosmic_text::Style) -> IcedStyle { + match s { + cosmic_text::Style::Normal => IcedStyle::Normal, + cosmic_text::Style::Italic => IcedStyle::Italic, + cosmic_text::Style::Oblique => IcedStyle::Oblique, + } + } + + /// span grouping key — color repr plus iced weight/style. + type StyleKey = (Option, IcedWeight, IcedStyle); + + let style_at = |byte_idx: usize| -> (IcedWeight, IcedStyle) { + let attrs = attrs_list.get_span(byte_idx); + (cosmic_to_iced_weight(attrs.weight), cosmic_to_iced_style(attrs.style)) + }; + + let push_span = |spans: &mut Vec>, + range: std::ops::Range, + key: StyleKey| { + if range.start >= range.end || range.end > text.len() { + return; + } + let mut span = Span::new(&text[range]); + if let Some(raw) = key.0 { + let c = cosmic_text::Color(raw); span = span.color(cosmic_to_iced(c, font_size_px)); } + if key.1 != IcedWeight::Normal || key.2 != IcedStyle::Normal { + span = span.font(Font { + weight: key.1, + style: key.2, + ..base_font + }); + } spans.push(span); + }; + + // subtract every marker range from the full line range. + let visible_segments: Vec> = if marker_ranges.is_empty() { + vec![0..text.len()] + } else { + let mut segs = vec![0..text.len()]; + for marker in marker_ranges { + let mut next = Vec::with_capacity(segs.len() + 1); + for seg in segs.into_iter() { + if marker.end <= seg.start || marker.start >= seg.end { + next.push(seg); + } else { + if seg.start < marker.start { + next.push(seg.start..marker.start); + } + if marker.end < seg.end { + next.push(marker.end..seg.end); + } + } + } + segs = next; + } + segs + }; + + if glyphs.is_empty() { + // empty line — fall back to line-level attrs. + let (w, s) = style_at(0); + let key: StyleKey = (None, w, s); + let mut spans = Vec::new(); + for seg in &visible_segments { + push_span(&mut spans, seg.clone(), key); + } + if spans.is_empty() { + spans.push(Span::new(text)); + } + return spans; + } + + let mut spans: Vec> = Vec::new(); + for seg in &visible_segments { + let mut seg_start = seg.start; + let mut cur_key: Option = None; + for glyph in glyphs { + if glyph.start < seg.start || glyph.start >= seg.end { + continue; + } + let (w, s) = style_at(glyph.start); + let key: StyleKey = (glyph.color_opt.map(|c| c.0), w, s); + match cur_key { + None => { + seg_start = glyph.start; + cur_key = Some(key); + } + Some(prev) if prev != key => { + push_span(&mut spans, seg_start..glyph.start, prev); + seg_start = glyph.start; + cur_key = Some(key); + } + _ => {} + } + } + if let Some(key) = cur_key { + push_span(&mut spans, seg_start..seg.end, key); + } } if spans.is_empty() { @@ -1505,20 +1594,41 @@ where let mut child_idx = 0; let children_layouts: Vec<_> = layout.children().collect(); - // Build paragraphs and retain in widget State. fill_paragraph - // stores Weak refs — the Paragraphs must survive until the - // renderer's prepare() phase. State lives in the widget tree. + // cursor line draws raw cosmic glyphs, other lines strip marker bytes for WYS. + let active_cursor_line = if self.is_focused_block { + self.cursor_line + } else { + None + }; + let highlighter_borrow = state.highlighter.borrow(); + let highlighter_any: &dyn std::any::Any = &*highlighter_borrow; + let syntax_highlighter = highlighter_any.downcast_ref::(); { let mut paras = state.retained_paragraphs.borrow_mut(); paras.clear(); let metrics = state.line_metrics.borrow(); for i in 0..line_count { let line_text = buffer.lines[i].text(); + let attrs_list = buffer.lines[i].attrs_list(); let glyphs: Vec = buffer.lines[i].layout_opt() .map(|layouts| layouts.iter().flat_map(|l| l.glyphs.iter().cloned()).collect()) .unwrap_or_default(); - let spans = build_color_spans(line_text, &glyphs, f32::from(text_size)); + let marker_ranges = if active_cursor_line == Some(i) { + Vec::new() + } else { + syntax_highlighter + .map(|h| h.line_marker_ranges(i, line_text)) + .unwrap_or_default() + }; + let spans = build_color_spans( + line_text, + &glyphs, + attrs_list, + font, + f32::from(text_size), + &marker_ranges, + ); let visual_rows = metrics.get(i).map(|m| m.visual_rows).unwrap_or(1).max(1); paras.push(iced_graphics::text::Paragraph::with_spans(Text { content: spans.as_slice(), diff --git a/windows/src/app.rs b/windows/src/app.rs index 943d318..603814a 100644 --- a/windows/src/app.rs +++ b/windows/src/app.rs @@ -17,8 +17,10 @@ use acord_viewport::{ viewport_set_line_indicator, viewport_set_gutter_rainbow, viewport_set_auto_pair_flags, viewport_send_command, viewport_free_string, + viewport_take_sidecar_bytes, viewport_apply_sidecar_bytes, viewport_free_bytes, ViewportHandle, }; +use acord_viewport::sidecar; use acord_viewport::browser::{self, BrowserHandle}; use crate::config::Config; @@ -204,19 +206,8 @@ impl App { fn drain_browser_open(&mut self) { let Some(handle) = self.browser_handle.as_mut() else { return }; let Some(path) = browser::handle::take_pending_open(handle) else { return }; - if let Ok(text) = std::fs::read_to_string(&path) { - let c = CString::new(text).unwrap_or_default(); - viewport_set_text(self.handle, c.as_ptr()); - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); - let c_ext = CString::new(ext).unwrap(); - viewport_set_lang(self.handle, c_ext.as_ptr()); - if let Some(w) = &self.window { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); - w.set_title(&format!("{name} - Acord")); - w.focus_window(); - } - self.current_file = Some(path); - self.last_autosaved_hash = None; + if let Ok(bytes) = std::fs::read(&path) { + self.load_file_bytes(&path, bytes); } self.close_browser(); } @@ -226,24 +217,95 @@ impl App { .add_filter("Markdown", &["md", "markdown"]) .add_filter("All Files", &["*"]); if let Some(path) = dialog.pick_file() { - if let Ok(text) = std::fs::read_to_string(&path) { - let c = CString::new(text).unwrap_or_default(); - viewport_set_text(self.handle, c.as_ptr()); - let ext = path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("md"); - let c_ext = CString::new(ext).unwrap(); - viewport_set_lang(self.handle, c_ext.as_ptr()); - if let Some(w) = &self.window { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); - w.set_title(&format!("{name} - Acord")); - } - self.current_file = Some(path); - self.last_autosaved_hash = None; + if let Ok(bytes) = std::fs::read(&path) { + self.load_file_bytes(&path, bytes); } } } + fn load_file_bytes(&mut self, path: &std::path::Path, bytes: Vec) { + let (text_bytes, archive) = if self.is_acord_note(path) { + sidecar::extract_from_md(&bytes) + } else { + (bytes, self.read_external_sidecar(path)) + }; + let text = String::from_utf8_lossy(&text_bytes).into_owned(); + let c = CString::new(text).unwrap_or_default(); + viewport_set_text(self.handle, c.as_ptr()); + if let Some(zip) = archive { + viewport_apply_sidecar_bytes(self.handle, zip.as_ptr(), zip.len()); + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("md"); + let c_ext = CString::new(ext).unwrap(); + viewport_set_lang(self.handle, c_ext.as_ptr()); + if let Some(w) = &self.window { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("Acord"); + w.set_title(&format!("{name} - Acord")); + w.focus_window(); + } + self.current_file = Some(path.to_path_buf()); + self.last_autosaved_hash = None; + } + + fn is_acord_note(&self, path: &std::path::Path) -> bool { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !ext.eq_ignore_ascii_case("md") { + return false; + } + let notes_dir = self.config.notes_dir(); + let canon_dir = std::fs::canonicalize(¬es_dir).unwrap_or(notes_dir); + let canon_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + canon_path.starts_with(&canon_dir) + } + + fn external_sidecar_path(&self, original: &std::path::Path) -> PathBuf { + let canon = std::fs::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()); + let s = canon.to_string_lossy(); + let trimmed = s.trim_start_matches('/').trim_start_matches('\\'); + let encoded: String = trimmed + .chars() + .map(|c| match c { + '/' | '\\' | ':' => '.', + _ => c, + }) + .collect(); + self.config + .notes_dir() + .join(".external") + .join(format!("{encoded}.acord")) + } + + fn read_external_sidecar(&self, original: &std::path::Path) -> Option> { + let path = self.external_sidecar_path(original); + std::fs::read(path).ok() + } + + fn write_external_sidecar(&self, original: &std::path::Path, archive: Option<&[u8]>) { + let path = self.external_sidecar_path(original); + match archive { + Some(bytes) if !bytes.is_empty() => { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, bytes); + } + _ => { + let _ = std::fs::remove_file(&path); + } + } + } + + fn take_archive_bytes(&self) -> Option> { + let mut len: usize = 0; + let ptr = viewport_take_sidecar_bytes(self.handle, &mut len as *mut usize); + if ptr.is_null() || len == 0 { + return None; + } + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + viewport_free_bytes(ptr, len); + Some(bytes) + } + fn save_file(&mut self) { if let Some(path) = self.current_file.clone() { self.write_to(&path); @@ -277,9 +339,19 @@ impl App { .to_string_lossy() .into_owned(); viewport_free_string(text_ptr); - if std::fs::write(path, &text).is_ok() { + + let archive = self.take_archive_bytes(); + let in_library = self.is_acord_note(path); + let file_bytes: Vec = match (&archive, in_library) { + (Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc), + _ => text.as_bytes().to_vec(), + }; + if std::fs::write(path, &file_bytes).is_ok() { self.last_autosaved_hash = Some(text_hash(&text)); } + if !in_library { + self.write_external_sidecar(path, archive.as_deref()); + } } fn derive_default_filename(&self) -> String { @@ -334,15 +406,29 @@ impl App { let hash = text_hash(&text); if Some(hash) == self.last_autosaved_hash { return; } + // skip the launch stub so it can't overwrite last session's Untitled.md. + if self.current_file.is_none() && is_effectively_blank(&text) { + return; + } + let path = self.current_file.clone().unwrap_or_else(|| { self.config.notes_dir().join("Untitled.md") }); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - if std::fs::write(&path, &text).is_ok() { + let archive = self.take_archive_bytes(); + let in_library = self.is_acord_note(&path); + let file_bytes: Vec = match (&archive, in_library) { + (Some(arc), true) => sidecar::embed_in_md(text.as_bytes(), arc), + _ => text.as_bytes().to_vec(), + }; + if std::fs::write(&path, &file_bytes).is_ok() { self.last_autosaved_hash = Some(hash); } + if !in_library { + self.write_external_sidecar(&path, archive.as_deref()); + } } fn winit_button(button: MouseButton) -> u8 { @@ -634,6 +720,15 @@ fn text_hash(s: &str) -> u64 { h.finish() } +/// true when the buffer is empty or just leading heading markers. +fn is_effectively_blank(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return true; + } + trimmed.trim_start_matches('#').trim().is_empty() +} + /// Map winit logical keys to iced keyboard keys for direct iced event push. fn winit_key_to_iced(key: &Key) -> iced_wgpu::core::keyboard::Key { use iced_wgpu::core::keyboard::{key as ikey, Key as IKey};