Compare commits
110
Commits
@@ -69,3 +69,9 @@ Issues and PRs use two label prefixes:
|
||||
## Questions
|
||||
|
||||
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Outpost is licensed under the [Business Source License 1.1](./LICENSE), not a traditional OSI open-source license — see the [README's License section](./README.md#license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.
|
||||
|
||||
@@ -1,22 +1,123 @@
|
||||
MIT License
|
||||
Business Source License 1.1
|
||||
|
||||
Copyright (c) 2026 Puranjay Savar Mattas
|
||||
Parameters
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Licensor: Puranjay Savar Mattas
|
||||
Licensed Work: Outpost
|
||||
The Licensed Work is (c) 2026 Puranjay Savar Mattas
|
||||
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
|
||||
Work, and build and distribute your own modified or
|
||||
unmodified copies of it, for personal, educational, or
|
||||
internal non-commercial purposes.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
You may not, without a separate commercial agreement
|
||||
with the Licensor:
|
||||
(a) offer the Licensed Work, or any modified or
|
||||
unmodified version of it, as a hosted or
|
||||
distributed product or service to third parties,
|
||||
whether for a fee or free of charge; or
|
||||
(b) distribute the Licensed Work, or any modified or
|
||||
unmodified version of it, under a name, logo, or
|
||||
branding that states or implies it is an official,
|
||||
endorsed, or affiliated product of Outline, Inc.
|
||||
or of any other third party, or that removes or
|
||||
obscures its origin as an independent,
|
||||
unaffiliated project.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Change Date: 2036-08-20
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
For information about alternative licensing arrangements for the Licensed
|
||||
Work, contact the Licensor.
|
||||
|
||||
Notice
|
||||
|
||||
The Business Source License (this document, or the "License") is not an
|
||||
Open Source license. However, the Licensed Work will eventually be made
|
||||
available under an Open Source License, as stated in this License.
|
||||
|
||||
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Business Source License 1.1
|
||||
|
||||
Terms
|
||||
|
||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||
works, redistribute, and make non-production use of the Licensed Work. The
|
||||
Licensor may make an Additional Use Grant, above, permitting limited
|
||||
production use.
|
||||
|
||||
Effective on the Change Date, or the fourth anniversary of the first
|
||||
publicly available distribution of a specific version of the Licensed Work
|
||||
under this License, whichever comes first, the Licensor hereby grants you
|
||||
rights under the terms of the Change License, and the rights granted in the
|
||||
paragraph above terminate.
|
||||
|
||||
If your use of the Licensed Work does not comply with the requirements
|
||||
currently in effect as described in this License, you must purchase a
|
||||
commercial license from the Licensor, its affiliated entities, or authorized
|
||||
resellers, or you must refrain from using the Licensed Work.
|
||||
|
||||
All copies of the original and modified Licensed Work, and derivative works
|
||||
of the Licensed Work, are subject to this License. This License applies
|
||||
separately for each version of the Licensed Work and the Change Date may
|
||||
vary for each version of the Licensed Work released by Licensor.
|
||||
|
||||
You must conspicuously display this License on each original or modified
|
||||
copy of the Licensed Work. If you receive the Licensed Work in original or
|
||||
modified form from a third party, the terms and conditions set forth in
|
||||
this License apply to your use of that work.
|
||||
|
||||
Any use of the Licensed Work in violation of this License will automatically
|
||||
terminate your rights under this License for the current and all other
|
||||
versions of the Licensed Work.
|
||||
|
||||
This License does not grant you any right in any trademark or logo of
|
||||
Licensor or its affiliates (provided that you may use a trademark or logo
|
||||
of Licensor as expressly required by this License).
|
||||
|
||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||
TITLE.
|
||||
|
||||
MariaDB hereby grants you permission to use this License's text to license
|
||||
your works, and to refer to it using the trademark "Business Source
|
||||
License", as long as you comply with the Covenants of Licensor below.
|
||||
|
||||
Covenants of Licensor
|
||||
|
||||
In consideration of the right to use this License's text and the "Business
|
||||
Source License" name and trademark, Licensor covenants to MariaDB, and to
|
||||
all other recipients of the licensed work to be provided by Licensor:
|
||||
|
||||
1. To specify as the Change License the GPL Version 2.0 or any later
|
||||
version, or a license that is compatible with GPL Version 2.0 or a later
|
||||
version, where "compatible" means that software provided under the
|
||||
Change License can be included in a program with software provided
|
||||
under GPL Version 2.0 or a later version. Licensor may specify
|
||||
additional Change Licenses without limitation.
|
||||
|
||||
2. To either: (a) specify an additional grant of rights to use that does
|
||||
not impose any additional restriction on the right granted in this
|
||||
License, as the Additional Use Grant; or (b) insert the text "None" to
|
||||
specify a Change License.
|
||||
|
||||
3. To specify a Change Date.
|
||||
|
||||
4. Not to modify this License in any other way.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Trademark Notice
|
||||
|
||||
"Outpost" and any associated logo are trademarks of the Licensor. This
|
||||
License does not grant permission to use them to identify or market any
|
||||
product, service, or distribution of the Licensed Work — modified or
|
||||
unmodified — that is not published by the Licensor, including forks. See
|
||||
the Additional Use Grant above.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Generic key/value row backing `OfflineCacheStore` — one table for every
|
||||
/// cached response shape instead of a `@Model` per Outline type, so adding a
|
||||
/// new cached endpoint never needs a schema migration.
|
||||
@Model
|
||||
public final class CachedPayload {
|
||||
@Attribute(.unique) public var key: String
|
||||
public var payload: Data
|
||||
public var cachedAt: Date
|
||||
|
||||
public init(key: String, payload: Data, cachedAt: Date) {
|
||||
self.key = key
|
||||
self.payload = payload
|
||||
self.cachedAt = cachedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
import Foundation
|
||||
|
||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||
/// support at the existing protocol boundary, so no view model needs to know
|
||||
/// the network exists:
|
||||
///
|
||||
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
|
||||
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
|
||||
/// cache. Always tries live first — never pre-checks reachability, since
|
||||
/// "did this specific request just fail" is a more honest signal than a
|
||||
/// reachability monitor, and the two can disagree (captive portals,
|
||||
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
|
||||
/// always overwrites the cache, so staleness is bounded by "last time this
|
||||
/// endpoint actually worked."
|
||||
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
|
||||
/// pin/star/subscribe create+delete): applied optimistically against the
|
||||
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
|
||||
/// failure, then replayed in order by `flushPendingOperations()` once back
|
||||
/// online. Deliberately doesn't include anything that creates new tree
|
||||
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
|
||||
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) — those need
|
||||
/// real server-assigned ids to stay consistent with the rest of the tree,
|
||||
/// and reconciling a locally-invented id with the one the server hands
|
||||
/// back on sync is a much bigger problem than this pass takes on. Sharing,
|
||||
/// permissions, search, and export also stay live-only — read the request,
|
||||
/// they need someone else's server session, not just a network.
|
||||
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
|
||||
/// attempting `live` entirely — same fallback/queue paths as a real
|
||||
/// failure, just chosen on purpose instead of discovered.
|
||||
public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
|
||||
|
||||
private let live: OutlineAPIClient
|
||||
private let cache: OfflineCacheStore
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let keyEncoder: JSONEncoder
|
||||
|
||||
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
|
||||
self.live = live
|
||||
self.cache = cache
|
||||
self.defaults = defaults
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
|
||||
let keyEncoder = JSONEncoder()
|
||||
keyEncoder.outputFormatting = .sortedKeys
|
||||
self.keyEncoder = keyEncoder
|
||||
}
|
||||
|
||||
private var isManualOfflineModeEnabled: Bool {
|
||||
defaults.bool(forKey: Self.offlineModeDefaultsKey)
|
||||
}
|
||||
|
||||
// MARK: - Cached reads
|
||||
|
||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) }
|
||||
}
|
||||
|
||||
public func listDocuments(
|
||||
collectionId: String?,
|
||||
parentDocumentId: String?,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
) async throws -> [OutlineDocument] {
|
||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
||||
return try await cachedFetch(key: key) {
|
||||
try await self.live.listDocuments(
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
offset: offset,
|
||||
limit: limit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) }
|
||||
}
|
||||
|
||||
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") {
|
||||
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") {
|
||||
try await self.live.listDrafts(request)
|
||||
}
|
||||
}
|
||||
|
||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
try await cachedFetch(key: "collections:\(offset):\(limit)") {
|
||||
try await self.live.listCollections(offset: offset, limit: limit)
|
||||
}
|
||||
}
|
||||
|
||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
||||
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) }
|
||||
}
|
||||
|
||||
// MARK: - Queueable writes
|
||||
|
||||
/// The one exception to "no new tree structure while offline" —
|
||||
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
|
||||
/// pin/star/subscribe), caches it so it's immediately readable/editable,
|
||||
/// and queues the real create. On sync, the server-assigned id replaces
|
||||
/// the placeholder in the cache; anything still referencing the old id
|
||||
/// directly (a currently-open reader, most likely) won't follow that
|
||||
/// rename automatically — a known, narrow limitation, not something this
|
||||
/// pass tries to solve generally.
|
||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
}
|
||||
return await queueDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueDocumentUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueDocumentUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
return result
|
||||
} catch {
|
||||
return try await queueCollectionUpdate(request, dueTo: error)
|
||||
}
|
||||
}
|
||||
return try await queueCollectionUpdate(request, dueTo: nil)
|
||||
}
|
||||
|
||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
|
||||
}
|
||||
return await queuePinCreate(request)
|
||||
}
|
||||
|
||||
public func deletePin(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deletePin(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||
}
|
||||
|
||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
|
||||
}
|
||||
return await queueSubscriptionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteSubscription(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteSubscription(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||
}
|
||||
|
||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
|
||||
}
|
||||
return await queueStarDocumentCreate(request)
|
||||
}
|
||||
|
||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||
if !isManualOfflineModeEnabled {
|
||||
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
|
||||
}
|
||||
return await queueStarCollectionCreate(request)
|
||||
}
|
||||
|
||||
public func deleteStar(id: String) async throws {
|
||||
if await cancelIfNeverSynced(id: id) { return }
|
||||
if !isManualOfflineModeEnabled {
|
||||
do {
|
||||
try await live.deleteStar(id: id)
|
||||
return
|
||||
} catch {
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
return
|
||||
}
|
||||
}
|
||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||
}
|
||||
|
||||
// MARK: - Pass-through
|
||||
|
||||
public func authInfo() async throws -> OutlineAuthInfo {
|
||||
try await live.authInfo()
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await live.searchDocuments(request)
|
||||
}
|
||||
|
||||
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
|
||||
try await live.searchDocumentTitles(request)
|
||||
}
|
||||
|
||||
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
|
||||
try await live.templatizeDocument(request)
|
||||
}
|
||||
|
||||
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
|
||||
try await live.duplicateDocument(request)
|
||||
}
|
||||
|
||||
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
|
||||
try await live.unpublishDocument(request)
|
||||
}
|
||||
|
||||
public func archiveDocument(id: String) async throws -> OutlineDocument {
|
||||
try await live.archiveDocument(id: id)
|
||||
}
|
||||
|
||||
public func moveDocument(_ request: MoveDocumentRequest) async throws {
|
||||
try await live.moveDocument(request)
|
||||
}
|
||||
|
||||
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
|
||||
try await live.deleteDocument(request)
|
||||
}
|
||||
|
||||
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
|
||||
try await live.documentInsights(request)
|
||||
}
|
||||
|
||||
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
|
||||
try await live.listRevisions(request)
|
||||
}
|
||||
|
||||
public func exportDocument(id: String) async throws -> String {
|
||||
try await live.exportDocument(id: id)
|
||||
}
|
||||
|
||||
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
|
||||
try await live.createShare(request)
|
||||
}
|
||||
|
||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||
try await live.shareInfo(documentId: documentId)
|
||||
}
|
||||
|
||||
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||
try await live.updateShare(request)
|
||||
}
|
||||
|
||||
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
|
||||
try await live.listShares(request)
|
||||
}
|
||||
|
||||
public func revokeShare(id: String) async throws {
|
||||
try await live.revokeShare(id: id)
|
||||
}
|
||||
|
||||
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
|
||||
try await live.listPins(request)
|
||||
}
|
||||
|
||||
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
|
||||
try await live.listSubscriptions(request)
|
||||
}
|
||||
|
||||
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
|
||||
try await live.listViews(request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await live.listComments(request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await live.commentInfo(id: id)
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await live.createComment(request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.resolveComment(id: id)
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await live.unresolveComment(id: id)
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.addReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await live.removeReaction(commentId: commentId, emoji: emoji)
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await live.addDocumentUser(request)
|
||||
}
|
||||
|
||||
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
|
||||
try await live.removeDocumentUser(request)
|
||||
}
|
||||
|
||||
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
|
||||
try await live.documentUsers(request)
|
||||
}
|
||||
|
||||
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
|
||||
try await live.listUsers(request)
|
||||
}
|
||||
|
||||
public func deleteCollection(id: String) async throws {
|
||||
try await live.deleteCollection(id: id)
|
||||
}
|
||||
|
||||
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
|
||||
try await live.exportCollection(request)
|
||||
}
|
||||
|
||||
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
|
||||
try await live.listStars(request)
|
||||
}
|
||||
|
||||
public func currentUser() async throws -> OutlineUser {
|
||||
try await live.currentUser()
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await live.createAttachment(request)
|
||||
}
|
||||
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
try await live.fetchAuthenticatedFile(path: path)
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await live.deleteAttachment(id: id)
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserAvatar(request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserName(request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserLanguage(request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await live.updateUserPreferences(request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await live.deleteAccount()
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.subscribeToNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await live.listApiKeys(request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await live.createApiKey(request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await live.deleteApiKey(id: id)
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await live.installationInfo()
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
await cache.pendingOperations().map {
|
||||
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
public func cacheStorageSummary() async -> CacheStorageSummary {
|
||||
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
|
||||
}
|
||||
|
||||
public func clearCache() async {
|
||||
await cache.clearAll()
|
||||
}
|
||||
|
||||
/// Replays every queued operation against `live`, in the order they were
|
||||
/// queued. Each is independent — one failing doesn't block the rest.
|
||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||
let operations = await cache.pendingOperations()
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
for operation in operations {
|
||||
do {
|
||||
try await replay(operation)
|
||||
await cache.removeOperation(id: operation.id)
|
||||
succeeded += 1
|
||||
} catch {
|
||||
await cache.recordFailure(id: operation.id, error: errorDescription(error))
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return SyncFlushSummary(succeeded: succeeded, failed: failed)
|
||||
}
|
||||
|
||||
/// "Full Local Sync": eagerly walks every collection and caches every
|
||||
/// document's full content (not just whatever's been opened), so
|
||||
/// browsing offline works for the whole workspace, not only what was
|
||||
/// already viewed. Goes through `self`, not `live`, directly — the
|
||||
/// existing cached-read methods already do the caching as a side effect,
|
||||
/// this just has to drive the walk and separately cache each document by
|
||||
/// id (`listDocuments`'s cache key is the list, not the individual doc).
|
||||
///
|
||||
/// Recurses into every document's children, not just collections' own
|
||||
/// root-level documents — a document with sub-documents used to leave
|
||||
/// them uncached entirely (only reachable if something else happened to
|
||||
/// open them individually first). Also caches each collection under its
|
||||
/// own `"collection:<id>"` key (previously only cached as part of the
|
||||
/// paginated list blob), so both are individually enumerable afterward
|
||||
/// via `OfflineCacheStore.loadAll(keyPrefix:)` — see
|
||||
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
|
||||
public func performFullSync() async -> FullSyncSummary {
|
||||
var documentsCount = 0
|
||||
var errors: [String] = []
|
||||
var collections: [OutlineCollection] = []
|
||||
var collectionsOffset = 0
|
||||
let collectionsLimit = 100
|
||||
// Outline rejects any `limit` over 100 outright — page in increments
|
||||
// of that instead of guessing a total up front (the protocol doesn't
|
||||
// expose `pagination`'s total count, only the page itself); a page
|
||||
// shorter than the limit is what signals "that was the last one".
|
||||
while true {
|
||||
let page: [OutlineCollection]
|
||||
do {
|
||||
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
|
||||
} catch {
|
||||
errors.append(errorDescription(error))
|
||||
break
|
||||
}
|
||||
collections.append(contentsOf: page)
|
||||
guard page.count == collectionsLimit else { break }
|
||||
collectionsOffset += collectionsLimit
|
||||
}
|
||||
|
||||
for collection in collections {
|
||||
await cacheCollection(collection)
|
||||
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
|
||||
documentsCount += result.count
|
||||
errors.append(contentsOf: result.errors)
|
||||
}
|
||||
|
||||
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
|
||||
}
|
||||
|
||||
/// Caches every document under `parentDocumentId` (`nil` = a
|
||||
/// collection's root level) and recurses into each one's own children,
|
||||
/// depth-first, until a branch runs out of sub-documents. Returns a
|
||||
/// plain `(count, errors)` pair rather than mutating shared state across
|
||||
/// `await` boundaries, since this calls itself recursively.
|
||||
private func cacheDocumentTree(
|
||||
collectionId: String,
|
||||
parentDocumentId: String?,
|
||||
collectionName: String
|
||||
) async -> (count: Int, errors: [String]) {
|
||||
var count = 0
|
||||
var errors: [String] = []
|
||||
var offset = 0
|
||||
let limit = 100
|
||||
while true {
|
||||
let documents: [OutlineDocument]
|
||||
do {
|
||||
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
|
||||
} catch {
|
||||
errors.append("\(collectionName): \(errorDescription(error))")
|
||||
break
|
||||
}
|
||||
for document in documents {
|
||||
await cacheDocument(document)
|
||||
count += 1
|
||||
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
|
||||
count += childResult.count
|
||||
errors.append(contentsOf: childResult.errors)
|
||||
}
|
||||
guard documents.count == limit else { break }
|
||||
offset += limit
|
||||
}
|
||||
return (count, errors)
|
||||
}
|
||||
|
||||
/// Every individually cached document from the last Full Local Sync —
|
||||
/// empty if a sync has never run (or found nothing). Purely a local
|
||||
/// SwiftData read, no network involved.
|
||||
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||
let payloads = await cache.loadAll(keyPrefix: "document:")
|
||||
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) }
|
||||
}
|
||||
|
||||
/// Every individually cached collection from the last Full Local Sync.
|
||||
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
||||
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
||||
// Manual offline mode means "skip the network entirely," not just
|
||||
// "prefer it" — without this check, a read would still hit `live`
|
||||
// (and succeed, showing content beyond whatever's cached) any time
|
||||
// the device actually had a connection, defeating the point of
|
||||
// deliberately testing/working as if offline.
|
||||
if isManualOfflineModeEnabled {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
do {
|
||||
let result = try await fetch()
|
||||
if let data = try? encoder.encode(result) {
|
||||
await cache.save(data, forKey: key)
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
|
||||
return cached
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||
return prefix
|
||||
}
|
||||
return "\(prefix):\(json)"
|
||||
}
|
||||
|
||||
private func cacheDocument(_ document: OutlineDocument) async {
|
||||
if let data = try? encoder.encode(document) {
|
||||
await cache.save(data, forKey: "document:\(document.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
||||
if let data = try? encoder.encode(collection) {
|
||||
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
||||
guard let data = try? encoder.encode(payload) else { return }
|
||||
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
|
||||
}
|
||||
|
||||
/// A delete targeting a `pending-*` id can only mean "cancel the create
|
||||
/// still sitting in the queue" — the server has never heard of that id,
|
||||
/// so queuing the delete would just fail once synced. Returns whether it
|
||||
/// found (and removed) a matching create, meaning the caller is done.
|
||||
private func cancelIfNeverSynced(id: String) async -> Bool {
|
||||
guard id.hasPrefix("pending-") else { return false }
|
||||
return await cache.removeOperation(id: id)
|
||||
}
|
||||
|
||||
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
let now = Date()
|
||||
let synthesized = OutlineDocument(
|
||||
id: pendingId,
|
||||
title: request.title,
|
||||
text: request.text,
|
||||
emoji: nil,
|
||||
collectionId: request.collectionId,
|
||||
parentDocumentId: request.parentDocumentId,
|
||||
url: "/doc/\(pendingId)",
|
||||
revision: nil,
|
||||
fullWidth: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: request.publish ? now : nil,
|
||||
archivedAt: nil,
|
||||
deletedAt: nil
|
||||
)
|
||||
await cacheDocument(synthesized)
|
||||
await enqueue(.createDocument, payload: request, id: pendingId)
|
||||
return synthesized
|
||||
}
|
||||
|
||||
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
||||
let merged = OutlineDocument(
|
||||
id: base.id,
|
||||
title: request.title ?? base.title,
|
||||
text: mergedText,
|
||||
emoji: base.emoji,
|
||||
collectionId: base.collectionId,
|
||||
parentDocumentId: base.parentDocumentId,
|
||||
url: base.url,
|
||||
revision: base.revision,
|
||||
fullWidth: request.fullWidth ?? base.fullWidth,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date(),
|
||||
publishedAt: base.publishedAt,
|
||||
archivedAt: base.archivedAt,
|
||||
deletedAt: base.deletedAt
|
||||
)
|
||||
await cacheDocument(merged)
|
||||
|
||||
// Still-unsynced create for this exact document — fold the edit into
|
||||
// the pending create's payload instead of queuing a separate update.
|
||||
// A separate update would target `merged.id`, which is still the
|
||||
// `pending-*` placeholder at replay time; the server has never heard
|
||||
// of it and the update would just fail every retry.
|
||||
if merged.id.hasPrefix("pending-"),
|
||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
||||
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
|
||||
let resolvedCreate = CreateDocumentRequest(
|
||||
title: merged.title,
|
||||
text: merged.text,
|
||||
collectionId: createRequest.collectionId,
|
||||
parentDocumentId: createRequest.parentDocumentId,
|
||||
publish: createRequest.publish
|
||||
)
|
||||
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
|
||||
return merged
|
||||
}
|
||||
|
||||
// Fully resolved (not the original partial request) so replaying just
|
||||
// this one queued operation reproduces `merged` exactly — that's what
|
||||
// makes coalescing a second edit onto the same queued id safe.
|
||||
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
|
||||
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
||||
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
|
||||
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||
}
|
||||
let merged = OutlineCollection(
|
||||
id: base.id,
|
||||
name: request.name ?? base.name,
|
||||
description: request.description ?? base.description,
|
||||
color: base.color,
|
||||
icon: base.icon,
|
||||
createdAt: base.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
await cacheCollection(merged)
|
||||
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
|
||||
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
|
||||
return merged
|
||||
}
|
||||
|
||||
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createPin, payload: request, id: pendingId)
|
||||
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
|
||||
}
|
||||
|
||||
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.createSubscription, payload: request, id: pendingId)
|
||||
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
|
||||
}
|
||||
|
||||
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starDocument, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
|
||||
}
|
||||
|
||||
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
|
||||
let pendingId = "pending-\(UUID().uuidString)"
|
||||
await enqueue(.starCollection, payload: request, id: pendingId)
|
||||
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
|
||||
}
|
||||
|
||||
private func replay(_ operation: PendingOperation) async throws {
|
||||
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
|
||||
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
|
||||
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
|
||||
))
|
||||
}
|
||||
switch kind {
|
||||
case .createDocument:
|
||||
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.createDocument(request)
|
||||
await cacheDocument(result)
|
||||
// The placeholder id (== operation.id) is now a dead orphan —
|
||||
// nothing server-side will ever answer to it again.
|
||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||
case .updateDocument:
|
||||
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
|
||||
let result = try await live.updateDocument(request)
|
||||
await cacheDocument(result)
|
||||
case .updateCollection:
|
||||
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
|
||||
let result = try await live.updateCollection(request)
|
||||
await cacheCollection(result)
|
||||
case .createPin:
|
||||
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
|
||||
_ = try await live.createPin(request)
|
||||
case .deletePin:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deletePin(id: request.id)
|
||||
case .createSubscription:
|
||||
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
|
||||
_ = try await live.createSubscription(request)
|
||||
case .deleteSubscription:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteSubscription(id: request.id)
|
||||
case .starDocument:
|
||||
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
|
||||
_ = try await live.starDocument(request)
|
||||
case .deleteStar:
|
||||
let request = try decoder.decode(IDPayload.self, from: operation.payload)
|
||||
try await live.deleteStar(id: request.id)
|
||||
case .starCollection:
|
||||
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
|
||||
_ = try await live.starCollection(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorDescription(_ error: Error) -> String {
|
||||
if let apiError = error as? OutlineAPIError {
|
||||
switch apiError {
|
||||
case .unauthorized: return "Sign-in expired."
|
||||
case .notFound: return "Not found on the server."
|
||||
case .server(let status, let message): return message ?? "Server error (\(status))."
|
||||
case .decoding: return "Unexpected response shape."
|
||||
case .transport: return "Couldn't reach the server."
|
||||
case .tokenUnavailable: return "Couldn't access the saved sign-in."
|
||||
}
|
||||
}
|
||||
return String(describing: error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
||||
/// last fetched successfully, keyed by request shape, so browsing keeps
|
||||
/// working (stale) once the network stops. Also owns the offline write
|
||||
/// queue (`PendingOperation`) — same store, same actor, since both need
|
||||
/// synchronized access to the same on-disk SwiftData container.
|
||||
@ModelActor
|
||||
public actor OfflineCacheStore {
|
||||
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
||||
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
|
||||
}
|
||||
|
||||
// MARK: - Read-through cache
|
||||
|
||||
public func save(_ data: Data, forKey key: String) {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
if let existing = try? modelContext.fetch(descriptor).first {
|
||||
existing.payload = data
|
||||
existing.cachedAt = Date()
|
||||
} else {
|
||||
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func load(forKey key: String) -> Data? {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
return try? modelContext.fetch(descriptor).first?.payload
|
||||
}
|
||||
|
||||
/// Everything cached under a key prefix — e.g. every individually
|
||||
/// cached document (`"document:<id>"`) or collection
|
||||
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
|
||||
/// search index without a per-item exact-key lookup.
|
||||
public func loadAll(keyPrefix: String) -> [Data] {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
|
||||
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
|
||||
}
|
||||
|
||||
/// Used to drop a temporary `pending-*` document's cache entry once a
|
||||
/// queued create syncs and the server hands back the real id — the
|
||||
/// placeholder key would otherwise sit around as a dead orphan forever.
|
||||
public func removeCacheEntry(forKey key: String) {
|
||||
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func itemCount() -> Int {
|
||||
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
||||
}
|
||||
|
||||
public func totalBytes() -> Int {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
||||
return rows.reduce(0) { $0 + $1.payload.count }
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
let descriptor = FetchDescriptor<CachedPayload>()
|
||||
guard let rows = try? modelContext.fetch(descriptor) else { return }
|
||||
rows.forEach { modelContext.delete($0) }
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Offline write queue
|
||||
|
||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||
/// onto a still-unsynced edit, or a create being cancelled by its own
|
||||
/// synthesized id) replaces the row in place rather than piling up.
|
||||
public func enqueueOperation(id: String, kind: String, payload: Data) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
if let existing = try? modelContext.fetch(descriptor).first {
|
||||
existing.kind = kind
|
||||
existing.payload = payload
|
||||
existing.lastError = nil
|
||||
existing.attemptCount = 0
|
||||
} else {
|
||||
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
public func pendingOperations() -> [PendingOperation] {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Returns whether a matching operation was actually found and removed —
|
||||
/// callers use this to detect "this targeted something that never made
|
||||
/// it to the server in the first place" and skip queuing a delete.
|
||||
@discardableResult
|
||||
public func removeOperation(id: String) -> Bool {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
return true
|
||||
}
|
||||
|
||||
public func recordFailure(id: String, error: String) {
|
||||
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
||||
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
||||
existing.lastAttemptAt = Date()
|
||||
existing.lastError = error
|
||||
existing.attemptCount += 1
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A queued mutation made while offline, waiting to replay against the live
|
||||
/// server. `id` is deliberately overloaded: for actions that create a new
|
||||
/// server-side record (pin, star, subscription), it's also the synthesized
|
||||
/// placeholder id handed back to the caller immediately — so a matching
|
||||
/// delete queued before that create ever syncs can cancel both out by id
|
||||
/// instead of hitting a server that's never heard of the placeholder. For
|
||||
/// actions that edit an existing record (document/collection updates), it's
|
||||
/// deterministic per target id, so a second edit before the first syncs
|
||||
/// coalesces into one queued operation instead of piling up.
|
||||
@Model
|
||||
public final class PendingOperation {
|
||||
@Attribute(.unique) public var id: String
|
||||
public var kind: String
|
||||
public var payload: Data
|
||||
public var createdAt: Date
|
||||
public var lastAttemptAt: Date?
|
||||
public var lastError: String?
|
||||
public var attemptCount: Int
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
kind: String,
|
||||
payload: Data,
|
||||
createdAt: Date,
|
||||
lastAttemptAt: Date? = nil,
|
||||
lastError: String? = nil,
|
||||
attemptCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.payload = payload
|
||||
self.createdAt = createdAt
|
||||
self.lastAttemptAt = lastAttemptAt
|
||||
self.lastError = lastError
|
||||
self.attemptCount = attemptCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
/// UI-facing snapshot of a queued offline mutation — deliberately not the
|
||||
/// `@Model` type itself, so Settings can display it without holding a
|
||||
/// reference into the SwiftData store.
|
||||
public struct PendingOperationSummary: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let kind: String
|
||||
public let createdAt: Date
|
||||
public let attemptCount: Int
|
||||
public let lastError: String?
|
||||
}
|
||||
|
||||
public struct CacheStorageSummary: Sendable {
|
||||
public let itemCount: Int
|
||||
public let totalBytes: Int
|
||||
}
|
||||
|
||||
public struct SyncFlushSummary: Sendable {
|
||||
public let succeeded: Int
|
||||
public let failed: Int
|
||||
}
|
||||
|
||||
public struct FullSyncSummary: Sendable {
|
||||
public let collectionsCount: Int
|
||||
public let documentsCount: Int
|
||||
public let errors: [String]
|
||||
public let finishedAt: Date
|
||||
}
|
||||
|
||||
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
|
||||
/// replay later. Deliberately a small, explicit set — see its doc comment
|
||||
/// for what's excluded and why.
|
||||
enum PendingOperationKind: String, Codable, Sendable {
|
||||
case createDocument
|
||||
case updateDocument
|
||||
case updateCollection
|
||||
case createPin
|
||||
case deletePin
|
||||
case createSubscription
|
||||
case deleteSubscription
|
||||
case starDocument
|
||||
case deleteStar
|
||||
case starCollection
|
||||
}
|
||||
|
||||
/// Shared payload shape for the delete-by-id queueable operations.
|
||||
struct IDPayload: Codable, Sendable {
|
||||
let id: String
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
|
||||
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
|
||||
/// Draft (unpublished) documents belonging to the current user. Backed
|
||||
/// by `documents.drafts`.
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
|
||||
/// Full-text search with snippets/ranking. Backed by `documents.search`.
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
|
||||
/// Title-only search — faster, no snippets. Backed by `documents.search_titles`.
|
||||
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// Historical view records, not live presence. Backed by `views.list`.
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
||||
|
||||
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are confirmed real against Outline's own server
|
||||
/// source but aren't in the vendored spec.
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
|
||||
func commentInfo(id: String) async throws -> OutlineComment
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
|
||||
func resolveComment(id: String) async throws -> OutlineComment
|
||||
func unresolveComment(id: String) async throws -> OutlineComment
|
||||
/// Reaction endpoints return `{success: true}`, not the updated
|
||||
/// comment — callers refetch via `commentInfo` for the fresh
|
||||
/// `reactions` array.
|
||||
func addReaction(commentId: String, emoji: String) async throws
|
||||
func removeReaction(commentId: String, emoji: String) async throws
|
||||
|
||||
/// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed
|
||||
/// from Outline's official docs, the rest are best-effort.
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
|
||||
@@ -69,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
|
||||
func deleteStar(id: String) async throws
|
||||
|
||||
func currentUser() async throws -> OutlineUser
|
||||
|
||||
/// Two-step presigned upload: this requests where/how to upload,
|
||||
/// `uploadAttachmentFile` performs the actual multipart POST to that
|
||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||
/// Fetches raw bytes from an authenticated, server-relative GET path —
|
||||
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
|
||||
/// own editor embeds for uploaded images in document Markdown. Unlike
|
||||
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
|
||||
/// actual (often presigned, cross-host) storage URL; `path` is resolved
|
||||
/// against the client's base URL, same as `uploadAttachmentFile`'s
|
||||
/// `uploadUrl` handling.
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data
|
||||
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||
/// against a live server specifically for attachments yet.
|
||||
func deleteAttachment(id: String) async throws
|
||||
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
|
||||
/// `users.update`, name only. See `UpdateUserNameRequest`.
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
|
||||
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
|
||||
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
|
||||
/// Backed by `users.delete` — self-service account deletion, no
|
||||
/// confirmation code param confirmed live, matches every other simple
|
||||
/// no-body delete in this API.
|
||||
func deleteAccount() async throws
|
||||
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
|
||||
/// Settings → API & Access.
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
|
||||
/// The returned `OutlineAPIKey.value` is the only time the full
|
||||
/// plaintext key is ever available — the caller is responsible for
|
||||
/// displaying it once and then discarding it.
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
|
||||
func deleteApiKey(id: String) async throws
|
||||
|
||||
/// Settings → Installation. Self-hosted server version info.
|
||||
func installationInfo() async throws -> OutlineInstallationInfo
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
|
||||
}
|
||||
|
||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
||||
try await post("documents.drafts", body: request)
|
||||
}
|
||||
|
||||
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
|
||||
try await post("documents.search", body: request)
|
||||
}
|
||||
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("views.list", body: request)
|
||||
}
|
||||
|
||||
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
|
||||
try await post("comments.list", body: request)
|
||||
}
|
||||
|
||||
public func commentInfo(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.info", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
|
||||
try await post("comments.create", body: request)
|
||||
}
|
||||
|
||||
public func resolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.resolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func unresolveComment(id: String) async throws -> OutlineComment {
|
||||
try await post("comments.unresolve", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func addReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func removeReaction(commentId: String, emoji: String) async throws {
|
||||
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
|
||||
}
|
||||
|
||||
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
|
||||
try await post("documents.add_user", body: request)
|
||||
}
|
||||
@@ -229,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await post("users.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
|
||||
try await post("attachments.create", body: request)
|
||||
}
|
||||
|
||||
/// No Bearer/CSRF header attached here, deliberately — the presigned
|
||||
/// `form.sig` field (short-lived, scoped to this exact upload key) is
|
||||
/// what authorizes this specific request, the same way an S3 presigned
|
||||
/// POST works. Best-effort against a live server: if it turns out the
|
||||
/// self-hosted local-storage backend also wants a bearer token here,
|
||||
/// that's a one-line addition once confirmed, not a design change.
|
||||
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: result.form,
|
||||
fileFieldName: "file",
|
||||
fileName: result.attachment.name ?? "avatar",
|
||||
fileData: fileData,
|
||||
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
|
||||
)
|
||||
|
||||
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
|
||||
// a self-hosted local-storage backend, not an absolute S3 URL —
|
||||
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
|
||||
// replaces the whole path for a leading-slash relative string per
|
||||
// RFC 3986, same resolution already used for user/team avatar URLs.
|
||||
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: uploadURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
}
|
||||
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
|
||||
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
// URLSession's default redirect handling drops Authorization on a
|
||||
// cross-host redirect (same as a browser dropping cookies on one) —
|
||||
// exactly what's wanted here: authorize the request to Outline's own
|
||||
// `attachments.redirect`, not the presigned storage URL it 302s to.
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
switch response.statusCode {
|
||||
case 401:
|
||||
throw OutlineAPIError.unauthorized
|
||||
case 404:
|
||||
throw OutlineAPIError.notFound
|
||||
default:
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
|
||||
try await post("users.update", body: request)
|
||||
}
|
||||
|
||||
public func deleteAccount() async throws {
|
||||
try await postForSuccess("users.delete", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
|
||||
try await post("apiKeys.list", body: request)
|
||||
}
|
||||
|
||||
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
|
||||
try await post("apiKeys.create", body: request)
|
||||
}
|
||||
|
||||
public func deleteApiKey(id: String) async throws {
|
||||
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
public func installationInfo() async throws -> OutlineInstallationInfo {
|
||||
try await post("installation.info", body: EmptyParams())
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
@@ -388,6 +556,11 @@ private struct StarIDParams: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct CommentReactionParams: Encodable {
|
||||
let id: String
|
||||
let emoji: String
|
||||
}
|
||||
|
||||
private struct MoveDocumentResponse: Decodable {
|
||||
let documents: [OutlineDocument]?
|
||||
let collections: [OutlineCollection]?
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal recursive JSON tree for fields the API returns as genuinely
|
||||
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
|
||||
/// OpenAPI spec) — currently just `Comment.data`, a ProseMirror document.
|
||||
/// Decode-only: nothing in this codebase writes comment bodies back yet.
|
||||
public enum JSONValue: Decodable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(value)
|
||||
} else if let value = try? container.decode([JSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension JSONValue {
|
||||
/// Best-effort plain-text extraction from a ProseMirror-shaped document —
|
||||
/// walks `content` arrays, concatenates `text` node strings, and adds a
|
||||
/// trailing newline after known block-level node types so paragraphs
|
||||
/// don't run together. Good enough for a read-only comment list; not a
|
||||
/// real ProseMirror renderer (no marks, no lists/tables structure).
|
||||
public func plainText() -> String {
|
||||
switch self {
|
||||
case .object(let fields):
|
||||
if case .string(let text)? = fields["text"] {
|
||||
return text
|
||||
}
|
||||
let childText: String
|
||||
if case .array(let items)? = fields["content"] {
|
||||
childText = items.map { $0.plainText() }.joined()
|
||||
} else {
|
||||
childText = ""
|
||||
}
|
||||
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
|
||||
return childText + "\n"
|
||||
}
|
||||
return childText
|
||||
case .array(let items):
|
||||
return items.map { $0.plainText() }.joined()
|
||||
case .string(let value):
|
||||
return value
|
||||
case .number, .bool, .null:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private static let blockTypes: Set<String> = [
|
||||
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// The subset of Outline's notification event types this app exposes a
|
||||
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||
/// responses showing the full `notificationSettings` dictionary after
|
||||
/// toggling each one in Outline's own web app). The server tracks more
|
||||
/// event types than this app has UI for (`revisions.create`,
|
||||
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||
/// clobbering risk in only covering a subset.
|
||||
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||
case documentPublish = "documents.publish"
|
||||
case documentUpdate = "documents.update"
|
||||
case commentCreate = "comments.create"
|
||||
case commentMentioned = "comments.mentioned"
|
||||
case documentMentioned = "documents.mentioned"
|
||||
case commentGroupMentioned = "comments.group_mentioned"
|
||||
case documentGroupMentioned = "documents.group_mentioned"
|
||||
case commentResolve = "comments.resolve"
|
||||
case reactionCreate = "reactions.create"
|
||||
case collectionCreate = "collections.create"
|
||||
case emailsInviteAccepted = "emails.invite_accepted"
|
||||
case documentAddUser = "documents.add_user"
|
||||
case collectionAddUser = "collections.add_user"
|
||||
case emailsExportCompleted = "emails.export_completed"
|
||||
case accessRequestCreate = "access_requests.create"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// A personal API key (Settings → API & Access). Only the last 4 characters
|
||||
/// of the actual token are ever returned by the server — there's no way to
|
||||
/// see a full key again after creation, matching every other API-key UI
|
||||
/// convention.
|
||||
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let last4: String?
|
||||
public let scope: [String]?
|
||||
public let createdAt: Date
|
||||
public let expiresAt: Date?
|
||||
public let lastActiveAt: Date?
|
||||
/// The full plaintext key — present *only* in `apiKeys.create`'s
|
||||
/// response, confirmed live: `apiKeys.list` never includes it, matching
|
||||
/// "shown once at creation" being enforced server-side, not just a
|
||||
/// client-side UI convention this app has to uphold on its own.
|
||||
public let value: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
last4: String? = nil,
|
||||
scope: [String]? = nil,
|
||||
createdAt: Date,
|
||||
expiresAt: Date? = nil,
|
||||
lastActiveAt: Date? = nil,
|
||||
value: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.last4 = last4
|
||||
self.scope = scope
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = expiresAt
|
||||
self.lastActiveAt = lastActiveAt
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// One uploaded file — created via a two-step presigned upload
|
||||
/// (`attachments.create` for the upload target, then a direct POST to
|
||||
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
|
||||
/// local-storage backend; `size` comes back as a string there, not a
|
||||
/// number — same "don't trust the vendored spec's implied types" lesson as
|
||||
/// everywhere else this codebase has hit it.
|
||||
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let documentId: String?
|
||||
public let contentType: String?
|
||||
public let name: String?
|
||||
public let url: String?
|
||||
public let size: String?
|
||||
}
|
||||
|
||||
/// `attachments.create`'s response — everything needed to perform the
|
||||
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, …)
|
||||
/// must all be included as their own multipart parts, in the same request
|
||||
/// as the file itself, POSTed to `uploadUrl`.
|
||||
public struct CreateAttachmentResult: Decodable, Sendable {
|
||||
public let attachment: OutlineAttachment
|
||||
public let uploadUrl: String
|
||||
public let form: [String: String]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
|
||||
/// `comments.*` — `create`/`info`/`update`/`delete`/`list` are in the
|
||||
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
|
||||
/// `remove_reaction` are not (confirmed against Outline's own server
|
||||
/// source instead — same "spec mirror is incomplete" lesson as
|
||||
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
|
||||
/// not plain text — see `JSONValue.plainText()`.
|
||||
public struct OutlineComment: Decodable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let data: JSONValue
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let createdAt: Date
|
||||
public let createdBy: OutlineUser?
|
||||
public let updatedAt: Date?
|
||||
public let resolvedAt: Date?
|
||||
public let resolvedBy: OutlineUser?
|
||||
public let reactions: [ReactionSummary]
|
||||
/// The document text this comment is anchored to — only populated when
|
||||
/// the request set `includeAnchorText: true`; `nil` for a document-level
|
||||
/// (non-anchored) comment either way. No prefix/suffix comes back on
|
||||
/// read (only accepted as create-time disambiguation input), so
|
||||
/// re-finding this text in the rendered document is inherently
|
||||
/// best-effort — first occurrence wins, same as Outline's own create
|
||||
/// behavior when nothing else disambiguates.
|
||||
public let anchorText: String?
|
||||
|
||||
public var isResolved: Bool { resolvedAt != nil }
|
||||
public var bodyText: String {
|
||||
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// One emoji's worth of reactions on a comment — grouped by emoji server-side
|
||||
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
|
||||
public struct ReactionSummary: Codable, Sendable, Equatable {
|
||||
public let emoji: String
|
||||
public let userIds: [String]
|
||||
|
||||
public init(emoji: String, userIds: [String]) {
|
||||
self.emoji = emoji
|
||||
self.userIds = userIds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
/// `installation.info` — the self-hosted server's own version, confirmed
|
||||
/// live. `policies` (a separate top-level array alongside `data` in the raw
|
||||
/// response) isn't modeled here — not used by this app's Installation
|
||||
/// settings page.
|
||||
public struct OutlineInstallationInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let latestVersion: String
|
||||
public let versionsBehind: Int
|
||||
}
|
||||
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
public let email: String?
|
||||
public let avatarUrl: String?
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||
/// fixed struct for that reason. `true` means subscribed.
|
||||
public let notificationSettings: [String: Bool]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
email: String? = nil,
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil,
|
||||
notificationSettings: [String: Bool]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.email = email
|
||||
self.avatarUrl = avatarUrl
|
||||
self.role = role
|
||||
self.language = language
|
||||
self.preferences = preferences
|
||||
self.notificationSettings = notificationSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// `User.preferences` — a free-form JSON blob on Outline's own `User` row,
|
||||
/// not a fixed-shape API resource. Wire key names below are confirmed
|
||||
/// against a live server's own web app traffic (captured toggling every
|
||||
/// Preferences setting one at a time), not guessed — see the `CodingKeys`
|
||||
/// mapping for the two that don't match this struct's own property names.
|
||||
///
|
||||
/// The server also validates `preferences` against a known key allowlist
|
||||
/// and rejects the whole `users.update` call (not just the bad field) if
|
||||
/// any key it doesn't recognize is present — confirmed live via a
|
||||
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
|
||||
/// was sent. That's also why `fullWidthDocuments` is kept here even though
|
||||
/// this app has no UI for it yet: this app always sends the *whole*
|
||||
/// preferences object back on every save (see
|
||||
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
|
||||
/// during decode would permanently clear it the next time any other
|
||||
/// preference here gets saved.
|
||||
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
|
||||
public var rememberLastPath: Bool?
|
||||
/// App-facing polarity: `true` means "separate editing mode is on" —
|
||||
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
|
||||
/// separate editing modes are each other's negation), inverted in
|
||||
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
|
||||
/// remember that.
|
||||
public var separateEditing: Bool?
|
||||
public var useCursorPointer: Bool?
|
||||
public var codeBlockLineNumbers: Bool?
|
||||
public var showCommentMarker: Bool?
|
||||
public var smartText: Bool?
|
||||
/// One of `NotificationBadgeStyle`'s raw values.
|
||||
public var notificationBadge: String?
|
||||
/// No UI in this app yet — preserved purely so saving any other
|
||||
/// preference here doesn't clobber it. See the type doc comment.
|
||||
public var fullWidthDocuments: Bool?
|
||||
|
||||
public init(
|
||||
rememberLastPath: Bool? = nil,
|
||||
separateEditing: Bool? = nil,
|
||||
useCursorPointer: Bool? = nil,
|
||||
codeBlockLineNumbers: Bool? = nil,
|
||||
showCommentMarker: Bool? = nil,
|
||||
smartText: Bool? = nil,
|
||||
notificationBadge: String? = nil,
|
||||
fullWidthDocuments: Bool? = nil
|
||||
) {
|
||||
self.rememberLastPath = rememberLastPath
|
||||
self.separateEditing = separateEditing
|
||||
self.useCursorPointer = useCursorPointer
|
||||
self.codeBlockLineNumbers = codeBlockLineNumbers
|
||||
self.showCommentMarker = showCommentMarker
|
||||
self.smartText = smartText
|
||||
self.notificationBadge = notificationBadge
|
||||
self.fullWidthDocuments = fullWidthDocuments
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case rememberLastPath
|
||||
case seamlessEdit
|
||||
case useCursorPointer
|
||||
case codeBlockLineNumbers
|
||||
case commentsInGutter
|
||||
case enableSmartText
|
||||
case notificationBadge
|
||||
case fullWidthDocuments
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
|
||||
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
|
||||
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
|
||||
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
|
||||
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
|
||||
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
|
||||
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
|
||||
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
|
||||
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
|
||||
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
|
||||
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
|
||||
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
|
||||
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
|
||||
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
|
||||
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
/// App-icon unread indicator style. Wire values confirmed live (captured
|
||||
/// setting all three from Outline's own web app).
|
||||
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
|
||||
case none = "disabled"
|
||||
case unreadIndicator = "indicator"
|
||||
case unreadCount = "count"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .unreadIndicator: return "Unread Indicator"
|
||||
case .unreadCount: return "Unread Count"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Builds the multipart/form-data body for Outline's presigned-upload
|
||||
/// targets (`attachments.create`'s `uploadUrl`/`form`) — an S3-style
|
||||
/// presigned POST: every `form` field has to ride along as its own part in
|
||||
/// the same request as the file, not as query params or headers.
|
||||
enum MultipartFormDataBuilder {
|
||||
static func build(
|
||||
fields: [String: String],
|
||||
fileFieldName: String,
|
||||
fileName: String,
|
||||
fileData: Data,
|
||||
fileContentType: String,
|
||||
boundary: String = "Boundary-\(UUID().uuidString)"
|
||||
) -> (body: Data, contentType: String) {
|
||||
var body = Data()
|
||||
|
||||
for (key, value) in fields {
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
|
||||
body.append(value.utf8Data)
|
||||
body.append("\r\n".utf8Data)
|
||||
}
|
||||
|
||||
body.append("--\(boundary)\r\n".utf8Data)
|
||||
body.append(
|
||||
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
|
||||
)
|
||||
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
|
||||
body.append(fileData)
|
||||
body.append("\r\n".utf8Data)
|
||||
body.append("--\(boundary)--\r\n".utf8Data)
|
||||
|
||||
return (body, "multipart/form-data; boundary=\(boundary)")
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
|
||||
/// literal `null`) confirmed live to mean no expiration — Swift's
|
||||
/// synthesized `Encodable` already omits `nil` optionals via
|
||||
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
|
||||
/// `UpdateUserAvatarRequest` needed one for the opposite case.
|
||||
public struct CreateApiKeyRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let expiresAt: Date?
|
||||
/// `nil`/omitted grants full access — confirmed live (every key created
|
||||
/// without a scope came back with unrestricted access). A specific
|
||||
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
|
||||
public let scope: [String]?
|
||||
|
||||
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
|
||||
self.name = name
|
||||
self.expiresAt = expiresAt
|
||||
self.scope = scope
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Requests an upload target for a new file — not the upload itself, see
|
||||
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
|
||||
/// user-avatar upload sends (confirmed live); a real value scopes the
|
||||
/// attachment to a document instead (e.g. an inline image embed).
|
||||
public struct CreateAttachmentRequest: Encodable, Sendable {
|
||||
public let name: String
|
||||
public let contentType: String
|
||||
public let size: Int
|
||||
public let documentId: String?
|
||||
|
||||
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
|
||||
self.name = name
|
||||
self.contentType = contentType
|
||||
self.size = size
|
||||
self.documentId = documentId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// `parentCommentId: nil` creates a document-level (top-level) comment;
|
||||
/// non-nil creates a reply (Outline supports one level of nesting — a
|
||||
/// reply's own `parentCommentId` should always be a top-level comment's
|
||||
/// id, never another reply's). `anchorText` creates an inline (anchored)
|
||||
/// comment instead of a document-level one — the first occurrence of that
|
||||
/// exact substring in the document's plain text is used, same as Outline's
|
||||
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
|
||||
/// no UI for choosing between multiple identical occurrences). `text` is
|
||||
/// the documented markdown convenience field for `data` (a ProseMirror
|
||||
/// document) — simplest path for plain-text comment bodies, no need to
|
||||
/// construct ProseMirror JSON by hand.
|
||||
public struct CreateCommentRequest: Encodable, Sendable {
|
||||
public let documentId: String
|
||||
public let parentCommentId: String?
|
||||
public let text: String
|
||||
public let anchorText: String?
|
||||
|
||||
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
|
||||
self.documentId = documentId
|
||||
self.parentCommentId = parentCommentId
|
||||
self.text = text
|
||||
self.anchorText = anchorText
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
public struct CreateDocumentRequest: Encodable, Sendable {
|
||||
public struct CreateDocumentRequest: Codable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
public let collectionId: String
|
||||
/// `nil` (with `parentDocumentId` also `nil`) creates a draft — Outline
|
||||
/// requires one of the two to publish at all, regardless of `publish`.
|
||||
public let collectionId: String?
|
||||
public let parentDocumentId: String?
|
||||
public let publish: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
text: String,
|
||||
collectionId: String,
|
||||
collectionId: String? = nil,
|
||||
parentDocumentId: String? = nil,
|
||||
publish: Bool = true
|
||||
) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
|
||||
/// Collection" — these are distinct actions on the real server.
|
||||
public struct CreatePinRequest: Encodable, Sendable {
|
||||
public struct CreatePinRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let collectionId: String?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// See `OutlineSubscription` — best-effort shape, not in the vendored spec.
|
||||
public struct CreateSubscriptionRequest: Encodable, Sendable {
|
||||
public struct CreateSubscriptionRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
public let event: String
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// `apiKeys.list` — matches every other paginated `.list` endpoint's flat
|
||||
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
|
||||
/// `pagination` object that only appears in list *responses*.
|
||||
public struct ListApiKeysRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 25) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListCommentsRequest: Encodable, Sendable {
|
||||
public let documentId: String
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
/// Include each anchored comment's `anchorText` (the document text it's
|
||||
/// attached to) in the response. Off by default — it's extra payload
|
||||
/// only needed when actually positioning inline markers.
|
||||
public let includeAnchorText: Bool
|
||||
|
||||
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
|
||||
self.documentId = documentId
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.includeAnchorText = includeAnchorText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct ListDraftsRequest: Encodable, Sendable {
|
||||
public let offset: Int
|
||||
public let limit: Int
|
||||
|
||||
public init(offset: Int = 0, limit: Int = 25) {
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||
/// live via Outline's own "All notifications" master toggle, which sends
|
||||
/// no `eventType` at all.
|
||||
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||
public let eventType: String?
|
||||
|
||||
public init(eventType: String? = nil) {
|
||||
self.eventType = eventType
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct StarCollectionRequest: Encodable, Sendable {
|
||||
public struct StarCollectionRequest: Codable, Sendable {
|
||||
public let collectionId: String
|
||||
|
||||
public init(collectionId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct StarDocumentRequest: Encodable, Sendable {
|
||||
public struct StarDocumentRequest: Codable, Sendable {
|
||||
public let documentId: String
|
||||
|
||||
public init(documentId: String) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateCollectionRequest: Encodable, Sendable {
|
||||
public struct UpdateCollectionRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
public let description: String?
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
public struct UpdateDocumentRequest: Codable, Sendable {
|
||||
public let id: String
|
||||
public let title: String?
|
||||
public let text: String?
|
||||
public let append: Bool?
|
||||
public let fullWidth: Bool?
|
||||
public let insightsEnabled: Bool?
|
||||
/// Moves the document to this collection. Combined with `publish: true`,
|
||||
/// this is how a draft (no `collectionId`, or one it just hasn't left
|
||||
/// yet) gets published into a specific collection in one call.
|
||||
public let collectionId: String?
|
||||
/// Publishes a draft, making it visible to other workspace members.
|
||||
/// Documented as a no-op if the document is already published.
|
||||
public let publish: Bool?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
text: String? = nil,
|
||||
append: Bool? = nil,
|
||||
fullWidth: Bool? = nil,
|
||||
insightsEnabled: Bool? = nil
|
||||
insightsEnabled: Bool? = nil,
|
||||
collectionId: String? = nil,
|
||||
publish: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
|
||||
self.append = append
|
||||
self.fullWidth = fullWidth
|
||||
self.insightsEnabled = insightsEnabled
|
||||
self.collectionId = collectionId
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
|
||||
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
|
||||
/// properties, which *omits* the key entirely when the value is `nil` —
|
||||
/// removing the avatar needs a literal `"avatarUrl": null` in the request
|
||||
/// body, not the key missing. `id` is required: confirmed live (the
|
||||
/// captured request body's length only matches `{"id": "...", "avatarUrl":
|
||||
/// ...}`, not a shorter shape without it).
|
||||
public struct UpdateUserAvatarRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let avatarUrl: String?
|
||||
|
||||
public init(id: String, avatarUrl: String?) {
|
||||
self.id = id
|
||||
self.avatarUrl = avatarUrl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, avatarUrl
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(avatarUrl, forKey: .avatarUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, language only.
|
||||
public struct UpdateUserLanguageRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let language: String
|
||||
|
||||
public init(id: String, language: String) {
|
||||
self.id = id
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, name only.
|
||||
public struct UpdateUserNameRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
|
||||
public init(id: String, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.update`, preferences only. Sends the *whole* preferences object
|
||||
/// back (not a single changed key) — avoids needing to know whether the
|
||||
/// server deep-merges a partial `preferences` body or replaces it outright.
|
||||
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
|
||||
public let id: String
|
||||
public let preferences: OutlineUserPreferences
|
||||
|
||||
public init(id: String, preferences: OutlineUserPreferences) {
|
||||
self.id = id
|
||||
self.preferences = preferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
private struct NotStubbed: Error {}
|
||||
|
||||
/// Conforms to the full protocol (so it can stand in for `live`), but only
|
||||
/// the two methods under test in this file have real behavior — everything
|
||||
/// else throws loudly if a test accidentally exercises it.
|
||||
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
|
||||
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
|
||||
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
|
||||
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
|
||||
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
|
||||
var deletePinHandler: (@Sendable (String) async throws -> Void)?
|
||||
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
|
||||
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
|
||||
|
||||
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
|
||||
|
||||
func documentInfo(id: String) async throws -> OutlineDocument {
|
||||
guard let handler = documentInfoHandler else { throw NotStubbed() }
|
||||
return try await handler(id)
|
||||
}
|
||||
|
||||
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
|
||||
guard let handler = listDocumentsHandler else { throw NotStubbed() }
|
||||
return try await handler(collectionId, parentDocumentId, offset, limit)
|
||||
}
|
||||
|
||||
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
|
||||
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||
guard let handler = createDocumentHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||
guard let handler = updateDocumentHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
|
||||
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
|
||||
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
|
||||
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
|
||||
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
|
||||
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
|
||||
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
|
||||
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
|
||||
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
|
||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
|
||||
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
|
||||
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
|
||||
func revokeShare(id: String) async throws { throw NotStubbed() }
|
||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||
guard let handler = createPinHandler else { throw NotStubbed() }
|
||||
return try await handler(request)
|
||||
}
|
||||
|
||||
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
|
||||
|
||||
func deletePin(id: String) async throws {
|
||||
guard let handler = deletePinHandler else { throw NotStubbed() }
|
||||
try await handler(id)
|
||||
}
|
||||
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
|
||||
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
|
||||
func deleteSubscription(id: String) async throws { throw NotStubbed() }
|
||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
|
||||
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
|
||||
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
|
||||
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
|
||||
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
|
||||
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
|
||||
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
|
||||
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
|
||||
|
||||
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||
guard let handler = listCollectionsHandler else { throw NotStubbed() }
|
||||
return try await handler(offset, limit)
|
||||
}
|
||||
|
||||
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
|
||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
|
||||
func deleteCollection(id: String) async throws { throw NotStubbed() }
|
||||
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
|
||||
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
|
||||
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
|
||||
func deleteStar(id: String) async throws { throw NotStubbed() }
|
||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
|
||||
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
|
||||
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
|
||||
func deleteApiKey(id: String) async throws { throw NotStubbed() }
|
||||
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||
private func makeCache() throws -> OfflineCacheStore {
|
||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||
}
|
||||
|
||||
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
|
||||
OutlineDocument(
|
||||
id: id,
|
||||
title: title,
|
||||
text: "body",
|
||||
url: "/doc/\(id)",
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
|
||||
OutlineCollection(
|
||||
id: id,
|
||||
name: name,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
updatedAt: Date(timeIntervalSince1970: 0)
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
stub.documentInfoHandler = { _ in document }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
}
|
||||
|
||||
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let document = makeDocument()
|
||||
var callCount = 0
|
||||
stub.documentInfoHandler = { _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return document }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// First call succeeds and populates the cache.
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
// Second call fails live — should transparently return the cached value.
|
||||
let result = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
XCTAssertEqual(result, document)
|
||||
XCTAssertEqual(callCount, 2)
|
||||
}
|
||||
|
||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let collections = [makeCollection()]
|
||||
var callCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
callCount += 1
|
||||
if callCount == 1 { return collections }
|
||||
throw StubTransportError()
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||
let result = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(result, collections)
|
||||
}
|
||||
|
||||
func testDifferentDocumentIdsAreCachedSeparately() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let docOne = makeDocument(id: "doc-1", title: "One")
|
||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.documentInfo(id: "doc-2")
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
|
||||
let cachedOne = try await sut.documentInfo(id: "doc-1")
|
||||
let cachedTwo = try await sut.documentInfo(id: "doc-2")
|
||||
|
||||
XCTAssertEqual(cachedOne, docOne)
|
||||
XCTAssertEqual(cachedTwo, docTwo)
|
||||
}
|
||||
|
||||
// MARK: - Offline writes
|
||||
|
||||
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
// Populate the cache with the base document first (as a real open would).
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
|
||||
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
XCTAssertEqual(updated.title, "Edited Offline")
|
||||
|
||||
// Re-opening the document (still offline) should reflect the optimistic edit.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: "doc-1")
|
||||
XCTAssertEqual(reopened.title, "Edited Offline")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "updateDocument")
|
||||
}
|
||||
|
||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
do {
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
||||
XCTFail("Expected an error")
|
||||
} catch is StubTransportError {
|
||||
// expected — nothing to optimistically merge onto
|
||||
}
|
||||
}
|
||||
|
||||
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
}
|
||||
|
||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
try await sut.deletePin(id: pin.id)
|
||||
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
let pendingCount1 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount1, 1)
|
||||
|
||||
// Network's back — replay should now succeed.
|
||||
stub.updateDocumentHandler = { request in
|
||||
self.makeDocument(id: request.id, title: request.title ?? "")
|
||||
}
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
let pendingCount0 = await sut.pendingOperations().count
|
||||
XCTAssertEqual(pendingCount0, 0)
|
||||
}
|
||||
|
||||
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
let original = makeDocument(id: "doc-1", title: "Original")
|
||||
stub.documentInfoHandler = { _ in original }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||
|
||||
// Still offline — replay should fail and keep the operation queued.
|
||||
let summary = await sut.flushPendingOperations()
|
||||
|
||||
XCTAssertEqual(summary.succeeded, 0)
|
||||
XCTAssertEqual(summary.failed, 1)
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.attemptCount, 1)
|
||||
XCTAssertNotNil(pending.first?.lastError)
|
||||
}
|
||||
|
||||
func testManualOfflineModeSkipsLiveEntirely() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
var liveCallCount = 0
|
||||
stub.createPinHandler = { _ in
|
||||
liveCallCount += 1
|
||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
||||
}
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
|
||||
|
||||
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
|
||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||
}
|
||||
|
||||
/// Regression test: manual offline mode used to only gate writes —
|
||||
/// reads (listCollections, documentInfo, etc.) still hit `live` first
|
||||
/// any time the device actually had a connection, silently defeating
|
||||
/// "skip the network entirely" for the one thing that mattered most:
|
||||
/// the sidebar showing more than what was actually cached.
|
||||
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
|
||||
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
|
||||
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
|
||||
|
||||
let stub = StubOutlineAPIClient()
|
||||
let cachedCollection = makeCollection(id: "col-1")
|
||||
var liveCallCount = 0
|
||||
stub.listCollectionsHandler = { _, _ in
|
||||
liveCallCount += 1
|
||||
return [cachedCollection]
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
|
||||
|
||||
// Online first — populates the cache normally.
|
||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
XCTAssertEqual(firstResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1)
|
||||
|
||||
// Flip manual offline mode on, still "connected" (stub would happily
|
||||
// answer) — the live call must not be attempted at all.
|
||||
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
|
||||
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||
|
||||
XCTAssertEqual(secondResult, [cachedCollection])
|
||||
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
|
||||
}
|
||||
|
||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
_ = try await sut.documentInfo(id: "doc-1")
|
||||
let summary = await sut.cacheStorageSummary()
|
||||
|
||||
XCTAssertEqual(summary.itemCount, 1)
|
||||
XCTAssertGreaterThan(summary.totalBytes, 0)
|
||||
|
||||
await sut.clearCache()
|
||||
let clearedSummary = await sut.cacheStorageSummary()
|
||||
XCTAssertEqual(clearedSummary.itemCount, 0)
|
||||
}
|
||||
|
||||
// MARK: - Full sync
|
||||
|
||||
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
var requestedLimits: [Int] = []
|
||||
// 105 collections across two pages (100 + 5) — regression test for a
|
||||
// real bug: this used to ask for `limit: 250` in one shot, which
|
||||
// Outline's server rejects outright ("Pagination limit is too large
|
||||
// (max 100)"), turning the whole sync into a single silent failure.
|
||||
stub.listCollectionsHandler = { offset, limit in
|
||||
requestedLimits.append(limit)
|
||||
let remaining = max(0, 105 - offset)
|
||||
let count = min(limit, remaining)
|
||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||
}
|
||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
XCTAssertEqual(summary.collectionsCount, 105)
|
||||
XCTAssertTrue(summary.errors.isEmpty)
|
||||
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
|
||||
}
|
||||
|
||||
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
// Must return empty for any non-nil parentDocumentId (no children) —
|
||||
// performFullSync now recurses into every document's own children,
|
||||
// so a stub that ignores parentDocumentId and always returns the
|
||||
// same root documents regardless would recurse into itself forever.
|
||||
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||
guard parentDocumentId == nil else { return [] }
|
||||
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
XCTAssertEqual(summary.documentsCount, 2)
|
||||
|
||||
// A plain documentInfo read (no live call available) should now hit
|
||||
// the cache full sync populated, not just the list-shaped cache key.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let cachedDoc = try await sut.documentInfo(id: "doc-2")
|
||||
XCTAssertEqual(cachedDoc.id, "doc-2")
|
||||
}
|
||||
|
||||
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
|
||||
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild) —
|
||||
// regression test for the real gap this fixed: only root-level
|
||||
// documents were ever cached before, so a document's own
|
||||
// sub-documents were never reachable offline at all unless
|
||||
// something else happened to open them individually first.
|
||||
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
|
||||
guard offset == 0 else { return [] }
|
||||
switch parentDocumentId {
|
||||
case nil: return [self.makeDocument(id: "doc-1")]
|
||||
case "doc-1": return [self.makeDocument(id: "doc-2")]
|
||||
case "doc-2": return [self.makeDocument(id: "doc-3")]
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
let cache = try makeCache()
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
||||
|
||||
let summary = await sut.performFullSync()
|
||||
|
||||
XCTAssertEqual(summary.documentsCount, 3)
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
|
||||
XCTAssertEqual(cachedGrandchild.id, "doc-3")
|
||||
}
|
||||
|
||||
// MARK: - Offline document creation
|
||||
|
||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
|
||||
XCTAssertTrue(created.id.hasPrefix("pending-"))
|
||||
XCTAssertEqual(created.title, "New Doc")
|
||||
XCTAssertEqual(created.text, "hello")
|
||||
|
||||
// It's immediately readable, same as any other cached document.
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "New Doc")
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
}
|
||||
|
||||
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||
)
|
||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
|
||||
|
||||
let pending = await sut.pendingOperations()
|
||||
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
|
||||
XCTAssertEqual(pending.first?.kind, "createDocument")
|
||||
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let reopened = try await sut.documentInfo(id: created.id)
|
||||
XCTAssertEqual(reopened.title, "Real Title")
|
||||
XCTAssertEqual(reopened.text, "Real body")
|
||||
}
|
||||
|
||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||
let stub = StubOutlineAPIClient()
|
||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
||||
|
||||
let created = try await sut.createDocument(
|
||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||
)
|
||||
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
|
||||
stub.createDocumentHandler = { _ in realDocument }
|
||||
|
||||
let summary = await sut.flushPendingOperations()
|
||||
XCTAssertEqual(summary.succeeded, 1)
|
||||
XCTAssertEqual(summary.failed, 0)
|
||||
|
||||
// The real document is now readable under its real id...
|
||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||
let byRealId = try await sut.documentInfo(id: "real-server-id")
|
||||
XCTAssertEqual(byRealId.id, "real-server-id")
|
||||
|
||||
// ...and the placeholder is gone rather than left as a dead orphan.
|
||||
do {
|
||||
_ = try await sut.documentInfo(id: created.id)
|
||||
XCTFail("Expected the placeholder cache entry to have been removed")
|
||||
} catch {
|
||||
// expected — nothing left under the old id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
|
||||
}
|
||||
|
||||
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [
|
||||
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
|
||||
],
|
||||
"data": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Sounds great" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
|
||||
|
||||
XCTAssertEqual(comments.first?.id, "comment-1")
|
||||
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
|
||||
XCTAssertFalse(comments.first?.isResolved ?? true)
|
||||
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
|
||||
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
|
||||
}
|
||||
|
||||
func testResolveCommentDecodesResolvedComment() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-1",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": "2026-01-02T00:00:00.000Z",
|
||||
"resolvedBy": { "id": "user-2", "name": "John Roe" },
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let comment = try await client.resolveComment(id: "comment-1")
|
||||
|
||||
XCTAssertTrue(comment.isResolved)
|
||||
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
|
||||
}
|
||||
|
||||
func testCreateCommentSendsParentCommentIdForAReply() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "comment-2",
|
||||
"documentId": "doc-1",
|
||||
"parentCommentId": "comment-1",
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"createdBy": { "id": "user-1", "name": "Jane Doe" },
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"resolvedAt": null,
|
||||
"resolvedBy": null,
|
||||
"reactions": [],
|
||||
"data": { "type": "doc", "content": [] }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let reply = try await client.createComment(
|
||||
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
|
||||
)
|
||||
|
||||
XCTAssertEqual(reply.parentCommentId, "comment-1")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
|
||||
}
|
||||
|
||||
func testAddReactionPostsIdAndEmoji() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
|
||||
}
|
||||
|
||||
func testCreatePinDecodesPin() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
@@ -993,6 +1124,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
|
||||
|
||||
XCTAssertNil(user.avatarUrl)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
|
||||
// Swift's synthesized Encodable would have omitted the key entirely
|
||||
// for a nil Optional instead of sending a literal null.
|
||||
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
|
||||
}
|
||||
|
||||
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"avatarUrl": "/api/attachments.redirect?id=abc"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserAvatar(
|
||||
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
|
||||
)
|
||||
|
||||
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
|
||||
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
|
||||
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
|
||||
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
|
||||
}
|
||||
|
||||
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "New Name"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
|
||||
|
||||
XCTAssertEqual(user.name, "New Name")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
}
|
||||
|
||||
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"language": "fr_FR"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
|
||||
|
||||
XCTAssertEqual(user.language, "fr_FR")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
|
||||
}
|
||||
|
||||
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
|
||||
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
|
||||
|
||||
XCTAssertEqual(user.preferences?.rememberLastPath, true)
|
||||
XCTAssertEqual(user.preferences?.useCursorPointer, true)
|
||||
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
let sentPreferences = sentBody?["preferences"] as? [String: Any]
|
||||
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
|
||||
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
|
||||
}
|
||||
|
||||
/// Locks in the wire mapping confirmed against a live server's own web
|
||||
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
|
||||
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
|
||||
/// this struct exposes), and `seamlessEdit` is the *negation* of this
|
||||
/// app's `separateEditing`.
|
||||
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
|
||||
let json = """
|
||||
{
|
||||
"seamlessEdit": false,
|
||||
"commentsInGutter": true,
|
||||
"enableSmartText": true,
|
||||
"rememberLastPath": true,
|
||||
"useCursorPointer": true,
|
||||
"codeBlockLineNumbers": false,
|
||||
"notificationBadge": "indicator",
|
||||
"fullWidthDocuments": true
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
|
||||
|
||||
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
|
||||
XCTAssertEqual(preferences.showCommentMarker, true)
|
||||
XCTAssertEqual(preferences.smartText, true)
|
||||
XCTAssertEqual(preferences.rememberLastPath, true)
|
||||
XCTAssertEqual(preferences.useCursorPointer, true)
|
||||
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
|
||||
XCTAssertEqual(preferences.notificationBadge, "indicator")
|
||||
XCTAssertEqual(preferences.fullWidthDocuments, true)
|
||||
}
|
||||
|
||||
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
|
||||
var preferences = OutlineUserPreferences()
|
||||
preferences.separateEditing = true
|
||||
preferences.showCommentMarker = false
|
||||
preferences.smartText = true
|
||||
preferences.fullWidthDocuments = true
|
||||
|
||||
let data = try JSONEncoder().encode(preferences)
|
||||
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
|
||||
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
|
||||
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
|
||||
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
|
||||
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
|
||||
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
|
||||
XCTAssertNil(object?["showCommentMarker"])
|
||||
XCTAssertNil(object?["smartText"])
|
||||
}
|
||||
|
||||
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": true }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||
|
||||
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||
}
|
||||
|
||||
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": false }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNil(sentBody?["eventType"])
|
||||
}
|
||||
|
||||
func testListApiKeysDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"pagination": { "limit": 25, "offset": 0 },
|
||||
"data": [
|
||||
{
|
||||
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
|
||||
"name": "Outpost",
|
||||
"scope": null,
|
||||
"last4": "dl1h",
|
||||
"createdAt": "2026-08-12T18:44:32.467Z",
|
||||
"updatedAt": "2026-08-12T18:44:32.467Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": "2026-08-18T01:01:36.553Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let keys = try await client.listApiKeys(ListApiKeysRequest())
|
||||
|
||||
XCTAssertEqual(keys.count, 1)
|
||||
XCTAssertEqual(keys.first?.name, "Outpost")
|
||||
XCTAssertEqual(keys.first?.last4, "dl1h")
|
||||
XCTAssertNil(keys.first?.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
|
||||
}
|
||||
|
||||
func testInstallationInfoDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
|
||||
"policies": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let info = try await client.installationInfo()
|
||||
|
||||
XCTAssertEqual(info.version, "1.9.2")
|
||||
XCTAssertEqual(info.versionsBehind, 0)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
|
||||
}
|
||||
|
||||
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
|
||||
"name": "test",
|
||||
"scope": null,
|
||||
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
|
||||
"last4": "0DGv",
|
||||
"createdAt": "2026-08-18T15:39:29.872Z",
|
||||
"expiresAt": null,
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
|
||||
|
||||
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
|
||||
XCTAssertNil(key.expiresAt)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["name"] as? String, "test")
|
||||
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
|
||||
XCTAssertNil(sentBody?["scope"])
|
||||
}
|
||||
|
||||
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
|
||||
"name": "Test",
|
||||
"scope": null,
|
||||
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
|
||||
"last4": "cLts",
|
||||
"createdAt": "2026-08-18T15:38:22.928Z",
|
||||
"expiresAt": "2026-11-16T23:59:59.999Z",
|
||||
"lastActiveAt": null
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
|
||||
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
|
||||
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNotNil(sentBody?["expiresAt"])
|
||||
}
|
||||
|
||||
func testDeleteApiKeySendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
|
||||
}
|
||||
|
||||
func testDeleteAccountSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteAccount()
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
|
||||
}
|
||||
|
||||
func testDeleteAttachmentSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
try await client.deleteAttachment(id: "attach-1")
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
|
||||
}
|
||||
|
||||
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"attachment": {
|
||||
"id": "attach-1",
|
||||
"documentId": null,
|
||||
"contentType": "image/jpeg",
|
||||
"name": "avatar.jpg",
|
||||
"url": "/api/attachments.redirect?id=attach-1",
|
||||
"size": "59304"
|
||||
},
|
||||
"uploadUrl": "/api/files.create",
|
||||
"form": {
|
||||
"Content-Type": "image/jpeg",
|
||||
"key": "uploads/user-1/attach-1/avatar.jpg",
|
||||
"acl": "public-read"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let result = try await client.createAttachment(
|
||||
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.attachment.id, "attach-1")
|
||||
XCTAssertEqual(result.attachment.size, "59304")
|
||||
XCTAssertEqual(result.uploadUrl, "/api/files.create")
|
||||
XCTAssertEqual(result.form["acl"], "public-read")
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
|
||||
}
|
||||
|
||||
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{ "success": true }
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let uploadTarget = CreateAttachmentResult(
|
||||
attachment: OutlineAttachment(
|
||||
id: "attach-1",
|
||||
documentId: nil,
|
||||
contentType: "image/jpeg",
|
||||
name: "avatar.jpg",
|
||||
url: "/api/attachments.redirect?id=attach-1",
|
||||
size: "3"
|
||||
),
|
||||
uploadUrl: "/api/files.create",
|
||||
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
|
||||
)
|
||||
|
||||
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
|
||||
|
||||
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
|
||||
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
|
||||
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
|
||||
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
|
||||
}
|
||||
|
||||
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
let client = LiveOutlineAPIClient(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import OutlineKit
|
||||
|
||||
final class MultipartFormDataBuilderTests: XCTestCase {
|
||||
func testBuildIncludesEveryFieldAndTheFile() throws {
|
||||
let fileData = Data("fake-jpeg-bytes".utf8)
|
||||
let (body, contentType) = MultipartFormDataBuilder.build(
|
||||
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
|
||||
fileFieldName: "file",
|
||||
fileName: "avatar.jpg",
|
||||
fileData: fileData,
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "TestBoundary"
|
||||
)
|
||||
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
|
||||
XCTAssertTrue(bodyString.contains("uploads/abc"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
|
||||
XCTAssertTrue(bodyString.contains("public-read"))
|
||||
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
|
||||
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
|
||||
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
|
||||
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
|
||||
}
|
||||
|
||||
func testFileFieldComesAfterAllFormFields() throws {
|
||||
let (body, _) = MultipartFormDataBuilder.build(
|
||||
fields: ["a": "1", "b": "2"],
|
||||
fileFieldName: "file",
|
||||
fileName: "x.jpg",
|
||||
fileData: Data("bytes".utf8),
|
||||
fileContentType: "image/jpeg",
|
||||
boundary: "B"
|
||||
)
|
||||
let bodyString = String(decoding: body, as: UTF8.self)
|
||||
|
||||
let fieldsRange = bodyString.range(of: "name=\"a\"")
|
||||
let fileRange = bodyString.range(of: "name=\"file\"")
|
||||
XCTAssertNotNil(fieldsRange)
|
||||
XCTAssertNotNil(fileRange)
|
||||
if let fieldsRange, let fileRange {
|
||||
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -31,6 +32,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
|
||||
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -59,9 +61,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
|
||||
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
|
||||
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
|
||||
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
|
||||
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
|
||||
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
|
||||
);
|
||||
};
|
||||
FAF99C24302CE96200C9949F /* Frameworks */ = {
|
||||
@@ -77,12 +80,21 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
FADBE085303734FE001E69F0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAF99C0F302CE96100C9949F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FAF99C1A302CE96100C9949F /* Outpost */,
|
||||
FAF99C2A302CE96200C9949F /* OutpostTests */,
|
||||
FAF99C34302CE96200C9949F /* OutpostUITests */,
|
||||
FADBE085303734FE001E69F0 /* Frameworks */,
|
||||
FAF99C19302CE96100C9949F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -116,9 +128,9 @@
|
||||
name = Outpost;
|
||||
packageProductDependencies = (
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */,
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */,
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */,
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
|
||||
);
|
||||
productName = Outpost;
|
||||
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
|
||||
@@ -200,7 +212,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
|
||||
@@ -299,7 +311,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -361,7 +373,7 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -385,15 +397,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -408,19 +425,21 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -430,15 +449,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@@ -453,19 +477,21 @@
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
MARKETING_VERSION = 0.0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||
MARKETING_VERSION = 0.0.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SDKROOT = auto;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
XROS_DEPLOYMENT_TARGET = 27.0;
|
||||
};
|
||||
name = Release;
|
||||
@@ -476,7 +502,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -502,7 +528,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -527,7 +553,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -552,7 +578,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = B95H74ZDY6;
|
||||
DEVELOPMENT_TEAM = CW6GQT9SK5;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||
@@ -610,43 +636,33 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "Vendor/swift-markdown-engine";
|
||||
};
|
||||
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = OutlineKit;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.12.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = OutlineKit;
|
||||
};
|
||||
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = FAF99C10302CE96100C9949F /* Project object */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
|
||||
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "highlighterswift",
|
||||
@@ -10,15 +10,6 @@
|
||||
"version" : "3.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-engine",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nodes-app/swift-markdown-engine",
|
||||
"state" : {
|
||||
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
|
||||
"version" : "0.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftmath",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "outpost-ios-1024.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -2,25 +2,17 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
/// Bare content (icon, name, version, links) with no window chrome — the
|
||||
/// macOS "About Outpost" app-menu command now opens Settings' own About
|
||||
/// section directly (no separate popup window), so this is its only caller.
|
||||
struct AboutInfoView: View {
|
||||
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
|
||||
|
||||
private var appName: String {
|
||||
var appName: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||
}
|
||||
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
private let releaseStage = "ALPHA"
|
||||
|
||||
private var versionString: String {
|
||||
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
|
||||
}
|
||||
var versionString: String { OutpostVersion.fullVersionString }
|
||||
|
||||
private var copyrightYear: String {
|
||||
String(Calendar.current.component(.year, from: Date()))
|
||||
@@ -51,29 +43,15 @@ struct AboutView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Link(destination: repositoryURL) {
|
||||
Label("View Source on Git", systemImage: "link")
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Button("Check for Updates…") {
|
||||
checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
// No Sparkle-style in-app updater yet — this just opens the releases page
|
||||
// on the self-hosted Gitea instance so the user can check/download manually.
|
||||
private func checkForUpdates() {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||
/// contains the avatar image reliably broke its own sizing on click (even after
|
||||
@@ -7,10 +8,11 @@ import SwiftUI
|
||||
/// label rendering, which a `Button` doesn't go through.
|
||||
struct AccountFooter: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@State private var isShowingProfile = false
|
||||
@@ -93,8 +95,17 @@ struct AccountFooter: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
menuItem("Profile…") { isShowingProfile = true }
|
||||
menuItem("Preferences…") { openSettings() }
|
||||
// Deferred a tick: setting this synchronously in the same call
|
||||
// that dismisses this popover collides two AppKit window/layer
|
||||
// transactions in the same runloop turn (visible in the console
|
||||
// as "Invalid attempt to open a new transaction during CA
|
||||
// commit") — letting the popover's dismissal finish first avoids it.
|
||||
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Crop/rotate/zoom editor shown after picking a photo, before it's
|
||||
/// uploaded — pan (drag), zoom (pinch or the slider), and 90°-increment
|
||||
/// rotate, all inside a circular mask matching how the avatar actually
|
||||
/// renders everywhere else in the app.
|
||||
///
|
||||
/// The on-screen preview and the final exported image are built from the
|
||||
/// exact same view composition (`avatarContent`), just instantiated once
|
||||
/// for display and once inside an `ImageRenderer` — that's deliberate:
|
||||
/// hand-deriving a separate set of crop-math for a higher-resolution
|
||||
/// render would risk it silently disagreeing with what the user actually
|
||||
/// saw and confirmed, and there's no way to visually verify that
|
||||
/// agreement without running the app. Reusing the identical view tree
|
||||
/// makes the export WYSIWYG by construction instead of by careful math.
|
||||
struct AvatarCropperView: View {
|
||||
let sourceImage: NSImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@State private var rotationDegrees: Double = 0
|
||||
@GestureState private var dragTranslation: CGSize = .zero
|
||||
|
||||
/// Used for both the live preview and the exported image — see the
|
||||
/// type-level doc comment for why that's the same size, not two.
|
||||
private let diameter: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Edit Photo")
|
||||
.font(.headline)
|
||||
|
||||
ZStack {
|
||||
avatarContent
|
||||
.clipShape(Circle())
|
||||
Circle()
|
||||
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture()
|
||||
.updating($dragTranslation) { value, state, _ in state = value.translation }
|
||||
.onEnded { value in
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.left")
|
||||
}
|
||||
.help("Rotate left")
|
||||
|
||||
Slider(value: $scale, in: 1...4)
|
||||
.frame(width: 140)
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
|
||||
} label: {
|
||||
Image(systemName: "rotate.right")
|
||||
}
|
||||
.help("Rotate right")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel, action: onCancel)
|
||||
Spacer()
|
||||
Button("Use Photo") {
|
||||
if let data = renderFinalImage() {
|
||||
onConfirm(data)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
|
||||
/// applies the user's pan/zoom/rotation on top — identical between the
|
||||
/// live preview and the final render (see the type-level doc comment).
|
||||
private var avatarContent: some View {
|
||||
Image(nsImage: sourceImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.scaleEffect(scale)
|
||||
.rotationEffect(.degrees(rotationDegrees))
|
||||
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
|
||||
.frame(width: diameter, height: diameter)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func renderFinalImage() -> Data? {
|
||||
let content = avatarContent
|
||||
.clipShape(Circle())
|
||||
.frame(width: diameter, height: diameter)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
|
||||
guard let nsImage = renderer.nsImage else { return nil }
|
||||
return nsImage.jpegData(compressionQuality: 0.9)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,40 +0,0 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
struct PreferencesView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
Picker("Appearance", selection: $appearance) {
|
||||
ForEach(AppAppearance.allCases) { option in
|
||||
Text(option.label).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section("Account") {
|
||||
LabeledContent("Signed in as", value: session.userName ?? "—")
|
||||
if let email = session.userEmail {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
if let teamName = session.teamName {
|
||||
LabeledContent("Workspace", value: teamName)
|
||||
}
|
||||
|
||||
Button("Log Out…", role: .destructive) {
|
||||
isShowingLogoutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 380, height: 300)
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,115 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Swapped into the real sidebar's content slot (search field, collections
|
||||
/// tree, account footer) while Settings is open — same sidebar, different
|
||||
/// content, rather than a separate mini sidebar nested inside a page. "Done"
|
||||
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
|
||||
/// back.
|
||||
///
|
||||
/// Grouped by `SettingsCategory` — `general` (ours) sits under an "Outpost"
|
||||
/// header at the top, then Outline's own Account/Workspace groups, matching
|
||||
/// the settings page structure of the Outline web app. Outline's own server
|
||||
/// version has no dedicated section (there used to be an Integrations &
|
||||
/// Installation category for just that) — it's cheap enough to show
|
||||
/// unconditionally in the footer here instead, alongside Outpost's own
|
||||
/// version.
|
||||
struct SettingsSidebarList: View {
|
||||
@Binding var selection: SettingsSection?
|
||||
let onDone: () -> Void
|
||||
|
||||
@Environment(SessionStore.self) private var session
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@State private var outlineVersion: String?
|
||||
|
||||
/// Mirrors `SettingsView`'s own check — a real dropped connection or
|
||||
/// the manual Offline Mode toggle both mean there's no server to ask.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Settings")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
List(selection: $selection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||
Section {
|
||||
ForEach(sections) { section in
|
||||
Label(section.title, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
} header: {
|
||||
if let title = category.title {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
// Not hardcoded to `.workspace` specifically —
|
||||
// stays correct on its own as sections get
|
||||
// built, only shows while every section in
|
||||
// the category is still `!isImplemented`.
|
||||
if sections.allSatisfy({ !$0.isImplemented }) {
|
||||
Text("Coming Soon")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
|
||||
versionFooter
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Done", action: onDone)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
}
|
||||
// Keyed to connectivity, not a one-shot `.task {}` — reconnecting
|
||||
// (or turning the manual Offline Mode toggle back off) re-fires
|
||||
// this automatically instead of leaving the footer stuck on
|
||||
// whatever it last knew, or blank, until Settings is reopened.
|
||||
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Outpost \(OutpostVersion.displayString)")
|
||||
if let outlineVersion {
|
||||
Text("Outline \(outlineVersion)")
|
||||
} else if !isEffectivelyOnline {
|
||||
Text("Outline — offline")
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func refreshOutlineVersion() async {
|
||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
||||
outlineVersion = try? await apiClient.installationInfo().version
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,21 +3,17 @@ import SwiftUI
|
||||
struct AuthHeaderView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
Image(systemName: "text.book.closed.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
|
||||
// A plain Image Set, not the AppIcon *app icon* asset — App Icon
|
||||
// sets aren't reliably resolvable through Image(_:)/UIImage
|
||||
// (named:) at runtime (confirmed live: showed nothing). This is
|
||||
// the same source artwork (outpost-ios-1024.png) duplicated
|
||||
// into a normal image set so SwiftUI can actually load it.
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("Welcome to Outpost")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||
@@ -9,21 +10,34 @@ import OutlineKit
|
||||
/// `isSelectable` and link-opening both still need to work.
|
||||
struct CollectionOverviewContent: View {
|
||||
@State private var markdown: String
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick`.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(collection: OutlineCollection) {
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
_markdown = State(initialValue: collection.description ?? "")
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
NativeTextViewWrapper(
|
||||
text: $markdown,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionOverviewView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
@State private var selectedTab: CollectionTab = .overview
|
||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
||||
searchQuery: Binding<String>,
|
||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
|
||||
if !trimmedSearchQuery.isEmpty {
|
||||
searchResultsList
|
||||
} else if selectedTab == .overview {
|
||||
CollectionOverviewContent(collection: collection)
|
||||
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||
} else {
|
||||
documentList
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
|
||||
} icon: {
|
||||
if let emoji = collection.emojiIcon {
|
||||
Text(emoji)
|
||||
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
|
||||
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
|
||||
Image(systemName: symbolName)
|
||||
.foregroundStyle(tintColor)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// ⌘K. Settings → Editor → Command Palette. Always searches locally, never a
|
||||
/// per-keystroke network request. Two data-source modes:
|
||||
///
|
||||
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
|
||||
/// fetch once when the palette opens — two small requests, near-instant,
|
||||
/// works with no setup.
|
||||
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
|
||||
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) — zero
|
||||
/// network calls at all, and includes every nested sub-document, not just
|
||||
/// collection roots. Requires Full Local Sync to actually have populated
|
||||
/// that cache first (gated in Settings — the toggle here is disabled
|
||||
/// without it); this view doesn't trigger a sync itself.
|
||||
struct CommandPaletteView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let cachingClient: CachingOutlineAPIClient?
|
||||
let fullWorkspaceSearch: Bool
|
||||
let onSelectDocument: (OutlineDocument) -> Void
|
||||
let onSelectCollection: (OutlineCollection) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var documents: [OutlineDocument] = []
|
||||
@State private var isLoading = true
|
||||
@State private var selectedIndex = 0
|
||||
@FocusState private var isSearchFieldFocused: Bool
|
||||
|
||||
private enum Result: Identifiable {
|
||||
case collection(OutlineCollection)
|
||||
case document(OutlineDocument)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .collection(let collection): return "collection-\(collection.id)"
|
||||
case .document(let document): return "document-\(document.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var results: [Result] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
// No query yet: surface collections first, then the most
|
||||
// recent/full-workspace documents as-is, capped so the panel
|
||||
// doesn't dump the entire workspace with nothing typed.
|
||||
return (collections.map(Result.collection) + documents.map(Result.document))
|
||||
.prefix(20)
|
||||
.map { $0 }
|
||||
}
|
||||
let scored: [(Result, Int)] = collections.compactMap { collection in
|
||||
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
|
||||
} + documents.compactMap { document in
|
||||
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
|
||||
}
|
||||
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
|
||||
}
|
||||
|
||||
/// Lower is better — exact match, then prefix match, then earliest
|
||||
/// contiguous-substring position, then (for multi-word queries) every
|
||||
/// word present somewhere in the title in any order. That last tier is
|
||||
/// what makes "test document" find a title like "Test Plan Document" —
|
||||
/// requiring the exact phrase contiguously (the previous behavior)
|
||||
/// meant a title with anything between the words never matched at all,
|
||||
/// which looked like "documents never show up, only collections" any
|
||||
/// time the real title didn't happen to contain the typed phrase
|
||||
/// verbatim. `nil` means no match at all. Still deliberately not a full
|
||||
/// fuzzy/Levenshtein algorithm — good enough for document/collection
|
||||
/// titles without the unpredictability that brings.
|
||||
private func matchScore(_ title: String, query: String) -> Int? {
|
||||
let haystack = title.lowercased()
|
||||
let needle = query.lowercased()
|
||||
if haystack == needle { return 0 }
|
||||
if haystack.hasPrefix(needle) { return 1 }
|
||||
if let range = haystack.range(of: needle) {
|
||||
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||
}
|
||||
let words = needle.split(separator: " ").map(String.init)
|
||||
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
|
||||
let totalPosition = words.reduce(0) { partial, word in
|
||||
guard let range = haystack.range(of: word) else { return partial }
|
||||
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
|
||||
}
|
||||
return 100 + totalPosition
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
|
||||
.onTapGesture { onDismiss() }
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search documents and collections…", text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.title3)
|
||||
.focused($isSearchFieldFocused)
|
||||
.onChange(of: query) { selectedIndex = 0 }
|
||||
.onSubmit { selectCurrent() }
|
||||
// Attached directly on the field itself, not an
|
||||
// ancestor — confirmed live that .onKeyPress on the
|
||||
// outer card never saw arrow-key events at all while
|
||||
// this TextField actually held focus, the up/down
|
||||
// presses just went nowhere. Escape still needs its
|
||||
// own handler below since this one only covers
|
||||
// whichever view is actually focused.
|
||||
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
|
||||
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
|
||||
.onKeyPress(.escape) { onDismiss(); return .handled }
|
||||
if isLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
Divider()
|
||||
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
isLoading ? "Loading…" : "No Results",
|
||||
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
|
||||
)
|
||||
.frame(height: 160)
|
||||
} else {
|
||||
ScrollViewReader { scrollProxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
|
||||
resultRow(result, isSelected: index == selectedIndex)
|
||||
.id(index)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedIndex = index
|
||||
selectCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
.onChange(of: selectedIndex) { _, newValue in
|
||||
scrollProxy.scrollTo(newValue, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
|
||||
.frame(width: 560)
|
||||
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
|
||||
}
|
||||
.task {
|
||||
// The window/responder chain isn't always ready to accept a
|
||||
// first-responder change in the same instant this view is
|
||||
// inserted — confirmed live: setting this synchronously on
|
||||
// appear left the field unfocused until manually clicked
|
||||
// (also the likely source of several "entangle context after
|
||||
// pre-commit" / CA-transaction warnings in the console, which
|
||||
// are exactly what fighting AppKit for first-responder status
|
||||
// mid-commit looks like). A one-frame-ish delay is enough for
|
||||
// the overlay's insertion to settle first.
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
isSearchFieldFocused = true
|
||||
await loadResults()
|
||||
}
|
||||
}
|
||||
|
||||
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
switch result {
|
||||
case .collection(let collection):
|
||||
// Reuses the sidebar's own icon logic (emoji vs Outline's
|
||||
// icon-key-to-SF-Symbol mapping vs fallback) instead of
|
||||
// guessing — `collection.icon` isn't a raw SF Symbol name.
|
||||
CollectionRowView(collection: collection)
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(collection.name)
|
||||
.lineLimit(1)
|
||||
Text("Collection")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .document(let document):
|
||||
if let emoji = document.emoji {
|
||||
Text(emoji).frame(width: 20)
|
||||
} else {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||
.lineLimit(1)
|
||||
Text("Document")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
|
||||
private func moveSelection(by delta: Int) {
|
||||
guard !results.isEmpty else { return }
|
||||
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
|
||||
}
|
||||
|
||||
private func selectCurrent() {
|
||||
guard results.indices.contains(selectedIndex) else { return }
|
||||
switch results[selectedIndex] {
|
||||
case .collection(let collection): onSelectCollection(collection)
|
||||
case .document(let document): onSelectDocument(document)
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
private func loadResults() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
if fullWorkspaceSearch {
|
||||
// Purely local SwiftData reads — no network at all, and (since
|
||||
// Full Local Sync now recurses into every document's children)
|
||||
// this includes nested sub-documents the live per-collection
|
||||
// fetch never could. Empty if a sync has never actually run.
|
||||
collections = await cachingClient?.cachedCollectionsIndex() ?? []
|
||||
documents = await cachingClient?.cachedDocumentsIndex() ?? []
|
||||
return
|
||||
}
|
||||
|
||||
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
|
||||
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
|
||||
collections = await fetchedCollections
|
||||
documents = await fetchedRecent
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,6 +4,9 @@ import OutlineKit
|
||||
|
||||
struct ContentView_macOS: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(AppNavigation.self) private var navigation
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||
/// The landing state — no collection selected yet is what Home actually
|
||||
/// means, so this starts `true` rather than auto-selecting the first
|
||||
/// collection the way this used to work.
|
||||
@@ -21,16 +24,83 @@ struct ContentView_macOS: View {
|
||||
/// Home's "New Document" buttons) — every expanded sidebar row reloads
|
||||
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
|
||||
@State private var documentsChangedToken = 0
|
||||
/// Guards the restore-on-launch attempt to exactly once per app launch
|
||||
/// — without this, `mainContent`'s `.task` would re-run (and
|
||||
/// re-navigate out from under the user) every time it reappears, e.g.
|
||||
/// after a trip through Settings.
|
||||
@State private var hasAttemptedLocationRestore = false
|
||||
|
||||
private var trimmedGlobalQuery: String {
|
||||
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
|
||||
/// own (that's `@Bindable`'s job, and introducing one in `body` would
|
||||
/// mean restructuring it away from a single implicit-return expression)
|
||||
/// — a manually-built `Binding` over the same reference is simpler here.
|
||||
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
|
||||
Binding(
|
||||
get: { navigation.selectedSettingsSection },
|
||||
set: { navigation.selectedSettingsSection = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// A totally separate top-level branch, not content swapped inside
|
||||
// one persistent `NavigationSplitView` — that was tried first (an
|
||||
// `if/else` inside a single split view, then an explicit `.id()` on
|
||||
// just the detail pane) and neither reliably replaced the detail
|
||||
// pane's content on macOS: `NavigationSplitView` bridges to
|
||||
// `NSSplitViewController`, and swapping a `NavigationStack` with
|
||||
// real push history for a plain view inside one persisting instance
|
||||
// doesn't propagate the way plain SwiftUI identity rules would
|
||||
// suggest. A different `if` branch is a genuinely different view
|
||||
// hierarchy, so there's no existing split view instance for AppKit
|
||||
// to get confused about reusing.
|
||||
if navigation.isShowingSettings {
|
||||
settingsContent
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsContent: some View {
|
||||
NavigationSplitView {
|
||||
SettingsSidebarList(
|
||||
selection: selectedSettingsSectionBinding,
|
||||
onDone: { navigation.isShowingSettings = false }
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
||||
} detail: {
|
||||
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
SidebarSearchField(text: $globalSearchQuery)
|
||||
Divider()
|
||||
if isOfflineModeEnabled {
|
||||
OfflineBanner(isManual: true)
|
||||
Divider()
|
||||
} else if !session.networkMonitor.isOnline {
|
||||
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
|
||||
Divider()
|
||||
}
|
||||
sidebar
|
||||
AccountFooter()
|
||||
}
|
||||
@@ -83,6 +153,38 @@ struct ContentView_macOS: View {
|
||||
if newValue != nil {
|
||||
isShowingHome = false
|
||||
}
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
.onChange(of: documentPath) { _, _ in
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
.onChange(of: isShowingHome) { _, _ in
|
||||
persistLastLocationIfEnabled()
|
||||
}
|
||||
// Once per launch, before the user has a chance to navigate
|
||||
// manually — restores whatever `restoreLastLocationIfEnabled`
|
||||
// finds, or leaves today's Home default alone if there's nothing
|
||||
// to restore (preference off, nothing stored yet, or resolution
|
||||
// fails e.g. a deleted document/collection or being offline).
|
||||
.task {
|
||||
guard !hasAttemptedLocationRestore else { return }
|
||||
hasAttemptedLocationRestore = true
|
||||
await restoreLastLocationIfEnabled()
|
||||
}
|
||||
.overlay {
|
||||
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
|
||||
CommandPaletteView(
|
||||
apiClient: apiClient,
|
||||
cachingClient: session.cachingClient,
|
||||
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
|
||||
onSelectDocument: openDocument,
|
||||
onSelectCollection: { collection in
|
||||
selectedCollection = collection
|
||||
replaceDocumentPath(with: [])
|
||||
},
|
||||
onDismiss: { navigation.isShowingCommandPalette = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +194,67 @@ struct ContentView_macOS: View {
|
||||
isContextualSearchExpanded = false
|
||||
selectedCollection = nil
|
||||
isShowingHome = true
|
||||
navigation.isShowingSettings = false
|
||||
replaceDocumentPath(with: [])
|
||||
}
|
||||
|
||||
// MARK: - Remember previous location (Preferences → Remember previous location)
|
||||
|
||||
private static let lastLocationDefaultsKey = "outline.lastLocation"
|
||||
|
||||
/// What gets persisted — `isHome` disambiguates "was on Home" from "no
|
||||
/// collection selected yet" (the latter only otherwise happens on the
|
||||
/// brief `ContentUnavailableView` placeholder state), since both would
|
||||
/// otherwise look identical (`collectionId == nil`).
|
||||
private struct LastLocation: Codable {
|
||||
var isHome: Bool
|
||||
var collectionId: String?
|
||||
var documentIds: [String]
|
||||
}
|
||||
|
||||
/// Called from every navigation-changing `.onChange` — cheap to persist
|
||||
/// on every change rather than debouncing, this is just a small JSON
|
||||
/// blob in `UserDefaults`, not a network call.
|
||||
private func persistLastLocationIfEnabled() {
|
||||
guard session.userPreferences?.rememberLastPath == true else { return }
|
||||
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
|
||||
guard let data = try? JSONEncoder().encode(location) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
|
||||
}
|
||||
|
||||
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
|
||||
/// objects via the API — stored IDs alone aren't enough to populate
|
||||
/// `selectedCollection`/`documentPath` directly. Resolves the document
|
||||
/// chain in order and stops at the first failure (deleted document,
|
||||
/// offline, etc.) rather than aborting the whole restore — whatever
|
||||
/// prefix of the chain resolved successfully is still a better landing
|
||||
/// spot than falling all the way back to Home.
|
||||
private func restoreLastLocationIfEnabled() async {
|
||||
guard session.userPreferences?.rememberLastPath == true,
|
||||
let apiClient = session.apiClient,
|
||||
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
|
||||
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
|
||||
else { return }
|
||||
// A pure "was on Home, nothing pushed" location needs no action —
|
||||
// Home is already the default state before this ever runs.
|
||||
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
|
||||
|
||||
if let collectionId = location.collectionId {
|
||||
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
|
||||
selectedCollection = collection
|
||||
isShowingHome = false
|
||||
}
|
||||
|
||||
var resolvedChain: [OutlineDocument] = []
|
||||
for documentId in location.documentIds {
|
||||
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
|
||||
resolvedChain.append(document)
|
||||
}
|
||||
if !resolvedChain.isEmpty {
|
||||
replaceDocumentPath(with: resolvedChain)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextualSearchField: some View {
|
||||
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
|
||||
@@ -285,6 +445,13 @@ struct ContentView_macOS: View {
|
||||
if !documentPath.isEmpty {
|
||||
documentPath.removeLast()
|
||||
}
|
||||
// Delete/Archive/Unpublish/Move all change what
|
||||
// should show in the sidebar tree — this only
|
||||
// popped the reader before, leaving the sidebar
|
||||
// showing the document until some unrelated
|
||||
// trigger (the 45s poll, navigating away and
|
||||
// back) happened to refresh it.
|
||||
documentsChangedToken += 1
|
||||
},
|
||||
onDocumentCreated: { documentsChangedToken += 1 }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Document-level and anchored comments + single-level replies + emoji
|
||||
/// reactions + resolve/unresolve. Composing new comments/replies is still
|
||||
/// plain text only (no bold/italic/lists/etc.) — sent through the `text`
|
||||
/// (markdown) convenience field, not a hand-built ProseMirror `data`
|
||||
/// document.
|
||||
@MainActor
|
||||
struct DocumentCommentsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
/// Set when opened by tapping an inline anchor marker — scrolls to and
|
||||
/// briefly highlights that comment/reply. `nil` opens unfocused (the
|
||||
/// plain toolbar marker).
|
||||
var focusedCommentId: String? = nil
|
||||
/// Set when opened via "Comment on Selection…" from the editor's
|
||||
/// right-click menu — the selected text to anchor a NEW comment to.
|
||||
/// The first occurrence of this exact substring in the document is
|
||||
/// what the server (and later, this app's own inline marker) anchors
|
||||
/// to; no prefix/suffix disambiguation UI for multiple identical
|
||||
/// occurrences.
|
||||
var pendingAnchorText: String? = nil
|
||||
/// Called after any successful create/reply/resolve/reaction — lets the
|
||||
/// reader refresh its own lightweight copy (inline anchor markers, the
|
||||
/// toolbar badge count) without waiting for the document to be reopened.
|
||||
var onCommentsChanged: (() -> Void)? = nil
|
||||
|
||||
/// A small curated set rather than the full system emoji picker — quick
|
||||
/// taps for the common reactions, matching how most chat apps default.
|
||||
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
|
||||
|
||||
@State private var comments: [OutlineComment] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var actionErrorMessage: String?
|
||||
@State private var currentUserId: String?
|
||||
|
||||
@State private var newCommentText = ""
|
||||
@State private var isPostingNewComment = false
|
||||
/// Mutable mirror of `pendingAnchorText` — lets the user clear it (fall
|
||||
/// back to a plain document-level comment) without touching the init
|
||||
/// param itself.
|
||||
@State private var composingAnchorText: String?
|
||||
|
||||
@State private var replyingToThreadId: String?
|
||||
@State private var replyText = ""
|
||||
@State private var isPostingReply = false
|
||||
|
||||
@State private var resolvingCommentIds: Set<String> = []
|
||||
@State private var reactingCommentIds: Set<String> = []
|
||||
@State private var reactionPickerCommentId: String?
|
||||
|
||||
private struct CommentThread: Identifiable {
|
||||
let top: OutlineComment
|
||||
let replies: [OutlineComment]
|
||||
var id: String { top.id }
|
||||
}
|
||||
|
||||
private var threads: [CommentThread] {
|
||||
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
|
||||
return topLevel.map { top in
|
||||
let replies = comments
|
||||
.filter { $0.parentCommentId == top.id }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
return CommentThread(top: top, replies: replies)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
.padding()
|
||||
|
||||
Divider()
|
||||
|
||||
Group {
|
||||
if isLoading && comments.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") { Task { await load() } }
|
||||
}
|
||||
} else if threads.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("This document has no comments yet.")
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
List(threads) { thread in
|
||||
threadSection(thread)
|
||||
}
|
||||
.task {
|
||||
guard let focusedCommentId else { return }
|
||||
// The list needs a beat to lay out before a
|
||||
// scrollTo lands correctly on first appear.
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
withAnimation {
|
||||
proxy.scrollTo(focusedCommentId, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
newCommentComposer
|
||||
}
|
||||
.frame(width: 480, height: 560)
|
||||
.task { await load() }
|
||||
.task { currentUserId = try? await apiClient.currentUser().id }
|
||||
.task { composingAnchorText = pendingAnchorText }
|
||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||
Button("OK") { actionErrorMessage = nil }
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread
|
||||
|
||||
private func threadSection(_ thread: CommentThread) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
commentRow(thread.top, isReply: false)
|
||||
|
||||
ForEach(thread.replies) { reply in
|
||||
commentRow(reply, isReply: true)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
if replyingToThreadId == thread.id {
|
||||
replyComposer(for: thread)
|
||||
.padding(.leading, 20)
|
||||
} else {
|
||||
Button("Reply") {
|
||||
replyingToThreadId = thread.id
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private func commentRow(_ comment: OutlineComment, isReply: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text(comment.createdBy?.name ?? "Unknown")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Text(comment.createdAt, format: .relative(presentation: .named))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(comment.bodyText.isEmpty ? "(empty)" : comment.bodyText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(comment.bodyText.isEmpty ? .secondary : .primary)
|
||||
|
||||
if !comment.reactions.isEmpty {
|
||||
reactionChips(comment)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
reactionPickerCommentId = comment.id
|
||||
} label: {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.popover(isPresented: Binding(
|
||||
get: { reactionPickerCommentId == comment.id },
|
||||
set: { if !$0 { reactionPickerCommentId = nil } }
|
||||
)) {
|
||||
quickReactionPicker(comment)
|
||||
}
|
||||
|
||||
if !isReply {
|
||||
if comment.isResolved {
|
||||
Label("Resolved", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
if resolvingCommentIds.contains(comment.id) {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(comment.isResolved ? "Unresolve" : "Resolve") {
|
||||
Task { await toggleResolved(comment) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.background(
|
||||
comment.id == focusedCommentId ? Color.accentColor.opacity(0.12) : Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 6)
|
||||
)
|
||||
.id(comment.id)
|
||||
}
|
||||
|
||||
private func reactionChips(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(comment.reactions, id: \.emoji) { reaction in
|
||||
let mine = currentUserId.map(reaction.userIds.contains) ?? false
|
||||
Button {
|
||||
Task { await toggleReaction(comment, emoji: reaction.emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text("\(reaction.emoji) \(reaction.userIds.count)")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(mine ? Color.accentColor.opacity(0.2) : Color.gray.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(reactingCommentIds.contains(comment.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quickReactionPicker(_ comment: OutlineComment) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Self.quickReactions, id: \.self) { emoji in
|
||||
let mine = currentUserId.map { userId in
|
||||
comment.reactions.first { $0.emoji == emoji }?.userIds.contains(userId) ?? false
|
||||
} ?? false
|
||||
Button {
|
||||
reactionPickerCommentId = nil
|
||||
Task { await toggleReaction(comment, emoji: emoji, currentlyReacted: mine) }
|
||||
} label: {
|
||||
Text(emoji)
|
||||
.font(.title2)
|
||||
.opacity(mine ? 1 : 0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
|
||||
// MARK: - Composers
|
||||
|
||||
private var newCommentComposer: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let composingAnchorText {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "text.quote")
|
||||
.foregroundStyle(.blue)
|
||||
Text(composingAnchorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Button {
|
||||
self.composingAnchorText = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Remove — this will post as a document-level comment instead")
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
composingAnchorText == nil ? "Add a comment…" : "Comment on this text…",
|
||||
text: $newCommentText,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postNewComment() } }
|
||||
if isPostingNewComment {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Post") {
|
||||
Task { await postNewComment() }
|
||||
}
|
||||
.disabled(newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func replyComposer(for thread: CommentThread) -> some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Reply…", text: $replyText, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...4)
|
||||
.onSubmit { Task { await postReply(to: thread) } }
|
||||
if isPostingReply {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button("Send") {
|
||||
Task { await postReply(to: thread) }
|
||||
}
|
||||
.disabled(replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button("Cancel") {
|
||||
replyingToThreadId = nil
|
||||
replyText = ""
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
comments = try await apiClient.listComments(ListCommentsRequest(documentId: document.id))
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load comments for this document.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postNewComment() async {
|
||||
let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingNewComment = true
|
||||
defer { isPostingNewComment = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, text: text, anchorText: composingAnchorText)
|
||||
)
|
||||
comments.append(created)
|
||||
composingAnchorText = nil
|
||||
newCommentText = ""
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
private func postReply(to thread: CommentThread) async {
|
||||
let text = replyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
isPostingReply = true
|
||||
defer { isPostingReply = false }
|
||||
do {
|
||||
let created = try await apiClient.createComment(
|
||||
CreateCommentRequest(documentId: document.id, parentCommentId: thread.id, text: text)
|
||||
)
|
||||
comments.append(created)
|
||||
replyText = ""
|
||||
replyingToThreadId = nil
|
||||
onCommentsChanged?()
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't post this reply.")
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleResolved(_ comment: OutlineComment) async {
|
||||
resolvingCommentIds.insert(comment.id)
|
||||
defer { resolvingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
let updated = comment.isResolved
|
||||
? try await apiClient.unresolveComment(id: comment.id)
|
||||
: try await apiClient.resolveComment(id: comment.id)
|
||||
replace(updated)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this comment.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reaction endpoints return `{success: true}`, not the updated comment
|
||||
/// (see `OutlineAPIClient.addReaction`) — refetch via `commentInfo` for
|
||||
/// the real post-toggle `reactions` array instead of guessing the merge
|
||||
/// locally (another user reacting concurrently would make a guess wrong).
|
||||
private func toggleReaction(_ comment: OutlineComment, emoji: String, currentlyReacted: Bool) async {
|
||||
reactingCommentIds.insert(comment.id)
|
||||
defer { reactingCommentIds.remove(comment.id) }
|
||||
do {
|
||||
if currentlyReacted {
|
||||
try await apiClient.removeReaction(commentId: comment.id, emoji: emoji)
|
||||
} else {
|
||||
try await apiClient.addReaction(commentId: comment.id, emoji: emoji)
|
||||
}
|
||||
let refreshed = try await apiClient.commentInfo(id: comment.id)
|
||||
replace(refreshed)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update that reaction.")
|
||||
}
|
||||
}
|
||||
|
||||
private func replace(_ updated: OutlineComment) {
|
||||
if let index = comments.firstIndex(where: { $0.id == updated.id }) {
|
||||
comments[index] = updated
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,6 +1,7 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
|
||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
||||
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
|
||||
@State private var text: String
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
|
||||
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
|
||||
/// mechanism, needed here too since this sheet renders its own images.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_text = State(initialValue: document.text)
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
|
||||
.padding(.bottom, 8)
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.background(.background)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
#if canImport(ImagePlayground)
|
||||
import ImagePlayground
|
||||
#endif
|
||||
|
||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||
/// below must run on the main thread — see the identical note on
|
||||
@@ -12,6 +16,55 @@ import OutlineKit
|
||||
struct DocumentReaderView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@Environment(StarStore.self) private var starStore
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
/// Local-only Outpost setting (Settings → Editor), not synced to
|
||||
/// Outline — see `SettingsView.editorDetail`.
|
||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||
|
||||
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
||||
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
||||
/// eligibility) — a supported OS with an unsupported Mac still reports
|
||||
/// `false`, so this is the one check that matters, not just `#available`.
|
||||
private var isImagePlaygroundSupported: Bool {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
return ImagePlaygroundViewController.isAvailable
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
/// Outline's own "Show line numbers" preference (synced, read via
|
||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
|
||||
/// problem here since this is read directly in the view, not the
|
||||
/// view model.
|
||||
private var showCodeBlockLineNumbers: Bool {
|
||||
session.userPreferences?.codeBlockLineNumbers ?? false
|
||||
}
|
||||
/// Widened left indent reserved for the number gutter when line numbers
|
||||
/// are on (default is 12pt, just enough margin, no room for digits).
|
||||
private static let lineNumberGutterWidth: CGFloat = 32
|
||||
private var editorCodeBlockStyle: CodeBlockStyle {
|
||||
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
|
||||
}
|
||||
/// Outline's own "Smart text replacements" preference (synced) — smart
|
||||
/// quotes/dashes while typing. Only meaningful on the editable pane.
|
||||
private var editorTextSubstitution: TextSubstitutionPolicy {
|
||||
let enabled = session.userPreferences?.smartText ?? false
|
||||
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
|
||||
}
|
||||
/// Local-only Outpost settings (Settings → Editor) — not synced to
|
||||
/// Outline, same as Split View above.
|
||||
private var editorTextCompletion: TextCompletionPolicy {
|
||||
.init(isEnabled: isAutocompleteEnabled)
|
||||
}
|
||||
private var editorWritingTools: WritingToolsPolicy {
|
||||
.init(isEnabled: isWritingToolsEnabled)
|
||||
}
|
||||
|
||||
@State private var viewModel: DocumentReaderViewModel
|
||||
let apiClient: OutlineAPIClient
|
||||
@@ -30,16 +83,70 @@ struct DocumentReaderView: View {
|
||||
let onDocumentCreated: () -> Void
|
||||
|
||||
@State private var isShowingUnpublishConfirmation = false
|
||||
@State private var isShowingPublishSheet = false
|
||||
@State private var isShowingArchiveConfirmation = false
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
@State private var isShowingMoveSheet = false
|
||||
@State private var isShowingHistorySheet = false
|
||||
@State private var isShowingInsightsSheet = false
|
||||
@State private var isShowingCommentsSheet = false
|
||||
/// Fetched once on load, purely to drive the toolbar marker/badge and
|
||||
/// the inline anchor bars below — the sheet itself fetches its own copy
|
||||
/// independently (see `DocumentCommentsSheet`), same as every other
|
||||
/// self-contained sheet in this file.
|
||||
@State private var loadedComments: [OutlineComment] = []
|
||||
/// Comment id to scroll/focus to when the sheet opens — set when an
|
||||
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
|
||||
@State private var focusedCommentId: String?
|
||||
/// Resolved on-screen positions for each anchored comment's text, from
|
||||
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
|
||||
@State private var commentAnchorRects: [CommentAnchorRect] = []
|
||||
/// Set from "Comment on Selection…" in the editor's right-click menu —
|
||||
/// the sheet opens straight into composing a new anchored comment.
|
||||
@State private var pendingCommentAnchorText: String?
|
||||
|
||||
private var commentCount: Int? {
|
||||
loadedComments.isEmpty ? nil : loadedComments.count
|
||||
}
|
||||
private var commentAnchorQueries: [CommentAnchorQuery] {
|
||||
loadedComments.compactMap { comment in
|
||||
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
|
||||
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
|
||||
}
|
||||
}
|
||||
@State private var isShowingPresentSheet = false
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var isShowingImagePlayground = false
|
||||
@State private var actionErrorMessage: String?
|
||||
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
||||
/// insert an Image Playground result's Markdown reference at the caret.
|
||||
@State private var pendingTextInsertion: TextInsertionRequest?
|
||||
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||
@State private var currentSelectedText: String?
|
||||
/// Resolves `` images in this document — shared by both
|
||||
/// panes (main + split-view preview), since they render the same text.
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value —
|
||||
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
|
||||
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
|
||||
/// notices the provider's `fingerprint()` changed) once an async image
|
||||
/// load completes. The engine has no polling of its own for this.
|
||||
@State private var imageReloadTick = 0
|
||||
/// Snapshot of `currentSelectedText` taken the moment the Image
|
||||
/// Playground button is pressed — the sheet's seed shouldn't shift if
|
||||
/// the user's selection happens to change while it's open.
|
||||
@State private var imagePlaygroundSeedText: String?
|
||||
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||
/// one array per instance (main pane, split-view preview pane), since
|
||||
/// each lays the same text out at a different width and gets different
|
||||
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
|
||||
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
|
||||
/// widened, which is gated on the same flag).
|
||||
@State private var readerCodeBlocks: [CodeBlockSelection] = []
|
||||
@State private var previewCodeBlocks: [CodeBlockSelection] = []
|
||||
|
||||
init(
|
||||
apiClient: OutlineAPIClient,
|
||||
@@ -50,50 +157,48 @@ struct DocumentReaderView: View {
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
// `separateEditingEnabled` can't be read from `@Environment` here —
|
||||
// environment values aren't populated yet inside a view's `init`,
|
||||
// only from `body` onward. Defaults to `true` (today's only
|
||||
// behavior) and gets set for real in `.task` below once `session`
|
||||
// is actually available.
|
||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
self.onDocumentCreated = onDocumentCreated
|
||||
}
|
||||
|
||||
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
||||
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
||||
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
||||
/// history, insights, export) hits the server directly with no offline
|
||||
/// path, so it's disabled rather than left to fail confusingly on tap.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
/// Split View needs the full window height (each pane scrolls itself),
|
||||
/// which an unbounded page-level `ScrollView` can't give it — a
|
||||
/// `minHeight` inside one just resolves to exactly that minimum, not
|
||||
/// "fill available space", since there's no bounded space to fill.
|
||||
/// Only switches over once there's real content to show; loading/error
|
||||
/// states still go through the normal scrolling layout.
|
||||
private var canShowSplitView: Bool {
|
||||
isSplitViewEnabled
|
||||
&& viewModel.isEffectivelyEditable
|
||||
&& viewModel.errorMessage == nil
|
||||
&& !(viewModel.isLoading && viewModel.text.isEmpty)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEditing {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if canShowSplitView {
|
||||
splitViewContent
|
||||
} else {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
isEditable: viewModel.isEditing
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
childrenSection
|
||||
scrollingReaderContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
@@ -110,11 +215,51 @@ struct DocumentReaderView: View {
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.help("Share")
|
||||
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
// Toolbar marker — only shows once there's actually
|
||||
// something to point at. Anchored comments additionally get
|
||||
// an inline vertical bar next to their text (see
|
||||
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
|
||||
// below) — this button opens the sheet unfocused, showing
|
||||
// every comment/thread.
|
||||
if let commentCount, commentCount > 0 {
|
||||
Button {
|
||||
focusedCommentId = nil
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
} label: {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
}
|
||||
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
|
||||
.disabled(!isEffectivelyOnline)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Text(commentCount > 10 ? "10+" : "\(commentCount)")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
.frame(minWidth: 12, minHeight: 12)
|
||||
.background(.blue, in: Circle())
|
||||
.offset(x: 0, y: -1)
|
||||
}
|
||||
}
|
||||
|
||||
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
|
||||
Button {
|
||||
imagePlaygroundSeedText = currentSelectedText
|
||||
isShowingImagePlayground = true
|
||||
} label: {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
.help("Create Image with Image Playground")
|
||||
.disabled(!viewModel.isEffectivelyEditable)
|
||||
}
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
@@ -125,6 +270,13 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
} else if viewModel.isSaving {
|
||||
// No Edit/Done affordance when documents are always
|
||||
// editable — this is the only feedback that an autosave
|
||||
// is actually happening.
|
||||
ProgressView().controlSize(.small)
|
||||
.help("Saving…")
|
||||
}
|
||||
|
||||
Button {
|
||||
isShowingNewDocumentSheet = true
|
||||
@@ -147,13 +299,35 @@ struct DocumentReaderView: View {
|
||||
.id(menuIdentity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task { await viewModel.loadFullContent() }
|
||||
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||
// for why this can't just be read at `init` time.
|
||||
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
|
||||
.onChange(of: viewModel.text) {
|
||||
guard !viewModel.separateEditingEnabled else { return }
|
||||
viewModel.scheduleAutosave()
|
||||
}
|
||||
.onChange(of: viewModel.title) {
|
||||
guard !viewModel.separateEditingEnabled else { return }
|
||||
viewModel.scheduleAutosave()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadInsightsEnabledState()
|
||||
}
|
||||
.task {
|
||||
loadedComments = (try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)) ?? []
|
||||
}
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
await viewModel.loadViewers()
|
||||
@@ -201,6 +375,36 @@ struct DocumentReaderView: View {
|
||||
} message: {
|
||||
Text(actionErrorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $isShowingCommentsSheet) {
|
||||
DocumentCommentsSheet(
|
||||
apiClient: apiClient,
|
||||
document: document,
|
||||
focusedCommentId: focusedCommentId,
|
||||
pendingAnchorText: pendingCommentAnchorText,
|
||||
onCommentsChanged: {
|
||||
Task {
|
||||
loadedComments = (try? await apiClient.listComments(
|
||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||
)) ?? loadedComments
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $isShowingPublishSheet) {
|
||||
PublishDocumentSheet(apiClient: apiClient, document: document) {
|
||||
// Stays in the reader — unlike Move/Unpublish, publishing
|
||||
// doesn't make the document any less visible from here, so
|
||||
// there's no reason to pop back like onDeleted() does
|
||||
// elsewhere. Refresh from the server rather than guessing
|
||||
// the new collectionId locally (moveDocument may have run
|
||||
// as a second step inside the sheet).
|
||||
await viewModel.loadFullContent()
|
||||
// The doc previously had no collectionId (or a different
|
||||
// one) — the sidebar tree for its new collection needs to
|
||||
// know it exists now, same signal "New Document" sends.
|
||||
onDocumentCreated()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingMoveSheet) {
|
||||
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
||||
onDeleted()
|
||||
@@ -224,6 +428,59 @@ struct DocumentReaderView: View {
|
||||
onOpenChild(child)
|
||||
}
|
||||
}
|
||||
.modifier(ImagePlaygroundPresenter(
|
||||
isPresented: $isShowingImagePlayground,
|
||||
seedText: imagePlaygroundSeedText,
|
||||
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
|
||||
onCompletion: { url in handleGeneratedImage(url) }
|
||||
))
|
||||
}
|
||||
|
||||
/// Adds "Comment on Selection…" to the right-click menu when there's an
|
||||
/// actual (non-empty) selection to anchor to — `currentSelectedText` is
|
||||
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
|
||||
/// top since it's the primary reason to right-click selected text here,
|
||||
/// matching Outline's own web editor surfacing comment as the first
|
||||
/// selection action.
|
||||
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
|
||||
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
|
||||
let item = ClosureMenuItem(title: "Comment on Selection…") {
|
||||
pendingCommentAnchorText = selection
|
||||
focusedCommentId = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
menu.insertItem(item, at: 0)
|
||||
menu.insertItem(.separator(), at: 1)
|
||||
return menu
|
||||
}
|
||||
|
||||
/// Uploads an Image Playground result the same way the reader would any
|
||||
/// other attachment (`attachments.create` presigned target, then the
|
||||
/// direct file POST — see `OutlineAPIClient.uploadAttachmentFile`), then
|
||||
/// inserts the hosted image's Markdown reference at the caret.
|
||||
private func handleGeneratedImage(_ localURL: URL) {
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: localURL)
|
||||
let created = try await apiClient.createAttachment(.init(
|
||||
name: localURL.lastPathComponent,
|
||||
contentType: "image/png",
|
||||
size: data.count,
|
||||
documentId: viewModel.documentId
|
||||
))
|
||||
try await apiClient.uploadAttachmentFile(created, fileData: data)
|
||||
// Outline's own editor never embeds the raw (presigned/storage)
|
||||
// upload URL in document Markdown — it writes this stable
|
||||
// redirect-by-id reference instead, which keeps resolving
|
||||
// correctly even if the underlying storage URL rotates/expires.
|
||||
pendingTextInsertion = TextInsertionRequest(
|
||||
documentId: viewModel.documentId,
|
||||
text: "\n\n)\n\n"
|
||||
)
|
||||
} catch {
|
||||
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||
@@ -235,7 +492,8 @@ struct DocumentReaderView: View {
|
||||
viewModel.isPinned,
|
||||
viewModel.isInsightsEnabled ?? false,
|
||||
viewModel.isFullWidth,
|
||||
viewModel.isEditing
|
||||
viewModel.isEditing,
|
||||
isEffectivelyOnline
|
||||
].map(String.init).joined(separator: "-")
|
||||
}
|
||||
|
||||
@@ -259,29 +517,155 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var childrenSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
||||
/// content, used for the normal reading/editing view, and for every
|
||||
/// loading/error state regardless of Split View.
|
||||
private var scrollingReaderContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEffectivelyEditable {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
|
||||
Text("Sub-documents")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadFullContent() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
textSubstitution: editorTextSubstitution,
|
||||
textCompletion: editorTextCompletion,
|
||||
writingTools: editorWritingTools,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(readerCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.children) { child in
|
||||
Button {
|
||||
onOpenChild(child)
|
||||
} label: {
|
||||
DocumentRowView(document: child)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
/// Split View's layout — title fixed at the top (not part of either
|
||||
/// scrolling pane), `splitEditorView` filling every remaining pixel of
|
||||
/// the window below it. No outer `ScrollView` here on purpose: each
|
||||
/// pane already scrolls itself, and nesting that inside another
|
||||
/// unbounded scroll container is exactly what was capping both panes
|
||||
/// at a fixed height instead of spanning the window.
|
||||
private var splitViewContent: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
if child.id != viewModel.children.last?.id {
|
||||
Divider()
|
||||
splitEditorView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
|
||||
/// hiding/styling, but still the real engine — needed so selection
|
||||
/// tracking and caret-position insertion, e.g. from the Image Playground
|
||||
/// button, work here the same as everywhere else); right is the same
|
||||
/// rich rendering used everywhere else in the app, read-only, bound to
|
||||
/// the same `viewModel.text` so it updates live as the left side is
|
||||
/// typed into.
|
||||
///
|
||||
/// Scroll position between the two panes is **not** synchronized — the
|
||||
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
||||
/// private internal view hierarchy to find its scroll view (the package
|
||||
/// exposes no scroll position/delegate hook at all), which is fragile
|
||||
/// enough to break silently on a package update. Flagged as a known
|
||||
/// follow-up, not attempted here.
|
||||
private var splitEditorView: some View {
|
||||
HSplitView {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
configuration: .init(rawSourceMode: true),
|
||||
fontName: "SFMono-Regular",
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable,
|
||||
onSelectedTextChange: { currentSelectedText = $0 }
|
||||
)
|
||||
.padding(8)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
ScrollView {
|
||||
ZStack(alignment: .topLeading) {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: false,
|
||||
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
|
||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||
onSelectedTextChange: { currentSelectedText = $0 },
|
||||
commentAnchorQueries: commentAnchorQueries,
|
||||
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
|
||||
)
|
||||
if showCodeBlockLineNumbers {
|
||||
ForEach(previewCodeBlocks) { selection in
|
||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||
}
|
||||
}
|
||||
ForEach(commentAnchorRects) { anchor in
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
pendingCommentAnchorText = nil
|
||||
isShowingCommentsSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -296,36 +680,51 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
}
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
// same isShowingShareSheet state.
|
||||
Button("Permissions…") {
|
||||
isShowingShareSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Templatize") {
|
||||
Task { await templatize() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Duplicate") {
|
||||
Task { await duplicate() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
if viewModel.publishedAt == nil {
|
||||
Button("Publish…") {
|
||||
isShowingPublishSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
} else {
|
||||
Button("Unpublish") {
|
||||
isShowingUnpublishConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
Button("Archive…") {
|
||||
isShowingArchiveConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Move") {
|
||||
isShowingMoveSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Multipart file upload is its own subsystem — deferred rather than
|
||||
// half-built here.
|
||||
Button("Import Document…") {}
|
||||
@@ -342,9 +741,13 @@ struct DocumentReaderView: View {
|
||||
Button("History") {
|
||||
isShowingHistorySheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Insights") {
|
||||
isShowingInsightsSheet = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Present/Search in Document both read `documents.info`, which is
|
||||
// read-through cached — they work offline on whatever's cached.
|
||||
Button("Present") {
|
||||
isShowingPresentSheet = true
|
||||
}
|
||||
@@ -354,6 +757,7 @@ struct DocumentReaderView: View {
|
||||
Button("Download") {
|
||||
Task { await download() }
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
Button("Copy") {
|
||||
Task { await copyMarkdown() }
|
||||
}
|
||||
@@ -370,6 +774,7 @@ struct DocumentReaderView: View {
|
||||
get: { viewModel.isInsightsEnabled ?? false },
|
||||
set: { _ in Task { await toggleInsights() } }
|
||||
))
|
||||
.disabled(!isEffectivelyOnline)
|
||||
// Confirmed against a live server: there's no per-document embeds
|
||||
// field. Only a workspace-level setting exists, and that's not
|
||||
// reachable via the API either (no `team.update` endpoint in the
|
||||
@@ -386,6 +791,7 @@ struct DocumentReaderView: View {
|
||||
Button("Delete…", role: .destructive) {
|
||||
isShowingDeleteConfirmation = true
|
||||
}
|
||||
.disabled(!isEffectivelyOnline)
|
||||
}
|
||||
|
||||
private func star() async {
|
||||
@@ -505,4 +911,127 @@ struct DocumentReaderView: View {
|
||||
operation.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// One code block's number gutter, positioned absolutely over a
|
||||
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay
|
||||
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
|
||||
///
|
||||
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
|
||||
/// + close fence line), matching what the engine actually lays out — the
|
||||
/// fence lines render with invisible (`.clear`) text once the caret leaves
|
||||
/// the block, but they don't collapse to zero height, so the block is
|
||||
/// always exactly `content line count + 2` rows tall. `selection.code` is
|
||||
/// content only, so the row height and number positions below both account
|
||||
/// for that phantom top/bottom row explicitly instead of dividing by the
|
||||
/// content line count alone (which would drift the numbers upward, more so
|
||||
/// per line, the taller the block).
|
||||
///
|
||||
/// Known limitation, accepted rather than fixable app-side: a content line
|
||||
/// that soft-wraps onto a second visual row (MarkdownEngine always
|
||||
/// char-wraps code blocks, no way to opt out without forking the package)
|
||||
/// throws this off — every row below it reads one line low. Documented in
|
||||
/// TODO.local.md alongside the same package's other gaps.
|
||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||
/// the comments sheet focused to that thread.
|
||||
private struct CommentAnchorMarker: View {
|
||||
let rect: CGRect
|
||||
let onTap: () -> Void
|
||||
|
||||
private static let width: CGFloat = 3
|
||||
private static let gap: CGFloat = 4
|
||||
private static let hitTargetWidth: CGFloat = 16
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: Self.width)
|
||||
}
|
||||
.position(
|
||||
x: rect.minX - Self.gap - Self.width / 2,
|
||||
y: rect.minY + rect.height / 2
|
||||
)
|
||||
.onTapGesture(perform: onTap)
|
||||
.help("View comment")
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeBlockLineNumberGutter: View {
|
||||
let selection: CodeBlockSelection
|
||||
let gutterWidth: CGFloat
|
||||
|
||||
/// `selection.code` (`token.contentRange`) always ends with exactly one
|
||||
/// trailing `\n` per content line — the range runs right up to the
|
||||
/// start of the closing fence's own line, so the newline that ends the
|
||||
/// last content line is included, but there's never an unterminated
|
||||
/// final line to add one more for. Counting `\n` characters directly
|
||||
/// (not `.components(separatedBy:).count`, which is one too many
|
||||
/// whenever the string ends in the separator) is what makes a
|
||||
/// single-line block read "1", not "2".
|
||||
private var contentLineCount: Int {
|
||||
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let totalRows = CGFloat(contentLineCount + 2)
|
||||
let rowHeight = selection.rect.height / totalRows
|
||||
ForEach(0..<contentLineCount, id: \.self) { line in
|
||||
Text("\(line + 1)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: gutterWidth - 6, alignment: .trailing)
|
||||
.position(
|
||||
x: selection.rect.minX + (gutterWidth - 6) / 2,
|
||||
// +1.5 rows: skip the invisible open-fence row, then
|
||||
// center within this content row.
|
||||
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
|
||||
)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
|
||||
/// only once the `ImagePlayground` framework is actually linked in Xcode —
|
||||
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
|
||||
/// valid to build before that link-up happens.
|
||||
private struct ImagePlaygroundPresenter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
/// Highlighted document text at the moment the button was pressed, if
|
||||
/// any — seeds Image Playground's prompt instead of opening blank.
|
||||
let seedText: String?
|
||||
/// Document title, used as the concept's title when `seedText` is used.
|
||||
let seedTitle: String
|
||||
let onCompletion: (URL) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
let concepts: [ImagePlaygroundConcept] = {
|
||||
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return []
|
||||
}
|
||||
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
|
||||
}()
|
||||
content.imagePlaygroundSheet(
|
||||
isPresented: $isPresented,
|
||||
concepts: concepts,
|
||||
onCompletion: { url in
|
||||
isPresented = false
|
||||
onCompletion(url)
|
||||
},
|
||||
onCancellation: { isPresented = false }
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
|
||||
var emoji: String?
|
||||
var text: String
|
||||
var collectionId: String?
|
||||
var parentDocumentId: String?
|
||||
/// `nil` = draft (not published/visible to other workspace members).
|
||||
var publishedAt: Date?
|
||||
var isFullWidth = false
|
||||
var children: [OutlineDocument] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
|
||||
var isSaving = false
|
||||
var saveErrorMessage: String?
|
||||
|
||||
/// Snapshot of the preference, set once via `.task` right after the
|
||||
/// view appears (can't be read from `@Environment` inside the view's
|
||||
/// own `init`) rather than a live binding to `SessionStore` — matches
|
||||
/// how `isFullWidth` etc. are already seeded from the document at init
|
||||
/// rather than observed reactively. A change made in Settings while a
|
||||
/// document is already open takes effect the next document opened, not
|
||||
/// mid-session; an acceptable tradeoff for how rarely this gets
|
||||
/// toggled versus the complexity of threading a live preference
|
||||
/// reference through every reader instance.
|
||||
var separateEditingEnabled: Bool
|
||||
|
||||
/// The single source of truth the view reads for both "show the title
|
||||
/// field" and "is the text view editable" — when separate editing is
|
||||
/// off there's no Edit/Done mode at all, the document is just always
|
||||
/// editable (assuming permission; there's no per-document permission
|
||||
/// field to pre-check against, so an unauthorized edit simply fails to
|
||||
/// save rather than being blocked client-side up front).
|
||||
var isEffectivelyEditable: Bool {
|
||||
separateEditingEnabled ? isEditing : true
|
||||
}
|
||||
|
||||
private var autosaveTask: Task<Void, Never>?
|
||||
/// Tracks the last known-synced-with-the-server values so
|
||||
/// `scheduleAutosave()` can no-op when called just because `text`/
|
||||
/// `title` were reassigned *from* a server response (initial load, or
|
||||
/// a completed save) rather than actually edited — without this, every
|
||||
/// document open in the always-editable mode would fire one pointless
|
||||
/// autosave round-trip immediately, re-sending exactly what was just
|
||||
/// received.
|
||||
private var lastSyncedText: String
|
||||
private var lastSyncedTitle: String
|
||||
|
||||
/// Recent viewers, `views.list` filtered to entries that actually have a
|
||||
/// `lastViewedAt` — this is historical/aggregated view data, not live
|
||||
/// "viewing right now" presence (that needs the Hocuspocus collaboration
|
||||
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
|
||||
let documentId: String
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
self.emoji = document.emoji
|
||||
self.text = document.text
|
||||
self.collectionId = document.collectionId
|
||||
self.parentDocumentId = document.parentDocumentId
|
||||
self.publishedAt = document.publishedAt
|
||||
self.isFullWidth = document.fullWidth ?? false
|
||||
self.separateEditingEnabled = separateEditingEnabled
|
||||
self.lastSyncedText = document.text
|
||||
self.lastSyncedTitle = document.title
|
||||
}
|
||||
|
||||
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
||||
@@ -61,17 +100,14 @@ final class DocumentReaderViewModel {
|
||||
emoji = full.emoji
|
||||
text = full.text
|
||||
collectionId = full.collectionId
|
||||
parentDocumentId = full.parentDocumentId
|
||||
publishedAt = full.publishedAt
|
||||
isFullWidth = full.fullWidth ?? false
|
||||
lastSyncedText = full.text
|
||||
lastSyncedTitle = full.title
|
||||
} catch {
|
||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||
}
|
||||
|
||||
children = (try? await apiClient.listDocuments(
|
||||
collectionId: nil,
|
||||
parentDocumentId: documentId,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)) ?? []
|
||||
}
|
||||
|
||||
func loadViewers() async {
|
||||
@@ -146,20 +182,55 @@ final class DocumentReaderViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turning editing off saves; turning it on is just a mode switch.
|
||||
/// Turning editing off saves; turning it on is just a mode switch. Only
|
||||
/// meaningful when `separateEditingEnabled` — the always-editable path
|
||||
/// uses `scheduleAutosave()` instead.
|
||||
func toggleEditing() async {
|
||||
guard isEditing else {
|
||||
isEditing = true
|
||||
return
|
||||
}
|
||||
await save()
|
||||
if saveErrorMessage == nil {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced save for the always-editable (separate editing off) path —
|
||||
/// cancels any pending save and starts a fresh countdown on every call,
|
||||
/// so a save only actually fires once typing pauses, not on every
|
||||
/// keystroke. Goes through the same `updateDocument` call the explicit
|
||||
/// Done-button save uses, which is already offline-queue-aware
|
||||
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
||||
/// like any other edit instead of needing separate handling here.
|
||||
func scheduleAutosave() {
|
||||
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
||||
autosaveTask?.cancel()
|
||||
autosaveTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await self.save()
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
saveErrorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let sentTitle = title
|
||||
let sentText = text
|
||||
do {
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
|
||||
title = updated.title
|
||||
text = updated.text
|
||||
isEditing = false
|
||||
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
||||
// Only reconcile with the server's response if nothing changed
|
||||
// locally while the request was in flight — otherwise this
|
||||
// would clobber keystrokes typed during a debounced autosave's
|
||||
// round trip. Whatever's newer goes out on the next autosave
|
||||
// cycle regardless, since `scheduleAutosave()` keeps getting
|
||||
// re-triggered by continued typing.
|
||||
if title == sentTitle { title = updated.title }
|
||||
if text == sentText { text = updated.text }
|
||||
lastSyncedTitle = sentTitle
|
||||
lastSyncedText = sentText
|
||||
} catch {
|
||||
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
Text("Draft (not published)").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
|
||||
if selectedCollectionID != nil {
|
||||
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Create") {
|
||||
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
|
||||
Task { await create() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(selectedCollectionID == nil || isCreating)
|
||||
.disabled(isCreating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 380)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
|
||||
// "By default" means a bare New Document (Home, reader
|
||||
// toolbar) starts as a draft — no `collections.first` fallback
|
||||
// like there used to be. A contextual entry point (right-click
|
||||
// a specific collection/document) still pre-fills that
|
||||
// location, since that already carries clear placement intent;
|
||||
// the publish warning above still applies to it either way.
|
||||
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
|
||||
selectedParentID = initialParentDocument?.id
|
||||
isTitleFocused = true
|
||||
}
|
||||
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isCreating = true
|
||||
defer { isCreating = false }
|
||||
do {
|
||||
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
|
||||
title: title.isEmpty ? "Untitled" : title,
|
||||
text: "",
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: selectedParentID
|
||||
parentDocumentId: selectedParentID,
|
||||
// No collection/parent selected = draft; Outline can't
|
||||
// publish without one of the two regardless of this
|
||||
// flag, but setting it explicitly rather than relying
|
||||
// on that keeps intent obvious at the call site.
|
||||
publish: selectedCollectionID != nil
|
||||
)
|
||||
)
|
||||
onCreated(document)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Shown at the top of the sidebar whenever the app is offline — either for
|
||||
/// real (`NetworkMonitor` reports no connection) or because the user turned
|
||||
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
|
||||
/// browsing working either way, but the user should know they might be
|
||||
/// looking at stale data.
|
||||
struct OfflineBanner: View {
|
||||
/// Whether this is the user's own "Offline Mode" toggle rather than a
|
||||
/// real dropped connection — same banner slot, different explanation.
|
||||
var isManual: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.orange)
|
||||
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
|
||||
.font(.callout)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,30 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Shown in the sidebar when the network is actually down and the user
|
||||
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
|
||||
/// any fetch behavior on its own — reads still try live and fall back to
|
||||
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
|
||||
/// actually taps the button here, same as `RemoteChangesBanner` never
|
||||
/// auto-refreshes on their behalf either.
|
||||
struct OfflineConnectionPromptBanner: View {
|
||||
let onEnableOfflineMode: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.orange)
|
||||
Text("No connection")
|
||||
.font(.callout)
|
||||
Spacer(minLength: 8)
|
||||
Button("Turn On Offline Mode", action: onEnableOfflineMode)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,144 @@
|
||||
#if os(macOS)
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
/// Destination picker for publishing a draft — same collection-then-
|
||||
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
|
||||
/// own web app treats "where does this go" identically for both actions.
|
||||
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
|
||||
/// already has one (a draft can already belong to a collection without
|
||||
/// being published) rather than starting blank, per explicit instruction.
|
||||
///
|
||||
/// Publishing itself is two calls, not one: `documents.update(publish:
|
||||
/// true, collectionId:)` does the actual publish and top-level collection
|
||||
/// placement in one round-trip (confirmed from a live network capture);
|
||||
/// nesting under a specific parent document isn't a documented
|
||||
/// `documents.update` field, so that part reuses `documents.move` — the
|
||||
/// same already-working mechanism `MoveDocumentSheet` uses — as a second
|
||||
/// step, only when a parent was actually chosen.
|
||||
@MainActor
|
||||
struct PublishDocumentSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let apiClient: OutlineAPIClient
|
||||
let document: OutlineDocument
|
||||
let onPublished: () async -> Void
|
||||
|
||||
@State private var collections: [OutlineCollection] = []
|
||||
@State private var selectedCollectionID: String?
|
||||
@State private var rootDocuments: [OutlineDocument] = []
|
||||
@State private var selectedParentID: String?
|
||||
@State private var isLoadingCollections = false
|
||||
@State private var isLoadingDestinationDocuments = false
|
||||
@State private var isPublishing = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
|
||||
.font(.headline)
|
||||
Text("Choose where this document should live once published.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isLoadingCollections {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Picker("Collection", selection: $selectedCollectionID) {
|
||||
Text("Choose a collection").tag(String?.none)
|
||||
ForEach(collections) { collection in
|
||||
Text(collection.name).tag(Optional(collection.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
Picker("Location", selection: $selectedParentID) {
|
||||
Text("Collection root").tag(String?.none)
|
||||
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
|
||||
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
Button("Publish") {
|
||||
Task { await publish() }
|
||||
}
|
||||
.disabled(selectedCollectionID == nil || isPublishing)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
.task {
|
||||
await loadCollections()
|
||||
selectedCollectionID = document.collectionId
|
||||
selectedParentID = document.parentDocumentId
|
||||
}
|
||||
.task(id: selectedCollectionID) {
|
||||
await loadRootDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCollections() async {
|
||||
isLoadingCollections = true
|
||||
defer { isLoadingCollections = false }
|
||||
do {
|
||||
collections = try await apiClient.listCollections(offset: 0, limit: 100)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRootDocuments() async {
|
||||
guard let selectedCollectionID else {
|
||||
rootDocuments = []
|
||||
return
|
||||
}
|
||||
isLoadingDestinationDocuments = true
|
||||
defer { isLoadingDestinationDocuments = false }
|
||||
do {
|
||||
rootDocuments = try await apiClient.listDocuments(
|
||||
collectionId: selectedCollectionID,
|
||||
parentDocumentId: nil,
|
||||
offset: 0,
|
||||
limit: 100
|
||||
)
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() async {
|
||||
guard let selectedCollectionID else { return }
|
||||
isPublishing = true
|
||||
defer { isPublishing = false }
|
||||
do {
|
||||
_ = try await apiClient.updateDocument(
|
||||
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
|
||||
)
|
||||
// Only a documents.move call actually supports parentDocumentId —
|
||||
// skip it entirely when publishing straight to the collection root,
|
||||
// the update above already placed it there.
|
||||
if let selectedParentID {
|
||||
try await apiClient.moveDocument(
|
||||
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
|
||||
)
|
||||
}
|
||||
await onPublished()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
|
||||
case popular = "Popular"
|
||||
case recentlyUpdated = "Recently Updated"
|
||||
case createdByMe = "Created by Me"
|
||||
case drafts = "Drafts"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final class HomeViewModel {
|
||||
private(set) var popular: [OutlineDocument] = []
|
||||
private(set) var recentlyUpdated: [OutlineDocument] = []
|
||||
private(set) var createdByMe: [OutlineDocument] = []
|
||||
private(set) var drafts: [OutlineDocument] = []
|
||||
|
||||
var isLoadingPinned = false
|
||||
var isLoadingTab = false
|
||||
@@ -32,6 +33,7 @@ final class HomeViewModel {
|
||||
case .popular: popular
|
||||
case .recentlyUpdated: recentlyUpdated
|
||||
case .createdByMe: createdByMe
|
||||
case .drafts: drafts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,25 +59,33 @@ final class HomeViewModel {
|
||||
/// Fetches fresh pinned docs and the current tab's documents to compare
|
||||
/// against what's displayed, without replacing either. Bails silently
|
||||
/// on a fetch failure rather than treating it as "changed" — a
|
||||
/// transient network hiccup shouldn't pop the refresh banner.
|
||||
/// transient network hiccup (or, offline, `listPins` failing outright —
|
||||
/// it isn't one of the cached endpoints) shouldn't pop the refresh
|
||||
/// banner. This needs the *throwing* pinned-fetch specifically: the
|
||||
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
|
||||
/// which used to read here as "pins changed" against whatever was
|
||||
/// already displayed and falsely popped the banner on every offline
|
||||
/// poll.
|
||||
func checkForRemoteChanges(tab: HomeTab) async {
|
||||
async let freshPinned = fetchPinned()
|
||||
async let freshPinnedTask = fetchPinnedThrowing()
|
||||
guard let freshTab = try? await fetch(tab: tab) else { return }
|
||||
let pinned = await freshPinned
|
||||
guard let pinned = try? await freshPinnedTask else { return }
|
||||
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
||||
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
||||
hasRemoteChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
(try? await fetchPinnedThrowing()) ?? []
|
||||
}
|
||||
|
||||
/// `pins.list` only returns pin records, not the documents themselves —
|
||||
/// fetches each pinned document individually. Pins are a small curated
|
||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
||||
/// where it wouldn't be in the sidebar.
|
||||
private func fetchPinned() async -> [OutlineDocument] {
|
||||
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
||||
return []
|
||||
}
|
||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
||||
var documents: [OutlineDocument] = []
|
||||
for pin in pins {
|
||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||
@@ -107,6 +117,8 @@ final class HomeViewModel {
|
||||
return try await apiClient.documentsList(
|
||||
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
||||
)
|
||||
case .drafts:
|
||||
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +128,7 @@ final class HomeViewModel {
|
||||
case .popular: popular = documents
|
||||
case .recentlyUpdated: recentlyUpdated = documents
|
||||
case .createdByMe: createdByMe = documents
|
||||
case .drafts: drafts = documents
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-13
@@ -14,17 +14,19 @@ import AppKit
|
||||
@main
|
||||
struct OutpostApp: App {
|
||||
@State private var session = SessionStore()
|
||||
@State private var navigation = AppNavigation()
|
||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var isShowingLogoutConfirmation = false
|
||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||
#endif
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(session)
|
||||
.environment(navigation)
|
||||
#if os(iOS)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
#endif
|
||||
@@ -32,15 +34,27 @@ struct OutpostApp: App {
|
||||
.onAppear { applyMacAppearance() }
|
||||
.onChange(of: appearance) { _, _ in applyMacAppearance() }
|
||||
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||
.background(TransparentTitlebarWindowAccessor())
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About Outpost") {
|
||||
openWindow(id: "about")
|
||||
navigation.selectedSettingsSection = .about
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
}
|
||||
// No `Settings {}` scene anymore — Settings renders inside the
|
||||
// root window (see `AppNavigation`), not a separate popup, so
|
||||
// ⌘, has to be wired up by hand instead of coming for free.
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
navigation.isShowingSettings = true
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
.disabled(!session.isSignedIn)
|
||||
}
|
||||
CommandGroup(after: .appSettings) {
|
||||
Divider()
|
||||
Button("Log Out…") {
|
||||
@@ -48,26 +62,26 @@ struct OutpostApp: App {
|
||||
}
|
||||
.disabled(!session.isSignedIn)
|
||||
}
|
||||
// Settings → Editor → Command Palette gates this — disabled
|
||||
// (not just a no-op) when the user's turned it off, matching
|
||||
// how Settings…/Log Out already disable rather than silently
|
||||
// do nothing.
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Command Palette…") {
|
||||
navigation.isShowingCommandPalette = true
|
||||
}
|
||||
.keyboardShortcut("k")
|
||||
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
Window("About Outpost", id: "about") {
|
||||
AboutView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
||||
KeyboardShortcutsView()
|
||||
.disablesFullScreen()
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Settings {
|
||||
PreferencesView()
|
||||
.environment(session)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -88,3 +102,27 @@ struct OutpostApp: App {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
|
||||
/// instead of reading as a separate bar — same look as Mail/Notes/Finder.
|
||||
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
|
||||
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
|
||||
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
|
||||
/// A `View` (not the window itself) is what actually needs to extend under
|
||||
/// the now-transparent titlebar — `.fullSizeContentView` just makes room;
|
||||
/// the sidebar's `.background` already does the rest with no other change.
|
||||
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import Observation
|
||||
|
||||
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
|
||||
/// `general` holds everything that's ours, not Outline's own settings
|
||||
/// categories — labeled "Outpost" so it reads as clearly distinct from the
|
||||
/// Outline-sourced groups below it.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general
|
||||
case account
|
||||
case workspace
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String? {
|
||||
switch self {
|
||||
case .general: return "Outpost"
|
||||
case .account: return "Account"
|
||||
case .workspace: return "Workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the Settings sidebar. Mirrors Outline's own settings
|
||||
/// categories (Account/Workspace) so this app's settings read as a native
|
||||
/// counterpart to the web app's, plus a `general` group for things that are
|
||||
/// ours and don't map onto Outline's structure (offline/sync, advanced,
|
||||
/// about, appearance). Outline's own version info moved to the sidebar
|
||||
/// footer (`SettingsSidebarList`) instead of a standalone
|
||||
/// Integrations & Installation section.
|
||||
///
|
||||
/// Most of the Account/Workspace cases are navigation-only for now —
|
||||
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, editor, navigation, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
|
||||
// Workspace
|
||||
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
|
||||
return .workspace
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .editor: return "Editor"
|
||||
case .navigation: return "Navigation"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
case .profile: return "Profile"
|
||||
case .preferences: return "Preferences"
|
||||
case .notifications: return "Notifications"
|
||||
case .passkeys: return "Passkeys"
|
||||
case .apiAccess: return "API & Access"
|
||||
case .details: return "Details"
|
||||
case .authentication: return "Authentication"
|
||||
case .security: return "Security"
|
||||
case .ai: return "AI"
|
||||
case .members: return "Members"
|
||||
case .groups: return "Groups"
|
||||
case .templates: return "Templates"
|
||||
case .emojis: return "Emojis"
|
||||
case .applications: return "Applications"
|
||||
case .shared: return "Shared"
|
||||
case .links: return "Links"
|
||||
case .webhooks: return "Webhooks"
|
||||
case .importData: return "Import"
|
||||
case .exportData: return "Export"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .editor: return "square.split.2x1"
|
||||
case .navigation: return "command"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
case .profile: return "person.crop.circle"
|
||||
case .preferences: return "gearshape"
|
||||
case .notifications: return "bell"
|
||||
case .passkeys: return "key"
|
||||
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
|
||||
case .details: return "building.2"
|
||||
case .authentication: return "lock"
|
||||
case .security: return "shield"
|
||||
case .ai: return "sparkles"
|
||||
case .members: return "person.2"
|
||||
case .groups: return "person.3"
|
||||
case .templates: return "doc.on.doc"
|
||||
case .emojis: return "face.smiling"
|
||||
case .applications: return "app.badge"
|
||||
case .shared: return "square.and.arrow.up.on.square"
|
||||
case .links: return "link"
|
||||
case .webhooks: return "bolt.horizontal"
|
||||
case .importData: return "square.and.arrow.down"
|
||||
case .exportData: return "square.and.arrow.up"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything actually built so far — everything else in Account/
|
||||
/// Workspace renders a "Coming Soon" placeholder until its content is
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
|
||||
/// `OutpostApp` (so both `RootView`'s content and its `⌘,` command can reach
|
||||
/// it) and is read wherever something needs to open Settings (the profile
|
||||
/// menu) or render it — as a swap of the *existing* sidebar/detail panes in
|
||||
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
|
||||
/// the sidebar.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppNavigation {
|
||||
var isShowingSettings = false
|
||||
var selectedSettingsSection: SettingsSection? = .appearance
|
||||
/// ⌘K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
|
||||
var isShowingCommandPalette = false
|
||||
}
|
||||
@@ -5,6 +5,19 @@ struct RootView: View {
|
||||
@Environment(SessionStore.self) private var session
|
||||
@State private var welcomeName: String?
|
||||
@State private var starStore = StarStore()
|
||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
|
||||
/// Real connectivity is only half of "can talk to the server" — the
|
||||
/// manual Offline Mode toggle is the other half. Syncing needs to
|
||||
/// resume on either one clearing, not just a real reconnect: turning
|
||||
/// the toggle off while the network had been up the whole time never
|
||||
/// changes `networkMonitor.isOnline`, so keying the flush task off that
|
||||
/// alone left pending operations stuck until the next real network
|
||||
/// blip.
|
||||
private var isEffectivelyOnline: Bool {
|
||||
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -32,6 +45,48 @@ struct RootView: View {
|
||||
starStore.reset()
|
||||
}
|
||||
}
|
||||
// Replay whatever queued up while offline the moment either signal
|
||||
// clears — a real reconnect, or the user turning Offline Mode back
|
||||
// off — no need to wait for the user to open Settings and hit Retry.
|
||||
// Also catches Full Local Sync back up immediately, rather than
|
||||
// leaving it to wait out the rest of the periodic loop below.
|
||||
//
|
||||
// The flush itself gets a retry loop with a cooloff, not just one
|
||||
// attempt: a single transient failure (one bad operation, a blip
|
||||
// mid-flush) used to leave everything else stuck until the user
|
||||
// manually hit Retry or connectivity changed again. Keeps retrying
|
||||
// every 5 minutes for as long as *anything* is still pending and
|
||||
// this signal stays on — stops on its own once the queue is empty,
|
||||
// and `.task(id:)` cancels/restarts it automatically if
|
||||
// isEffectivelyOnline flips again in the meantime.
|
||||
.task(id: isEffectivelyOnline) {
|
||||
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
|
||||
if isFullLocalSyncEnabled {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
_ = await cachingClient.flushPendingOperations()
|
||||
let remaining = await cachingClient.pendingOperations()
|
||||
guard !remaining.isEmpty else { break }
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
}
|
||||
}
|
||||
// Fully automatic — this is the only place Full Local Sync actually
|
||||
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
|
||||
// same call). Syncs immediately whenever this task (re)starts, which
|
||||
// covers both "just switched on" and "was already on at launch" —
|
||||
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
|
||||
// changing anywhere updates every view reading that key — then every
|
||||
// 20 minutes after, for as long as it stays enabled.
|
||||
.task(id: isFullLocalSyncEnabled) {
|
||||
guard isFullLocalSyncEnabled else { return }
|
||||
while !Task.isCancelled {
|
||||
if isEffectivelyOnline, let cachingClient = session.cachingClient {
|
||||
_ = await cachingClient.performFullSync()
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1200))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||
|
||||
@@ -6,17 +6,38 @@ import OutlineKit
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
private static let serverURLDefaultsKey = "outline.serverURL"
|
||||
/// Preferences now drive real editor behavior (separate editing, etc.),
|
||||
/// not just a settings screen — they need to survive a cold launch with
|
||||
/// no network, not just live in memory from the last successful fetch.
|
||||
/// Still read-only while offline (Settings already gates every toggle
|
||||
/// on `isEffectivelyOnline`) — this only makes the *last known* values
|
||||
/// available, never lets them be changed without a server round-trip.
|
||||
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||
|
||||
private let tokenStore: TokenStoring
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var isSignedIn: Bool
|
||||
private(set) var userId: String?
|
||||
var userName: String?
|
||||
var userEmail: String?
|
||||
var userAvatarURL: URL?
|
||||
var userLanguage: String?
|
||||
var userPreferences: OutlineUserPreferences?
|
||||
var userNotificationSettings: [String: Bool]?
|
||||
var teamName: String?
|
||||
var teamAvatarURL: URL?
|
||||
private(set) var apiClient: OutlineAPIClient?
|
||||
/// Same object as `apiClient` when the offline cache is available — kept
|
||||
/// as a separately-typed reference so Settings can reach cache/sync-queue
|
||||
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
|
||||
/// without downcasting the protocol-typed `apiClient` everywhere.
|
||||
private(set) var cachingClient: CachingOutlineAPIClient?
|
||||
let networkMonitor = NetworkMonitor()
|
||||
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
|
||||
/// full) — in that case `apiClient` falls back to talking to the server
|
||||
/// directly with no offline cache, rather than failing sign-in outright.
|
||||
private let cacheStore: OfflineCacheStore?
|
||||
|
||||
var serverURL: URL? {
|
||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
@@ -25,36 +46,90 @@ final class SessionStore {
|
||||
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
||||
self.tokenStore = tokenStore
|
||||
self.defaults = defaults
|
||||
self.isSignedIn = (try? tokenStore.token()) != nil
|
||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||
|
||||
if isSignedIn, let serverURL {
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
let hasToken = (try? tokenStore.token()) != nil
|
||||
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
||||
|
||||
if hasToken, let storedServerURL {
|
||||
isSignedIn = true
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||
} else {
|
||||
// Keychain and the sandboxed UserDefaults container don't
|
||||
// always survive together — a Keychain item written by an
|
||||
// older-signed build can outlive a reinstall that wipes the
|
||||
// container (or vice versa), leaving a token with no server or
|
||||
// a server with no token. Clear whichever half survived rather
|
||||
// than showing a broken "signed in" UI with no working
|
||||
// apiClient — a fresh sign-in rewrites both consistently.
|
||||
if hasToken {
|
||||
try? tokenStore.clear()
|
||||
}
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
||||
apiClient = LiveOutlineAPIClient(
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
}
|
||||
|
||||
/// `static` (not an instance method) so `init` can call it before every
|
||||
/// stored property has a value — same reason `makeAPIClient` is static.
|
||||
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
|
||||
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
|
||||
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
|
||||
}
|
||||
|
||||
/// `nil` clears the cache instead of writing a `null` — happens whenever
|
||||
/// a fresh fetch legitimately comes back with no preferences set, so a
|
||||
/// stale cached value from a previous account/state can't linger.
|
||||
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
|
||||
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
|
||||
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
|
||||
}
|
||||
|
||||
private static func makeAPIClient(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
cache: OfflineCacheStore?
|
||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||
let live = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: serverURL),
|
||||
tokenStore: tokenStore
|
||||
)
|
||||
apply(user: user, team: team, serverURL: serverURL)
|
||||
isSignedIn = true
|
||||
guard let cache else { return (live, nil) }
|
||||
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
||||
return (caching, caching)
|
||||
}
|
||||
|
||||
func signOut() {
|
||||
try? tokenStore.clear()
|
||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||
isSignedIn = false
|
||||
userId = nil
|
||||
userName = nil
|
||||
userEmail = nil
|
||||
userAvatarURL = nil
|
||||
userLanguage = nil
|
||||
userPreferences = nil
|
||||
userNotificationSettings = nil
|
||||
teamName = nil
|
||||
teamAvatarURL = nil
|
||||
apiClient = nil
|
||||
cachingClient = nil
|
||||
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||
// Same key `ContentView_macOS` persists "Remember previous
|
||||
// location" under — cleared here too so switching accounts/servers
|
||||
// can't restore a stale location that belongs to a different sign-in.
|
||||
defaults.removeObject(forKey: "outline.lastLocation")
|
||||
}
|
||||
|
||||
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
||||
@@ -65,13 +140,32 @@ final class SessionStore {
|
||||
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||
}
|
||||
|
||||
/// Settings calls this after a successful name/avatar change so the
|
||||
/// sidebar's account footer and everywhere else reading these reflect
|
||||
/// it immediately, without waiting for the next `auth.info` refresh.
|
||||
func applyUpdatedProfile(_ user: OutlineUser) {
|
||||
guard let serverURL else { return }
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
}
|
||||
|
||||
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
||||
userId = user.id
|
||||
userName = user.name
|
||||
userEmail = user.email
|
||||
// Outline can return either an absolute URL or a server-relative path
|
||||
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
||||
// configured server so relative paths don't fail as "unsupported URL".
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
|
||||
.task(id: avatarURL) {
|
||||
loadedImage = nil
|
||||
guard let avatarURL else { return }
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
|
||||
loadedImage = PlatformImage(data: data)
|
||||
loadedImage = await Self.loadImage(from: avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// The real fix, confirmed against a live network capture: every other
|
||||
/// request this app makes attaches `Authorization: Bearer <token>` —
|
||||
/// this one never did, sending a bare unauthenticated GET. Outline's
|
||||
/// browser session authenticates `attachments.redirect` via cookies
|
||||
/// instead, which a native app doesn't have; the API-token equivalent
|
||||
/// is the same Bearer header every RPC call already uses. Almost
|
||||
/// certainly means no avatar image (not just a freshly-uploaded one)
|
||||
/// has ever actually loaded in this app — a 401 and a "no avatar set"
|
||||
/// look identical here, both just fall back to the placeholder icon
|
||||
/// with nothing on screen to flag it as an error.
|
||||
///
|
||||
/// The retry loop is a secondary, independent hardening — cheap
|
||||
/// insurance against a self-hosted reverse-proxied storage backend not
|
||||
/// being instantly consistent right after an upload — kept alongside
|
||||
/// the auth fix rather than instead of it.
|
||||
private static func loadImage(from url: URL) async -> PlatformImage? {
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
if let token = try? KeychainTokenStore().token() {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
for attempt in 0..<3 {
|
||||
if attempt > 0 {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
if let (data, response) = try? await URLSession.shared.data(for: request),
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode),
|
||||
let image = PlatformImage(data: data) {
|
||||
return image
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func platformImage(_ image: PlatformImage) -> Image {
|
||||
#if os(macOS)
|
||||
Image(nsImage: image)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
/// `NSMenuItem` has no closure-based initializer — the standard AppKit
|
||||
/// pattern is a small subclass that's its own target/action, so callers can
|
||||
/// just pass a Swift closure instead of wiring up a selector by hand.
|
||||
final class ClosureMenuItem: NSMenuItem {
|
||||
private let handler: () -> Void
|
||||
|
||||
init(title: String, handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
super.init(title: title, action: #selector(invokeHandler), keyEquivalent: "")
|
||||
self.target = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func invokeHandler() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#if os(macOS)
|
||||
import MarkdownEngineCodeBlocks
|
||||
|
||||
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
|
||||
/// context (expensive to spin up) plus its own highlight cache, so every
|
||||
/// `NativeTextViewWrapper` should share this instance rather than each
|
||||
/// constructing its own.
|
||||
enum CodeSyntaxHighlighting {
|
||||
static let shared = HighlighterSwiftBridge()
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) — routes
|
||||
/// through a bitmap representation to get one.
|
||||
func jpegData(compressionQuality: CGFloat) -> Data? {
|
||||
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
|
||||
return nil
|
||||
}
|
||||
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to
|
||||
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
|
||||
/// request failures rather than pre-checking reachability.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkMonitor {
|
||||
private(set) var isOnline = true
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
|
||||
|
||||
init() {
|
||||
// Weak on the outer closure (it's held by `monitor` for the object's
|
||||
// whole lifetime, so a strong capture here would be a retain cycle),
|
||||
// then unwrapped once into a plain strong local — the inner `Task`
|
||||
// closure capturing that local `self` is what a nested closure needs
|
||||
// to be consistent about capture semantics with its enclosing one.
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
|
||||
let online = path.status == .satisfied
|
||||
Task { @MainActor in
|
||||
self.isOnline = online
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import MarkdownEngine
|
||||
import OutlineKit
|
||||
|
||||
/// Resolves `` Markdown image references for the editor.
|
||||
///
|
||||
/// `EmbeddedImageProvider.image(for:)` is called synchronously from the
|
||||
/// styling pipeline and must return immediately — it can't `await` a
|
||||
/// network fetch inline. So a miss kicks off an async load in the
|
||||
/// background, caches the result, and calls `onImageLoaded` (on the main
|
||||
/// thread) once it lands; the embedder is responsible for turning that into
|
||||
/// a real SwiftUI update (see `DocumentReaderView`'s `imageReloadTick`) so
|
||||
/// `updateNSView` runs again, notices `fingerprint()` changed, and
|
||||
/// restyles — the engine has no polling of its own.
|
||||
///
|
||||
/// `url` is usually a server-relative path like
|
||||
/// `/api/attachments.redirect?id=<uuid>` — Outline's own stable reference
|
||||
/// for an uploaded attachment, which needs the same Bearer auth as every
|
||||
/// other OutlineKit request (`OutlineAPIClient.fetchAuthenticatedFile`).
|
||||
/// A plain absolute `http(s)://` URL (an external image someone pasted) is
|
||||
/// fetched directly instead, no auth attached.
|
||||
///
|
||||
/// Thread-safety mirrors `HighlighterSwiftBridge`: `NSCache` for the image
|
||||
/// store (inherently thread-safe), a lock for the small bit of state that
|
||||
/// isn't (`version`, `inFlight`) — no actor isolation, since the engine may
|
||||
/// call `image(for:)`/`fingerprint()` from whatever thread is styling.
|
||||
final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
|
||||
private let apiClient: OutlineAPIClient
|
||||
private let cache = NSCache<NSString, NSImage>()
|
||||
private let lock = NSLock()
|
||||
private var inFlight: Set<String> = []
|
||||
private var version = 0
|
||||
/// Set by the embedder; called on the main thread whenever a load
|
||||
/// completes and `fingerprint()` has changed.
|
||||
var onImageLoaded: (@Sendable () -> Void)?
|
||||
|
||||
init(apiClient: OutlineAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
func image(for reference: EmbeddedImageRequest) -> NSImage? {
|
||||
let url = reference.name
|
||||
if let cached = cache.object(forKey: url as NSString) {
|
||||
return cached
|
||||
}
|
||||
beginLoad(url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fingerprint() -> AnyHashable {
|
||||
lock.withLock { version }
|
||||
}
|
||||
|
||||
private func beginLoad(_ url: String) {
|
||||
let alreadyLoading: Bool = lock.withLock {
|
||||
if inFlight.contains(url) { return true }
|
||||
inFlight.insert(url)
|
||||
return false
|
||||
}
|
||||
guard !alreadyLoading else { return }
|
||||
|
||||
Task {
|
||||
defer { lock.withLock { inFlight.remove(url) } }
|
||||
do {
|
||||
let data: Data
|
||||
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
|
||||
(data, _) = try await URLSession.shared.data(from: externalURL)
|
||||
} else {
|
||||
data = try await apiClient.fetchAuthenticatedFile(path: url)
|
||||
}
|
||||
guard let image = NSImage(data: data) else { return }
|
||||
cache.setObject(image, forKey: url as NSString)
|
||||
lock.withLock { version += 1 }
|
||||
onImageLoaded?()
|
||||
} catch {
|
||||
// Best-effort: a failed load just leaves the Markdown source
|
||||
// visible (the engine's existing fallback for `image(for:)
|
||||
// == nil`), no separate error UI for an inline image fetch.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Outline's interface-language options. Not exhaustive — Outline accepts
|
||||
/// community translations via its own translation portal, so the real list
|
||||
/// on any given server can be longer than this; this covers the common
|
||||
/// cases and falls back to showing whatever code the server already has
|
||||
/// set even if it isn't in this list.
|
||||
struct OutlineLocale: Identifiable, Hashable {
|
||||
let code: String
|
||||
let label: String
|
||||
|
||||
var id: String { code }
|
||||
|
||||
static let all: [OutlineLocale] = [
|
||||
OutlineLocale(code: "en_US", label: "English (US)"),
|
||||
OutlineLocale(code: "en_GB", label: "English (UK)"),
|
||||
OutlineLocale(code: "de_DE", label: "Deutsch"),
|
||||
OutlineLocale(code: "fr_FR", label: "Français"),
|
||||
OutlineLocale(code: "es_ES", label: "Español"),
|
||||
OutlineLocale(code: "pt_PT", label: "Português"),
|
||||
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
|
||||
OutlineLocale(code: "it_IT", label: "Italiano"),
|
||||
OutlineLocale(code: "nl_NL", label: "Nederlands"),
|
||||
OutlineLocale(code: "pl_PL", label: "Polski"),
|
||||
OutlineLocale(code: "ru_RU", label: "Русский"),
|
||||
OutlineLocale(code: "ja_JP", label: "日本語"),
|
||||
OutlineLocale(code: "ko_KR", label: "한국어"),
|
||||
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
|
||||
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
|
||||
]
|
||||
|
||||
static func label(for code: String) -> String {
|
||||
all.first(where: { $0.code == code })?.label ?? code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Single source of truth for how Outpost's own version is formatted —
|
||||
/// used by both the About page and the Settings sidebar footer, so they
|
||||
/// can't drift out of sync the way `AboutInfoView` was already written to
|
||||
/// avoid for its own two call sites.
|
||||
enum OutpostVersion {
|
||||
/// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out
|
||||
/// of the bundle version itself since `CFBundleShortVersionString` is
|
||||
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
|
||||
static let releaseStage = "ALPHA"
|
||||
|
||||
static var shortVersion: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
|
||||
}
|
||||
|
||||
static var buildNumber: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||
}
|
||||
|
||||
/// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer).
|
||||
static var displayString: String {
|
||||
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
|
||||
return "\(shortVersion)\(stageSuffix)"
|
||||
}
|
||||
|
||||
/// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page.
|
||||
static var fullVersionString: String {
|
||||
"Version \(displayString) (\(buildNumber))"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
# Outpost
|
||||
<p align="center">
|
||||
<img src="Outpost/Assets.xcassets/AppLogo.imageset/outpost-ios-1024.png" width="120" alt="Outpost logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Outpost</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://testflight.apple.com/join/y1mYcYAM">
|
||||
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing.
|
||||
|
||||
> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything.
|
||||
|
||||
## Why
|
||||
|
||||
Outline's web app is great, but there's no native Apple client with full editing parity. This project connects to a self-hosted Outline instance over its REST API and realtime collaboration socket to provide a proper native experience across the Apple ecosystem.
|
||||
|
||||
## Status
|
||||
|
||||
Early development. See `CLAUDE.md` for the current architecture and phased build plan.
|
||||
|
||||
- [ ] Phase 1 — Auth, browse, search, REST-only editing
|
||||
- [ ] Phase 2 — Realtime collaborative editing (Yjs/Hocuspocus)
|
||||
- [ ] Phase 3 — Offline cache, tables, embeds, comments, macOS polish
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16+
|
||||
- iOS 17+ / iPadOS 17+ / macOS 14+
|
||||
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion)
|
||||
- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands
|
||||
- A self-hosted (or hosted) Outline instance with API access
|
||||
|
||||
> They will be updated. These are old requirements.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone the repo and open the `.xcodeproj` in Xcode.
|
||||
@@ -40,7 +42,23 @@ Parts of this codebase are AI-assisted (built with the help of AI coding tools).
|
||||
|
||||
## License
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's (BSL 1.1 licensed) server source. See [`LICENSE`](./LICENSE) for this repository's own license.
|
||||
Outpost itself is licensed under the [Business Source License 1.1](./LICENSE) — the same license family Outline's own server uses, for the same reason. In short: free to read, self-host, and modify for personal or non-commercial use; not free to repackage as a competing hosted product or to distribute under a name/branding that claims official or affiliated status. It converts automatically to Apache License 2.0 on the change date stated in [`LICENSE`](./LICENSE). "Outpost" and its logo are trademarks of the project — see the license's trademark notice.
|
||||
|
||||
This project is a client only — it does not include, vendor, or redistribute any of Outline's own (also BSL 1.1 licensed) server source.
|
||||
|
||||
## Credits
|
||||
|
||||
The native markdown editor is built on [swift-markdown-engine](https://github.com/nodes-app/swift-markdown-engine) by Luca Chen, licensed under Apache License 2.0. It's vendored directly in [`Vendor/swift-markdown-engine`](./Vendor/swift-markdown-engine) (see its [`LICENSE`](./Vendor/swift-markdown-engine/LICENSE)); local fixes made in that copy haven't been upstreamed yet.
|
||||
|
||||
## Privacy
|
||||
|
||||
Outpost collects nothing about you — no analytics, no telemetry, no crash reporting of its own, no age or demographic data, nothing. The only thing stored locally is your Outline server URL and API token (in the device Keychain) and, optionally, a local offline cache of what you've viewed. Everything else goes straight from your device to whatever Outline server you configure — there's no backend in between, and the developer has no access to your data or your server.
|
||||
|
||||
Full policy, terms of service, and data-processing statement are on the [wiki](https://git.psmattas.com/psmattas/Outpost/wiki):
|
||||
|
||||
- [Privacy Policy](https://git.psmattas.com/psmattas/Outpost/wiki/Privacy-Policy.-)
|
||||
- [Terms of Service](https://git.psmattas.com/psmattas/Outpost/wiki/Terms-of-Service.-)
|
||||
- [Data Processing Statement](https://git.psmattas.com/psmattas/Outpost/wiki/Data-Processing-Statement.-)
|
||||
|
||||
## Not affiliated with Outline
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & Test (macOS)
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Show Swift version
|
||||
run: swift --version
|
||||
|
||||
- name: Build
|
||||
run: swift build -v
|
||||
|
||||
- name: Test
|
||||
run: swift test --parallel
|
||||
@@ -0,0 +1,17 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Swift Package Manager
|
||||
/.build
|
||||
/.swiftpm/xcode
|
||||
Package.resolved
|
||||
|
||||
# Xcode
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
*.xcodeproj/xcuserdata/
|
||||
|
||||
# Generated documentation
|
||||
/docs
|
||||
*.doccarchive
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [MarkdownEngine]
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Architecture
|
||||
|
||||
## Source layout
|
||||
|
||||
```bash
|
||||
Sources/
|
||||
├── MarkdownEngine/ # core target — zero deps
|
||||
│ ├── Configuration/ # MarkdownEditorConfiguration + MarkdownEditorTheme
|
||||
│ ├── Extensions/ # the extension seam: MarkdownExtension + bundled opt-ins
|
||||
│ ├── Services/ # 4 protocols, no-op defaults, WikiLinkService
|
||||
│ ├── Parser/ # two-phase AST: BlockParser → InlineParser → DocumentAST (+ token projection)
|
||||
│ ├── Styling/ # MarkdownASTStyler (AST walk) + MarkdownStyler facade for NSImage passes
|
||||
│ ├── Renderer/ # LayoutBridge, MarkdownTextLayoutFragment, EmbeddedImageCache
|
||||
│ ├── Input/ # MarkdownInputHandler + MarkdownListHandler
|
||||
│ ├── TextView/
|
||||
│ │ ├── NativeTextViewWrapper.swift # SwiftUI entry point (NSViewRepresentable)
|
||||
│ │ ├── NativeTextViewContainer.swift # the scroll view's documentView: header band + text column stacking
|
||||
│ │ ├── ScrollingHeaderController.swift # scroll-away header: hosting, collapse/expand, teardown
|
||||
│ │ ├── ClampedScrollView.swift # scroll range clamped to real content height
|
||||
│ │ ├── NativeTextView/ # AppKit subclass + UX extensions (paste, drag-select, …)
|
||||
│ │ └── Coordinator/ # NSTextViewDelegate split by concern (restyling, find, …)
|
||||
│ └── MarkdownEngine.docc/ # DocC catalog
|
||||
├── MarkdownEngineCodeBlocks/ # opt-in SPM product — pulls in HighlighterSwift
|
||||
│ └── HighlighterSwiftBridge.swift # SyntaxHighlighter conformance
|
||||
└── MarkdownEngineLatex/ # opt-in SPM product — pulls in SwiftMath
|
||||
└── SwiftMathBridge.swift # LatexRenderer conformance
|
||||
```
|
||||
|
||||
The rest of this file is a per-directory tour, in the order text flows
|
||||
through the engine.
|
||||
|
||||
## [`Parser/`](Sources/MarkdownEngine/Parser): text → AST → tokens
|
||||
|
||||
A two-phase AST pipeline following CommonMark's model — block structure first,
|
||||
inline content second. There is no regex tokenizer anymore; the structural
|
||||
regexes are gone, replaced by hand-written scanners and a real syntax tree.
|
||||
|
||||
1. **`BlockParser`** splits the document into a flat, gap-free (tiling)
|
||||
sequence of `Block`s: `heading`, `paragraph`, `blockquote`, `list`,
|
||||
`fencedCode`, `blockLatex`, `table`, `thematicBreak`, `blank`. Hand-written
|
||||
line scanners. It memoizes the last parse (UTF-16 buffer cache) so the
|
||||
per-keystroke callers share one line-scan.
|
||||
2. **`InlineParser`** turns a single inline-bearing block's text into an inline
|
||||
AST (`[InlineNode]`) with correct CommonMark precedence: code spans →
|
||||
escapes → link family (`![[…]]`, `[[…]]`, ``, `[…](…)`, `~~…~~`,
|
||||
`$…$`) → emphasis (`*`/`_` delimiter runs) → `buildTree`. Each pass claims
|
||||
spans only in regions not already claimed, so there are never partial
|
||||
overlaps and the tree is a clean containment tree. That invariant is also
|
||||
what keeps the pass linear in span count: claimed ranges are consulted
|
||||
through a cursor rather than rescanned, and `buildTree` derives containment
|
||||
from a sort instead of comparing spans pairwise.
|
||||
3. **`MarkdownAST` / `DocumentAST.parse`** combines the two into the semantic
|
||||
document AST — `[BlockNode]`, each inline-bearing block carrying its parsed
|
||||
`[InlineNode]` children in absolute document coordinates. `BlockNode`,
|
||||
`InlineNode`, and `ListItem` are defined here.
|
||||
|
||||
**Tokens are now a projection of the AST, not the source of truth.**
|
||||
`MarkdownTokenizer` is just a namespace; its entry point `parseTokensViaAST`
|
||||
(implemented in **`BlockScopedTokenizer`** — the live tokenization pipeline)
|
||||
walks each `BlockParser` block and emits the legacy flat `[MarkdownToken]`
|
||||
shape: block-level tokens (heading, blockquote, fenced code, table, block
|
||||
LaTeX) come from **`BlockLevelTokenizer`** (hand scanners), inline tokens from
|
||||
the AST via **`InlineASTAdapter`** (`[InlineNode]` → `[MarkdownToken]`). Token
|
||||
shapes are reproduced 1:1 from the old regex tokenizer (parity-checked), so the
|
||||
consumers that still read tokens — the NSImage render passes, code-block
|
||||
handling, `MarkdownInputHandler`, `ContextMenu`, and `MarkdownDetection`
|
||||
(caret-aware active-token indices) — keep working unchanged.
|
||||
|
||||
**Invariant:** Ranges everywhere are absolute UTF-16 `NSRange`s into the source
|
||||
(the editor is TextKit-2 / `NSTextView`-based, so UTF-16 offsets are the native
|
||||
currency).
|
||||
|
||||
**Invariant:** Parsing is incremental. With `scopedRanges`, `DocumentAST.parse`
|
||||
parses inlines only for blocks intersecting the edit, and `BlockScopedTokenizer`
|
||||
memoizes per-block tokens (substring → tokens, FIFO-capped) — so a keystroke
|
||||
re-parses one block, not the whole document (≈ O(edit)).
|
||||
|
||||
## [`Extensions/`](Sources/MarkdownEngine/Extensions): opt-in constructs beyond pure markdown
|
||||
|
||||
`MarkdownExtension` contributes an inline span form (`InlineSyntax`, e.g.
|
||||
`==highlight==`), a fenced block form (`BlockSyntax`, e.g. `::: … :::`), or
|
||||
both — plus content attributes and an HTML wrapper for the clean-copy path.
|
||||
Registered via `MarkdownEditorConfiguration.extensions`; unregistered syntax
|
||||
stays literal text. Extensions never emit ranges — the parser derives all
|
||||
geometry — and every parse cache keys on the registry fingerprint, so the
|
||||
registered set can change at runtime.
|
||||
|
||||
**Invariant:** built-in constructs always classify first; an extension can
|
||||
never take text away from core markdown.
|
||||
|
||||
## [`Services/`](Sources/MarkdownEngine/Services): how does the engine talk to your app?
|
||||
|
||||
`MarkdownEditorServices.swift` declares the four service protocols. Each is
|
||||
called synchronously when its construct is styled or rendered: `WikiLinkResolver`
|
||||
while styling wiki-links, `EmbeddedImageProvider` from the image-embed render
|
||||
pass, `SyntaxHighlighter` from code styling, `LatexRenderer` from the LaTeX
|
||||
render passes.
|
||||
|
||||
`WikiLinkService.swift` handles the dual-form storage / display transform —
|
||||
storage is `[[Name|<id>]]`, display is `[[Name]]`. The coordinator runs it both
|
||||
ways every time `rebuildTextStorageAndStyle()` fires.
|
||||
|
||||
**Invariant:** Service callbacks are synchronous. If an embedder's
|
||||
implementation is slow, it caches (both bundled bridges do); the engine never
|
||||
async-renders.
|
||||
|
||||
**Invariant:** Wiki-link storage and display are different strings. Display IDs
|
||||
never leak into the binding.
|
||||
|
||||
## [`Styling/`](Sources/MarkdownEngine/Styling): how does the AST become attributes?
|
||||
|
||||
`MarkdownASTStyler.styleAttributes()` is the live styler. It walks the document
|
||||
AST and emits `[StyledRange]`, **composing** attributes on descent: a heading
|
||||
sets a large bold font, descending into bold adds the bold trait (keeping the
|
||||
size), into italic adds italic — so nested / combined inline styles stack
|
||||
instead of overwriting each other. (Composition is what the old flat pass
|
||||
pipeline got wrong, e.g. the shrinking bold in `# **n*o*des**`.)
|
||||
|
||||
`MarkdownStyler.styleAttributes()` (`MarkdownStyler.swift:43`) is now a thin
|
||||
facade: it builds the `StylingContext`, runs the AST styler for all text
|
||||
styling, then appends the passes that still render **NSImages** and therefore
|
||||
still consume tokens — block / inline LaTeX (`+Latex`), image embeds and image
|
||||
links (`+Images`), and rendered tables (`+Tables`). `MarkdownStyler+TaskCheckboxes`
|
||||
and `+BulletMarkers` no longer style (the AST styler does); they keep only the
|
||||
caret / selection range helpers (`taskSyntaxRange`, `bulletSyntaxRange`,
|
||||
`hrLineRange`) the text-view delegate uses.
|
||||
|
||||
If the coordinator passes `scopedRanges`, only the intersecting blocks are
|
||||
re-styled — the optimization that keeps per-keystroke restyling cheap.
|
||||
|
||||
**Invariant:** Markers shrink, they don't disappear. Inactive markers render at
|
||||
`hiddenMarkerFontSize`; they're never removed from text storage. Every
|
||||
selection / copy / find / undo bug downstream traces back to violating this.
|
||||
|
||||
## [`Renderer/`](Sources/MarkdownEngine/Renderer): TextKit 2 layout
|
||||
|
||||
Thin wrappers around `NSTextLayoutManager` (`LayoutBridge.swift`), a custom
|
||||
`MarkdownTextLayoutFragment` for precise positioning, and `EmbeddedImageCache`
|
||||
keyed by an embedder-supplied fingerprint so images and LaTeX results
|
||||
invalidate when the embedder says so.
|
||||
|
||||
## [`Input/`](Sources/MarkdownEngine/Input): typing-time helpers
|
||||
|
||||
`MarkdownInputHandler.swift` handles auto-wrap for `$…$` / `$$…$$` / `![[…]]`.
|
||||
`MarkdownListHandler.swift` handles list continuation, indent / outdent, and
|
||||
task-checkbox toggling on Enter / Tab / Backspace. Both run synchronously inside
|
||||
the text-view delegate.
|
||||
|
||||
## [`TextView/`](Sources/MarkdownEngine/TextView): NSTextView + SwiftUI bridge
|
||||
|
||||
The entry point is `NativeTextViewWrapper.swift` — an `NSViewRepresentable` that
|
||||
owns the coordinator and the configured text view.
|
||||
|
||||
The scroll view's `documentView` is **always** `NativeTextViewContainer`, never
|
||||
the text view itself. The container stacks up to three kinds of siblings in a
|
||||
flipped coordinate space: the optional scroll-away header band at the top (a
|
||||
clipped `NSHostingView` managed by `ScrollingHeaderController`, reserved height
|
||||
mirrored into `container.headerHeight`), the `NativeTextView` at
|
||||
`y = headerHeight` (centered at a fixed width when
|
||||
`configuration.readingWidth` is set), and — in reading-column mode — the
|
||||
full-width wide-table breakout overlays. Anything that converts between
|
||||
text-view-local rects and scroll/document space must lift by the text view's
|
||||
origin inside the container (`convert(_:to:)` or `frame.origin`); see
|
||||
`viewRect(forCharacterRange:)` and the find-in-document paths for the pattern.
|
||||
|
||||
Two sub-folders matter:
|
||||
|
||||
- `NativeTextView/` — extensions on the AppKit subclass (paste, drag-select
|
||||
boost, spell policy, caret workarounds, frame/overscroll management)
|
||||
- `Coordinator/` — `NSTextViewDelegate` glue, split by concern (restyling,
|
||||
writing-tools, find, code-blocks, inline selection, autocorrect)
|
||||
|
||||
Application of `[StyledRange]` to text storage happens in
|
||||
`Coordinator/NativeTextViewCoordinator+Restyling.swift` →
|
||||
`rebuildTextStorageAndStyle()`, which tokenizes via `parseTokensViaAST` and
|
||||
calls `MarkdownStyler.styleAttributes()`.
|
||||
|
||||
## [`Configuration/`](Sources/MarkdownEngine/Configuration): the tunables
|
||||
|
||||
`MarkdownEditorConfiguration` is a struct of structs — one nested group per
|
||||
concern (headings, codeBlock, blockLatex, overscroll, markers, lists, …) —
|
||||
passed by reference into the styler via the `StylingContext`.
|
||||
`MarkdownEditorTheme` is its colour sub-field.
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to swift-markdown-engine are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.12.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
- `onPersistScrollOffset` / `restoreScrollOffset` on `NativeTextViewWrapper` —
|
||||
scroll memory an embedder can keep somewhere that outlives the editor. The
|
||||
engine's own per-document offsets live on the coordinator, so an embedder that
|
||||
routes to a different screen and back lost them: nothing recorded the offset on
|
||||
the way out (there was no `dismantleNSView` at all), and the restore was gated
|
||||
on a document switch, which a remount is not — `makeCoordinator` seeds
|
||||
`documentId`, so the first update pass never looks like one. Teardown now hands
|
||||
the offset over, and the restore is latched instead of gated, retrying for a
|
||||
bounded few passes because the first pass after a remount still carries the
|
||||
embedder's empty buffer. Both closures are asked at call time, so the
|
||||
embedder's own retention rules can see changes made on the way out. Passing
|
||||
neither leaves behavior unchanged.
|
||||
|
||||
- `NSAttributedString.Key.markdownBlockBackground` — a background painted
|
||||
across the whole line box by `MarkdownTextLayoutFragment` instead of the
|
||||
glyph box AppKit's `.backgroundColor` covers. Embedder extensions can use it
|
||||
wherever a fill should read as a block.
|
||||
|
||||
### Changed
|
||||
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
|
||||
pass after the first consulted the claimed ranges by scanning the whole array
|
||||
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
|
||||
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
|
||||
each span against every other. The passes walk the string left to right and
|
||||
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
|
||||
questions in amortised constant time, and sorting spans by start ascending /
|
||||
length descending turns containment into a single ordered walk. A paragraph of
|
||||
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
|
||||
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
|
||||
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
|
||||
parse result changes.
|
||||
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
|
||||
ascent + descent only, so the marker fell short of the line height by the
|
||||
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
|
||||
came out as a stack of bands. `HighlightExtension` returns
|
||||
`.markdownBlockBackground` now, so the block is continuous at any font size.
|
||||
Table cells rasterize their own text and keep the glyph-box fill.
|
||||
|
||||
### Fixed
|
||||
- Bare URLs and emails survive rich copy as real links. The editor styler
|
||||
linkifies them with `NSDataDetector`, but the HTML renderer emitted them as
|
||||
plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor
|
||||
at all, and whether a copied URL arrived clickable was left to the receiving
|
||||
app — Apple Mail runs its own detection and linkifies anyway, a consumer that
|
||||
takes the rich flavor verbatim pastes dead text. `MarkdownHTMLRenderer` now
|
||||
wraps detector matches in `<a href>` (emails as `mailto:`) using the same
|
||||
system detector as the styler; the RTF and web-archive flavors are derived
|
||||
from that HTML, so all three inherit the link. Explicit `[title](url)` links
|
||||
were already correct; a URL-shaped run inside a link's own title stays plain
|
||||
so anchors never nest, and code spans remain excluded, matching the styler.
|
||||
Table cells are unaffected: they render no inline markup on the copy path.
|
||||
- Markdown link labels may hold inline code and escaped punctuation —
|
||||
``[`App`](/tmp/App.swift:56)`` stayed literal. Code spans and escapes are
|
||||
claimed before links so they stay opaque, and the link pass rejected every
|
||||
candidate overlapping a claimed span, including one lying entirely inside the
|
||||
label. Spans contained in the label are permitted now, links act as
|
||||
containers when the tree is built, and partial overlaps or spans crossing the
|
||||
label boundary are still rejected.
|
||||
- Initially narrow tables reflow when the editor width shrinks instead of
|
||||
retaining stale image geometry until an unrelated full restyle.
|
||||
|
||||
## [0.11.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- **Ordered lists render their position.** An item's number is computed from its
|
||||
place in the run and painted over the source digits, so typing, deleting,
|
||||
merging and pasting renumber live. The `.md` file is never rewritten — the
|
||||
source stays valid CommonMark whatever it says. The whole source marker is
|
||||
hidden as one unit and the slot is kerned to the display width, so the dot
|
||||
travels with the digits at any digit count; the raw digits are revealed while
|
||||
they are edited.
|
||||
- `MarkdownEditorConfiguration.cursorFollowsSpanInk` (opt-in, off by default):
|
||||
the caret and the I-beam take the ink of the extension span they sit in. It
|
||||
matters for an extension that INVERTS its content — dark ink on a light block
|
||||
— where both cursors are otherwise drawn in the block's own color and
|
||||
disappear inside it. `InvertedIBeamCursor` recolors the live `NSCursor.iBeam`
|
||||
image, which keeps the system shape and the user's pointer size.
|
||||
|
||||
### Fixed
|
||||
- Find-in-document no longer erases other backgrounds. Clearing its highlights
|
||||
removed `.backgroundColor` across the whole document, which blanked extension
|
||||
spans, code fences and table cells until some unrelated restyle repainted
|
||||
them. Find now marks its own backgrounds and restyles only the paragraphs it
|
||||
touched.
|
||||
|
||||
### Performance
|
||||
- **Large notes open ~14× faster.** Measured on a 346k-char / 5,241-block note
|
||||
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
|
||||
away 440 ms → 115 ms. Styling is built on a detached string and transferred
|
||||
with one `setAttributedString` — per-key `addAttribute` on live TextKit-2
|
||||
storage left weak tombstones in Foundation's attribute-intern table, which
|
||||
turned quadratic. The restyle apply uses a paragraph overlap index above 32
|
||||
paragraphs, the redundant second full-document parse and the re-entrant
|
||||
full-document layout during rebuild are gone, and the SwiftMath render cache
|
||||
persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry).
|
||||
- **Editing long ordered lists.** One Return in an 800-item loose list:
|
||||
944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered
|
||||
list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans:
|
||||
0.134 ms → 0.002 ms per keystroke.
|
||||
|
||||
### Known limitations
|
||||
- A list item's continuation line is a paragraph and ends the run, so a
|
||||
multi-line item switches numbering off below it.
|
||||
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not
|
||||
classified as structural).
|
||||
- Ordered task items (`1. [ ] x`) consume a number but render none.
|
||||
|
||||
## [0.10.1] - 2026-07-22
|
||||
|
||||
### Added
|
||||
- Custom SF Symbols for task checkboxes: `MarkdownEditorConfiguration` accepts
|
||||
custom unchecked and checked symbols for `- [ ]` / `- [x]` task-list items
|
||||
(opt-in; the defaults are unchanged).
|
||||
|
||||
### Fixed
|
||||
- List markers no longer disappear while a selection covers them, and selecting
|
||||
a list item now reveals its raw marker syntax like other inline constructs.
|
||||
- An unclosed ``` fence no longer swallows the rest of the document: typing an
|
||||
opening fence above existing content left every block below it (tables,
|
||||
block LaTeX, thematic breaks, links) unrendered until the closing fence was
|
||||
typed. A fence now forms a code block only once its closing fence exists.
|
||||
|
||||
## [0.10.0] - 2026-07-15
|
||||
|
||||
### Added
|
||||
- **Extension seam**: opt-in constructs beyond pure markdown. A
|
||||
`MarkdownExtension` contributes an inline form (`==highlight==`), a fenced
|
||||
block form (`::: … :::`), or both — plus content attributes and an HTML
|
||||
wrapper for the clean-copy path; register instances via
|
||||
`MarkdownEditorConfiguration.extensions`. Extensions never emit ranges — the
|
||||
parser derives all geometry, so a misbehaving extension can at worst restyle
|
||||
its own construct. Marker/fence hiding, caret reveal, incremental restyle,
|
||||
table cells, and rich copy are handled generically. Registered extensions can
|
||||
change at runtime; all parse caches key on the registry.
|
||||
- `HighlightExtension` (`==text==`) and `StrikethroughExtension` (`~~text~~`),
|
||||
the former built-ins repackaged as extensions, and `ContainerExtension`
|
||||
(`::: … :::`), the first fenced block extension.
|
||||
|
||||
### Changed
|
||||
- **Breaking**: `==highlight==` and `~~strikethrough~~` are no longer part of
|
||||
the core grammar. Unregistered, the syntax stays literal text. To keep the
|
||||
previous behavior:
|
||||
`configuration.extensions = [HighlightExtension(), StrikethroughExtension()]`.
|
||||
The formatting actions (context menu, `applyHighlightRequest` /
|
||||
`applyStrikethroughRequest`) still insert/remove the markers either way;
|
||||
construct detection (toggle-off, selection state) requires the extension.
|
||||
|
||||
### Fixed
|
||||
- A pre-existing incremental-parse gap surfaced by the seam review:
|
||||
backspace-joining two paragraphs could leave transiently wrong styling
|
||||
(extra spacing or a stray emphasis/code span across the join) until the next
|
||||
edit re-parsed the region.
|
||||
|
||||
## [0.9.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorConfiguration.rawSourceMode`: present the document as raw
|
||||
Markdown source — no syntax hiding, no markdown styling, and no wiki-link
|
||||
display transform (`[[Name|UUID]]` shows verbatim). The editor keeps base
|
||||
font/paragraph styling and stays fully editable; smart Markdown input
|
||||
handling (list continuation, `$$`/`![[` auto-wrap, ⇧⇥ outdent) is disabled
|
||||
while raw. Runtime switching is supported and rebuilds the document
|
||||
immediately; the current document's undo stack is dropped on a switch
|
||||
because undo actions recorded against the other mode's display text would
|
||||
replay at stale ranges. Default `false` — existing embedders are unaffected.
|
||||
- Find & replace: two optional bus notifications, `replaceCurrent` (replace the
|
||||
focused match and advance) and `replaceAll` (replace every match in one undo
|
||||
step, back-to-front so ranges stay valid). Both edit the engine's displayed
|
||||
text with proper `shouldChangeText`/`didChangeText` undo registration and
|
||||
report the remaining count via `findResults`. Purely additive — embedders that
|
||||
don't set the bus names are unaffected.
|
||||
- Clean clipboard: ⌘C copies the selection as rich text (RTF + `com.apple.webarchive`)
|
||||
built from the AST rather than the raw storage form, and paste converts HTML to
|
||||
Markdown. Wiki-link `[[Name|UUID]]` side-channels no longer leak into copied text.
|
||||
|
||||
### Fixed
|
||||
- Find/jump scroll now works without a reading column. The TextKit 2
|
||||
fragment-enumeration scroll path (with `.ensuresLayout`) runs universally
|
||||
instead of only when `readingWidth` was set; the unreliable
|
||||
`NSTextView.scrollRangeToVisible` (which routes through the absent TextKit 1
|
||||
layout manager for off-screen content) is now only the last-resort fallback.
|
||||
- Inline syntax markers (`**`, `*`, `~~`, `==`) now use `mutedText` foreground
|
||||
color while the caret is inside the corresponding span, matching the existing
|
||||
behavior of inline code backticks and link/wiki-link brackets. This makes
|
||||
highlight `==` markers visually distinct from body text in edit state.
|
||||
- Web links `[text](url)` now share the wiki-link "edit zone": clicking the outer
|
||||
~30% of the link's first/last visible character places the caret just outside the
|
||||
markers (before `[` / after `)`) and reveals the source for editing instead of
|
||||
navigating, matching `[[…]]` behavior. Previously the edit zone only resolved
|
||||
`.wikiLink` tokens, so a web link dropped the caret between its brackets and did
|
||||
not reveal. Middle-of-link clicks still navigate; read-only links stay navigable.
|
||||
- Auto-linking (`NSDataDetector`) no longer linkifies a URL that sits inside a
|
||||
markdown or wiki link's own range. A link's `(url)` previously got its own
|
||||
competing `.link` attribute on top of the link — making the raw URL independently
|
||||
navigable and offsetting the click edit zone. Bare URLs outside links still
|
||||
autolink; URLs inside code were already excluded.
|
||||
- Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline
|
||||
autocomplete list.
|
||||
|
||||
### Contributors
|
||||
- Find/jump scroll fix and find & replace by @ChristineTham
|
||||
- Inline syntax-marker color fix by @sospartan
|
||||
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
|
||||
|
||||
## [0.8.0] - 2026-06-28
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorBus.findQuery` / `findResults`: query-based in-document find. The host posts a
|
||||
search string (+ current index) and the engine matches against its OWN displayed text,
|
||||
highlighting in display coordinates and posting the match count back. This is correct where the
|
||||
displayed text differs from the source — e.g. node links rendered shorter than `[[Name|UUID]]`,
|
||||
LaTeX, or images — which the legacy `findScrollToRange` (host-computed source-coordinate ranges)
|
||||
highlighted at the wrong offset. Opt-in; `findScrollToRange` is unchanged for existing embedders.
|
||||
- `NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?` — embedder-supplied
|
||||
predicate that suppresses the edit-mode I-beam cursor when the mouse is inside
|
||||
a defined exclusion zone (e.g. a formatting toolbar). When the closure returns
|
||||
`true`, `mouseMoved:` skips calling `super.mouseMoved` to avoid NSTextView's
|
||||
built-in I-beam cursor, setting the arrow cursor instead. Exposed through
|
||||
`NativeTextViewWrapper.isCursorExcluded`.
|
||||
- `NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?` —
|
||||
embedder hook to build the editor's right-click menu. The engine hands over the
|
||||
default `NSMenu` + the current selection; the embedder returns the menu to show
|
||||
(driving the `didMarkdown*` actions through the bus). Keeps the engine UI-free.
|
||||
- `==highlight==` inline markup: double-equals markers around text apply a
|
||||
background color (configurable via `MarkdownEditorTheme.highlightColor`,
|
||||
default `.systemOrange.withAlphaComponent(0.4)`). Content is recursively parsed so nested emphasis,
|
||||
code, etc. work inside highlights.
|
||||
- `MarkdownEditorBus.applyHighlightRequest` /
|
||||
`selectionHighlightDidChange`: bus notification names for driving a
|
||||
highlight toolbar button from host UI.
|
||||
- `MarkdownEditorBus` extended with nine new notification types for
|
||||
formatting toolbar integration: `applyStrikethroughRequest`,
|
||||
`applyInlineCodeRequest`, `applyBlockquoteRequest`,
|
||||
`applyUnorderedListRequest`, `applyOrderedListRequest`,
|
||||
`applyLinkRequest`, `applyCodeBlockRequest`,
|
||||
`applyHorizontalRuleRequest`, `applyImageRequest`. Embedders wire
|
||||
these into `NotificationCenter` to trigger formatting from
|
||||
external UI (toolbars, menus) without reaching into the editor's
|
||||
view hierarchy.
|
||||
- New formatting actions on the coordinator (callable directly or
|
||||
via the bus above): `didMarkdownStrikethrough`, `didMarkdownInlineCode`,
|
||||
`didMarkdownBlockquote`, `didMarkdownLink`, `didMarkdownCodeBlock`,
|
||||
`didMarkdownHorizontalRule`, `didMarkdownImage`.
|
||||
- Word-boundary detection in inline formatting: when the cursor is
|
||||
placed inside an English word with no active text selection, bold,
|
||||
italic, strikethrough, and inline-code actions now auto-select the
|
||||
containing word before wrapping. If no word character is adjacent
|
||||
to the cursor, empty markers are inserted as before. The cursor's
|
||||
relative offset within the word is preserved after wrapping
|
||||
(e.g. `wo|rd` → `**wo|rd**`).
|
||||
- Headless test suite for formatting actions (`FormattingActionTests`
|
||||
— 21 tests covering bold, strikethrough, inline code, blockquote,
|
||||
link, code block, horizontal rule, and image insertion).
|
||||
|
||||
### Changed
|
||||
- The engine no longer ships a built-in right-click "Format" context menu — menus
|
||||
are now embedder-supplied via `onBuildContextMenu` (above). The system rich-text
|
||||
"Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since
|
||||
those font traits don't apply to Markdown.
|
||||
|
||||
### Fixed
|
||||
- Blockquote removal no longer doubles trailing newlines when the
|
||||
original line already carries one.
|
||||
|
||||
## [0.7.1] - 2026-06-20
|
||||
|
||||
### Added
|
||||
- `MarkdownEditorConfiguration.heightBehavior` (`.scrolls` default / `.fitsContent`):
|
||||
in `.fitsContent` the editor grows to its content height and reports it to
|
||||
SwiftUI, so an enclosing `ScrollView` scrolls the page instead of a nested
|
||||
internal scroller. Opt-in, off by default — no change for existing embedders. (#75)
|
||||
- `BlockquoteStyle` configuration struct with `extraLineHeight` to control line
|
||||
spacing inside blockquotes, following the `ListStyle.extraLineHeight` /
|
||||
`ParagraphStyle.lineHeightExtraSpacing` pattern. Defaults to `0` (no extra
|
||||
spacing), preserving existing rendering. (#76)
|
||||
|
||||
### Fixed
|
||||
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a
|
||||
stale-small content-height measurement. (#71)
|
||||
- Inspector clip mask and caret reveal at the document end. (#73)
|
||||
- Scroll position is remembered per document across switches, and Writing Tools
|
||||
results stay styled and visible after accept. (#70)
|
||||
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
|
||||
|
||||
### Added
|
||||
- Scroll-away header: `NativeTextViewWrapper` gains `header: AnyView?`,
|
||||
`headerCollapsedHeight: CGFloat`, and `headerExpanded: Bool`. The engine
|
||||
hosts the supplied SwiftUI view above the document body, scrolling with
|
||||
it; collapsing animates the reserved band down to `headerCollapsedHeight`
|
||||
(the top row stays visible, lower rows clip away). The hosted content
|
||||
refreshes on every SwiftUI update and stays fully interactive. Composes
|
||||
with `readingWidth`. See the README's *Scrolling Header* section.
|
||||
|
||||
### Changed
|
||||
- The scroll view's `documentView` is now always an engine-internal
|
||||
container view (hosting the text view, the optional scroll-away header,
|
||||
and the reading column's breakout overlays) rather than sometimes the
|
||||
`NSTextView` itself. Embedders that reached into
|
||||
`scrollView.documentView` expecting an `NSTextView` must adapt — the
|
||||
document view's class was never API.
|
||||
- **Breaking**: The editor's enclosing scroll view no longer applies a
|
||||
hard-coded `top: 55.4` content inset. The default is now `0` on every
|
||||
edge, matching the most common embedding case where the editor fills
|
||||
its container exactly. Embedders that previously relied on the engine
|
||||
reserving header space (e.g. for a translucent toolbar) must opt in
|
||||
explicitly:
|
||||
|
||||
```swift
|
||||
var config = MarkdownEditorConfiguration.default
|
||||
config.safeAreaInsets = SafeAreaInsets(top: 55.4)
|
||||
```
|
||||
|
||||
### Added
|
||||
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
|
||||
inset knobs for the editor's enclosing scroll view, configurable via
|
||||
`MarkdownEditorConfiguration.safeAreaInsets`.
|
||||
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code blocks
|
||||
and inline `` `code` `` spans, completing the engine's existing
|
||||
spell-check suppression convention (links, wiki-links, LaTeX, and tables
|
||||
already carry the same attribute). The system spell-checker no longer
|
||||
underlines tokens inside code regions even when continuous spell
|
||||
checking is enabled.
|
||||
|
||||
### Fixed
|
||||
- Undo is now kept per `documentId`, so Cmd+Z keeps working after switching
|
||||
files. The single reused `NSTextView` previously wiped its undo manager on
|
||||
every document switch; the editor now vends a per-document `UndoManager`
|
||||
(via the new `undoManager(for:)` delegate method) whose undo/redo stack
|
||||
survives switching away and back. (#77)
|
||||
- A document's surviving undo stack is dropped when its text is reloaded
|
||||
*changed* while it was switched away (e.g. renaming a node rewrites the
|
||||
`[[label]]` in every file that links it), so Cmd+Z can no longer replay
|
||||
stale ranges against the rewritten content. (#78)
|
||||
- `NativeTextViewWrapper` keeps links clickable and text selectable
|
||||
when `isEditable: false`; `isSelectable` is no longer coupled to
|
||||
`isEditable`. (#31)
|
||||
- `NativeTextViewWrapper` now applies its initial styling pass even when
|
||||
the bound text starts at its final value (e.g. supplied as a SwiftUI
|
||||
`@State` initializer). Previously the editor would render the raw
|
||||
Markdown source until the user clicked into the document, because the
|
||||
coordinator's `lastSyncedText` already matched the bound text at first
|
||||
`updateNSView`. The early-return now also requires `didInitialFormatting`
|
||||
to be true, which only flips after the first styling pass completes.
|
||||
|
||||
### Added
|
||||
- Initial public API surface:
|
||||
- `NativeTextViewWrapper` — SwiftUI bridge for the AppKit-backed editor
|
||||
- `MarkdownEditorConfiguration` — every spacing / sizing / behavior knob
|
||||
- `MarkdownEditorTheme` — color palette, defaults to system colors
|
||||
- `MarkdownEditorServices` — container for the four service protocols
|
||||
- Service protocols: `WikiLinkResolver`, `EmbeddedImageProvider`,
|
||||
`SyntaxHighlighter`, `LatexRenderer`
|
||||
- No-op default implementations: `NoOpWikiLinkResolver`,
|
||||
`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`,
|
||||
`NoOpLatexRenderer`
|
||||
- `WikiLinkService` — bidirectional storage / display roundtrip helper
|
||||
- `PasteboardImageReader` — pasteboard image inspection helpers
|
||||
- Selection / replacement value types: `WikiLinkSelection`,
|
||||
`InlineSelectionState`, `InlineReplacementRequest`, `CodeBlockSelection`
|
||||
- `CodeBlockButton` — drop-in copy button overlay
|
||||
- DocC documentation catalog with landing page and topic groups
|
||||
- Triple-slash documentation comments on the full public API surface
|
||||
|
||||
[Unreleased]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.1...HEAD
|
||||
[0.7.1]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.0...0.7.1
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Contributing to MarkdownEngine
|
||||
|
||||
Thanks for your interest. **MarkdownEngine is maintained by one person —
|
||||
expect 1–2 weeks for review.** A pull request is the normal way in, for
|
||||
fixes, documentation and new extensions alike. If a change is large or
|
||||
architectural, open it as a draft PR with the design sketched in the
|
||||
description — that gets you an answer faster than describing it in prose.
|
||||
|
||||
> **New here?** Start with [ARCHITECTURE.md](ARCHITECTURE.md) — a
|
||||
> codemap that walks each directory in the order text flows through
|
||||
> the engine.
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nodes-app/swift-markdown-engine.git
|
||||
cd swift-markdown-engine
|
||||
swift build
|
||||
swift test
|
||||
```
|
||||
|
||||
Open `Package.swift` in Xcode for a graphical environment. The runnable
|
||||
demo is in `Demo/MarkdownEngineDemo.xcodeproj` — open and **Run** to see
|
||||
your changes against a real app target.
|
||||
|
||||
### Local DocC preview
|
||||
|
||||
Temporarily add the [swift-docc-plugin](https://github.com/swiftlang/swift-docc-plugin)
|
||||
to `Package.swift`, then `swift package --disable-sandbox preview-documentation
|
||||
--target MarkdownEngine`. It's intentionally not a permanent dependency — the
|
||||
core product stays free of optional tooling.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Include:
|
||||
|
||||
- A minimal reproducer (the smallest Markdown input + code that triggers
|
||||
it)
|
||||
- macOS, Xcode, and Swift versions
|
||||
- Expected vs. actual behavior
|
||||
|
||||
Screen recordings welcome.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One logical change per PR, branched from `main`
|
||||
- Tests for new tokenizer / styler / service / extension behavior in
|
||||
`Tests/MarkdownEngineTests/`
|
||||
- DocC comments for any public-API change; update `Demo/` if relevant
|
||||
- One-line entry in `CHANGELOG.md` under `[Unreleased]`
|
||||
- `swift build` and `swift test` must be green; CI runs the same checks
|
||||
|
||||
## Design constraints
|
||||
|
||||
Non-negotiable for the core `MarkdownEngine` target:
|
||||
|
||||
- **Don't add external dependencies to the core `MarkdownEngine`
|
||||
target.** App-specific behaviors plug in through the four service
|
||||
protocols (`WikiLinkResolver`, `EmbeddedImageProvider`,
|
||||
`SyntaxHighlighter`, `LatexRenderer`) instead. The two existing
|
||||
bridge products (`MarkdownEngineCodeBlocks` → HighlighterSwift,
|
||||
`MarkdownEngineLatex` → SwiftMath) are the deliberate exception so
|
||||
consumers can opt in. A new bridge or a new core dependency is a bigger
|
||||
call — make the case in the PR description.
|
||||
- **New constructs are extensions, not core grammar.** A construct like
|
||||
`==highlight==` (inline) or a `::: … :::` fenced block belongs in
|
||||
`Sources/MarkdownEngine/Extensions/` as a `MarkdownExtension` — see
|
||||
`HighlightExtension` / `ContainerExtension` as templates — never a new case
|
||||
threaded through the parser, styler, and renderer. This keeps the core pure
|
||||
markdown and each construct isolated. Image/overlay-rendered constructs
|
||||
(tables, math) are the exception — they still need core work.
|
||||
- **Public surface stays small.** Favor `internal`; new public symbols
|
||||
need a DocC comment.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Imperative subject, blank line, then a paragraph explaining *why*:
|
||||
|
||||
```
|
||||
Tokenize escaped backticks inside fenced code blocks
|
||||
|
||||
The previous tokenizer treated `\`` inside ``` … ``` as a token
|
||||
delimiter, which broke any code block containing escaped backtick
|
||||
examples. The new behavior matches CommonMark.
|
||||
```
|
||||
|
||||
The "what" is in the diff.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under
|
||||
the [Apache 2.0 License](LICENSE).
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */; };
|
||||
1A2B3C0000000000000005BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000005AA /* ContentView.swift */; };
|
||||
1A2B3C0000000000000006BB /* MDE.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C0000000000000006AA /* MDE.icon */; };
|
||||
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000007BB /* MarkdownEngine */; };
|
||||
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */; };
|
||||
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C0000000000000009AA /* MarkdownEngineLatex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MarkdownEngineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownEngineDemoApp.swift; sourceTree = "<group>"; };
|
||||
1A2B3C0000000000000005AA /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
1A2B3C0000000000000006AA /* MDE.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = MDE.icon; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
1A2B3C0000000000000003BB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000007CC /* MarkdownEngine in Frameworks */,
|
||||
1A2B3C0000000000000008BB /* MarkdownEngineCodeBlocks in Frameworks */,
|
||||
1A2B3C0000000000000009BB /* MarkdownEngineLatex in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
1A2B3C0000000000000001BB = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000001DD /* MarkdownEngineDemo */,
|
||||
1A2B3C0000000000000001CC /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C0000000000000001CC /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1A2B3C0000000000000001DD /* MarkdownEngineDemo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1A2B3C0000000000000004AA /* MarkdownEngineDemoApp.swift */,
|
||||
1A2B3C0000000000000005AA /* ContentView.swift */,
|
||||
1A2B3C0000000000000006AA /* MDE.icon */,
|
||||
);
|
||||
path = MarkdownEngineDemo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
1A2B3C0000000000000001EE /* MarkdownEngineDemo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */;
|
||||
buildPhases = (
|
||||
1A2B3C0000000000000003AA /* Sources */,
|
||||
1A2B3C0000000000000003BB /* Frameworks */,
|
||||
1A2B3C0000000000000003CC /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MarkdownEngineDemo;
|
||||
packageProductDependencies = (
|
||||
1A2B3C0000000000000007BB /* MarkdownEngine */,
|
||||
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */,
|
||||
1A2B3C0000000000000009AA /* MarkdownEngineLatex */,
|
||||
);
|
||||
productName = MarkdownEngineDemo;
|
||||
productReference = 1A2B3C0000000000000003DD /* MarkdownEngineDemo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
1A2B3C0000000000000001AA /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1500;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
1A2B3C0000000000000001EE = {
|
||||
CreatedOnToolsVersion = 15.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 1A2B3C0000000000000001BB;
|
||||
packageReferences = (
|
||||
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */,
|
||||
);
|
||||
productRefGroup = 1A2B3C0000000000000001CC /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1A2B3C0000000000000001EE /* MarkdownEngineDemo */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
1A2B3C0000000000000003CC /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000006BB /* MDE.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1A2B3C0000000000000003AA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A2B3C0000000000000004BB /* MarkdownEngineDemoApp.swift in Sources */,
|
||||
1A2B3C0000000000000005BB /* ContentView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
1A2B3C0000000000000002BB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C0000000000000002CC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = MDE;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "MarkdownEngine Demo";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nodes-app.MarkdownEngineDemo";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1A2B3C0000000000000002DD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1A2B3C0000000000000002EE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
1A2B3C0000000000000001FF /* Build configuration list for PBXNativeTarget "MarkdownEngineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C0000000000000002BB /* Debug */,
|
||||
1A2B3C0000000000000002CC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
1A2B3C0000000000000002AA /* Build configuration list for PBXProject "MarkdownEngineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1A2B3C0000000000000002DD /* Debug */,
|
||||
1A2B3C0000000000000002EE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
1A2B3C0000000000000007AA /* XCLocalSwiftPackageReference ".." */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ..;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
1A2B3C0000000000000007BB /* MarkdownEngine */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngine;
|
||||
};
|
||||
1A2B3C0000000000000008AA /* MarkdownEngineCodeBlocks */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineCodeBlocks;
|
||||
};
|
||||
1A2B3C0000000000000009AA /* MarkdownEngineLatex */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = MarkdownEngineLatex;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 1A2B3C0000000000000001AA /* Project object */;
|
||||
}
|
||||
Generated
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||
BuildableName = "MarkdownEngineDemo.app"
|
||||
BlueprintName = "MarkdownEngineDemo"
|
||||
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||
BuildableName = "MarkdownEngineDemo.app"
|
||||
BlueprintName = "MarkdownEngineDemo"
|
||||
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "1A2B3C0000000000000001EE"
|
||||
BuildableName = "MarkdownEngineDemo.app"
|
||||
BlueprintName = "MarkdownEngineDemo"
|
||||
ReferencedContainer = "container:MarkdownEngineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,351 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Nicolas von Mallinckrodt on 29.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
|
||||
// Optional bridge products. Each is independent — drop either of these
|
||||
// `#if` blocks (or remove the matching Swift Package product dependency
|
||||
// from the Xcode project) and the demo still compiles. Code blocks fall
|
||||
// back to plain monospace; LaTeX falls back to its raw `$…$` source.
|
||||
#if canImport(MarkdownEngineCodeBlocks)
|
||||
import MarkdownEngineCodeBlocks
|
||||
#endif
|
||||
#if canImport(MarkdownEngineLatex)
|
||||
import MarkdownEngineLatex
|
||||
#endif
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var text: String = sampleMarkdown
|
||||
|
||||
// Engine modes, flipped live from the toolbar.
|
||||
@State private var isReadOnly = false
|
||||
@State private var showRawSource = false
|
||||
@State private var useReadingColumn = false
|
||||
|
||||
// Base font size; all relative sizing (headings, code, math) tracks it.
|
||||
@State private var fontSize: CGFloat = 16
|
||||
|
||||
// Scroll-away header demo.
|
||||
@State private var showHeader = false
|
||||
@State private var headerExpanded = true
|
||||
|
||||
var body: some View {
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
configuration: configuration,
|
||||
fontSize: fontSize,
|
||||
isEditable: !isReadOnly,
|
||||
placeholder: NSAttributedString(
|
||||
string: "Empty document — start typing, markdown styles live…",
|
||||
attributes: [
|
||||
.font: NSFont.systemFont(ofSize: fontSize),
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
]
|
||||
),
|
||||
header: showHeader ? AnyView(demoHeader) : nil,
|
||||
headerCollapsedHeight: 40,
|
||||
headerExpanded: headerExpanded
|
||||
)
|
||||
// `readingWidth` is applied when the underlying NSView is built, so
|
||||
// flipping the reading column recreates the editor via `.id`. The
|
||||
// `text` binding survives; scroll position resets — fine for a demo.
|
||||
.id(useReadingColumn)
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
Toggle(isOn: $isReadOnly) {
|
||||
Label("Read-only", systemImage: isReadOnly ? "lock" : "lock.open")
|
||||
}
|
||||
.help("Read-only: the styled document stays scrollable and selectable, editing is off")
|
||||
|
||||
Toggle(isOn: $showRawSource) {
|
||||
Label("Raw source", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||
}
|
||||
.help("Raw markdown source: no styling, no syntax hiding")
|
||||
|
||||
Toggle(isOn: $useReadingColumn) {
|
||||
Label("Reading column", systemImage: "arrow.right.and.line.vertical.and.arrow.left")
|
||||
}
|
||||
.help("Centered fixed-width reading column — wide tables still break out to full width")
|
||||
|
||||
ControlGroup {
|
||||
Button {
|
||||
fontSize = max(10, fontSize - 2)
|
||||
} label: {
|
||||
Label("Smaller text", systemImage: "textformat.size.smaller")
|
||||
}
|
||||
.disabled(fontSize <= 10)
|
||||
|
||||
Button {
|
||||
fontSize = min(28, fontSize + 2)
|
||||
} label: {
|
||||
Label("Larger text", systemImage: "textformat.size.larger")
|
||||
}
|
||||
.disabled(fontSize >= 28)
|
||||
}
|
||||
.help("Base font size — headings, code, and math scale relative to it")
|
||||
|
||||
Menu {
|
||||
Toggle("Show header", isOn: $showHeader)
|
||||
Toggle("Expanded", isOn: $headerExpanded)
|
||||
.disabled(!showHeader)
|
||||
} label: {
|
||||
Label("Header", systemImage: "rectangle.topthird.inset.filled")
|
||||
}
|
||||
.help("Scroll-away header: an embedder-supplied SwiftUI view hosted above the document")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample scroll-away header: a fixed top row (kept visible when collapsed)
|
||||
/// plus detail rows that reveal/hide with the `headerExpanded` toggle.
|
||||
private var demoHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Scroll-away header").font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.frame(height: 40) // == headerCollapsedHeight: the always-visible row
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("These rows clip away when the header collapses.")
|
||||
Text("The header scrolls with the document body and stays fully interactive.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
/// The engine talks to your app through service protocols. Two of them —
|
||||
/// `SyntaxHighlighter` and `LatexRenderer` — render the code-block and
|
||||
/// LaTeX visuals. The base `MarkdownEngine` ships no-op defaults
|
||||
/// (plain monospace, raw `$…$`); the optional `MarkdownEngineCodeBlocks`
|
||||
/// and `MarkdownEngineLatex` products ship ready-made bridges backed by
|
||||
/// HighlighterSwift and SwiftMath respectively.
|
||||
///
|
||||
/// This demo opportunistically plugs in whichever bridges are linked,
|
||||
/// so you can see exactly what each one adds.
|
||||
private var configuration: MarkdownEditorConfiguration {
|
||||
var config = MarkdownEditorConfiguration.default
|
||||
|
||||
#if canImport(MarkdownEngineCodeBlocks)
|
||||
// Syntax highlighting for fenced code blocks. Auto-switches between
|
||||
// `atom-one-light` and `atom-one-dark` with system appearance.
|
||||
config.services.syntaxHighlighter = HighlighterSwiftBridge()
|
||||
#endif
|
||||
|
||||
#if canImport(MarkdownEngineLatex)
|
||||
// LaTeX rendering for `$inline$` and `$$block$$` math. Uses the
|
||||
// Latin Modern math font and tints formulas to match the theme.
|
||||
config.services.latex = SwiftMathBridge()
|
||||
#endif
|
||||
|
||||
// Opt-in constructs beyond pure markdown. The core engine no longer
|
||||
// knows `==highlight==` or `~~strikethrough~~` — they are extensions
|
||||
// you register. Unregistered syntax stays literal text.
|
||||
config.extensions = [HighlightExtension(), StrikethroughExtension()]
|
||||
|
||||
// Toolbar-driven modes.
|
||||
config.rawSourceMode = showRawSource
|
||||
config.readingWidth = useReadingColumn ? 620 : nil
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the demo markdown shown when the editor first loads.
|
||||
///
|
||||
/// The text is composed from a fixed header/footer plus feature sections.
|
||||
/// Three of them — inline formatting, block math, and code — swap between
|
||||
/// a full showcase and a short "feature unavailable" note depending on
|
||||
/// which optional bridge products are linked.
|
||||
///
|
||||
/// When a bridge is missing, the fallback links to the README section
|
||||
/// that explains how to enable that feature in your own app.
|
||||
private var sampleMarkdown: String {
|
||||
[
|
||||
markdownHeader,
|
||||
inlineFormattingSection,
|
||||
blocksSection,
|
||||
taskListSection,
|
||||
extensionSection,
|
||||
tableSection,
|
||||
latexSection,
|
||||
codeSection,
|
||||
markdownFooter,
|
||||
].joined(separator: "\n\n")
|
||||
}
|
||||
|
||||
/// Blockquote + list demo: quotes keep inline styling; lists auto-continue
|
||||
/// on Return, renumber, and change nesting with Tab / Shift-Tab.
|
||||
private let blocksSection = """
|
||||
## Blockquotes & lists
|
||||
|
||||
> Blockquotes keep full **inline** styling — and quote markers hide like every other marker.
|
||||
|
||||
Lists auto-continue on Return; Tab and Shift-Tab move the nesting level:
|
||||
|
||||
- Unordered lists
|
||||
- nest two spaces per level
|
||||
- up to three levels deep
|
||||
|
||||
1. Ordered lists renumber as you edit
|
||||
2. and auto-continue too
|
||||
"""
|
||||
|
||||
/// Task-list demo: click a checkbox to toggle it. The glyphs are SF Symbols;
|
||||
/// embedders can swap them via `TaskCheckboxStyle` (`config.taskCheckbox`).
|
||||
private let taskListSection = """
|
||||
## Task lists
|
||||
|
||||
- [x] Draw checkboxes as SF Symbols
|
||||
- [ ] Click a box to toggle it
|
||||
- [ ] Ship it
|
||||
"""
|
||||
|
||||
/// Extension seam demo: `==highlight==` and `~~strikethrough~~` are NOT part
|
||||
/// of the core grammar anymore — they're supplied by the opt-in
|
||||
/// `HighlightExtension` and `StrikethroughExtension` registered above.
|
||||
private let extensionSection = """
|
||||
## Extensions
|
||||
|
||||
The engine core parses pure markdown; extra constructs are opt-in extensions. \
|
||||
This ==highlighted text== comes from `HighlightExtension`, and this \
|
||||
~~struck-through text~~ from `StrikethroughExtension`. Unregistered, the exact \
|
||||
same characters would stay literal markdown. Nesting works too: \
|
||||
==with *italic* inside== and ~~also *nested*~~.
|
||||
"""
|
||||
|
||||
/// Table layout demo: the first table's cells WRAP to the available width
|
||||
/// (CSS auto-layout style); the second has so many columns that even the
|
||||
/// longest-word minimums don't fit — it stays wide and scrolls horizontally.
|
||||
private let tableSection = """
|
||||
## Tables
|
||||
|
||||
Cells wrap to the available width:
|
||||
|
||||
| Novel | Opening line |
|
||||
|---|---|
|
||||
| Der Zauberberg (1924) | "Ein einfacher junger Mensch reiste im Hochsommer von Hamburg, seiner Vaterstadt, nach Davos-Platz im Graubündischen." |
|
||||
| The Master and Margarita (1966–67) | "At the sunset hour of one warm spring day two men were to be seen at Patriarch's Ponds." (trans. Michael Glenny) |
|
||||
| The Picture of Dorian Gray (1890) | "The studio was filled with the rich odour of roses, and when the light summer wind stirred amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn." |
|
||||
|
||||
Too many columns → horizontal scroll instead of crushed cells:
|
||||
|
||||
| Movement | Landmark novel | Narrative signature | Characteristic preoccupations | Philosophical undercurrents | Contemporaneous reception | Posthumous reputation | Author | Structural device | Central symbol | Typical setting | Enduring influence |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Modernism | Der Zauberberg | Essayistic time-dilation | Sanatorium cosmopolitanism | Schopenhauer-inflected pessimism | Immediate bestseller | Cornerstone of literary modernism | Thomas Mann | Bildungsroman inversion | The mountain as timeless enclosure | Alpine sanatorium | Shaped the European novel of ideas |
|
||||
| Menippean satire | The Master and Margarita | Novel-within-a-novel | Cowardice and censorship | Faustian epigraph | Suppressed, samizdat-circulated | Perennial Russian favorite | Mikhail Bulgakov | Interleaved dual narratives | The devil as satirical mirror | Soviet Moscow and biblical Jerusalem | Model for satire under censorship |
|
||||
| Aestheticism | The Picture of Dorian Gray | Epigrammatic wit | Portrait-as-conscience | Paterian hedonism | Scandalized reviewers | Perpetually adapted | Oscar Wilde | Portrait as moral ledger | The aging portrait | Fin de siècle London | Touchstone for art for art’s sake |
|
||||
"""
|
||||
|
||||
private let markdownHeader = """
|
||||
# MarkdownEngine
|
||||
|
||||
A native macOS Markdown editor built on **TextKit 2**, bridged to SwiftUI — brought to you by [nodes-web.com](https://nodes-web.com).
|
||||
|
||||
Edit this text live. Formatting updates as you type — and the toolbar flips engine modes at runtime: read-only, raw markdown source, and a centered reading column.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
/// Inline formatting demo. Drops the inline-LaTeX example sentence when
|
||||
/// the LaTeX bridge isn't linked, so the reader doesn't see raw `$…$`.
|
||||
private var inlineFormattingSection: String {
|
||||
#if canImport(MarkdownEngineLatex)
|
||||
return #"""
|
||||
## Inline formatting
|
||||
|
||||
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps. Inline math fits naturally in prose — the Pythagorean identity says $a^2 + b^2 = c^2$, and Euler's identity famously claims $e^{i\pi} + 1 = 0$.
|
||||
"""#
|
||||
#else
|
||||
return """
|
||||
## Inline formatting
|
||||
|
||||
Mix **bold**, *italic*, and ***both at once***. Reach for `inline code` when a short snippet helps.
|
||||
"""
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Block LaTeX demo when the `MarkdownEngineLatex` bridge is linked;
|
||||
/// otherwise a short note pointing to the README section that explains
|
||||
/// how to enable LaTeX rendering.
|
||||
private var latexSection: String {
|
||||
#if canImport(MarkdownEngineLatex)
|
||||
return #"""
|
||||
## Block math
|
||||
|
||||
$$
|
||||
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{\partial}{\partial t}\Psi(\mathbf{r}, t) = -\frac{i}{\hbar}\hat{H}\,\Psi(\mathbf{r}, t)
|
||||
$$
|
||||
"""#
|
||||
#else
|
||||
return """
|
||||
## LaTeX
|
||||
|
||||
LaTeX (`$inline$` and `$$block$$`) is parsed but not rendered without the optional `MarkdownEngineLatex` product. See [LaTeX Rendering](https://github.com/nodes-app/swift-markdown-engine#latex-rendering) in the README to wire it up.
|
||||
"""
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Fenced code-block demo when the `MarkdownEngineCodeBlocks` bridge is
|
||||
/// linked; otherwise a plain monospace example and a link to the
|
||||
/// README's Code Blocks section.
|
||||
private var codeSection: String {
|
||||
#if canImport(MarkdownEngineCodeBlocks)
|
||||
return #"""
|
||||
## Code
|
||||
|
||||
Swift, with syntax highlighting:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
|
||||
struct Editor: View {
|
||||
@State private var text = "# Hello"
|
||||
|
||||
var body: some View {
|
||||
NativeTextViewWrapper(text: $text)
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And a little JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"engine": "MarkdownEngine",
|
||||
"features": ["latex", "code", "wiki-links"],
|
||||
"version": 1.0
|
||||
}
|
||||
```
|
||||
"""#
|
||||
#else
|
||||
return #"""
|
||||
## Code
|
||||
|
||||
Fenced code blocks render as plain monospace without the optional `MarkdownEngineCodeBlocks` product. See [Code Blocks](https://github.com/nodes-app/swift-markdown-engine#code-blocks) in the README for syntax-highlighted output:
|
||||
|
||||
```swift
|
||||
let greeting = "Hello, world!"
|
||||
```
|
||||
"""#
|
||||
#endif
|
||||
}
|
||||
|
||||
private let markdownFooter = """
|
||||
---
|
||||
|
||||
"""
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1869_2042)">
|
||||
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#E0A347"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1869_2042">
|
||||
<rect width="173" height="185" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1869_2051)">
|
||||
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#D1403F"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1869_2051">
|
||||
<rect width="173" height="185" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+10
@@ -0,0 +1,10 @@
|
||||
<svg width="173" height="185" viewBox="0 0 173 185" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1869_2047)">
|
||||
<path d="M84.1452 185H85.3003C97.6509 185 102.36 181.114 102.36 168.928V109.41L90.8093 115.68L142.878 145.616C146.788 147.646 149.897 148.971 152.741 148.971C157.628 148.971 161.004 145.616 165.091 139.523L165.714 138.552C168.38 134.577 169.712 130.957 169.712 127.955C169.712 123.009 166.247 119.301 160.116 115.945L107.07 85.4796V98.1072L160.027 69.0549C166.247 65.7875 169.445 62.167 169.445 57.2219C169.445 54.2195 168.113 50.7756 165.891 46.8019L165.27 45.6539C161.271 39.2076 157.628 35.9403 152.83 35.9403C149.987 35.9403 146.698 37.2649 142.79 39.2959L90.8981 69.2315L102.36 75.5011V16.0716C102.36 3.88544 97.6509 0 85.3003 0H84.1452C71.7944 0 67.0852 3.88544 67.0852 16.0716V75.5011L78.814 69.2315L26.7452 39.2959C22.8356 37.0883 19.1926 35.6754 16.1715 35.6754C11.2845 35.6754 7.28607 39.031 4.17616 45.8305L3.64304 46.8019C1.68824 50.8639 0.444272 54.3961 0.444272 57.3986C0.444272 62.0787 3.37647 65.6992 9.59631 69.1431L62.6425 98.1072V85.4796L9.50741 115.945C3.37647 119.301 0 122.922 0 127.778C0 130.78 1.42167 134.401 3.73189 138.463L4.26502 139.258C7.9969 145.704 11.5511 149.06 16.6158 149.06C19.4591 149.06 22.8356 147.646 26.6564 145.616L78.814 115.68L67.0852 109.41V168.928C67.0852 181.114 71.7944 185 84.1452 185Z" fill="#056CC1"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1869_2047">
|
||||
<rect width="173" height="185" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"groups" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"blur-material" : 0.5,
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : "automatic",
|
||||
"image-name" : "staroflife.fill 2-1.svg",
|
||||
"name" : "staroflife.fill 2-1",
|
||||
"position" : {
|
||||
"scale" : 3,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
90
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "staroflife.fill 2.svg",
|
||||
"name" : "staroflife.fill 2",
|
||||
"position" : {
|
||||
"scale" : 3,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"image-name" : "staroflife.fill 1.svg",
|
||||
"name" : "staroflife.fill 1",
|
||||
"position" : {
|
||||
"scale" : 3,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-90
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.45
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// MarkdownEngineDemoApp.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Nicolas von Mallinckrodt on 29.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MarkdownEngineDemoApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup("MarkdownEngine Demo") {
|
||||
ContentView()
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2026] [Luca Chen]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// MarkdownEngine — a TextKit-2 backed Markdown editor view for macOS.
|
||||
//
|
||||
// Embedders import `MarkdownEngine` and supply their own adapters that
|
||||
// conform to the engine's service protocols (`WikiLinkResolver`,
|
||||
// `EmbeddedImageProvider`, `SyntaxHighlighter`, `LatexRenderer`). The engine
|
||||
// itself has zero external dependencies.
|
||||
//
|
||||
// Users who want turnkey adapters for the two highest-friction protocols
|
||||
// (code-block styling/highlighting, LaTeX rendering) can additionally
|
||||
// depend on the `MarkdownEngineCodeBlocks` and/or `MarkdownEngineLatex`
|
||||
// products, which ship pre-built bridges backed by HighlighterSwift and
|
||||
// SwiftMath respectively. Both products are opt-in: the core
|
||||
// `MarkdownEngine` library stays free of those transitive dependencies
|
||||
// at link time.
|
||||
let package = Package(
|
||||
name: "MarkdownEngine",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "MarkdownEngine", targets: ["MarkdownEngine"]),
|
||||
.library(name: "MarkdownEngineCodeBlocks", targets: ["MarkdownEngineCodeBlocks"]),
|
||||
.library(name: "MarkdownEngineLatex", targets: ["MarkdownEngineLatex"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.0.0"),
|
||||
.package(url: "https://github.com/mgriebling/SwiftMath", from: "1.7.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "MarkdownEngine"),
|
||||
.target(
|
||||
name: "MarkdownEngineCodeBlocks",
|
||||
dependencies: [
|
||||
"MarkdownEngine",
|
||||
.product(name: "Highlighter", package: "HighlighterSwift"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "MarkdownEngineLatex",
|
||||
dependencies: [
|
||||
"MarkdownEngine",
|
||||
.product(name: "SwiftMath", package: "SwiftMath"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "MarkdownEngineTests",
|
||||
dependencies: ["MarkdownEngine"]
|
||||
)
|
||||
]
|
||||
)
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
<p align="center">
|
||||
<img width="128" alt="SwiftMarkdownEngine logo" src="media/logo.png" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">SwiftMarkdownEngine</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-5.9+-F05138?logo=swift&logoColor=white" alt="Swift 5.9+" /></a>
|
||||
<a href="https://developer.apple.com/macos/"><img src="https://img.shields.io/badge/Platforms-macOS%2014+-lightgrey" alt="Platforms macOS 14+" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-yellow.svg" alt="License: Apache 2.0" /></a>
|
||||
<a href="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml"><img src="https://github.com/nodes-app/swift-markdown-engine/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
<video src="https://github.com/user-attachments/assets/b61ed622-0e9a-4e91-9de5-9cd6c53752e5"
|
||||
autoplay loop muted playsinline
|
||||
width="100%">
|
||||
</video>
|
||||
|
||||
|
||||
A native AppKit Markdown editor for macOS, built on TextKit 2 and bridged to SwiftUI. It is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**, a macOS notes app. Live styling, wiki-link support, fenced code blocks with syntax highlighting, LaTeX rendering, embedded images, and GitHub-style task
|
||||
checkboxes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live Markdown styling** — bold, italic, headings, lists, blockquotes, GFM tables, code, links, task checkboxes, horizontal rules
|
||||
- **Extensions** — opt-in constructs beyond CommonMark (`==highlight==`, `~~strikethrough~~`, …); add your own via [`MarkdownExtension`](#extensions)
|
||||
- **Wiki-style linking** with two-form storage / display roundtripping
|
||||
(`[[Name|<id>]]` ↔ `[[Name]]`)
|
||||
- **Image embeds** — both `![[Name]]` (Obsidian-style, embedder supplies the
|
||||
bytes) and standard Markdown ``
|
||||
- **LaTeX** — both block (`$$ … $$`) and inline (`$…$`), embedder supplies
|
||||
the renderer
|
||||
- **Code blocks** with embedder-supplied syntax highlighting and overlayable
|
||||
copy buttons
|
||||
- **Reading column** — opt-in fixed-width centered column, wide tables
|
||||
break out to the full window width (`readingWidth`)
|
||||
- **Scroll-away header** — host your own SwiftUI view above the document;
|
||||
it scrolls with the content and collapses to a pinned top row
|
||||
- **TextKit 2** layout for accurate, modern text rendering
|
||||
- **Writing Tools** integration on macOS 15.1+
|
||||
- **Comfortable bottom overscroll** so the caret never pins to the viewport
|
||||
edge while typing
|
||||
- **Drag-select autoscroll boost** for long documents
|
||||
- **Spelling & grammar** with code/LaTeX/wiki-link suppression
|
||||
|
||||
## Installation
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/nodes-app/swift-markdown-engine", from: "0.1.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "YourApp",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownEngine", package: "swift-markdown-engine"),
|
||||
]
|
||||
)
|
||||
]
|
||||
```
|
||||
|
||||
Or in Xcode: **File → Add Package Dependencies…** and paste the repo URL.
|
||||
|
||||
The package ships three library products — add only what you need:
|
||||
|
||||
| Product | Use when |
|
||||
|---|---|
|
||||
| `MarkdownEngine` | You want the editor only. Zero external dependencies. |
|
||||
| `MarkdownEngineCodeBlocks` | You want the full visual code-block experience — background fill, monospace font, and syntax highlighting — without writing your own bridge. Pulls in [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) transitively. See [Customization → Code Blocks](#code-blocks). |
|
||||
| `MarkdownEngineLatex` | You want LaTeX formula rendering without writing your own bridge. Pulls in [SwiftMath](https://github.com/mgriebling/SwiftMath) transitively. See [Customization → LaTeX Rendering](#latex-rendering). |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import MarkdownEngine
|
||||
|
||||
struct EditorScreen: View {
|
||||
@State private var text: String = "# Hello, *world*"
|
||||
|
||||
var body: some View {
|
||||
NativeTextViewWrapper(text: $text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it. See [Customization](#customization) below for syntax
|
||||
highlighting, themes, wiki-link state, and more.
|
||||
|
||||
> **Displaying multiple editors?** Pass a stable, unique
|
||||
> `documentId: "your-doc-id"` so undo history and pending replacements
|
||||
> stay scoped to each editor instance.
|
||||
|
||||
## Customization
|
||||
|
||||
### Service Protocols
|
||||
|
||||
The engine talks to your app through four service protocols, each with
|
||||
a no-op default so you only implement what you actually need:
|
||||
|
||||
| Protocol | What you supply | Ready-made bridge / suggested library |
|
||||
|---|---|---|
|
||||
| `WikiLinkResolver` | Resolve a `[[Name]]` to a stable opaque id | (your data model) |
|
||||
| `EmbeddedImageProvider` | Look up an `NSImage` for `![[Name]]` | (your asset store) |
|
||||
| `SyntaxHighlighter` | Highlight code blocks for a given language | **`HighlighterSwiftBridge`** ([recommended](#code-blocks)) — built on [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) |
|
||||
| `LatexRenderer` | Render a LaTeX string to an `NSImage` | **`SwiftMathBridge`** ([recommended](#latex-rendering)) — built on [SwiftMath](https://github.com/mgriebling/SwiftMath) |
|
||||
|
||||
Implement what you need and pass it through `MarkdownEditorServices`:
|
||||
|
||||
```swift
|
||||
struct MyResolver: WikiLinkResolver {
|
||||
func resolve(displayName: String, range: NSRange) -> WikiLinkResolution? {
|
||||
myIndex[displayName].map { WikiLinkResolution(id: $0, exists: true) }
|
||||
}
|
||||
}
|
||||
|
||||
configuration.services = MarkdownEditorServices(
|
||||
wikiLinks: MyResolver()
|
||||
// images, syntaxHighlighter, latex omitted → no-op defaults
|
||||
)
|
||||
```
|
||||
|
||||
Each protocol and its no-op default are documented in DocC.
|
||||
|
||||
### Extensions
|
||||
|
||||
The core engine parses pure markdown. Extra constructs like `==highlight==`,
|
||||
`~~strikethrough~~`, and `::: … :::` container blocks are opt-in extensions:
|
||||
|
||||
```swift
|
||||
var config = MarkdownEditorConfiguration()
|
||||
config.extensions = [HighlightExtension(), StrikethroughExtension(), ContainerExtension()]
|
||||
```
|
||||
|
||||
Unregistered syntax stays literal text. An extension contributes an inline
|
||||
form (`InlineSyntax`), a fenced block form (`BlockSyntax`), or both — plus the
|
||||
attributes for its content and an HTML wrapper for rich copy. The parser owns
|
||||
all geometry, marker/fence hiding, caret reveal, and incremental restyling, so
|
||||
extensions behave identically to built-ins and cannot affect neighboring
|
||||
constructs. Conform to `MarkdownExtension` to add your own.
|
||||
|
||||
### Code Blocks
|
||||
|
||||
**Recommended path: depend on the `MarkdownEngineCodeBlocks` product
|
||||
and use the bundled `HighlighterSwiftBridge`.** Rolling your own
|
||||
`SyntaxHighlighter` has subtle footguns the bridge already handles —
|
||||
line-height metrics across light/dark themes, appearance-change
|
||||
observation, layout-pass timing, font name extraction from the theme,
|
||||
and CSS-theme-derived background colors. Use the bundle unless you
|
||||
specifically need a non-HighlighterSwift library.
|
||||
|
||||
```swift
|
||||
import MarkdownEngineCodeBlocks
|
||||
|
||||
var configuration = MarkdownEditorConfiguration.default
|
||||
configuration.services = MarkdownEditorServices(
|
||||
syntaxHighlighter: HighlighterSwiftBridge()
|
||||
)
|
||||
```
|
||||
|
||||
The bridge auto-switches between `atom-one-light` and `atom-one-dark`
|
||||
with system appearance. Different theme names or a pinned single theme
|
||||
are configurable via init params — see DocC.
|
||||
|
||||
Need a different highlighter library entirely? Implement
|
||||
`SyntaxHighlighter` yourself (see [Service Protocols](#service-protocols)
|
||||
above for the declaration) and reference the bundled bridge in
|
||||
`Sources/MarkdownEngineCodeBlocks/` as a working example.
|
||||
|
||||
### LaTeX Rendering
|
||||
|
||||
**Recommended path: depend on the `MarkdownEngineLatex` product and use
|
||||
the bundled `SwiftMathBridge`.** Hand-rolling a `LatexRenderer` has
|
||||
real footguns the bridge already handles — appearance-aware text color,
|
||||
zero-sized output guards (`lockFocus` crashes on 0×0 images),
|
||||
window-vs-NSApp appearance distinction, single-letter padding, and an
|
||||
internal cache keyed by (latex, font size, appearance, theme color).
|
||||
|
||||
```swift
|
||||
import MarkdownEngineLatex
|
||||
|
||||
var configuration = MarkdownEditorConfiguration.default
|
||||
configuration.services = MarkdownEditorServices(
|
||||
latex: SwiftMathBridge()
|
||||
)
|
||||
```
|
||||
|
||||
The bridge uses the Latin Modern math font and tints formulas with
|
||||
`MarkdownEditorTheme.latexLightModeText` / `latexDarkModeText`. Pass
|
||||
`singleLetterPaddingBottom:` to override the engine's matching default.
|
||||
|
||||
### Theming
|
||||
|
||||
Every color the editor puts on screen reads from `MarkdownEditorTheme`:
|
||||
|
||||
```swift
|
||||
var theme = MarkdownEditorTheme.default
|
||||
theme.bodyText = .labelColor
|
||||
theme.findMatchHighlight = NSColor(named: "MyAccent")!
|
||||
|
||||
var configuration = MarkdownEditorConfiguration.default
|
||||
configuration.theme = theme
|
||||
```
|
||||
|
||||
Defaults map to `NSColor` dynamic system colors, so light/dark mode
|
||||
keeps working without extra code.
|
||||
|
||||
### Tuning
|
||||
|
||||
`MarkdownEditorConfiguration` exposes every spacing / sizing / behavior
|
||||
knob the engine has, grouped by concern:
|
||||
|
||||
```swift
|
||||
var configuration = MarkdownEditorConfiguration.default
|
||||
configuration.codeBlock.fontSizeScale = 0.9
|
||||
configuration.headings.fontMultipliers = [2.4, 1.8, 1.4, 1.1, 0.9, 0.75]
|
||||
configuration.overscroll.percent = 0.4
|
||||
configuration.lists.helpersEnabled = false
|
||||
configuration.safeAreaInsets = SafeAreaInsets(top: 56) // headroom under a translucent toolbar
|
||||
```
|
||||
|
||||
### Wiki-Links & Replacement State
|
||||
|
||||
Two optional bindings on `NativeTextViewWrapper` let you observe
|
||||
wiki-link state and push inline replacements programmatically. Pass
|
||||
only what you need — each is independent and defaults to a no-op:
|
||||
|
||||
```swift
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
isWikiLinkActive: $isWikiLinkActive,
|
||||
pendingInlineReplacement: $pendingReplacement
|
||||
)
|
||||
```
|
||||
|
||||
- `isWikiLinkActive` — the wrapper sets this to `true` while the caret
|
||||
sits inside a `[[Name]]` link, so you can present a contextual UI.
|
||||
- `pendingInlineReplacement` — assign a non-nil value to push a
|
||||
replacement (e.g. an autocomplete result); the engine consumes it
|
||||
and clears the binding.
|
||||
|
||||
### Height Behavior
|
||||
|
||||
By default the editor scrolls internally. Set `heightBehavior` to
|
||||
`.fitsContent` to make it grow to fit its content and report that height to
|
||||
SwiftUI, so an enclosing `ScrollView` scrolls the page instead:
|
||||
|
||||
```swift
|
||||
ScrollView {
|
||||
NativeTextViewWrapper(text: $text, configuration: .init(heightBehavior: .fitsContent))
|
||||
}
|
||||
```
|
||||
|
||||
Composes with `readingWidth` and the scrolling header, and is switchable at
|
||||
runtime. `.fitsContent` lays out the whole document (no viewport
|
||||
virtualization), so prefer it for small-to-medium content. See
|
||||
``HeightBehavior`` in DocC for the full behavior.
|
||||
|
||||
### Reading Column
|
||||
|
||||
Give long documents a fixed-width centered column; wide GFM tables break out
|
||||
to the full window width, Google-Docs-style:
|
||||
|
||||
```swift
|
||||
configuration.readingWidth = 650
|
||||
```
|
||||
|
||||
Text wraps at `readingWidth` and never re-wraps on resize (only the column's
|
||||
position moves), keeping live resize smooth. Leave it `nil` (default) to fill
|
||||
the container edge-to-edge.
|
||||
|
||||
### Scrolling Header
|
||||
|
||||
Host a SwiftUI view above the document body that scrolls away with it —
|
||||
metadata, a property table, a contextual toolbar:
|
||||
|
||||
```swift
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
header: AnyView(MyDocumentHeader(document: document)),
|
||||
headerCollapsedHeight: 40,
|
||||
headerExpanded: isHeaderExpanded
|
||||
)
|
||||
```
|
||||
|
||||
The engine hosts it in an `NSHostingView`, reserves its intrinsic height, and
|
||||
keeps it fully interactive. `headerExpanded: false` collapses to
|
||||
`headerCollapsedHeight` (top row stays, rows below clip away, animated). Inject
|
||||
any required environment *before* wrapping in `AnyView`, and give wrapping
|
||||
content an explicit height so it doesn't clip at the band's bottom. Composes
|
||||
with `readingWidth`; an optional `placeholder:` shows ghost text while empty;
|
||||
`header: nil` (default) adds nothing. The demo's **Header** toggle shows it.
|
||||
|
||||
## Demo
|
||||
|
||||
A runnable SwiftUI demo lives in [`Demo/`](Demo/MarkdownEngineDemo.xcodeproj).
|
||||
Open it in Xcode and hit **Run** — the demo references the package via
|
||||
a local path, so any engine edit rebuilds into the demo on the next run.
|
||||
|
||||
> If you're seeing a "missing package product" error, it's almost always
|
||||
> stale package cache. Use **File → Packages → Reset Package Caches**
|
||||
> once and rebuild.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API docs ship as DocC. In Xcode: **Product → Build Documentation**
|
||||
(`⇧⌃⌘D`); for local CLI preview see [CONTRIBUTING.md](CONTRIBUTING.md). Once
|
||||
hosted on Swift Package Index, docs will live at
|
||||
`https://swiftpackageindex.com/nodes-app/swift-markdown-engine/documentation`.
|
||||
|
||||
## Requirements & Status
|
||||
|
||||
- macOS 14 or later (15.1+ for Apple Writing Tools integration)
|
||||
- Swift 5.9 / Xcode 15 or later
|
||||
|
||||
MarkdownEngine is currently **pre-1.0**. The public API may change between
|
||||
minor releases as it stabilizes. Production use is fine — pin a specific
|
||||
version (`0.x.y`) in your `Package.swift`.
|
||||
|
||||
## Who makes it
|
||||
|
||||
<a href="https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8">
|
||||
<img align="right" width="96" alt="Nodes" src="media/nodes-app-icon.png" />
|
||||
</a>
|
||||
|
||||
MarkdownEngine is the editor inside **[Nodes](https://apps.apple.com/app/apple-store/id6745401961?pt=127809373&ct=github&mt=8)**,
|
||||
a macOS app for writing, linking and exploring notes. This is not a side project
|
||||
we open-sourced and walked away from — it is the editor our own users type in
|
||||
every day, and every fix here ships in a real app first.
|
||||
|
||||
If it is useful to you, telling someone about it is all we would ask for.
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports, ideas, and pull requests are welcome.
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — codemap and pipeline guide for
|
||||
contributors
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, PR process, and design
|
||||
constraints
|
||||
|
||||
## License
|
||||
|
||||
MarkdownEngine is released under the Apache 2.0 License. See [LICENSE](LICENSE)
|
||||
for the full text.
|
||||
|
||||
---
|
||||
Built by a small team in Munich and Zurich. Day-to-day on [Instagram](https://www.instagram.com/nodes.app).
|
||||
Vendored
+727
@@ -0,0 +1,727 @@
|
||||
//
|
||||
// MarkdownEditorConfiguration.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Luca Chen on 16.03.26.
|
||||
//
|
||||
// Centralized configuration for the Markdown editor engine.
|
||||
//
|
||||
// This struct exposes every spacing, sizing, and behavior knob that is
|
||||
// shared across the engine. The defaults reproduce the historical
|
||||
// Nodes-app behavior, so passing `.default` keeps existing rendering
|
||||
// pixel-identical. Embedders that want a different look-and-feel can
|
||||
// override individual fields without forking the engine.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Top-level Configuration
|
||||
|
||||
/// All tunable values for the Markdown editor engine grouped by concern.
|
||||
///
|
||||
/// The struct is deliberately flat-with-nested-groups: top level holds
|
||||
/// orthogonal feature areas (markers, code blocks, lists, …), each group
|
||||
/// owns the values that belong together. Default values are the production
|
||||
/// defaults used by the Nodes app and have been chosen empirically.
|
||||
public struct MarkdownEditorConfiguration: Sendable {
|
||||
|
||||
public var theme: MarkdownEditorTheme
|
||||
public var services: MarkdownEditorServices
|
||||
public var markers: MarkerStyle
|
||||
public var codeBlock: CodeBlockStyle
|
||||
public var inlineCode: InlineCodeStyle
|
||||
public var lists: ListStyle
|
||||
public var taskCheckbox: TaskCheckboxStyle
|
||||
public var headings: HeadingStyle
|
||||
public var imageEmbed: ImageEmbedStyle
|
||||
public var blockLatex: BlockLatexStyle
|
||||
public var inlineLatex: InlineLatexStyle
|
||||
public var blockquote: BlockquoteStyle
|
||||
public var link: LinkStyle
|
||||
public var paragraph: ParagraphStyle
|
||||
public var overscroll: OverscrollPolicy
|
||||
public var dragSelection: DragSelectionPolicy
|
||||
public var safeAreaInsets: SafeAreaInsets
|
||||
public var scrollers: ScrollersPolicy
|
||||
public var textInsets: TextInsets
|
||||
/// Centered reading-column width; wide tables break out to full width. nil = full width (default).
|
||||
public var readingWidth: CGFloat?
|
||||
public var spellChecking: SpellCheckingPolicy
|
||||
/// Smart quote/dash substitution while typing. Independent of `spellChecking`
|
||||
/// — AppKit tracks these as separate `NSTextView` flags.
|
||||
public var textSubstitution: TextSubstitutionPolicy
|
||||
/// Inline predictive-text completion (the ghost-text suggestion AppKit
|
||||
/// shows as you type, same feature as Notes/TextEdit). Mirrors
|
||||
/// `NSTextView.isAutomaticTextCompletionEnabled`.
|
||||
public var textCompletion: TextCompletionPolicy
|
||||
/// System Writing Tools (proofread/rewrite/summarize). Mirrors
|
||||
/// `NSTextView.writingToolsBehavior` — `.none` when disabled.
|
||||
public var writingTools: WritingToolsPolicy
|
||||
/// How the editor resolves its own height.
|
||||
///
|
||||
/// - `.scrolls` (default): the editor scrolls internally within whatever
|
||||
/// height SwiftUI gives it. This is the historical behavior.
|
||||
/// - `.fitsContent`: the editor grows to fit its content and reports that
|
||||
/// height to SwiftUI, so an enclosing `ScrollView` scrolls the page
|
||||
/// instead of a nested internal scroller. The editor re-reports its
|
||||
/// height per keystroke as well as after async content changes (image
|
||||
/// loads, font-size changes, header band resizes).
|
||||
///
|
||||
/// Switching at runtime is supported; the editor reconfigures immediately
|
||||
/// (scroller visibility, overscroll, inflation, and intrinsic size all
|
||||
/// update in the same SwiftUI update cycle).
|
||||
///
|
||||
/// - SeeAlso: ``HeightBehavior``
|
||||
public var heightBehavior: HeightBehavior
|
||||
/// Present the document as raw Markdown source: no syntax hiding, no
|
||||
/// styling, no wiki-link display transform (`[[Name|UUID]]` shows verbatim).
|
||||
/// Stays editable, but smart input (list continuation, auto-wrap, ⇧⇥) is off.
|
||||
/// Runtime-switchable; a flip rebuilds immediately and drops the document's
|
||||
/// undo stack (actions from the other mode would replay at stale ranges).
|
||||
public var rawSourceMode: Bool
|
||||
/// Opt-in constructs beyond pure markdown (e.g. `==highlight==`). Empty by
|
||||
/// default: unregistered syntax stays literal text. Order defines match
|
||||
/// precedence among extensions; built-in constructs always win first.
|
||||
public var extensions: [any MarkdownExtension]
|
||||
/// Let the caret and the I-beam take the ink of the extension span they sit
|
||||
/// in, instead of `theme.bodyText` and the plain system pointer.
|
||||
///
|
||||
/// Off by default. It only matters for an extension that INVERTS its
|
||||
/// content (dark ink on a light block), where both cursors would otherwise
|
||||
/// be drawn in the block's own color and disappear inside it. An extension
|
||||
/// whose `contentAttributes` set no foreground is unaffected either way —
|
||||
/// so this stays the embedder's explicit decision rather than something the
|
||||
/// engine infers from a color it happens to see.
|
||||
public var cursorFollowsSpanInk: Bool
|
||||
|
||||
public init(
|
||||
theme: MarkdownEditorTheme = .default,
|
||||
services: MarkdownEditorServices = .default,
|
||||
markers: MarkerStyle = .default,
|
||||
codeBlock: CodeBlockStyle = .default,
|
||||
inlineCode: InlineCodeStyle = .default,
|
||||
lists: ListStyle = .default,
|
||||
taskCheckbox: TaskCheckboxStyle = .default,
|
||||
headings: HeadingStyle = .default,
|
||||
imageEmbed: ImageEmbedStyle = .default,
|
||||
blockLatex: BlockLatexStyle = .default,
|
||||
inlineLatex: InlineLatexStyle = .default,
|
||||
blockquote: BlockquoteStyle = .default,
|
||||
link: LinkStyle = .default,
|
||||
paragraph: ParagraphStyle = .default,
|
||||
overscroll: OverscrollPolicy = .default,
|
||||
dragSelection: DragSelectionPolicy = .default,
|
||||
safeAreaInsets: SafeAreaInsets = .default,
|
||||
scrollers: ScrollersPolicy = .default,
|
||||
textInsets: TextInsets = .default,
|
||||
readingWidth: CGFloat? = nil,
|
||||
spellChecking: SpellCheckingPolicy = .default,
|
||||
textSubstitution: TextSubstitutionPolicy = .default,
|
||||
textCompletion: TextCompletionPolicy = .default,
|
||||
writingTools: WritingToolsPolicy = .default,
|
||||
heightBehavior: HeightBehavior = .scrolls,
|
||||
rawSourceMode: Bool = false,
|
||||
extensions: [any MarkdownExtension] = [],
|
||||
cursorFollowsSpanInk: Bool = false
|
||||
) {
|
||||
self.theme = theme
|
||||
self.services = services
|
||||
self.markers = markers
|
||||
self.codeBlock = codeBlock
|
||||
self.inlineCode = inlineCode
|
||||
self.lists = lists
|
||||
self.taskCheckbox = taskCheckbox
|
||||
self.headings = headings
|
||||
self.imageEmbed = imageEmbed
|
||||
self.blockLatex = blockLatex
|
||||
self.inlineLatex = inlineLatex
|
||||
self.blockquote = blockquote
|
||||
self.link = link
|
||||
self.paragraph = paragraph
|
||||
self.overscroll = overscroll
|
||||
self.dragSelection = dragSelection
|
||||
self.safeAreaInsets = safeAreaInsets
|
||||
self.scrollers = scrollers
|
||||
self.textInsets = textInsets
|
||||
self.readingWidth = readingWidth
|
||||
self.spellChecking = spellChecking
|
||||
self.textSubstitution = textSubstitution
|
||||
self.textCompletion = textCompletion
|
||||
self.writingTools = writingTools
|
||||
self.heightBehavior = heightBehavior
|
||||
self.rawSourceMode = rawSourceMode
|
||||
self.extensions = extensions
|
||||
self.cursorFollowsSpanInk = cursorFollowsSpanInk
|
||||
}
|
||||
|
||||
public static let `default` = MarkdownEditorConfiguration()
|
||||
}
|
||||
|
||||
// MARK: - Spell checking
|
||||
|
||||
/// Initial state for the three "Spelling and Grammar" toggles. Only consulted
|
||||
/// at `makeNSView` time; afterwards the user's context-menu choices take
|
||||
/// precedence and are surfaced via ``NativeTextViewWrapper/onSpellCheckingPolicyChanged``.
|
||||
public struct SpellCheckingPolicy: Sendable {
|
||||
/// Mirrors `NSTextView.isContinuousSpellCheckingEnabled`.
|
||||
public var continuousSpellChecking: Bool
|
||||
/// Mirrors `NSTextView.isGrammarCheckingEnabled`.
|
||||
public var grammarChecking: Bool
|
||||
/// Mirrors `NSTextView.isAutomaticSpellingCorrectionEnabled`.
|
||||
public var automaticSpellingCorrection: Bool
|
||||
|
||||
public init(
|
||||
continuousSpellChecking: Bool = true,
|
||||
grammarChecking: Bool = true,
|
||||
automaticSpellingCorrection: Bool = true
|
||||
) {
|
||||
self.continuousSpellChecking = continuousSpellChecking
|
||||
self.grammarChecking = grammarChecking
|
||||
self.automaticSpellingCorrection = automaticSpellingCorrection
|
||||
}
|
||||
|
||||
public static let `default` = SpellCheckingPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Text substitution
|
||||
|
||||
/// Smart quote/dash substitution while typing. Mirrors the historical
|
||||
/// hardcoded `NativeTextViewWrapper` behavior (quotes on, dashes off) as the
|
||||
/// default, now exposed as a config knob instead of fixed AppKit calls.
|
||||
public struct TextSubstitutionPolicy: Sendable {
|
||||
/// Mirrors `NSTextView.isAutomaticQuoteSubstitutionEnabled`.
|
||||
public var quoteSubstitution: Bool
|
||||
/// Mirrors `NSTextView.isAutomaticDashSubstitutionEnabled`.
|
||||
public var dashSubstitution: Bool
|
||||
|
||||
public init(
|
||||
quoteSubstitution: Bool = true,
|
||||
dashSubstitution: Bool = false
|
||||
) {
|
||||
self.quoteSubstitution = quoteSubstitution
|
||||
self.dashSubstitution = dashSubstitution
|
||||
}
|
||||
|
||||
public static let `default` = TextSubstitutionPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Text completion
|
||||
|
||||
/// Inline predictive-text completion while typing (ghost-text suggestion,
|
||||
/// accepted with Tab/→ — same feature as Notes/TextEdit on macOS 14+).
|
||||
public struct TextCompletionPolicy: Sendable {
|
||||
/// Mirrors `NSTextView.isAutomaticTextCompletionEnabled`.
|
||||
public var isEnabled: Bool
|
||||
|
||||
public init(isEnabled: Bool = true) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public static let `default` = TextCompletionPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Writing Tools
|
||||
|
||||
/// System Writing Tools (proofread / rewrite / summarize / compose), backed
|
||||
/// by Apple Intelligence where available.
|
||||
public struct WritingToolsPolicy: Sendable {
|
||||
/// When `false`, `NSTextView.writingToolsBehavior` is set to `.none`
|
||||
/// instead of `.limited` — hides Writing Tools entirely for this view.
|
||||
public var isEnabled: Bool
|
||||
|
||||
public init(isEnabled: Bool = true) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
public static let `default` = WritingToolsPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Scroll bars
|
||||
|
||||
/// Scroll bar visibility. Default: vertical only, autohide on.
|
||||
public struct ScrollersPolicy: Sendable {
|
||||
public var hasVerticalScroller: Bool
|
||||
public var hasHorizontalScroller: Bool
|
||||
public var autohidesScrollers: Bool
|
||||
|
||||
public init(
|
||||
hasVerticalScroller: Bool = true,
|
||||
hasHorizontalScroller: Bool = false,
|
||||
autohidesScrollers: Bool = true
|
||||
) {
|
||||
self.hasVerticalScroller = hasVerticalScroller
|
||||
self.hasHorizontalScroller = hasHorizontalScroller
|
||||
self.autohidesScrollers = autohidesScrollers
|
||||
}
|
||||
|
||||
public static let `default` = ScrollersPolicy()
|
||||
/// No scrollers (use with a custom scroll overlay).
|
||||
public static let hidden = ScrollersPolicy(hasVerticalScroller: false, hasHorizontalScroller: false)
|
||||
/// Vertical only — same as `.default`.
|
||||
public static let vertical = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: false)
|
||||
/// Both axes (code-heavy / wide content).
|
||||
public static let both = ScrollersPolicy(hasVerticalScroller: true, hasHorizontalScroller: true)
|
||||
/// Vertical, no auto-hide.
|
||||
public static let alwaysVisible = ScrollersPolicy(hasVerticalScroller: true, autohidesScrollers: false)
|
||||
}
|
||||
|
||||
// MARK: - Text insets
|
||||
|
||||
/// Margins inside the text view (`NSTextView.textContainerInset`). Scroll bar stays at the outer edge.
|
||||
public struct TextInsets: Sendable {
|
||||
public var horizontal: CGFloat
|
||||
public var vertical: CGFloat
|
||||
|
||||
public init(horizontal: CGFloat = 0, vertical: CGFloat = 0) {
|
||||
self.horizontal = horizontal
|
||||
self.vertical = vertical
|
||||
}
|
||||
|
||||
public static let `default` = TextInsets()
|
||||
}
|
||||
|
||||
// MARK: - Marker visibility
|
||||
|
||||
/// How Markdown syntax markers (e.g. `**`, `*`, `$`) are visualized when
|
||||
/// the cursor is not inside the corresponding token.
|
||||
///
|
||||
/// The engine's default approach is to keep markers in the text storage but
|
||||
/// shrink them to a near-zero font size (`hiddenMarkerFontSize`). This avoids
|
||||
/// any range translation between displayed and stored text — cursor movement,
|
||||
/// find/replace, selection, and copy/paste all stay trivially correct.
|
||||
/// The trade-off is a sub-pixel residue at extreme zoom levels.
|
||||
public struct MarkerStyle: Sendable {
|
||||
/// Font size used for "hidden" inline markers. Effectively invisible at
|
||||
/// normal zoom while keeping displayed-range == stored-range.
|
||||
public var hiddenMarkerFontSize: CGFloat
|
||||
/// Alpha applied to inline-code's secondary marker color.
|
||||
public var inlineCodeMarkerAlpha: CGFloat
|
||||
/// Alpha applied to non-focused find matches when in-document search
|
||||
/// highlights are visible. The focused match is drawn at full opacity.
|
||||
public var findMatchHighlightAlpha: CGFloat
|
||||
|
||||
public init(
|
||||
hiddenMarkerFontSize: CGFloat = 0.1,
|
||||
inlineCodeMarkerAlpha: CGFloat = 0.5,
|
||||
findMatchHighlightAlpha: CGFloat = 0.65
|
||||
) {
|
||||
self.hiddenMarkerFontSize = hiddenMarkerFontSize
|
||||
self.inlineCodeMarkerAlpha = inlineCodeMarkerAlpha
|
||||
self.findMatchHighlightAlpha = findMatchHighlightAlpha
|
||||
}
|
||||
|
||||
public static let `default` = MarkerStyle()
|
||||
}
|
||||
|
||||
// MARK: - Code blocks
|
||||
|
||||
/// Styling for fenced code blocks (```language ... ```).
|
||||
public struct CodeBlockStyle: Sendable {
|
||||
/// Code-block font size as a fraction of the document base font size.
|
||||
public var fontSizeScale: CGFloat
|
||||
/// Vertical paragraph spacing applied above and below the code block.
|
||||
public var paragraphSpacing: CGFloat
|
||||
/// Left/right indent (in points) so code blocks don't run into the gutter.
|
||||
public var horizontalIndent: CGFloat
|
||||
|
||||
public init(
|
||||
fontSizeScale: CGFloat = 0.85,
|
||||
paragraphSpacing: CGFloat = 2.0,
|
||||
horizontalIndent: CGFloat = 12.0
|
||||
) {
|
||||
self.fontSizeScale = fontSizeScale
|
||||
self.paragraphSpacing = paragraphSpacing
|
||||
self.horizontalIndent = horizontalIndent
|
||||
}
|
||||
|
||||
public static let `default` = CodeBlockStyle()
|
||||
}
|
||||
|
||||
// MARK: - Inline code
|
||||
|
||||
/// Styling for inline `` `code` `` spans.
|
||||
public struct InlineCodeStyle: Sendable {
|
||||
/// Inline-code reuses the code block font size scale by default.
|
||||
public var fontSizeScale: CGFloat
|
||||
|
||||
public init(fontSizeScale: CGFloat = 0.85) {
|
||||
self.fontSizeScale = fontSizeScale
|
||||
}
|
||||
|
||||
public static let `default` = InlineCodeStyle()
|
||||
}
|
||||
|
||||
// MARK: - Lists
|
||||
|
||||
/// Behavior toggles and metrics for ordered / unordered list editing.
|
||||
public struct ListStyle: Sendable {
|
||||
/// Master switch for list-related editing helpers (auto-continue,
|
||||
/// auto-indent, marker conversion). When `false`, lists are still
|
||||
/// rendered, but typing-time conveniences are skipped.
|
||||
public var helpersEnabled: Bool
|
||||
/// Master switch for auto-closing pairs `()`, `{}`, `[]` while typing.
|
||||
public var autoClosePairsEnabled: Bool
|
||||
/// Indent (in points) that one nesting level adds to the list item.
|
||||
public var indentPerLevel: CGFloat
|
||||
/// Maximum nesting level reachable by pressing Tab inside a list.
|
||||
public var maximumNestingLevel: Int
|
||||
/// Extra line height added on top of the default to give list items room.
|
||||
public var extraLineHeight: CGFloat
|
||||
|
||||
public init(
|
||||
helpersEnabled: Bool = true,
|
||||
autoClosePairsEnabled: Bool = true,
|
||||
indentPerLevel: CGFloat = 27.5,
|
||||
maximumNestingLevel: Int = 3,
|
||||
extraLineHeight: CGFloat = 2
|
||||
) {
|
||||
self.helpersEnabled = helpersEnabled
|
||||
self.autoClosePairsEnabled = autoClosePairsEnabled
|
||||
self.indentPerLevel = indentPerLevel
|
||||
self.maximumNestingLevel = maximumNestingLevel
|
||||
self.extraLineHeight = extraLineHeight
|
||||
}
|
||||
|
||||
public static let `default` = ListStyle()
|
||||
}
|
||||
|
||||
// MARK: - Task checkboxes
|
||||
|
||||
/// SF Symbol names used to draw task-list checkboxes (`- [ ]` / `- [x]`).
|
||||
///
|
||||
/// Any SF Symbol available on the deployment target can be substituted, for
|
||||
/// example `"circle"` / `"checkmark.circle.fill"`. A name that doesn't
|
||||
/// resolve falls back to the corresponding default symbol at draw time, so a
|
||||
/// typo degrades to the stock look instead of drawing nothing. Tint colors
|
||||
/// stay theme-driven (`MarkdownEditorTheme/mutedText` unchecked,
|
||||
/// `MarkdownEditorTheme/bodyText` checked).
|
||||
public struct TaskCheckboxStyle: Sendable {
|
||||
/// SF Symbol drawn for an unchecked task item (`[ ]`).
|
||||
public var uncheckedSymbolName: String
|
||||
/// SF Symbol drawn for a checked task item (`[x]`).
|
||||
public var checkedSymbolName: String
|
||||
|
||||
public init(
|
||||
uncheckedSymbolName: String = "square",
|
||||
checkedSymbolName: String = "checkmark.square.fill"
|
||||
) {
|
||||
self.uncheckedSymbolName = uncheckedSymbolName
|
||||
self.checkedSymbolName = checkedSymbolName
|
||||
}
|
||||
|
||||
public static let `default` = TaskCheckboxStyle()
|
||||
}
|
||||
|
||||
// MARK: - Headings
|
||||
|
||||
/// Per-level heading metrics. Defaults follow the historical Nodes ratios,
|
||||
/// which are loosely based on browser default heading sizes.
|
||||
public struct HeadingStyle: Sendable {
|
||||
/// Font-size multiplier per heading level (1...6).
|
||||
public var fontMultipliers: [CGFloat]
|
||||
/// Top spacing in `em` units per heading level (1...6).
|
||||
public var topSpacingEm: [CGFloat]
|
||||
|
||||
public init(
|
||||
fontMultipliers: [CGFloat] = [2.0, 1.5, 1.17, 1.0, 0.83, 0.67],
|
||||
topSpacingEm: [CGFloat] = [0.35, 0.30, 0.25, 0.20, 0.15, 0.10]
|
||||
) {
|
||||
self.fontMultipliers = fontMultipliers
|
||||
self.topSpacingEm = topSpacingEm
|
||||
}
|
||||
|
||||
public func fontMultiplier(for level: Int) -> CGFloat {
|
||||
let index = max(1, min(level, fontMultipliers.count)) - 1
|
||||
return fontMultipliers[index]
|
||||
}
|
||||
|
||||
public func topSpacingEm(for level: Int) -> CGFloat {
|
||||
let index = max(1, min(level, topSpacingEm.count)) - 1
|
||||
return topSpacingEm[index]
|
||||
}
|
||||
|
||||
public static let `default` = HeadingStyle()
|
||||
}
|
||||
|
||||
// MARK: - Image embeds (![[...]])
|
||||
|
||||
/// Sizing and spacing rules for `![[Name]]` image embeds.
|
||||
public struct ImageEmbedStyle: Sendable {
|
||||
/// Minimum allowed display width (points) for an embedded image.
|
||||
public var minimumWidth: CGFloat
|
||||
/// Fallback maximum width if no usable text container width is available.
|
||||
public var fallbackMaxWidth: CGFloat
|
||||
/// Sanity bound — container widths above this are treated as invalid.
|
||||
public var unreasonableMaxWidth: CGFloat
|
||||
/// Vertical paragraph spacing above/below the image paragraph.
|
||||
public var paragraphSpacing: CGFloat
|
||||
/// Gap between the source line and the rendered image (visibleSource mode).
|
||||
public var imageGap: CGFloat
|
||||
|
||||
public init(
|
||||
minimumWidth: CGFloat = 50,
|
||||
fallbackMaxWidth: CGFloat = 650,
|
||||
unreasonableMaxWidth: CGFloat = 1_000_000,
|
||||
paragraphSpacing: CGFloat = 8,
|
||||
imageGap: CGFloat = 8
|
||||
) {
|
||||
self.minimumWidth = minimumWidth
|
||||
self.fallbackMaxWidth = fallbackMaxWidth
|
||||
self.unreasonableMaxWidth = unreasonableMaxWidth
|
||||
self.paragraphSpacing = paragraphSpacing
|
||||
self.imageGap = imageGap
|
||||
}
|
||||
|
||||
public static let `default` = ImageEmbedStyle()
|
||||
}
|
||||
|
||||
// MARK: - LaTeX
|
||||
|
||||
/// Vertical spacing for block-LaTeX `$$...$$` paragraphs.
|
||||
public struct BlockLatexStyle: Sendable {
|
||||
/// Top spacing for $$...$$ block paragraphs.
|
||||
public var paragraphSpacingBefore: CGFloat
|
||||
/// Bottom spacing for $$...$$ block paragraphs.
|
||||
public var paragraphSpacing: CGFloat
|
||||
/// Extra bottom padding added to single-letter formulas to avoid clipping.
|
||||
public var singleLetterPaddingBottom: CGFloat
|
||||
|
||||
public init(
|
||||
paragraphSpacingBefore: CGFloat = 16,
|
||||
paragraphSpacing: CGFloat = 20,
|
||||
singleLetterPaddingBottom: CGFloat = 1.0
|
||||
) {
|
||||
self.paragraphSpacingBefore = paragraphSpacingBefore
|
||||
self.paragraphSpacing = paragraphSpacing
|
||||
self.singleLetterPaddingBottom = singleLetterPaddingBottom
|
||||
}
|
||||
|
||||
public static let `default` = BlockLatexStyle()
|
||||
}
|
||||
|
||||
/// Reserved for future inline-LaTeX (`$...$`) tuning. Currently has no
|
||||
/// effect; inline LaTeX inherits font size from the surrounding context.
|
||||
public struct InlineLatexStyle: Sendable {
|
||||
/// Reserved for future inline-LaTeX tuning — currently the engine inherits
|
||||
/// font size from the surrounding heading context.
|
||||
public var placeholder: Void
|
||||
|
||||
public init() { self.placeholder = () }
|
||||
|
||||
public static let `default` = InlineLatexStyle()
|
||||
}
|
||||
|
||||
// MARK: - Blockquote
|
||||
|
||||
/// Extra line height added to blockquote lines.
|
||||
///
|
||||
/// By default blockquote lines use the font's natural line height with no
|
||||
/// extra spacing. Set `extraLineHeight` to add breathing room, matching
|
||||
/// the pattern used by `ListStyle.extraLineHeight` and
|
||||
/// `ParagraphStyle.lineHeightExtraSpacing`.
|
||||
public struct BlockquoteStyle: Sendable {
|
||||
/// Extra height (points) added to the default line height for blockquote lines.
|
||||
public var extraLineHeight: CGFloat
|
||||
|
||||
public init(extraLineHeight: CGFloat = 0) {
|
||||
self.extraLineHeight = extraLineHeight
|
||||
}
|
||||
|
||||
public static let `default` = BlockquoteStyle()
|
||||
}
|
||||
|
||||
// MARK: - Links
|
||||
|
||||
/// Foreground alpha values applied to link content in different states.
|
||||
public struct LinkStyle: Sendable {
|
||||
/// Foreground alpha for the visible label of an active markdown link.
|
||||
public var activeLinkAlpha: CGFloat
|
||||
/// Foreground alpha applied to "incomplete" link content (e.g. `[text]`
|
||||
/// without a target).
|
||||
public var incompleteLinkAlpha: CGFloat
|
||||
|
||||
public init(activeLinkAlpha: CGFloat = 0.55, incompleteLinkAlpha: CGFloat = 0.7) {
|
||||
self.activeLinkAlpha = activeLinkAlpha
|
||||
self.incompleteLinkAlpha = incompleteLinkAlpha
|
||||
}
|
||||
|
||||
public static let `default` = LinkStyle()
|
||||
}
|
||||
|
||||
// MARK: - Paragraphs
|
||||
|
||||
/// Default paragraph spacing and line height applied to body text.
|
||||
public struct ParagraphStyle: Sendable {
|
||||
/// Extra paragraph spacing as a fraction of the document's default line height.
|
||||
public var spacingFactor: CGFloat
|
||||
/// Extra height (points) added to the default paragraph line height.
|
||||
public var lineHeightExtraSpacing: CGFloat
|
||||
|
||||
public init(spacingFactor: CGFloat = 0.3, lineHeightExtraSpacing: CGFloat = 2) {
|
||||
self.spacingFactor = spacingFactor
|
||||
self.lineHeightExtraSpacing = lineHeightExtraSpacing
|
||||
}
|
||||
|
||||
public static let `default` = ParagraphStyle()
|
||||
}
|
||||
|
||||
// MARK: - Bottom overscroll
|
||||
|
||||
/// Controls the empty space below the last line so that typing at the bottom
|
||||
/// of a long document remains comfortable instead of pinning to the viewport
|
||||
/// bottom edge.
|
||||
public struct OverscrollPolicy: Sendable {
|
||||
/// Desired overscroll as a fraction of the visible viewport height.
|
||||
public var percent: CGFloat
|
||||
/// Hard upper bound for the overscroll in points.
|
||||
public var maxPoints: CGFloat
|
||||
/// Hard lower bound for the overscroll in points.
|
||||
public var minPoints: CGFloat
|
||||
/// Fraction of the viewport above which overscroll starts ramping up.
|
||||
public var activationStartFraction: CGFloat
|
||||
/// Fraction of the viewport over which overscroll fully ramps in.
|
||||
public var activationRangeFraction: CGFloat
|
||||
|
||||
public init(
|
||||
percent: CGFloat = 0.5,
|
||||
maxPoints: CGFloat = 450,
|
||||
minPoints: CGFloat = 40,
|
||||
activationStartFraction: CGFloat = 0.15,
|
||||
activationRangeFraction: CGFloat = 0.85
|
||||
) {
|
||||
self.percent = percent
|
||||
self.maxPoints = maxPoints
|
||||
self.minPoints = minPoints
|
||||
self.activationStartFraction = activationStartFraction
|
||||
self.activationRangeFraction = activationRangeFraction
|
||||
}
|
||||
|
||||
public static let `default` = OverscrollPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Drag selection
|
||||
|
||||
/// Tuning for the auto-scroll boost that engages while the user drags a
|
||||
/// selection past the visible viewport edges.
|
||||
public struct DragSelectionPolicy: Sendable {
|
||||
/// Movement threshold (points) before the auto-scroll boost engages.
|
||||
public var movementThreshold: CGFloat
|
||||
/// Distance from the window edge that triggers the boost.
|
||||
public var edgeTriggerDistance: CGFloat
|
||||
/// Pixels per tick scrolled while the boost is active.
|
||||
public var scrollStepPerTick: CGFloat
|
||||
/// Boost timer frequency (ticks per second).
|
||||
public var ticksPerSecond: Double
|
||||
|
||||
public init(
|
||||
movementThreshold: CGFloat = 5.0,
|
||||
edgeTriggerDistance: CGFloat = 5.0,
|
||||
scrollStepPerTick: CGFloat = 12.0,
|
||||
ticksPerSecond: Double = 60.0
|
||||
) {
|
||||
self.movementThreshold = movementThreshold
|
||||
self.edgeTriggerDistance = edgeTriggerDistance
|
||||
self.scrollStepPerTick = scrollStepPerTick
|
||||
self.ticksPerSecond = ticksPerSecond
|
||||
}
|
||||
|
||||
public static let `default` = DragSelectionPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Safe-area insets
|
||||
|
||||
/// Reserves space on the scroll view for system overlays (e.g. a translucent toolbar to scroll underneath). Maps to `NSScrollView.contentInsets`; scroll bar follows the inset.
|
||||
public struct SafeAreaInsets: Sendable {
|
||||
public var top: CGFloat
|
||||
public var leading: CGFloat
|
||||
public var trailing: CGFloat
|
||||
public var bottom: CGFloat
|
||||
|
||||
public init(
|
||||
top: CGFloat = 0,
|
||||
leading: CGFloat = 0,
|
||||
trailing: CGFloat = 0,
|
||||
bottom: CGFloat = 0
|
||||
) {
|
||||
self.top = top
|
||||
self.leading = leading
|
||||
self.trailing = trailing
|
||||
self.bottom = bottom
|
||||
}
|
||||
|
||||
public static let `default` = SafeAreaInsets()
|
||||
}
|
||||
|
||||
// MARK: - Height behavior
|
||||
|
||||
extension MarkdownEditorConfiguration {
|
||||
/// How the editor resolves its own height.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// // Inline editor inside a page scroll view:
|
||||
/// ScrollView {
|
||||
/// NativeTextViewWrapper(
|
||||
/// text: $text,
|
||||
/// configuration: .init(heightBehavior: .fitsContent)
|
||||
/// )
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ## Behavior
|
||||
///
|
||||
/// In `.fitsContent` mode:
|
||||
/// - The editor reports `headerHeight + text content height` to SwiftUI.
|
||||
/// - Typing grows/shrinks the block per keystroke; SwiftUI re-lays-out.
|
||||
/// - An empty document shows at least one body line of height.
|
||||
/// - Scroll-wheel events pass through to the enclosing scroll view.
|
||||
/// - Caret visibility propagates to the enclosing (page-level) scroll
|
||||
/// view so editing at the bottom of a tall block keeps the caret
|
||||
/// on-screen.
|
||||
/// - Async content changes (image/LaTeX finishing layout, font-size
|
||||
/// change) re-report size via `invalidateIntrinsicContentSize`.
|
||||
/// - Switching between `.scrolls` and `.fitsContent` at runtime is
|
||||
/// supported; the editor reconfigures immediately.
|
||||
///
|
||||
/// ## Composition
|
||||
///
|
||||
/// - **Reading column** (`readingWidth`): the centered fixed-width column
|
||||
/// is preserved; height grows to the column's content height.
|
||||
/// - **Scroll-away header**: a static header's band is included in the
|
||||
/// reported height. The collapse-on-scroll animation is driven by the
|
||||
/// inner scroll offset, which is always zero in `.fitsContent`, so the
|
||||
/// collapse never triggers. Combining a collapsing header with
|
||||
/// `.fitsContent` is allowed but the collapse behavior is not meaningful.
|
||||
///
|
||||
/// ## Trade-offs
|
||||
///
|
||||
/// `.fitsContent` forces full-document layout so the total height is known.
|
||||
/// For small-to-medium documents this is fine; for very large documents it
|
||||
/// forgoes TextKit-2 viewport virtualization.
|
||||
public enum HeightBehavior: Sendable {
|
||||
/// The editor scrolls internally within the height SwiftUI gives it.
|
||||
/// This is the historical behavior and the default.
|
||||
case scrolls
|
||||
|
||||
/// The editor grows to fit its content and reports that height back to
|
||||
/// SwiftUI, so an enclosing scroll view / page scrolls instead of a
|
||||
/// nested scroll view. Internal scrolling and bottom-overscroll slack
|
||||
/// are disabled in this mode.
|
||||
case fitsContent
|
||||
|
||||
/// Whether the vertical scroller should be shown for this height
|
||||
/// behavior and scroller policy combination.
|
||||
///
|
||||
/// In `.fitsContent` the editor never scrolls internally, so the
|
||||
/// vertical scroller is always hidden regardless of the policy.
|
||||
/// In `.scrolls` the policy's `hasVerticalScroller` is respected.
|
||||
public func wantsVerticalScroller(for scrollers: ScrollersPolicy) -> Bool {
|
||||
switch self {
|
||||
case .fitsContent: return false
|
||||
case .scrolls: return scrollers.hasVerticalScroller
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+117
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// MarkdownEditorTheme.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Luca Chen on 16.03.26.
|
||||
//
|
||||
// Color palette for the Markdown editor engine.
|
||||
//
|
||||
// All user-visible colors used by the engine are routed through this
|
||||
// struct. Defaults map to system colors so the editor adapts to light/
|
||||
// dark mode automatically. Embedders that want a custom palette (for
|
||||
// example, a sepia or high-contrast preset) can replace any subset of
|
||||
// the colors without touching engine source files.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Theme
|
||||
|
||||
/// Color palette consumed by the Markdown editor engine.
|
||||
///
|
||||
/// Every color the engine puts on screen is read from this struct, so a
|
||||
/// single override is enough to retheme the entire editor. The defaults
|
||||
/// reproduce a system-native macOS look using `NSColor` dynamic system
|
||||
/// colors, so light/dark-mode switching keeps working without extra code.
|
||||
public struct MarkdownEditorTheme: Sendable {
|
||||
|
||||
// MARK: Text colors
|
||||
|
||||
/// Foreground color for plain body text and the typing caret.
|
||||
public var bodyText: NSColor
|
||||
/// Foreground color for de-emphasized text and most syntax markers.
|
||||
/// Defaults to `secondaryLabelColor` so it tracks the system style.
|
||||
public var mutedText: NSColor
|
||||
/// Foreground color for content the engine wants to deemphasize further
|
||||
/// than `mutedText` — for example, broken wiki-links.
|
||||
public var disabledText: NSColor
|
||||
/// Foreground color for heading marker glyphs (`#`, `##`, …).
|
||||
public var headingMarker: NSColor
|
||||
|
||||
// MARK: Links
|
||||
|
||||
/// Foreground color for hyperlinks that resolve to an URL.
|
||||
public var link: NSColor
|
||||
/// Foreground color for incomplete `[text]` patterns (no URL yet).
|
||||
public var incompleteLink: NSColor
|
||||
|
||||
// MARK: Find / search highlights
|
||||
|
||||
/// Background color used to highlight all matches when the user is
|
||||
/// running an in-document search.
|
||||
///
|
||||
/// The default is `.systemYellow` so embedders that don't customize
|
||||
/// this still get a sensible result. Apps with their own brand color
|
||||
/// (for example, the Nodes app uses its custom yellow) should override
|
||||
/// this to match their palette.
|
||||
public var findMatchHighlight: NSColor
|
||||
/// Background color used to highlight the currently-focused match
|
||||
/// during in-document search. Typically a stronger version of
|
||||
/// ``findMatchHighlight``.
|
||||
public var findCurrentMatchHighlight: NSColor
|
||||
|
||||
// MARK: LaTeX rendering
|
||||
|
||||
/// Foreground color used when rendering LaTeX formulas in light mode.
|
||||
public var latexLightModeText: NSColor
|
||||
/// Foreground color used when rendering LaTeX formulas in dark mode.
|
||||
public var latexDarkModeText: NSColor
|
||||
|
||||
// MARK: Strikethrough / decoration
|
||||
|
||||
/// Stroke color used for strikethrough decorations
|
||||
/// (e.g. completed task list items, horizontal rules).
|
||||
public var strikethroughColor: NSColor
|
||||
|
||||
// MARK: Highlight
|
||||
|
||||
/// Background color used for `==highlight==` inline markup.
|
||||
public var highlightColor: NSColor
|
||||
|
||||
// MARK: Init
|
||||
|
||||
public init(
|
||||
bodyText: NSColor = .labelColor,
|
||||
mutedText: NSColor = .secondaryLabelColor,
|
||||
disabledText: NSColor = .tertiaryLabelColor,
|
||||
headingMarker: NSColor = .gray,
|
||||
link: NSColor = .linkColor,
|
||||
incompleteLink: NSColor = .systemBlue,
|
||||
findMatchHighlight: NSColor = .systemYellow,
|
||||
findCurrentMatchHighlight: NSColor = .systemYellow,
|
||||
latexLightModeText: NSColor = .black,
|
||||
latexDarkModeText: NSColor = .white,
|
||||
strikethroughColor: NSColor = .labelColor,
|
||||
highlightColor: NSColor = .systemOrange.withAlphaComponent(0.4)
|
||||
) {
|
||||
self.bodyText = bodyText
|
||||
self.mutedText = mutedText
|
||||
self.disabledText = disabledText
|
||||
self.headingMarker = headingMarker
|
||||
self.link = link
|
||||
self.incompleteLink = incompleteLink
|
||||
self.findMatchHighlight = findMatchHighlight
|
||||
self.findCurrentMatchHighlight = findCurrentMatchHighlight
|
||||
self.latexLightModeText = latexLightModeText
|
||||
self.latexDarkModeText = latexDarkModeText
|
||||
self.strikethroughColor = strikethroughColor
|
||||
self.highlightColor = highlightColor
|
||||
}
|
||||
|
||||
/// System-native palette built from `NSColor` dynamic system colors.
|
||||
///
|
||||
/// Use this if you want the engine to look like a stock macOS
|
||||
/// `NSTextView`. It's also the default when no theme is supplied.
|
||||
public static let `default` = MarkdownEditorTheme()
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// PerfTrace.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Luca Chen on 07.07.26.
|
||||
//
|
||||
// TEMP diagnostics (typing performance). Prints one compact line per keystroke
|
||||
// with a per-phase breakdown plus the current document length, so we can see
|
||||
// which costs grow with file size instead of staying constant. The whole point:
|
||||
// type in a short file, then a long one, and compare `total` for the same edit.
|
||||
//
|
||||
// Toggle: set the env var MD_PERF=0 in the run scheme to silence.
|
||||
// Debug-only — the whole thing compiles out in Release.
|
||||
// Remove before shipping (this file + the `PerfTrace.` call sites).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PerfTrace {
|
||||
#if DEBUG
|
||||
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0"
|
||||
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
|
||||
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
|
||||
/// on every 64th keystroke — periodic spikes that pollute the PERF
|
||||
/// numbers — so they stay off unless explicitly requested.
|
||||
static let verifyEnabled = ProcessInfo.processInfo.environment["MD_PERF_VERIFY"] == "1"
|
||||
#else
|
||||
static let enabled = false
|
||||
static let verifyEnabled = false
|
||||
#endif
|
||||
|
||||
// All call sites run on the main thread (the coordinator + text view are
|
||||
// main-actor), so plain static state is safe under the package's Swift 5 mode.
|
||||
private static var active = false
|
||||
private static var frameStart: UInt64 = 0
|
||||
private static var docLength = 0
|
||||
private static var phases: [(String, Double)] = []
|
||||
private static var notes: [String] = []
|
||||
/// Summed costs for code that runs MANY times per frame or from inside
|
||||
/// AppKit callbacks (caret reveal, spell-checker callbacks) — printed as
|
||||
/// `+label=…(×n)` after the sequential phases.
|
||||
private static var accumulated: [(String, Double, Int)] = []
|
||||
|
||||
private static func nowMs() -> Double {
|
||||
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000
|
||||
}
|
||||
|
||||
/// Open a per-keystroke frame. Every `measure`/`note` until `end()` attaches to it.
|
||||
/// A frame already opened this keystroke is CONTINUED, not reset:
|
||||
/// shouldChangeTextIn opens the frame (so the pre-edit parse and the
|
||||
/// smart-input interceptors are counted — they used to run before the
|
||||
/// frame and were invisible), the mid-edit selection change and
|
||||
/// textDidChange attach to it. A frame left open by an edit that never
|
||||
/// reached textDidChange is considered stale after 1s and reset.
|
||||
static func begin(docLength len: Int) {
|
||||
guard enabled else { return }
|
||||
let now = DispatchTime.now().uptimeNanoseconds
|
||||
docLength = len
|
||||
if active, Double(now - frameStart) / 1_000_000 < 1_000 { return }
|
||||
active = true
|
||||
phases.removeAll(keepingCapacity: true)
|
||||
notes.removeAll(keepingCapacity: true)
|
||||
accumulated.removeAll(keepingCapacity: true)
|
||||
frameStart = now
|
||||
}
|
||||
|
||||
/// Like `measure`, but SUMS repeated calls under one label instead of
|
||||
/// appending a phase per call — for work triggered from inside AppKit
|
||||
/// (caret reveal, spell-checker callbacks) that can fire several times
|
||||
/// per keystroke and would otherwise stay invisible in the frame.
|
||||
@discardableResult
|
||||
static func accumulate<T>(_ label: String, _ body: () -> T) -> T {
|
||||
guard enabled, active else { return body() }
|
||||
let t0 = nowMs()
|
||||
let result = body()
|
||||
let dt = nowMs() - t0
|
||||
if let i = accumulated.firstIndex(where: { $0.0 == label }) {
|
||||
accumulated[i].1 += dt
|
||||
accumulated[i].2 += 1
|
||||
} else {
|
||||
accumulated.append((label, dt, 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Time one sequential top-level phase of the current frame.
|
||||
@discardableResult
|
||||
static func measure<T>(_ label: String, _ body: () -> T) -> T {
|
||||
guard enabled, active else { return body() }
|
||||
let t0 = nowMs()
|
||||
let result = body()
|
||||
phases.append((label, nowMs() - t0))
|
||||
return result
|
||||
}
|
||||
|
||||
/// Attach a free-form detail line (e.g. how many tables were re-rendered).
|
||||
/// The closure only runs when tracing is active, so it costs nothing when off.
|
||||
static func note(_ make: () -> String) {
|
||||
guard enabled, active else { return }
|
||||
notes.append(make())
|
||||
}
|
||||
|
||||
/// Record a named timestamp (offset from frame start) inline in the
|
||||
/// breakdown, printed as `@label=12.34`. The gaps BETWEEN checkpoints and
|
||||
/// the measured spans locate work the spans don't cover (AppKit edit
|
||||
/// application, layout, notification dispatch between our callbacks).
|
||||
static func checkpoint(_ label: String) {
|
||||
guard enabled, active else { return }
|
||||
phases.append(("@" + label, Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000))
|
||||
}
|
||||
|
||||
/// Close the frame and print total + per-phase breakdown + notes.
|
||||
/// `other` = total − Σ(phases + accumulated): time inside the frame that
|
||||
/// no span covers (AppKit edit processing, layout, unmeasured code).
|
||||
static func end() {
|
||||
guard enabled, active else { return }
|
||||
active = false
|
||||
let total = Double(DispatchTime.now().uptimeNanoseconds - frameStart) / 1_000_000
|
||||
var breakdown = phases.map { String(format: "%@=%.2f", $0.0, $0.1) }.joined(separator: " ")
|
||||
if !accumulated.isEmpty {
|
||||
breakdown += " " + accumulated.map { String(format: "+%@=%.2f(×%d)", $0.0, $0.1, $0.2) }.joined(separator: " ")
|
||||
}
|
||||
let covered = phases.filter { !$0.0.hasPrefix("@") }.reduce(0) { $0 + $1.1 }
|
||||
+ accumulated.reduce(0) { $0 + $1.1 }
|
||||
print(String(format: "⌨️ PERF doc=%dch total=%.2fms | %@ other=%.2f", docLength, total, breakdown, total - covered))
|
||||
for note in notes { print(" └─ \(note)") }
|
||||
}
|
||||
|
||||
/// Standalone timing print for a cost that runs *outside* the keystroke frame
|
||||
/// (e.g. the async wide-table overlay reconcile fired after the edit settles).
|
||||
static func stamp(_ label: String, _ ms: Double, _ detail: @autoclosure () -> String = "") {
|
||||
guard enabled else { return }
|
||||
print(String(format: "⏱️ PERF %@ %.2fms %@", label, ms, detail()))
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// ContainerExtension.swift
|
||||
// MarkdownEngine
|
||||
//
|
||||
// Created by Luca Chen on 15.07.26.
|
||||
//
|
||||
// `:::` fenced container as the first block extension. Not registered by
|
||||
// default — the core engine parses pure markdown; embedders opt in via:
|
||||
//
|
||||
// configuration.extensions = [ContainerExtension()]
|
||||
//
|
||||
// Syntax:
|
||||
//
|
||||
// ::: note
|
||||
// Body text with **inline** markdown.
|
||||
// :::
|
||||
//
|
||||
// The fence lines hide while the caret is outside the block and reveal
|
||||
// muted while editing (mirroring code fences); the body keeps full inline
|
||||
// styling plus the container background. An unclosed container runs to the end
|
||||
// of the document (unlike an unclosed ``` fence, which stays literal text
|
||||
// until its closing fence exists).
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
public struct ContainerExtension: MarkdownExtension {
|
||||
|
||||
/// Well-known id.
|
||||
public static let identifier = "container"
|
||||
|
||||
/// Background tint applied to the container block.
|
||||
public var backgroundColor: NSColor
|
||||
|
||||
/// The default resolves per appearance: `withAlphaComponent` on a dynamic
|
||||
/// system color FREEZES it (same gotcha the table renderer documents), so
|
||||
/// the tint is rebuilt inside a dynamic provider and keeps tracking
|
||||
/// light/dark mode.
|
||||
public init(backgroundColor: NSColor = NSColor(name: nil) { appearance in
|
||||
var resolved = NSColor.systemBlue
|
||||
appearance.performAsCurrentDrawingAppearance {
|
||||
resolved = NSColor.systemBlue.usingColorSpace(.sRGB) ?? .systemBlue
|
||||
}
|
||||
return resolved.withAlphaComponent(0.12)
|
||||
}) {
|
||||
self.backgroundColor = backgroundColor
|
||||
}
|
||||
|
||||
public var id: String { Self.identifier }
|
||||
|
||||
public var block: BlockSyntax? {
|
||||
BlockSyntax(fence: ":::")
|
||||
}
|
||||
|
||||
public func contentAttributes(theme: MarkdownEditorTheme) -> [NSAttributedString.Key: Any] {
|
||||
[.backgroundColor: backgroundColor]
|
||||
}
|
||||
|
||||
public func html(childrenHTML: String) -> String {
|
||||
"<blockquote>\(childrenHTML)</blockquote>"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user