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
8 changes: 8 additions & 0 deletions MAPP.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
331000043066ED47005D07A2 /* Marfa in Frameworks */ = {isa = PBXBuildFile; productRef = 331000053066ED47005D07A2 /* Marfa */; };
331000073066ED47005D07A2 /* MarfaTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 331000083066ED47005D07A2 /* MarfaTesting */; };
331000093066ED47005D07A2 /* MarfaAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 3310000A3066ED47005D07A2 /* MarfaAPI */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -77,6 +78,7 @@
isa = PBXFrameworksBuildPhase;
files = (
331000043066ED47005D07A2 /* Marfa in Frameworks */,
331000093066ED47005D07A2 /* MarfaAPI in Frameworks */,
331000073066ED47005D07A2 /* MarfaTesting in Frameworks */,
);
};
Expand Down Expand Up @@ -141,6 +143,7 @@
name = MAPP;
packageProductDependencies = (
331000053066ED47005D07A2 /* Marfa */,
3310000A3066ED47005D07A2 /* MarfaAPI */,
331000083066ED47005D07A2 /* MarfaTesting */,
);
productName = MAPP;
Expand Down Expand Up @@ -720,6 +723,11 @@
package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */;
productName = Marfa;
};
3310000A3066ED47005D07A2 /* MarfaAPI */ = {
isa = XCSwiftPackageProductDependency;
package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */;
productName = MarfaAPI;
};
331000083066ED47005D07A2 /* MarfaTesting */ = {
isa = XCSwiftPackageProductDependency;
package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */;
Expand Down
18 changes: 16 additions & 2 deletions MAPP/Editor/EditorWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,19 @@ final class EditorWebView: WKWebView {
controller.titleDrafts?.typeChange = TypeChangeRequest(ids: [id], undoManager: windowUndoManager)
}

@objc private func versionHistory(_ sender: NSMenuItem) {
guard let controller, let id = controller.noteID else { return }
controller.titleDrafts?.history = VersionHistoryRequest(item: id, undoManager: windowUndoManager)
}

/// Version History, for the note on the page, from the window the editor belongs to.
private var versionHistoryItem: NSMenuItem? {
guard let controller, controller.noteID != nil, controller.titleDrafts != nil else { return nil }
let item = NSMenuItem(title: String(localized: "Version History…"), action: #selector(versionHistory(_:)), keyEquivalent: "")
item.target = self
return item
}

/// Change Type, for the note on the page, from the window the editor belongs to.
private var changeTypeItem: NSMenuItem? {
guard let controller, let id = controller.noteID, controller.titleDrafts != nil, let library = controller.library,
Expand Down Expand Up @@ -215,9 +228,10 @@ final class EditorWebView: WKWebView {
let isRichText = item.submenu?.items.contains { $0.action.map(Self.richTextActions.contains) ?? false } ?? false
if isUnwanted || isRichText { menu.removeItem(item) }
}
if !isEditingTitle, let changeType = changeTypeItem {
if !isEditingTitle, changeTypeItem != nil || versionHistoryItem != nil {
menu.addItem(.separator())
menu.addItem(changeType)
if let changeType = changeTypeItem { menu.addItem(changeType) }
if let history = versionHistoryItem { menu.addItem(history) }
}
// Removing items can leave two separators together, or one at an end.
while menu.items.first?.isSeparatorItem == true { menu.removeItem(at: 0) }
Expand Down
34 changes: 34 additions & 0 deletions MAPP/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
{
"sourceLanguage" : "en",
"strings" : {
"Version History" : { "extractionState" : "manual" },
"Version History…" : { "extractionState" : "manual", "generatesSymbol" : false },
"Loading versions…" : { "extractionState" : "manual" },
"No Versions to Show" : { "extractionState" : "manual" },
"Load More" : { "extractionState" : "manual" },
"No Earlier Versions" : { "extractionState" : "manual" },
"This item hasn’t been changed since it was made, or the server has kept no earlier version of it." : { "extractionState" : "manual" },
"Choose a Version" : { "extractionState" : "manual" },
"Pick one on the left to read it beside the item as it is now." : { "extractionState" : "manual" },
"No text" : { "extractionState" : "manual" },
"Other Fields in This Version" : { "extractionState" : "manual" },
"%@ more characters aren’t shown." : { "extractionState" : "manual" },
"Earlier versions of “%@”." : { "extractionState" : "manual" },
"This item is no longer in the library." : { "extractionState" : "manual" },
"This item is in Recently Deleted, where its history can’t be read. Recover it to see its versions." : { "extractionState" : "manual" },
"This Version" : { "extractionState" : "manual" },
"Now" : { "extractionState" : "manual" },
"Version %lld" : { "extractionState" : "manual" },
"Browser" : { "extractionState" : "manual" },
"App" : { "extractionState" : "manual" },
"This device" : { "extractionState" : "manual" },
"The item already has this version’s text and title." : { "extractionState" : "manual" },
"Can’t Restore Version" : { "extractionState" : "manual" },
"Restore Version" : { "extractionState" : "manual" },
"This item can’t be changed right now." : { "extractionState" : "manual" },
"“%@” has changed since, so that can’t be put back here. Earlier versions are in Version History." : { "extractionState" : "manual" },
"MAPP can’t reach the server. Version history comes from the server. If its address starts with http://, macOS may be refusing it. Your notes and changes aren’t affected." : { "extractionState" : "manual" },
"The key MAPP uses isn’t allowed to read this item’s history. Ask whoever runs the server to allow it." : { "extractionState" : "manual" },
"The server has no history to show for this item. It may be new and not sent yet, deleted, or of a kind your key can’t read." : { "extractionState" : "manual" },
"The server didn’t accept MAPP’s key. Choose a new key in Settings." : { "extractionState" : "manual" },
"This library doesn’t keep version history." : { "extractionState" : "manual" },
"This item is in Recently Deleted. Recover it to restore a version." : { "extractionState" : "manual" },
"A file’s versions can be read here, but not restored." : { "extractionState" : "manual" },
"This version has no text or title that fits this item, so it can only be read." : { "extractionState" : "manual" },
"Change Type" : { "extractionState" : "manual" },
"Change Type…" : { "extractionState" : "manual", "generatesSymbol" : false },
"Can’t Change Type" : { "extractionState" : "manual" },
Expand Down
4 changes: 4 additions & 0 deletions MAPP/Model/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The data model, free of SwiftUI: the `ItemLibrary` protocol, `MarfaLibrary` over
- Change Type (`ItemLibrary.changeType`) changes an item's type in one queued edit, so it works offline. Marfa keeps the properties the new type does not declare, refuses a type whose required fields the item lacks, and reads the title and text from the new type's own keys without moving them (`device/body-retype`). So `TypeChange.plan` moves the title and the text into the new type's keys in the same edit: a title that the new type reads from the first line goes to the top of the text, and a first line becomes the title of a type that has one. Nothing is left only under a key the page no longer reads, written twice, or dropped: where the new type's title key already holds a different title, as another app may leave, that title stays the item's and the old one stays under its own key, which the sheet names as unused. `ItemLibrary.typeChangeOptions` offers only the types every selected item can become: never a file type, never a type with no text for an item that has text, and never one where a value does not fit the new field. The sheet (`TypeChangeSheet`) asks for each required field the items lack and names the fields the new type does not use, which stay on the items. It runs once every open page has handed over its last edit (`changeTypeAfterSaving`), and closes only once the change is sent, so a change refused first keeps what was typed in it. A change the package refuses before queueing gives back the text values it carried, those typed in the sheet among them (`MarfaLibrary.unqueuedText`). Undo and Redo also wait for the open pages to hand over their last edit. Undo puts back exactly the values the change replaced; if the item has changed since, its text or its type, Undo moves it back from what it is now as a new change, so what was typed is kept and no text is left under a key the old type does not read.
- Folders are facts on items, never places: MAPP syncs no folders and has no Folders section, Move To or New Folder.

## Version History

- Version History (the File and context menus, the More menu, the palette and the page's context menu) opens `VersionHistorySheet` for one item: its snapshots from the server (`ItemLibrary.history`), the chosen one beside the item as it is now, the version's other fields to read, and Restore. The command stays available offline and for a deleted item, and the sheet says plainly what it can't show. Restore waits for the open pages to hand over their last edit (`restoreVersionAfterSaving`) and its availability is judged on the item as it stands (`ItemLibrary.restoreCheck`), so an item changed or deleted elsewhere while the sheet is open is refused, not overwritten. See `Marfa/AGENTS.md`.

## Writes the library cannot always make

- Deleting for good (`purge`) needs the server, so `ItemLibrary.canPurge` is true only while it can be reached, and the views dim the command until then. Saving, updating, renaming and deleting a view also need the server and are never queued, so `ItemLibrary.canEditViews` is true only while it can be reached, and the views dim those commands until then. An update made on a version changed elsewhere is refused and told, overwrites nothing, and the window goes back to the view as it is (`Browser.viewRefused`). A deleted view is gone for every app at once; there is no bin for views. Every other write goes to the copy's queue and can always be made.
Expand Down
107 changes: 107 additions & 0 deletions MAPP/Model/ItemHistoryModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation
import Observation

/// The versions of one item that the Version History sheet lists: read a page at a time from the library's source,
/// oldest first as the server gives them, never all at once.
@Observable final class ItemHistoryModel {
enum Phase: Equatable {
case idle
/// The first page is on its way.
case loading
case loaded
/// Nothing could be listed; a failure after the first page keeps the list and is in `moreFailure`.
case failed(ItemHistoryFailure)
}

/// How many snapshots one request asks for. The server allows 200.
static let pageSize = 100
/// Empty pages followed in a row before the list stops and offers Load More, so a server that answers only
/// empty pages with a cursor never keeps the sheet busy.
private static let emptyPagesFollowed = 20

let id: ItemID
@ObservationIgnored private let source: (any ItemHistorySource)?
private(set) var snapshots: [ItemSnapshot] = []
private(set) var phase = Phase.idle
/// Whether the server has more versions after those listed.
private(set) var hasMore = false
private(set) var isLoadingMore = false
/// Why the last Load More failed. The versions listed stay.
private(set) var moreFailure: ItemHistoryFailure?
var selection: ItemSnapshot.ID?

@ObservationIgnored private var cursor: String?
@ObservationIgnored private var loading: Task<Void, Never>?

init(id: ItemID, source: (any ItemHistorySource)?) {
self.id = id
self.source = source
}

var selected: ItemSnapshot? {
selection.flatMap { id in snapshots.first { $0.id == id } }
}

/// Reads the first page. Run again to try again after a failure.
func load() {
guard let source else {
phase = .failed(.unsupported)
return
}
loading?.cancel()
snapshots = []
cursor = nil
hasMore = false
moreFailure = nil
selection = nil
phase = .loading
isLoadingMore = false
loading = Task { await self.fetch(from: source, first: true) }
}

func loadMore() {
guard let source, hasMore, !isLoadingMore, phase == .loaded else { return }
moreFailure = nil
isLoadingMore = true
loading = Task { await self.fetch(from: source, first: false) }
}

/// Stops a read still under way, as the sheet closes.
func cancel() {
loading?.cancel()
loading = nil
}

private func fetch(from source: any ItemHistorySource, first: Bool) async {
var empty = 0
while !Task.isCancelled {
let page: ItemHistoryPage
do {
page = try await source.page(of: id, after: cursor, limit: Self.pageSize)
} catch {
guard !Task.isCancelled else { return }
if first { phase = .failed(error) } else { moreFailure = error }
isLoadingMore = false
return
}
guard !Task.isCancelled else { return }
// Versions are not contiguous, since the server thins old ones, so they are told apart by number alone.
let held = Set(snapshots.map(\.version))
let fresh = page.snapshots.filter { !held.contains($0.version) }
snapshots = (snapshots + fresh).sorted { $0.version < $1.version }
// A cursor that leads back to the page just read ends the list instead of looping.
let next = page.next == cursor ? nil : page.next
cursor = next
hasMore = next != nil
phase = .loaded
isLoadingMore = false
if fresh.isEmpty && next != nil {
empty += 1
guard empty < Self.emptyPagesFollowed else { return }
isLoadingMore = true
continue
}
return
}
}
}
Loading
Loading