27 Commits
Author SHA1 Message Date
Puranjay Savar Mattas 7c083b0ba5 fix: silence unused withLock result warning in OutlineImageProvider
lock.withLock { inFlight.remove(url) } inside the defer returned
Set.remove's String? result unused - withLock mirrors its closure's
return type, so an unused Set.remove leaked through as an unused
withLock result. Explicitly discard it inside the closure instead.
2026-08-21 04:40:33 +01:00
Puranjay Savar Mattas d0b6b35bda fix: lower project file format from objectVersion 110 to 77
Xcode Cloud (running a stable, non-beta Xcode) rejected the project
outright: "cannot be opened because it is in a future Xcode project
file format (110)". 110 is whatever the local beta Xcode (this whole
project has been developed against Xcode 27 beta) bumps the format to
on save - not something the project actually needs.

The project itself already had the answer: preferredProjectObjectVersion
= 77 was already present, Xcode's own note that 77 is the actual
minimum format required (it's what introduced fileSystemSynchronizedGroups,
which this project uses - the mechanism that's been letting new source
files just get picked up without explicit PBXFileReference entries all
session). Set objectVersion to match.

Opening/saving this project again in the local beta Xcode will very
likely bump objectVersion back to 110 silently - worth checking this
value before any future push if that happens.
2026-08-21 04:35:43 +01:00
Puranjay Savar Mattas 289e3062a1 chore: bump build number to 3 (0.1.0 build 3)
CURRENT_PROJECT_VERSION 2 -> 3 for the Outpost app target only
(Debug + Release), test targets left alone. OutpostVersion.swift's
buildNumber fallback and doc-comment updated to match.
2026-08-21 03:41:03 +01:00
Puranjay Savar Mattas 71070daf86 chore: bump build number to 2 (0.1.0 build 2)
CURRENT_PROJECT_VERSION 1 -> 2 for the Outpost app target (Debug +
Release only, test targets left alone, same scope as the
MARKETING_VERSION bump). OutpostVersion.swift's buildNumber fallback
and doc-comment example updated to match.
2026-08-21 02:55:19 +01:00
Puranjay Savar Mattas f1cc8607b0 fix: lower macOS deployment target from 27.0 to 14.0
MACOSX_DEPLOYMENT_TARGET was 27.0 across all 6 build configs (app +
both test targets) - an artifact of Xcode defaulting it to match the
beta SDK this is developed against, not an actual code requirement.
At 27.0 the app would only install for people running a macOS beta
that doesn't even exist for the general public yet.

14.0 is the real floor, already documented as the target in
CLAUDE.md ("macOS 14+ minimum, needed for mature AttributedString/
TextKit 2 APIs") and required independently by @Observable/SwiftData,
which are used throughout (SessionStore, StarStore, TipJarStore,
OfflineCacheStore, APIFailureCenter, etc.). Everything else added
this session - StoreKit 2, ImageRenderer, requestReview, CryptoKit -
is available well before 14, so nothing pushes the floor higher.

README's "macOS 27+" end-user requirement corrected to 14+ to match
(the "Xcode 27+" line just above it is a separate, still-accurate
build-from-source requirement, left alone).

Not compiler-verified against the 14.0 SDK - this project has never
actually been built with this deployment target before (always 27.0
during this session's development), so a real Xcode build now may
surface "only available in macOS 15+/26+" errors on any API used
without realizing it needed newer than 14. Needs an actual build
before submission, not just a lower number in the project file.
2026-08-21 02:53:26 +01:00
Puranjay Savar Mattas 48377668b0 Merge pull request 'v0.1.0 stability, security, and App Store readiness' (#14) from chore/v0.1.0-stability-audit into main
Reviewed-on: #14
2026-08-21 02:02:23 +01:00
Puranjay Savar Mattas 8db8e985a7 release: drop alpha framing, TestFlight badge -> Mac App Store, bump to 0.1.0
App Store link: https://apps.apple.com/us/app/outpost-for-outline/id6802736230

README's TestFlight badge replaced with Apple's official "Download on
the Mac App Store" badge (docs/assets/mac-app-store-badge.svg, black
lockup, from Apple's official marketing badge kit), linked to the
real App Store listing. "Early alpha" language dropped from README,
CONTRIBUTING.md, SECURITY.md, and both Gitea issue templates -
these are now "0.1.x"/"early" rather than "0.0.x"/"alpha", matching
the actual release.

OutpostVersion.releaseStage is now "" instead of "ALPHA" - About page
and the Settings sidebar footer both read through this single source
of truth, so this alone drops the "-ALPHA" suffix everywhere it was
shown without touching either call site.

MARKETING_VERSION bumped 0.0.4 -> 0.1.0 for the Outpost target
(Debug + Release) - left OutpostTests/OutpostUITests' MARKETING_VERSION
alone, that's just Xcode's unrelated template default for test
bundles, never shown to a user.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:58:39 +01:00
Puranjay Savar Mattas b8ce517b60 perf: stop recomputing derived state on every SwiftUI render
An audit for v0.1.0 turned up the same pattern in four places: a
computed property doing real work (filtering/sorting/scoring a
collection), read multiple times per render including from
unrelated state changes (selection, hover, scroll), so the work
reran far more often than the underlying data actually changed.
Converted each to a @State cache recomputed only via onChange of its
real inputs:

- DocumentSearchSheet: matchingLineIndices re-scanned the whole
  document per access, read once per visible row plus twice more in
  the header/step logic - O(n^2) case-insensitive scan per frame on
  a large document. Also split into an ordered array (for
  currentMatchIndex/stepping) plus a parallel Set for the per-row
  highlight check, which was an O(k) linear .contains before.
- CollectionDocumentsOutline: tree rebuilt the whole dictionary-
  grouped, recursively-sorted document tree on every body
  evaluation, not just when documents/sortOption actually changed.
- CommandPaletteView: results re-scored and re-sorted the entire
  index (up to the whole local workspace cache in Full Workspace
  mode) on every render, including ones from selectedIndex moving as
  arrow keys are pressed.
- CollectionOverviewView: sortedDocuments re-sorted on every render;
  same pattern, smaller blast radius (capped at 100 docs).

Also:
- HomeViewModel.fetchPinnedThrowing fetched each pinned document
  serially in a for loop (one round trip at a time) - switched to a
  TaskGroup so latency doesn't scale with pin count, results
  reordered back to pins.list's own order since task completion
  order isn't submission order.
- AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG
  compression synchronously on the main actor from the "Use Photo"
  button tap. ImageRenderer itself has to stay on the main actor (it
  captures live SwiftUI state), but JPEG compression on the already-
  rendered bitmap has no SwiftUI dependency left - hopped that part
  to a detached Task via tiffRepresentation (plain Data, unlike
  NSImage itself isn't Sendable) so it doesn't hitch the UI.

No crash risks or retain cycles found in the same audit (no try!/
as!, force-unwraps essentially absent outside a hardcoded URL
literal, weak self already used where it matters) - this is purely
the perf half of the findings.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:48:20 +01:00
Puranjay Savar Mattas 5de445daa5 feat(app): rewire account footer menu - App Store feedback, support link
Center-aligned TipJarView's "Support Outpost" heading and thank-you/
error text to match the rest of AboutInfoView (was VStack(alignment:
.leading), out of place among everything else there being centered).

AccountFooter's Documentation/API Documentation/Changelog links
pointed at Outpost's own repo or the signed-in Outline server's own
/developers page - not actually useful here, removed along with the
now-unused repositoryURL/issuesURL/apiDocumentationURL. Send Us
Feedback and Report a Bug (previously both just opening the Gitea
issues page) collapsed into one "Leave Us Feedback" wired to Apple's
native requestReview() prompt - there's no separate Apple-native
channel for "bug" vs "feedback", so one button covers both.

Added "Support Outpost" near the bottom of the same menu, alongside
Profile/Settings (same visual weight, not pinned to the top) - opens
Settings -> About, same navigation the app-menu's "About Outpost"
command already uses.

Also checking in the shared Xcode scheme (previously untracked/
nonexistent) now that it references Configuration.storekit, so the
StoreKit testing setup travels with the repo instead of being
machine-local.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:38:11 +01:00
Puranjay Savar Mattas 8ca5735019 feat(app): tip jar (StoreKit consumables) in Settings -> About
Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99),
Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small,
medium,large,generous}. TipJarStore loads them via
Product.products(for:), purchases via product.purchase(), finishes
the transaction immediately on success - consumables have no
entitlement to persist or restore (a tip doesn't unlock anything), so
there's none of the Transaction.currentEntitlements restore-on-launch
logic a real purchase would need. TipJarView shows one button per
tier (price + name, StoreKit's own localized display strings) with a
"Thank you!" after success or a plain message on failure - no manual
retry button, tapping a tier again just re-attempts.

Wired into AboutInfoView between the source link and the copyright
line.

Added Configuration.storekit (4 products matching the IDs above) for
local testing in Xcode without needing real App Store Connect
products yet - enable it via Edit Scheme -> Run/Preview -> Options ->
StoreKit Configuration. Before actually shipping, the same 4 product
IDs need to exist for real in App Store Connect (Consumable type,
matching reference names) - the local file doesn't create anything
there.

Not compiler-verified - Outpost app target has no CLI build path.
2026-08-21 01:23:48 +01:00
Puranjay Savar Mattas f624ce6c9f feat(app): clear the cache encryption key on sign-out, note it in Settings
SessionStore.signOut() now clears the offline cache's Keychain-stored
encryption key alongside the API token, and wipes the cache/pending-
write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut())
before doing so - so a previous account's cached content isn't sitting
there readable (even in principle, if the on-disk rows survive) by
whoever signs in next on the same machine. signOut() is async now to
do this properly instead of firing a detached Task; both call sites
(the logout confirmation dialog, delete-account) updated.

Settings -> Offline & Sync now states plainly that the local cache is
encrypted at rest and cleared on log out - not compiler-verified
(Outpost app target has no CLI build path), worth a look in Xcode.
2026-08-21 01:15:26 +01:00
Puranjay Savar Mattas 20baab78c0 feat(outlinekit): encrypt the offline cache at rest
Both CachedPayload and PendingOperation only ever stored their
payload as plain JSON on disk (SwiftData/SQLite, no encryption of its
own) - readable by anyone with access to the logged-in session, per
the earlier discussion on where this cache lives. Adds AES-GCM
encryption at the one place raw bytes cross into/out of
OfflineCacheStore (CachingOutlineAPIClient, which already owns
encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob
store, since its key/id/kind columns can't be encrypted without
breaking the #Predicate queries built against them.

Key management (KeychainCacheEncryptionKeyStore, mirrors
KeychainTokenStore exactly): a random 256-bit key, generated once and
Keychain-stored, not derived from anything guessable. It doesn't need
deriving to survive an uninstall/reinstall either - Keychain items
are scoped to the app's code signature, not its on-disk presence, so
a reinstall of the same app regains access to the same key
automatically (same reason a saved API token already survives a
reinstall today). If the on-disk cache also happens to survive
(dragging the .app to the Trash doesn't clean ~/Library/Containers),
a reinstall can still read it.

A row written before this shipped (still plaintext) or encrypted
under a since-cleared key just fails to decrypt and is treated as a
cache miss - same as any other decode failure, so it silently
refetches and re-caches encrypted rather than crashing. No explicit
migration needed.

Also: OfflineCacheStore.clearEverything() wipes both the cache AND
the pending write queue (clearAll(), used by Settings' "Clear All
Cache", still only touches the cache - it shouldn't silently discard
someone's unsynced edits). Exposed as
CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to
use alongside clearing the key.

CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests
never touch the real Keychain - existing CachingOutlineAPIClientTests
now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests
(retry/failure-log tests from the previous commit plus 3 new ones
here: ciphertext isn't plaintext JSON, a cleared key makes old rows
unreadable, clearEverythingForSignOut wipes both tables). 95/95
passing.
2026-08-21 01:15:15 +01:00
Puranjay Savar Mattas 1580510cfb fix(engine): silence PERF logs by default, fix "modifying state during view update"
PerfTrace was opt-out (MD_PERF=0 to silence) so every Debug build
printed a PERF line per keystroke unconditionally. Flipped to opt-in
(MD_PERF=1 to enable) - still fully available for future perf work,
just quiet by default.

"Modifying state during view update": onCodeBlockSelectionChange,
onSelectedTextChange, and onCommentAnchorRectsChange could all fire
synchronously from inside NativeTextViewWrapper.updateNSView's own
call stack - a programmatic edit (pendingInlineReplacement/
pendingTextInsertion/pendingTextRangeReplacement) re-enters
textViewDidChangeSelection/textDidChange synchronously (AppKit resets
selection on edit), which is still a SwiftUI view update in progress.
Calling straight into the embedder's @State setter there is exactly
what trips the warning. Routed all three through new
fireCodeBlockSelectionChange/fireSelectedTextChange/
fireCommentAnchorRectsChange helpers on the coordinator that defer
one runloop tick via DispatchQueue.main.async - same technique
NativeTextViewWrapper already uses to clear its own pending*
bindings, just centralized instead of ad-hoc per call site.

322/322 tests passing.
2026-08-21 00:56:37 +01:00
Puranjay Savar Mattas 5b876d7085 chore: remove Keyboard Shortcuts menu item and window
No real app-specific shortcuts existed - the panel only listed
Return/⌘,/⌘W/⌘Q (standard macOS conventions everyone already knows,
and it didn't even include the app's actual shortcuts like ⌘K for
Command Palette). Removed the menu item from AccountFooter's bottom
menu, the Window scene that hosted it, and KeyboardShortcutsView
itself plus WindowConfigurator.swift (disablesFullScreen() had no
other caller once this was gone).
2026-08-21 00:50:39 +01:00
Puranjay Savar Mattas 5d5cda9cea fix: RetryPolicy.withRetry closure label, missing Foundation import
RetryPolicy.withRetry's operation param wasn't anonymous (_), so the
14 Outpost call sites that pass the closure in parens - RetryPolicy.
withRetry({ ... }) - rather than as a trailing closure failed to
compile ("Missing argument label 'operation:'" cascading into
nonsense errors about maxAttempts). OutlineKit's own internal call
sites all happened to use trailing-closure syntax, so this only
showed up once the app target actually got compiled. One-line fix at
the declaration (_ operation:) instead of touching every call site -
trailing-closure calls are unaffected either way.

APIFailureCenter.swift used Date/TimeInterval/URL/URLComponents/
URLQueryItem while only importing Observation and OutlineKit -
missing import Foundation. Other new files in the same commit escaped
this because they import SwiftUI, which re-exports Foundation
transitively; this one didn't.

OutlineKit: 92/92 still passing.
2026-08-21 00:48:17 +01:00
Puranjay Savar Mattas 7369b46b87 feat(app): auto-retry every remaining try?-swallowed API call, add repeated-failure banner
Second half of the silent-failure fix - OutlineKit's RetryPolicy and
CachingOutlineAPIClient tracking landed in 512c6d2, this wires the
rest of the app onto it.

Every bare `try? await apiClient.X(...)` that bypasses
CachingOutlineAPIClient's own caching (listPins, listSubscriptions,
listViews, listStars, documentUsers, listUsers, listComments,
currentUser, installationInfo, authInfo, deleteAttachment - the
"pass-through" methods) now goes through RetryPolicy.withRetry first,
so a single transient blip gets absorbed automatically instead of
just returning nil. Calls that were already routed through
CachingOutlineAPIClient's cached-read path (documentInfo,
listDocuments, listCollections, etc.) are left alone - they picked up
retry and repeated-failure tracking for free from the previous commit
and wrapping them again would've just retried twice.

New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's
repeatedFailureSummaries() into a banner - RootView polls it every
30s while signed in (cheap, no network call of its own) and shows
RepeatedFailureBanner for whichever category is currently past the
threshold. No manual "Retry" button - the retries already happened
automatically before the banner ever appears, so the only actions are
Report (opens a prefilled Gitea issue - category, generic error
description, app/OS version, no document content or server URL) and
dismiss, which starts a 15-minute cooldown so a still-flaky operation
doesn't immediately pop the same banner back up.

Not compiler-verified - the Outpost app target has no CLI build path,
only OutlineKit does (92/92 passing as of the previous commit, no
OutlineKit changes here).
2026-08-21 00:45:58 +01:00
Puranjay Savar Mattas 512c6d22bf feat(outlinekit): auto-retry with backoff and repeated-failure tracking
Foundation for turning the app's try?-swallowed API failures (see the
pins bug) into something self-diagnosing instead of silent, without
a manual "Retry" button nagging the user for every blip.

RetryPolicy.withRetry wraps a call with exponential backoff, but only
for OutlineAPIError.transport - a decode/auth/server error will look
identical on a second try, so those fail immediately instead of
burning the cooldown window. CachingOutlineAPIClient now runs every
live call (both the cached-read path and the queueable-write path)
through it, and keeps a per-category sliding-window failure log:
repeatedFailureSummaries() surfaces a category only once it's failed
3+ times in 5 minutes with a structural (non-transport) error -
plain connectivity loss is deliberately excluded since that already
has its own offline UI elsewhere, and logging it here too would just
be a redundant second banner every time Wi-Fi drops.

Pull-based (polled), not push - this actor has no UI dependency of
its own, so the Outpost-side banner reads this periodically instead
of the client taking a callback. Categories are coarse (documents,
collections, pins, subscriptions, stars, drafts, etc.) and the
summaries carry no document content or server URL, only a generic
error description - safe to show a user or attach to a bug report
as-is.

10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests),
92/92 passing overall.
2026-08-21 00:41:26 +01:00
Puranjay Savar Mattas aa02153b5d chore: hide unbuilt Workspace section and Advanced coming-soon rows
App Store review won't accept a settings section that's just "Coming
Soon" placeholders. Workspace (all 14 sub-sections: details,
authentication, security, ai, members, groups, templates, emojis,
applications, shared, links, webhooks, importData, exportData) had
zero built content, so it's filtered out of SettingsSidebarList
entirely rather than shown with a Coming Soon badge - via a new
visibleCategories helper (categories with at least one isImplemented
section), not by touching the SettingsSection enum itself, so nothing
else that switches over it needs to change.

Advanced's three comingSoonRow placeholders (Export All Data,
Developer Diagnostics, Reset Local Database) are commented out the
same way, comingSoonRow() itself kept (unused for now) so re-enabling
either is a one-line job once real content lands. Both marked TODO.
2026-08-21 00:34:53 +01:00
Puranjay Savar Mattas 0393fe267e Merge pull request 'feat: Comments, drafts/publish, code block tooling, and editing preferences' (#13) from feature/document-editing into main
Reviewed-on: #13
2026-08-21 00:04:52 +01:00
Puranjay Savar Mattas c36c70382b redesign: pinned document cards on Home
PinnedDocumentCard was a squat single-line HStack chip (12/9 padding,
cornerRadius 8, flat tertiary fill) - in a grid of several it read as a
row of tab/segmented-control buttons rather than distinct documents.
Restyled to the same tall VStack card shape as DocumentCardView (the
tab grids below it): icon row, 2-line headline title, relative-date
caption, subtle border. The one thing that still marks these as
pinned vs. the grids below is an accent-filled circular pin badge in
the corner, replacing the old plain gray pin glyph.

Also wired the new pointerCursorOnHover() modifier onto the pinned
card buttons, same as every other clickable row in the sidebar/lists.
2026-08-20 22:26:21 +01:00
Puranjay Savar Mattas f01c2b9467 feat: pointer cursor preference
App-side chrome: new "Pointer Cursor" toggle (Settings -> Editor,
outpost.pointerCursorEnabled, on by default) driving a shared
.pointerCursorOnHover() view modifier (push/pop NSCursor.pointingHand,
macOS-only, no-op on iOS) wired onto every clickable sidebar/list row:
collection tree rows + disclosure chevron, flat collection list,
document outline rows, collection-overview tab bar + document/search
rows, global search results, command palette rows.

In-document link hover: MarkdownEditorConfiguration gets a new
pointerCursorOverLinksWhileEditing flag (default true). Read-only mode
already showed a pointing hand over links unconditionally; editable
mode never did (I-beam only) until now - applyReadOnlyCursor in
NativeTextView+CursorRects.swift now applies the same over any .link
range while editing too, gated by the flag. Wiki links already carry
.link alongside their own custom .wikiLinkID, so they're covered with
no extra work.

Closes out the last item from the original preferences-wiring scope.
2026-08-20 22:21:36 +01:00
Puranjay Savar Mattas 283186b28f feat: code block language picker
Top-left pill overlay on every code block (opposite corner from the
engine's own copy button) showing the current language; tapping opens
a curated ~20-language menu that rewrites just the fence line,
content untouched.

New engine mechanism to support it: CodeBlockSelection now exposes
fenceRange (the opening ```lang line's range, straight from the
tokenizer's own markerRanges[0] - was already computed, just never
exposed) plus a generic TextRangeReplacementRequest/
pendingTextRangeReplacement, since the existing pendingTextInsertion
only replaces at the current caret/selection, not an arbitrary
already-known range like a fence line the user isn't necessarily
positioned at.

Horizontal scroll for long lines investigated and dropped, per
explicit instruction after weighing it: the only viable path (a real,
selectable/editable independently-scrolling text region) needs an
embedded NSTextAttachmentViewProvider-backed NSTextView per code
block, which would split selection/copy/undo into two contexts
(the code block's own vs. the surrounding document's) instead of one
unified editing surface - a different regression, not a clean win.
Code blocks keep wrapping.
2026-08-20 22:07:48 +01:00
Puranjay Savar Mattas 2d0972de1e feat: create inline (anchored) comments from a text selection
Right-click a selection -> "Comment on Selection..." -> opens the
comments sheet straight into composing, with a removable chip showing
what's being anchored to (clearing it falls back to a plain
document-level comment). Posts via comments.create with anchorText
set to the selection.

Only wired on rendered panes (main reader pane, Split View's preview
pane) - not the raw-markdown pane, since a selection there would
capture literal Markdown syntax as the anchor instead of clean prose.

NSMenuItem has no closure initializer, so this needed a small
target-is-self subclass (ClosureMenuItem) to wire a Swift closure
into onBuildContextMenu's NSMenu without a separate selector per
action.

Also moved the comments sheet's .sheet(...) modifier off the toolbar
badge button (which only exists once the doc already has comments)
onto the view root, since a document with zero comments still needs
to be able to open the sheet to create its first one. Reader now
refetches its own comment list after any create/reply via the sheet's
new onCommentsChanged callback, so a freshly anchored comment's inline
marker appears without reopening the document.
2026-08-20 21:52:13 +01:00
Puranjay Savar Mattas b31d49bb7f feat: drafts, publish flow, and full comment threading
Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
  confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish...  for an unpublished
  document instead of an unconditional Unpublish (which could
  previously be tapped on a draft at all). Publish opens a
  MoveDocumentSheet-style collection/parent picker, pre-filled from
  the draft's own collectionId/parentDocumentId when it already has
  one. Publishing itself is documents.update(publish: true,
  collectionId:) for the collection placement, plus a second
  documents.move call only when a specific parent document was also
  picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
  optional on CreateDocumentRequest, previously collectionId was
  required so a draft couldn't be created from this sheet at all).
  Contextual entry points (right-click a collection/document) still
  pre-fill that location, but now show a warning that doing so
  auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
  the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
  sidebar showing stale state until an unrelated trigger (the 45s
  poll, navigating away and back) happened to catch it up.

Comments:
- Replies (comments.create with parentCommentId, one level of nesting
  same as Outline's own limit) and emoji reactions
  (comments.add_reaction/remove_reaction, confirmed against Outline's
  server source - not in the spec, and return {success: true} rather
  than the updated comment, so a toggle refetches via comments.info
  for the real post-toggle state) plus a document-level "new comment"
  composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
  (CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
  resolves an anchored comment's anchorText to an on-screen rect via
  the same viewRect utility the code-block copy button uses, kept in
  sync on typing/resize/reflow the same way the code-block and image
  positioning fixes earlier this session are. Renders as a thin blue
  bar next to the commented text; tapping it opens the comments sheet
  scrolled and highlighted to that thread. First-occurrence text
  search only (Outline's API returns no position data, and no
  prefix/suffix on read) - creating new anchored comments from this
  app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
  icon, caps at "10+".
2026-08-20 21:31:36 +01:00
Puranjay Savar Mattas 277e5fb4ae feat: add comment marker (list + resolve/unresolve)
Wraps Outline's Comments API in OutlineKit for the first time -
comments.list is documented in the vendored spec; comments.resolve/
unresolve are not, confirmed real against Outline's own server source
instead of guessed. Comment bodies are ProseMirror documents (data),
not plain text - added a small recursive JSONValue tree plus a
best-effort plainText() walk for display, since nothing here needs to
write comment bodies back (out of scope for this pass, see
DocumentCommentsSheet's doc comment).

Reader toolbar gets a marker (bubble icon + count badge) only when
the document actually has comments, per explicit instruction that
this should be a simple symbol rather than a true in-document gutter
marker at each comment's anchor position - anchoring is plain-text-
substring-based server-side and doesn't map cleanly onto this app's
own Markdown rendering. Opens a read-only comment list with a
Resolve/Unresolve toggle per the confirmed scope for this pass; no
creating or replying yet.
2026-08-20 20:52:44 +01:00
Puranjay Savar Mattas 1a58d91bb3 fix: images resize with window, blend toolbar into content
Images sized themselves once at restyle time and never got
re-measured on a pure window/pane resize (no text change, no image
fingerprint change) - so they'd stay whatever width they were last
styled at. The engine already had the fix for exactly this shape of
problem for wide tables (a stamped .scrollableBlockFullRange
attribute triggers a targeted restyle on width change), and the
shared image-rendering helper already had a restyleOnWidthChange flag
to opt into it - just never passed at the image call sites. Wired it
on for both ![]() and ![[embed]] rendering.

Also makes the titlebar/toolbar strip blend into the sidebar's
background instead of reading as a separate bar, matching Mail/Notes/
Finder - titlebarAppearsTransparent + fullSizeContentView via a small
NSViewRepresentable, same "reach into NSWindow directly" pattern
applyMacAppearance() already uses since SwiftUI's WindowGroup has no
direct API for either.
2026-08-20 15:04:50 +01:00
Puranjay Savar Mattas ccbd84c05b feat: add Image Playground and fix Markdown image rendering
Image Playground integration: reader toolbar button ("Create Image
with Image Playground") opens the system generator, seeded with the
current text selection when there is one. Result uploads through the
same attachments.create/upload flow as everything else and inserts
Outline's own stable ![](/api/attachments.redirect?id=...) reference
at the caret, via a new generic TextInsertionRequest/
pendingTextInsertion mechanism on NativeTextViewWrapper (nothing
previously let an embedder insert text into the editor from outside
at all).

Along the way, found and fixed a real pre-existing gap: standard
![alt](url) Markdown images never rendered anywhere in the app -
services.images was never wired to anything but the no-op default, so
every such image silently fell back to dimmed raw source. Added
OutlineAPIClient.fetchAuthenticatedFile (Bearer-authed GET, for
attachments.redirect and similar) and OutlineImageProvider, a real
EmbeddedImageProvider backed by it, wired into every render surface
(reader, split-view preview, present sheet, collection overview).

Also fixes two Split View bugs surfaced while building this: the
raw-source pane was a plain SwiftUI TextEditor with no selection or
insertion hook, so the Image Playground button couldn't see a
highlighted selection there and generated images had nowhere to land;
replaced it with NativeTextViewWrapper in rawSourceMode (same engine,
no styling overhead, but now selection/insertion work like every
other pane). Also moved onSelectedTextChange's firing point earlier
in the delegate, since it was previously placed after the
rawSourceMode early-return and so could never fire for a raw-mode
editor at all. And images sized off a possibly-not-yet-settled text
container width during Split View's frequent per-keystroke rebuilds,
sticking at the wrong size until the document was reopened - now
re-measured once more a tick after layout settles.

Also reorganizes Settings: Command Palette and its full-workspace-
search toggle move out of Editor into a new Navigation section, since
they're about finding things, not about how documents are edited.
2026-08-20 14:49:34 +01:00
83 changed files with 3613 additions and 322 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on. Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on.
- type: input - type: input
id: summary id: summary
attributes: attributes:
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea. Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea.
- type: input - type: input
id: summary id: summary
attributes: attributes:
+1 -1
View File
@@ -2,7 +2,7 @@
Thank you for contributing. Please read this guide before opening issues or PRs. Thank you for contributing. Please read this guide before opening issues or PRs.
Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it. Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it.
--- ---
@@ -0,0 +1,16 @@
import Foundation
import CryptoKit
/// Boundary over wherever the offline cache's symmetric encryption key
/// lives. Mirrors `TokenStoring` same reasoning, different secret.
public protocol CacheEncryptionKeyStoring: Sendable {
/// Returns the existing key, generating and persisting a new random one
/// on first use if none exists yet.
func key() throws -> SymmetricKey
/// Called on sign-out. Anything still encrypted with the cleared key
/// becomes permanently unreadable that's the point, not a bug: the
/// next person signed in on this machine shouldn't be able to read a
/// previous account's cached content just because the on-disk rows
/// happen to still be there.
func clear() throws
}
@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads
/// from `OfflineCacheStore`. Encryption lives at this layer rather than
/// inside `OfflineCacheStore` itself that stays a dumb opaque-blob store;
/// its `key`/`id`/`kind` columns can't be encrypted without breaking the
/// `#Predicate` queries built directly against them.
enum CachePayloadCryptor {
enum CryptoError: Error {
case sealingFailed
}
static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed }
return combined
}
static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import CryptoKit
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline /// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know /// support at the existing protocol boundary, so no view model needs to know
@@ -36,11 +37,33 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private let encoder: JSONEncoder private let encoder: JSONEncoder
private let decoder: JSONDecoder private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder private let keyEncoder: JSONEncoder
private let encryptionKeyStore: CacheEncryptionKeyStoring
/// Resolved lazily and kept for this instance's lifetime a fresh
/// instance is created on every sign-in/sign-out anyway (see
/// `SessionStore.makeAPIClient`), so there's no staleness risk, just
/// one fewer Keychain round-trip per cache read/write.
private var cachedEncryptionKey: SymmetricKey?
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { /// Recent failure timestamps per category see `recordFailure` /
/// `repeatedFailureSummaries()`. A category only shows up there once it's
/// failed `failureThreshold` times within `failureWindow`; a single
/// transient blip (which `RetryPolicy` already tries to absorb) never
/// reaches this at all.
private var failureLog: [String: [Date]] = [:]
private var lastFailureMessage: [String: String] = [:]
private let failureWindow: TimeInterval = 300
private let failureThreshold: Int = 3
public init(
live: OutlineAPIClient,
cache: OfflineCacheStore,
defaults: UserDefaults = .standard,
encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore()
) {
self.live = live self.live = live
self.cache = cache self.cache = cache
self.defaults = defaults self.defaults = defaults
self.encryptionKeyStore = encryptionKeyStore
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// MARK: - Cached reads // MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument { public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
} }
public func listDocuments( public func listDocuments(
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
limit: Int limit: Int
) async throws -> [OutlineDocument] { ) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key) { return try await cachedFetch(key: key, category: "documents") {
try await self.live.listDocuments( try await self.live.listDocuments(
collectionId: collectionId, collectionId: collectionId,
parentDocumentId: parentDocumentId, parentDocumentId: parentDocumentId,
@@ -83,23 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
} }
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
try await self.live.listViewedDocuments(offset: offset, limit: 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)", category: "drafts") {
try await self.live.listDrafts(request)
}
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await cachedFetch(key: "collections:\(offset):\(limit)") { try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
try await self.live.listCollections(offset: offset, limit: limit) try await self.live.listCollections(offset: offset, limit: limit)
} }
} }
public func collectionInfo(id: String) async throws -> OutlineCollection { public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
} }
// MARK: - Queueable writes // MARK: - Queueable writes
@@ -115,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.createDocument(request) let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result) await cacheDocument(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return await queueDocumentCreate(request) return await queueDocumentCreate(request)
} }
} }
@@ -128,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.updateDocument(request) let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result) await cacheDocument(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return try await queueDocumentUpdate(request, dueTo: error) return try await queueDocumentUpdate(request, dueTo: error)
} }
} }
@@ -141,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
let result = try await live.updateCollection(request) let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
recordSuccess(category: "collections-write")
await cacheCollection(result) await cacheCollection(result)
return result return result
} catch { } catch {
recordWriteFailureIfStructural(category: "collections-write", error)
return try await queueCollectionUpdate(request, dueTo: error) return try await queueCollectionUpdate(request, dueTo: error)
} }
} }
@@ -153,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) }
recordSuccess(category: "pins")
return result
} catch {
recordWriteFailureIfStructural(category: "pins", error)
return await queuePinCreate(request)
}
} }
return await queuePinCreate(request) return await queuePinCreate(request)
} }
@@ -162,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deletePin(id: id) try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
recordSuccess(category: "pins")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "pins", error)
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return return
} }
@@ -174,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) }
recordSuccess(category: "subscriptions")
return result
} catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
return await queueSubscriptionCreate(request)
}
} }
return await queueSubscriptionCreate(request) return await queueSubscriptionCreate(request)
} }
@@ -183,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deleteSubscription(id: id) try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
recordSuccess(category: "subscriptions")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return return
} }
@@ -195,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarDocumentCreate(request)
}
} }
return await queueStarDocumentCreate(request) return await queueStarDocumentCreate(request)
} }
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) } do {
let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) }
recordSuccess(category: "stars")
return result
} catch {
recordWriteFailureIfStructural(category: "stars", error)
return await queueStarCollectionCreate(request)
}
} }
return await queueStarCollectionCreate(request) return await queueStarCollectionCreate(request)
} }
@@ -211,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
if await cancelIfNeverSynced(id: id) { return } if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled { if !isManualOfflineModeEnabled {
do { do {
try await live.deleteStar(id: id) try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
recordSuccess(category: "stars")
return return
} catch { } catch {
recordWriteFailureIfStructural(category: "stars", error)
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return return
} }
@@ -303,6 +372,34 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.listViews(request) 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 { public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request) try await live.addDocumentUser(request)
} }
@@ -343,6 +440,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.uploadAttachmentFile(result, fileData: fileData) 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 { public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id) try await live.deleteAttachment(id: id)
} }
@@ -407,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
await cache.clearAll() await cache.clearAll()
} }
/// Sign-out only see `OfflineCacheStore.clearEverything()` and
/// `CacheEncryptionKeyStoring.clear()`. Callers must clear the
/// encryption key too (this actor doesn't own that decision); wiping
/// the storage here without it would leave the key to be reused by
/// whoever signs in next.
public func clearEverythingForSignOut() async {
await cache.clearEverything()
}
/// Replays every queued operation against `live`, in the order they were /// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest. /// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary { public func flushPendingOperations() async -> SyncFlushSummary {
@@ -515,43 +625,87 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
/// SwiftData read, no network involved. /// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] { public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:") let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) }
} }
/// Every individually cached collection from the last Full Local Sync. /// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] { public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:") let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) } return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
} }
// MARK: - Helpers // MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T { private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just // Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live` // "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time // (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of // the device actually had a connection, defeating the point of
// deliberately testing/working as if offline. // deliberately testing/working as if offline.
if isManualOfflineModeEnabled { if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
do { do {
let result = try await fetch() let result = try await RetryPolicy.withRetry { try await fetch() }
if let data = try? encoder.encode(result) { recordSuccess(category: category)
if let data = encryptedEncode(result) {
await cache.save(data, forKey: key) await cache.save(data, forKey: key)
} }
return result return result
} catch { } catch {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { // Only a structural failure (decode/auth/server the server
// answered, but something's actually wrong) counts toward the
// repeated-failure log. Plain connectivity loss already has its
// own offline UI elsewhere; logging it here too would just be a
// second banner for the same thing every time Wi-Fi drops.
if !RetryPolicy.isRetryable(error) {
recordFailure(category: category, message: errorDescription(error))
}
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached return cached
} }
throw error throw error
} }
} }
private func resolvedEncryptionKey() throws -> SymmetricKey {
if let cachedEncryptionKey { return cachedEncryptionKey }
let key = try encryptionKeyStore.key()
cachedEncryptionKey = key
return key
}
/// Encrypts before it ever reaches SwiftData. `nil` on any failure
/// (matches the shape of the plain `try? encoder.encode(...)` this
/// replaces) a Keychain hiccup here should behave exactly like an
/// encode failure already did: skip caching this one value, not crash.
private func encryptedEncode(_ value: some Encodable) -> Data? {
guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil }
return try? CachePayloadCryptor.encrypt(plain, key: key)
}
/// Decrypts + decodes a value previously written by `encryptedEncode`.
/// A row written before this feature shipped (still plaintext JSON, or
/// anything encrypted under a key that's since been cleared by
/// sign-out) fails to decrypt and returns `nil` here same as any
/// other decode failure, so `cachedFetch` treats it as a cache miss and
/// refetches, not a crash.
private func decryptedDecode<T: Decodable>(_ type: T.Type, from data: Data) -> T? {
guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil }
return try? decoder.decode(type, from: plain)
}
/// Throwing counterpart for `replay(_:)`, where a genuine decode
/// failure needs to propagate (so `flushPendingOperations` records it
/// as a failed sync attempt) instead of silently vanishing.
private func decryptedDecodeThrowing<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey())
return try decoder.decode(type, from: plain)
}
private func requestKey(_ prefix: String, _ request: some Encodable) -> String { private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix return prefix
@@ -560,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
private func cacheDocument(_ document: OutlineDocument) async { private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) { if let data = encryptedEncode(document) {
await cache.save(data, forKey: "document:\(document.id)") await cache.save(data, forKey: "document:\(document.id)")
} }
} }
private func cacheCollection(_ collection: OutlineCollection) async { private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) { if let data = encryptedEncode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)") await cache.save(data, forKey: "collection:\(collection.id)")
} }
} }
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return } guard let data = encryptedEncode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
} }
@@ -611,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"), guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
@@ -640,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
// of it and the update would just fail every retry. // of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"), if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), 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 createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest( let resolvedCreate = CreateDocumentRequest(
title: merged.title, title: merged.title,
text: merged.text, text: merged.text,
@@ -662,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"), guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
} }
let merged = OutlineCollection( let merged = OutlineCollection(
@@ -712,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
} }
switch kind { switch kind {
case .createDocument: case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request) let result = try await live.createDocument(request)
await cacheDocument(result) await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan // The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again. // nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)") await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument: case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request) let result = try await live.updateDocument(request)
await cacheDocument(result) await cacheDocument(result)
case .updateCollection: case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request) let result = try await live.updateCollection(request)
await cacheCollection(result) await cacheCollection(result)
case .createPin: case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request) _ = try await live.createPin(request)
case .deletePin: case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id) try await live.deletePin(id: request.id)
case .createSubscription: case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request) _ = try await live.createSubscription(request)
case .deleteSubscription: case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id) try await live.deleteSubscription(id: request.id)
case .starDocument: case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request) _ = try await live.starDocument(request)
case .deleteStar: case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload) let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id) try await live.deleteStar(id: request.id)
case .starCollection: case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request) _ = try await live.starCollection(request)
} }
} }
/// Writes always queue on any failure (existing behavior, unchanged)
/// this only decides whether the failure is worth logging. A structural
/// error (decode/auth/server) queuing for later replay will likely just
/// fail the same way again next sync; a transient one might not. Either
/// way the queue doesn't change, only whether it's counted toward
/// `repeatedFailureSummaries()`.
private func recordWriteFailureIfStructural(category: String, _ error: Error) {
guard !RetryPolicy.isRetryable(error) else { return }
recordFailure(category: category, message: errorDescription(error))
}
private func recordFailure(category: String, message: String) {
let now = Date()
var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow }
timestamps.append(now)
failureLog[category] = timestamps
lastFailureMessage[category] = message
}
private func recordSuccess(category: String) {
failureLog[category] = nil
lastFailureMessage[category] = nil
}
/// Categories that have failed `failureThreshold`+ times within the last
/// `failureWindow` seconds meant to be polled periodically (see
/// `RootView`), not pushed, since this actor has no UI-facing dependency
/// of its own. A single blip never shows up here: `RetryPolicy` absorbs
/// transient failures before they're ever logged, and only structural
/// ones (decode/auth/server) get logged at all see
/// `recordWriteFailureIfStructural` and `cachedFetch`.
public func repeatedFailureSummaries() async -> [RepeatedFailure] {
let now = Date()
return failureLog.compactMap { category, timestamps in
let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow }
guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil }
return RepeatedFailure(category: category, message: message, count: recent.count)
}
}
private func errorDescription(_ error: Error) -> String { private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError { if let apiError = error as? OutlineAPIError {
switch apiError { switch apiError {
@@ -0,0 +1,76 @@
import Foundation
import CryptoKit
import Security
/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain
/// random 256-bit value deliberately NOT derived from anything guessable
/// (bundle id, device id, etc.). It doesn't need deriving to survive an
/// uninstall/reinstall of the app either: Keychain items are scoped to the
/// requesting app's code signature (bundle id + team id), not its on-disk
/// presence, so reinstalling the same app regains access to the same
/// Keychain item automatically exactly how `KeychainTokenStore`'s saved
/// token already survives a reinstall today. If the on-disk cache also
/// happens to survive (macOS doesn't clean out `~/Library/Containers` just
/// because the .app bundle was dragged to the Trash), the reinstalled app
/// can still decrypt it; a different app, or the same app after an explicit
/// sign-out (`clear()`), can't.
public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private let service: String
private let account: String
public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") {
self.service = service
self.account = account
}
public func key() throws -> SymmetricKey {
if let existing = try? readKey() { return existing }
let generated = SymmetricKey(size: .bits256)
try store(generated)
return generated
}
public func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw TokenStoreError.deleteFailed(status)
}
}
private func readKey() throws -> SymmetricKey {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw TokenStoreError.notFound
}
return SymmetricKey(data: data)
}
private func store(_ key: SymmetricKey) throws {
let data = key.withUnsafeBytes { Data($0) }
let query = baseQuery()
let existsStatus = SecItemCopyMatching(query as CFDictionary, nil)
if existsStatus == errSecSuccess {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) }
} else {
var addQuery = query
addQuery[kSecValueData as String] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) }
}
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
try? modelContext.save() try? modelContext.save()
} }
/// Wipes both the read-through cache AND the pending write queue
/// used only on sign-out (see `SessionStore.signOut()`), since the
/// encryption key backing every row here is about to be cleared too.
/// Anything left un-wiped would just become permanently undecryptable
/// garbage instead of readable by the next account signed in on this
/// machine deliberately more thorough than `clearAll()` (Settings'
/// "Clear All Cache", which never touches pending writes; that button
/// shouldn't silently discard someone's unsynced edits).
public func clearEverything() {
let cachedDescriptor = FetchDescriptor<CachedPayload>()
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
let pendingDescriptor = FetchDescriptor<PendingOperation>()
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue // MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing /// Upserts by `id` a second call with the same id (an edit coalescing
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
/// Documents the current user has recently viewed. Backed by `documents.viewed`. /// Documents the current user has recently viewed. Backed by `documents.viewed`.
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] 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`. /// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`. /// 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`. /// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] 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 /// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort. /// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
@@ -75,6 +92,14 @@ public protocol OutlineAPIClient: Sendable {
/// target. See `OutlineAttachment`/`CreateAttachmentResult`. /// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws 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 /// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed /// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet. /// against a live server specifically for attachments yet.
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit)) 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] { public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request) try await post("documents.search", body: request)
} }
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request) 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 { public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request) try await post("documents.add_user", body: request)
} }
@@ -278,6 +310,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
} }
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 { public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id)) try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
} }
@@ -485,6 +556,11 @@ private struct StarIDParams: Encodable {
let id: String let id: String
} }
private struct CommentReactionParams: Encodable {
let id: String
let emoji: String
}
private struct MoveDocumentResponse: Decodable { private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]? let documents: [OutlineDocument]?
let collections: [OutlineCollection]? 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,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,19 @@
import Foundation
/// A category of API call that's failed repeatedly within a short window
/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately
/// carries no request/response payload, document content, or server URL:
/// this is meant to be safe to show a user or attach to a bug report as-is.
public struct RepeatedFailure: Sendable, Identifiable, Equatable {
public let category: String
public let message: String
public let count: Int
public var id: String { category }
public init(category: String, message: String, count: Int) {
self.category = category
self.message = message
self.count = count
}
}
@@ -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
}
}
@@ -3,14 +3,16 @@ import Foundation
public struct CreateDocumentRequest: Codable, Sendable { public struct CreateDocumentRequest: Codable, Sendable {
public let title: String public let title: String
public let text: 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 parentDocumentId: String?
public let publish: Bool public let publish: Bool
public init( public init(
title: String, title: String,
text: String, text: String,
collectionId: String, collectionId: String? = nil,
parentDocumentId: String? = nil, parentDocumentId: String? = nil,
publish: Bool = true publish: Bool = true
) { ) {
@@ -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
}
}
@@ -7,6 +7,13 @@ public struct UpdateDocumentRequest: Codable, Sendable {
public let append: Bool? public let append: Bool?
public let fullWidth: Bool? public let fullWidth: Bool?
public let insightsEnabled: 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( public init(
id: String, id: String,
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Codable, Sendable {
text: String? = nil, text: String? = nil,
append: Bool? = nil, append: Bool? = nil,
fullWidth: Bool? = nil, fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil insightsEnabled: Bool? = nil,
collectionId: String? = nil,
publish: Bool? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Codable, Sendable {
self.append = append self.append = append
self.fullWidth = fullWidth self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled self.insightsEnabled = insightsEnabled
self.collectionId = collectionId
self.publish = publish
} }
} }
@@ -0,0 +1,38 @@
import Foundation
/// Automatic retry-with-backoff for API calls, so a single transient network
/// blip doesn't turn into a user-visible failure (or a silently swallowed
/// one) the way one `try?` used to.
///
/// Only retries `OutlineAPIError.transport` a dropped connection or
/// timeout might succeed a second later. Everything else (`.decoding`,
/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried
/// zero times: a response-shape mismatch or a 404 will look exactly the same
/// on attempt two, so retrying just burns the cooldown window for nothing
/// callers should treat those as immediate failures instead.
public enum RetryPolicy {
public static func withRetry<T: Sendable>(
maxAttempts: Int = 3,
initialDelay: Duration = .seconds(1),
_ operation: () async throws -> T
) async throws -> T {
var attempt = 1
var delay = initialDelay
while true {
do {
return try await operation()
} catch {
guard attempt < maxAttempts, isRetryable(error) else { throw error }
attempt += 1
try? await Task.sleep(for: delay)
delay *= 2
}
}
}
static func isRetryable(_ error: Error) -> Bool {
guard let apiError = error as? OutlineAPIError else { return false }
if case .transport = apiError { return true }
return false
}
}
@@ -1,4 +1,5 @@
import XCTest import XCTest
import CryptoKit
@testable import OutlineKit @testable import OutlineKit
private struct NotStubbed: Error {} private struct NotStubbed: Error {}
@@ -29,6 +30,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) 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 searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
@@ -71,6 +73,13 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() } func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { 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 addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() } func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() } func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
@@ -91,6 +100,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func currentUser() async throws -> OutlineUser { throw NotStubbed() } func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() } func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { 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 deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() } func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
@@ -107,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
private struct StubTransportError: Error {} private struct StubTransportError: Error {}
/// In-memory `CacheEncryptionKeyStoring` tests must never touch the real
/// Keychain (would pollute the developer's machine and can hang/fail in a
/// sandboxed CI runner with no Keychain access).
private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable {
private var stored: SymmetricKey?
func key() throws -> SymmetricKey {
if let stored { return stored }
let generated = SymmetricKey(size: .bits256)
stored = generated
return generated
}
func clear() throws { stored = nil }
}
final class CachingOutlineAPIClientTests: XCTestCase { final class CachingOutlineAPIClientTests: XCTestCase {
private func makeCache() throws -> OfflineCacheStore { private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
@@ -136,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
let document = makeDocument() let document = makeDocument()
stub.documentInfoHandler = { _ in document } stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let result = try await sut.documentInfo(id: "doc-1") let result = try await sut.documentInfo(id: "doc-1")
@@ -152,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return document } if callCount == 1 { return document }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// First call succeeds and populates the cache. // First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -166,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() } stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -185,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
if callCount == 1 { return collections } if callCount == 1 { return collections }
throw StubTransportError() throw StubTransportError()
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.listCollections(offset: 0, limit: 25) _ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25) let result = try await sut.listCollections(offset: 0, limit: 25)
@@ -198,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let docOne = makeDocument(id: "doc-1", title: "One") let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two") let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2") _ = try await sut.documentInfo(id: "doc-2")
@@ -218,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Populate the cache with the base document first (as a real open would). // Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
@@ -239,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do { do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
@@ -254,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = 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: "First Edit"))
@@ -267,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() } stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-")) XCTAssertTrue(pin.id.hasPrefix("pending-"))
@@ -285,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -309,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let original = makeDocument(id: "doc-1", title: "Original") let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original } stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
@@ -336,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
liveCallCount += 1 liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
} }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
@@ -361,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return [cachedCollection] return [cachedCollection]
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Online first populates the cache normally. // Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25) let firstResult = try await sut.listCollections(offset: 0, limit: 25)
@@ -380,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCacheStorageSummaryReflectsCachedItems() async throws { func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() } stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary() let summary = await sut.cacheStorageSummary()
@@ -409,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") } return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
} }
stub.listDocumentsHandler = { _, _, _, _ in [] } stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -430,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : [] return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2) XCTAssertEqual(summary.documentsCount, 2)
@@ -460,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
} }
} }
let cache = try makeCache() let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache) let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync() let summary = await sut.performFullSync()
@@ -475,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws { func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -499,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() } stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1") CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
@@ -519,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws { func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient() let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() } stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument( let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1") CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
@@ -544,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
// expected nothing left under the old id // expected nothing left under the old id
} }
} }
// MARK: - Repeated-failure tracking
/// A structural failure (decode/auth/server) isn't retried it fails
/// the same way every time, so `RetryPolicy` gives up after one attempt
/// and it's logged immediately. No cache entry means no fallback either,
/// so every call rethrows.
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.count, 1)
XCTAssertEqual(summaries.first?.category, "document")
XCTAssertEqual(summaries.first?.count, 3)
}
/// Two failures alone shouldn't trip the banner only three or more
/// within the window counts as "repeated."
func testFewerThanThresholdFailuresDoNotSurface() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<2 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// A later success clears the category entirely a transient run of
/// bad luck shouldn't leave a stale banner up after things recover.
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
return document
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
let beforeRecovery = await sut.repeatedFailureSummaries()
XCTAssertEqual(beforeRecovery.count, 1)
_ = try await sut.documentInfo(id: "doc-1")
let afterRecovery = await sut.repeatedFailureSummaries()
XCTAssertTrue(afterRecovery.isEmpty)
}
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
/// own offline UI elsewhere it shouldn't also pile up in the repeated-
/// failure log and pop a second, redundant banner.
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try? await sut.documentInfo(id: "doc-1")
let summaries = await sut.repeatedFailureSummaries()
XCTAssertTrue(summaries.isEmpty)
}
/// Different categories (document reads vs. pin writes) track
/// independently a broken pins endpoint shouldn't mask, or be masked
/// by, unrelated document failures, and one crossing the threshold
/// shouldn't drag an unrelated one along with it.
func testFailuresInDifferentCategoriesDoNotMix() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Document reads cross the threshold...
for _ in 0..<3 {
_ = try? await sut.documentInfo(id: "doc-1")
}
// ...pin creates don't.
for _ in 0..<2 {
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
}
let summaries = await sut.repeatedFailureSummaries()
XCTAssertEqual(summaries.map(\.category), ["document"])
}
// MARK: - At-rest encryption
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument(title: "Secret Title")
stub.documentInfoHandler = { _ in document }
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let raw = await cache.load(forKey: "document:doc-1")
XCTAssertNotNil(raw)
// A plain JSON encode would contain the literal title text in the
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
}
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
/// same shape as `SessionStore.makeAPIClient` always creating a new
/// instance) anything still on disk from before is unreadable under
/// the new key, which is the entire point of clearing it on sign-out.
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let cache = try makeCache()
let keyStore = StaticCacheEncryptionKeyStore()
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
_ = try await beforeSignOut.documentInfo(id: "doc-1")
try keyStore.clear()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
do {
_ = try await afterSignIn.documentInfo(id: "doc-1")
XCTFail("Expected the now-undecryptable cache entry to be unusable")
} catch is StubTransportError {
// expected live fails, and the leftover cache entry can't be
// decrypted under the new key either, so there's no fallback.
}
}
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try? await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Offline edit"))
let summaryBefore = await sut.cacheStorageSummary()
let pendingBefore = await sut.pendingOperations()
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
XCTAssertFalse(pendingBefore.isEmpty)
await sut.clearEverythingForSignOut()
let summaryAfter = await sut.cacheStorageSummary()
let pendingAfter = await sut.pendingOperations()
XCTAssertEqual(summaryAfter.itemCount, 0)
XCTAssertTrue(pendingAfter.isEmpty)
}
} }
@@ -846,6 +846,137 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list") 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 { func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -0,0 +1,69 @@
import XCTest
@testable import OutlineKit
private struct PlainError: Error {}
final class RetryPolicyTests: XCTestCase {
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
callCount += 1
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 1)
}
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
var callCount = 0
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
return "ok"
}
XCTAssertEqual(result, "ok")
XCTAssertEqual(callCount, 3)
}
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.transport(URLError(.timedOut))
}
XCTFail("Expected the persistent failure to be rethrown")
} catch {
XCTAssertEqual(callCount, 3)
}
}
/// A decode failure means the response is structurally wrong trying
/// again gets the exact same wrong response, so it isn't worth the
/// cooldown window the way a network blip is.
func testDoesNotRetryNonTransportErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw OutlineAPIError.decoding(PlainError())
}
XCTFail("Expected the decoding error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
var callCount = 0
do {
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
callCount += 1
throw PlainError()
}
XCTFail("Expected the error to be rethrown without retrying")
} catch {
XCTAssertEqual(callCount, 1)
}
}
}
+11 -11
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 110; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -402,7 +402,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -424,8 +424,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.4; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -454,7 +454,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -476,8 +476,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.0.4; MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -505,7 +505,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -531,7 +531,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -556,7 +556,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -581,7 +581,7 @@
DEVELOPMENT_TEAM = CW6GQT9SK5; DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0; IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C26302CE96200C9949F"
BuildableName = "OutpostTests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C30302CE96200C9949F"
BuildableName = "OutpostUITests.xctest"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</TestableReference>
</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"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../Outpost/Configuration.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAF99C17302CE96100C9949F"
BuildableName = "Outpost.app"
ReferencedContainer = "container:Outpost.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+74
View File
@@ -0,0 +1,74 @@
{
"identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A",
"nonRenewingSubscriptions" : [],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6",
"localizations" : [
{
"description" : "A small tip to support Outpost's development.",
"displayName" : "Small Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.small",
"referenceName" : "Small Tip",
"type" : "Consumable"
},
{
"displayPrice" : "2.99",
"familyShareable" : false,
"internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938",
"localizations" : [
{
"description" : "A medium tip to support Outpost's development.",
"displayName" : "Medium Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.medium",
"referenceName" : "Medium Tip",
"type" : "Consumable"
},
{
"displayPrice" : "4.99",
"familyShareable" : false,
"internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D",
"localizations" : [
{
"description" : "A large tip to support Outpost's development.",
"displayName" : "Large Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.large",
"referenceName" : "Large Tip",
"type" : "Consumable"
},
{
"displayPrice" : "9.99",
"familyShareable" : false,
"internalID" : "E072C1AC-2719-483E-8A54-F4924808B724",
"localizations" : [
{
"description" : "A generous tip to support Outpost's development.",
"displayName" : "Generous Tip",
"locale" : "en_US"
}
],
"productID" : "com.psmattas.OutpostApp.tip.generous",
"referenceName" : "Generous Tip",
"type" : "Consumable"
}
],
"settings" : {
"_askToBuyEnabled" : false
},
"subscriptionGroups" : [],
"version" : {
"major" : 3,
"minor" : 0
}
}
+5
View File
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
} }
.font(.callout) .font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas") Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
+75
View File
@@ -0,0 +1,75 @@
#if os(macOS)
import SwiftUI
import StoreKit
/// One button per consumable tip tier no "restore purchases" (nothing to
/// restore, consumables aren't entitlements) and no manual retry: a failed
/// load just shows a message, tapping a tier again re-attempts naturally.
struct TipJarView: View {
@State private var store = TipJarStore()
var body: some View {
VStack(spacing: 8) {
Text("Support Outpost")
.font(.callout.weight(.semibold))
if store.isLoading && store.products.isEmpty {
ProgressView()
.controlSize(.small)
} else if !store.products.isEmpty {
HStack(spacing: 8) {
ForEach(store.products) { product in
tipButton(for: product)
}
}
}
switch store.purchaseState {
case .thankYou:
Label("Thank you!", systemImage: "heart.fill")
.font(.caption)
.foregroundStyle(.pink)
case .failed(let message):
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
case .idle, .purchasing:
EmptyView()
}
}
.task { await store.loadProductsIfNeeded() }
}
private func tipButton(for product: Product) -> some View {
Button {
Task { await store.purchase(product) }
} label: {
VStack(spacing: 2) {
if isPurchasing(product) {
ProgressView()
.controlSize(.small)
} else {
Text(product.displayPrice)
.font(.callout.weight(.semibold))
}
Text(product.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(minWidth: 64)
.padding(.vertical, 6)
}
.buttonStyle(.bordered)
.disabled(isAnyPurchaseInFlight)
}
private func isPurchasing(_ product: Product) -> Bool {
store.purchaseState == .purchasing(product.id)
}
private var isAnyPurchaseInFlight: Bool {
if case .purchasing = store.purchaseState { return true }
return false
}
}
#endif
+14 -23
View File
@@ -1,5 +1,6 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import StoreKit
import OutlineKit import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
@@ -9,21 +10,13 @@ import OutlineKit
struct AccountFooter: View { struct AccountFooter: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation @Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL @Environment(\.requestReview) private var requestReview
@Environment(\.openWindow) private var openWindow
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false @State private var isShowingProfile = false
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
private var apiDocumentationURL: URL? {
session.serverURL?.appendingPathComponent("developers")
}
var body: some View { var body: some View {
Button { Button {
isMenuPresented = true isMenuPresented = true
@@ -63,20 +56,10 @@ struct AccountFooter: View {
private var menuContent: some View { private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") } // Apple's own review/feedback prompt there's no separate
// native channel for "bug" vs. "feedback", so one button covers
Divider() // both.
menuItem("Leave Us Feedback") { requestReview() }
menuItem("Documentation") { openURL(repositoryURL) }
if let apiDocumentationURL {
menuItem("API Documentation") { openURL(apiDocumentationURL) }
}
menuItem("Changelog") { openURL(repositoryURL) }
Divider()
menuItem("Send Us Feedback") { openURL(issuesURL) }
menuItem("Report a Bug") { openURL(issuesURL) }
Divider() Divider()
@@ -106,6 +89,14 @@ struct AccountFooter: View {
// as "Invalid attempt to open a new transaction during CA // as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it. // commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } } menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
// Same deferred-Task reasoning as Settings above this also
// sets isShowingSettings synchronously.
menuItem("Support Outpost") {
Task { @MainActor in
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
Divider() Divider()
@@ -74,8 +74,10 @@ struct AvatarCropperView: View {
Button("Cancel", role: .cancel, action: onCancel) Button("Cancel", role: .cancel, action: onCancel)
Spacer() Spacer()
Button("Use Photo") { Button("Use Photo") {
if let data = renderFinalImage() { Task {
onConfirm(data) if let data = await renderFinalImage() {
onConfirm(data)
}
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -100,15 +102,26 @@ struct AvatarCropperView: View {
.clipped() .clipped()
} }
/// `ImageRenderer` itself has to run on the main actor (it captures live
/// SwiftUI view state), but JPEG compression on the bitmap it produces
/// is pure CPU work with no SwiftUI dependency left hopping off for
/// just that part avoids a visible hitch on tapping "Use Photo".
/// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what
/// actually crosses the actor boundary; mirrors `NSImage.jpegData(
/// compressionQuality:)`'s own logic rather than calling it directly, so
/// crossing doesn't require handing a non-Sendable `NSImage` to a
/// detached task.
@MainActor @MainActor
private func renderFinalImage() -> Data? { private func renderFinalImage() async -> Data? {
let content = avatarContent let content = avatarContent
.clipShape(Circle()) .clipShape(Circle())
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content) let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let nsImage = renderer.nsImage else { return nil } guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
return nsImage.jpegData(compressionQuality: 0.9) return await Task.detached(priority: .userInitiated) {
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
}.value
} }
} }
#endif #endif
@@ -1,39 +0,0 @@
#if os(macOS)
import SwiftUI
struct KeyboardShortcutsView: View {
private struct Shortcut: Identifiable {
let id = UUID()
let action: String
let keys: String
}
private let shortcuts: [Shortcut] = [
Shortcut(action: "Sign In", keys: ""),
Shortcut(action: "Preferences", keys: "⌘ ,"),
Shortcut(action: "Close Window", keys: "⌘ W"),
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Keyboard Shortcuts")
.font(.title3.bold())
VStack(spacing: 10) {
ForEach(shortcuts) { shortcut in
HStack {
Text(shortcut.action)
Spacer()
Text(shortcut.keys)
.foregroundStyle(.secondary)
.monospaced()
}
}
}
}
.padding(24)
.frame(width: 280)
}
}
#endif
@@ -42,7 +42,15 @@ struct SettingsSidebarList: View {
Divider() Divider()
List(selection: $selection) { List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in // TODO: Workspace is entirely `!isImplemented` placeholders
// right now (details/authentication/security/ai/members/
// groups/templates/emojis/applications/shared/links/
// webhooks/importData/exportData) App Store review won't
// accept a section that's just "Coming Soon" rows, so it's
// filtered out of the sidebar below (via `visibleCategories`)
// until real content lands. Remove the filter once at least
// one Workspace section is built.
ForEach(visibleCategories) { category in
let sections = SettingsSection.allCases.filter { $0.category == category } let sections = SettingsSection.allCases.filter { $0.category == category }
Section { Section {
ForEach(sections) { section in ForEach(sections) { section in
@@ -91,6 +99,14 @@ struct SettingsSidebarList: View {
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() } .task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
} }
/// Categories with at least one built (`isImplemented`) section see the
/// TODO above the `ForEach` that uses this.
private var visibleCategories: [SettingsCategory] {
SettingsCategory.allCases.filter { category in
SettingsSection.allCases.contains { $0.category == category && $0.isImplemented }
}
}
private var versionFooter: some View { private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)") Text("Outpost \(OutpostVersion.displayString)")
@@ -109,7 +125,7 @@ struct SettingsSidebarList: View {
private func refreshOutlineVersion() async { private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return } guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
} }
} }
#endif #endif
+54 -7
View File
@@ -17,6 +17,8 @@ struct SettingsView: View {
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true @AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true @AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true @AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false @AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -98,6 +100,7 @@ struct SettingsView: View {
switch section { switch section {
case .appearance: appearanceDetail case .appearance: appearanceDetail
case .editor: editorDetail case .editor: editorDetail
case .navigation: navigationDetail
case .profile: profileDetail case .profile: profileDetail
case .preferences: preferencesDetail case .preferences: preferencesDetail
case .notifications: notificationsDetail case .notifications: notificationsDetail
@@ -188,6 +191,37 @@ struct SettingsView: View {
Divider().frame(maxWidth: 480) Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Image Playground", isOn: $isImagePlaygroundEnabled)
Text("Adds a \"Create Image with Image Playground\" button to the reader toolbar — generates an image from a text description (or your current selection, if any) and inserts it into the document. Requires macOS 15.1+ and a supported Mac — the toggle has no effect where it isn't available.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Pointer Cursor", isOn: $isPointerCursorEnabled)
Text("Show a pointing-hand cursor when hovering sidebar rows and links in a document, instead of the default arrow or I-beam.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
}
}
// MARK: - Navigation
/// Local-only settings for finding your way around the app separate
/// from Editor, which is scoped to how documents are actually edited.
private var navigationDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for finding documents and collections.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled) Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.") Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
@@ -584,7 +618,7 @@ struct SettingsView: View {
defer { isDeletingAccount = false } defer { isDeletingAccount = false }
do { do {
try await apiClient.deleteAccount() try await apiClient.deleteAccount()
session.signOut() await session.signOut()
} catch { } catch {
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
} }
@@ -1148,6 +1182,11 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
sectionHeader sectionHeader
Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
@@ -1291,9 +1330,10 @@ struct SettingsView: View {
} }
.frame(maxWidth: 480, alignment: .leading) .frame(maxWidth: 480, alignment: .leading)
Divider() // TODO: Export All Data / Developer Diagnostics / Reset Local
.frame(maxWidth: 480) // Database aren't built yet App Store review won't accept
// "Coming Soon" rows, so commented out until real content lands.
/*
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data") comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics") comingSoonRow("Developer Diagnostics")
@@ -1301,6 +1341,10 @@ struct SettingsView: View {
} }
.frame(maxWidth: 480, alignment: .leading) .frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
*/
Divider() Divider()
.frame(maxWidth: 480) .frame(maxWidth: 480)
@@ -1366,6 +1410,9 @@ struct SettingsView: View {
) )
} }
/// Only referenced from the commented-out block above right now kept
/// (not deleted) so re-enabling those rows is a one-line uncomment once
/// they're actually built.
private func comingSoonRow(_ title: String) -> some View { private func comingSoonRow(_ title: String) -> some View {
HStack { HStack {
Text(title) Text(title)
@@ -1469,7 +1516,7 @@ struct SettingsView: View {
private func refreshProfile() async { private func refreshProfile() async {
guard let apiClient = session.apiClient else { return } guard let apiClient = session.apiClient else { return }
guard let fresh = try? await apiClient.currentUser() else { return } guard let fresh = try? await RetryPolicy.withRetry({ try await apiClient.currentUser() }) else { return }
session.applyUpdatedProfile(fresh) session.applyUpdatedProfile(fresh)
} }
@@ -1501,7 +1548,7 @@ struct SettingsView: View {
// Best-effort the new avatar is already live either way, this // Best-effort the new avatar is already live either way, this
// just stops the old upload from sitting around unreferenced. // just stops the old upload from sitting around unreferenced.
if let previousAttachmentId { if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId) try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
} }
} catch { } catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.") avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
@@ -1518,7 +1565,7 @@ struct SettingsView: View {
session.applyUpdatedProfile(updated) session.applyUpdatedProfile(updated)
avatarErrorMessage = nil avatarErrorMessage = nil
if let previousAttachmentId { if let previousAttachmentId {
try? await apiClient.deleteAttachment(id: previousAttachmentId) try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
} }
} catch { } catch {
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.") avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
/// every document in the tree. /// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:] @State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] { /// Recomputed only when `viewModel.documents`/`sortOption` actually change
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) /// (below) instead of being a computed property this rebuilt the whole
/// dictionary-grouped, recursively-sorted tree on every `body` evaluation,
/// including renders triggered by unrelated state (selection, hover,
/// pins) that don't change the tree's shape at all.
@State private var tree: [DocumentNode] = []
private func rebuildTree() {
tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
} }
init( init(
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
.task(id: "\(refreshToken)-\(externalRefreshToken)") { .task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load() await viewModel.load()
await loadPins() await loadPins()
rebuildTree()
} }
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
} }
private func loadPins() async { private func loadPins() async {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return }
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
} }
} }
@@ -157,6 +167,7 @@ private struct DocumentNodeRow: View {
.frame(width: 12) .frame(width: 12)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
Button { Button {
@@ -186,6 +197,7 @@ private struct DocumentNodeRow: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 4) .padding(.horizontal, 4)
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
CollectionRowView(collection: collection) CollectionRowView(collection: collection)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
} }
} }
@@ -10,9 +10,13 @@ import OutlineKit
/// `isSelectable` and link-opening both still need to work. /// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View { struct CollectionOverviewContent: View {
@State private var markdown: String @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 ?? "") _markdown = State(initialValue: collection.description ?? "")
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
} }
var body: some View { var body: some View {
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $markdown, text: $markdown,
configuration: .init( configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent heightBehavior: .fitsContent
), ),
isEditable: false isEditable: false
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
.padding() .padding()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
} }
} }
#endif #endif
@@ -3,6 +3,7 @@ import SwiftUI
import OutlineKit import OutlineKit
struct CollectionOverviewView: View { struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel @State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview @State private var selectedTab: CollectionTab = .overview
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
searchQuery: Binding<String>, searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void onOpenDocument: @escaping (OutlineDocument) -> Void
) { ) {
self.apiClient = apiClient
self.collection = collection self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id)) _searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
self.onOpenDocument = onOpenDocument self.onOpenDocument = onOpenDocument
} }
private var sortedDocuments: [OutlineDocument] { /// Recomputed only when `viewModel.documents`/`selectedTab` actually
selectedTab.sorted(viewModel.documents) /// change (below) instead of being a computed property re-sorted on
/// every render capped at 100 documents per collection page, so lower
/// blast radius than the sidebar/command-palette versions of this same
/// pattern, but the same fix.
@State private var sortedDocuments: [OutlineDocument] = []
private func resortDocuments() {
sortedDocuments = selectedTab.sorted(viewModel.documents)
} }
var body: some View { var body: some View {
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty { if !trimmedSearchQuery.isEmpty {
searchResultsList searchResultsList
} else if selectedTab == .overview { } else if selectedTab == .overview {
CollectionOverviewContent(collection: collection) CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else { } else {
documentList documentList
} }
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
await viewModel.checkForRemoteChanges() await viewModel.checkForRemoteChanges()
} }
} }
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
} }
// Spans the full window width, centered, directly under the toolbar // Spans the full window width, centered, directly under the toolbar
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
DocumentRowView(document: document) DocumentRowView(document: document)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
} }
} }
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
} }
} }
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded) .animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent } .contextMenu { contextMenuContent }
@@ -41,22 +41,31 @@ struct CommandPaletteView: View {
} }
} }
private var results: [Result] { /// Recomputed only when `query`/`collections`/`documents` actually change
/// (below) instead of being a computed property Full Workspace mode's
/// index can be large (every document in the local cache, sub-documents
/// included), and this was re-scanning + re-sorting the entire thing on
/// every render, including ones triggered by unrelated state like
/// `selectedIndex` changing as arrow keys move the selection.
@State private var results: [Result] = []
private func recomputeResults() {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most // No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel // recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed. // doesn't dump the entire workspace with nothing typed.
return (collections.map(Result.collection) + documents.map(Result.document)) results = (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20) .prefix(20)
.map { $0 } .map { $0 }
return
} }
let scored: [(Result, Int)] = collections.compactMap { collection in let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) } matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in } + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) } matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
} }
return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
} }
/// Lower is better exact match, then prefix match, then earliest /// Lower is better exact match, then prefix match, then earliest
@@ -100,7 +109,10 @@ struct CommandPaletteView: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(.title3) .font(.title3)
.focused($isSearchFieldFocused) .focused($isSearchFieldFocused)
.onChange(of: query) { selectedIndex = 0 } .onChange(of: query) {
selectedIndex = 0
recomputeResults()
}
.onSubmit { selectCurrent() } .onSubmit { selectCurrent() }
// Attached directly on the field itself, not an // Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the // ancestor confirmed live that .onKeyPress on the
@@ -134,6 +146,7 @@ struct CommandPaletteView: View {
resultRow(result, isSelected: index == selectedIndex) resultRow(result, isSelected: index == selectedIndex)
.id(index) .id(index)
.contentShape(Rectangle()) .contentShape(Rectangle())
.pointerCursorOnHover()
.onTapGesture { .onTapGesture {
selectedIndex = index selectedIndex = index
selectCurrent() selectCurrent()
@@ -168,6 +181,8 @@ struct CommandPaletteView: View {
isSearchFieldFocused = true isSearchFieldFocused = true
await loadResults() await loadResults()
} }
.onChange(of: collections) { recomputeResults() }
.onChange(of: documents) { recomputeResults() }
} }
private func resultRow(_ result: Result, isSelected: Bool) -> some View { private func resultRow(_ result: Result, isSelected: Bool) -> some View {
@@ -445,6 +445,13 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty { if !documentPath.isEmpty {
documentPath.removeLast() 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 } 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 RetryPolicy.withRetry({ 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
@@ -17,11 +17,17 @@ struct DocumentPresentSheet: View {
@State private var text: String @State private var text: String
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @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) { init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
_text = State(initialValue: document.text) _text = State(initialValue: document.text)
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
} }
var body: some View { var body: some View {
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $text, text: $text,
configuration: .init( configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent heightBehavior: .fitsContent
), ),
isEditable: false isEditable: false
@@ -71,6 +77,12 @@ struct DocumentPresentSheet: View {
} }
.frame(minWidth: 800, minHeight: 600) .frame(minWidth: 800, minHeight: 600)
.background(.background) .background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() } .task { await load() }
} }
@@ -5,6 +5,9 @@ import UniformTypeIdentifiers
import MarkdownEngine import MarkdownEngine
import MarkdownEngineCodeBlocks import MarkdownEngineCodeBlocks
import OutlineKit import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions /// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread see the identical note on /// below must run on the main thread see the identical note on
@@ -19,6 +22,21 @@ struct DocumentReaderView: View {
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true @AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true @AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = 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 /// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the /// `session.userPreferences`, not `@AppStorage` this one's the
@@ -66,16 +84,65 @@ struct DocumentReaderView: View {
let onDocumentCreated: () -> Void let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false @State private var isShowingUnpublishConfirmation = false
@State private var isShowingPublishSheet = false
@State private var isShowingArchiveConfirmation = false @State private var isShowingArchiveConfirmation = false
@State private var isShowingDeleteConfirmation = false @State private var isShowingDeleteConfirmation = false
@State private var isShowingMoveSheet = false @State private var isShowingMoveSheet = false
@State private var isShowingHistorySheet = false @State private var isShowingHistorySheet = false
@State private var isShowingInsightsSheet = 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 isShowingPresentSheet = false
@State private var isShowingSearchSheet = false @State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false @State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false @State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String? @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?
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
/// line when the user switches its language from the picker.
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
/// 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 `![alt](url)` 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` /// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange`
/// one array per instance (main pane, split-view preview pane), since /// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different /// each lays the same text out at a different width and gets different
@@ -100,6 +167,7 @@ struct DocumentReaderView: View {
// behavior) and gets set for real in `.task` below once `session` // behavior) and gets set for real in `.task` below once `session`
// is actually available. // is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
self.onOpenChild = onOpenChild self.onOpenChild = onOpenChild
self.onDeleted = onDeleted self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated self.onDocumentCreated = onDocumentCreated
@@ -157,6 +225,44 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) 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 { if viewModel.separateEditingEnabled {
Button { Button {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
@@ -197,6 +303,12 @@ struct DocumentReaderView: View {
.id(menuIdentity) .id(menuIdentity)
} }
} }
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.task { await viewModel.loadFullContent() } .task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled` // See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time. // for why this can't just be read at `init` time.
@@ -215,6 +327,13 @@ struct DocumentReaderView: View {
.task { .task {
await viewModel.loadInsightsEnabledState() await viewModel.loadInsightsEnabledState()
} }
.task {
loadedComments = (try? await RetryPolicy.withRetry({
try await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)
})) ?? []
}
.task { .task {
while !Task.isCancelled { while !Task.isCancelled {
await viewModel.loadViewers() await viewModel.loadViewers()
@@ -262,6 +381,38 @@ struct DocumentReaderView: View {
} message: { } message: {
Text(actionErrorMessage ?? "") Text(actionErrorMessage ?? "")
} }
.sheet(isPresented: $isShowingCommentsSheet) {
DocumentCommentsSheet(
apiClient: apiClient,
document: document,
focusedCommentId: focusedCommentId,
pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: {
Task {
loadedComments = (try? await RetryPolicy.withRetry({
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) { .sheet(isPresented: $isShowingMoveSheet) {
MoveDocumentSheet(apiClient: apiClient, document: document) { MoveDocumentSheet(apiClient: apiClient, document: document) {
onDeleted() onDeleted()
@@ -285,6 +436,59 @@ struct DocumentReaderView: View {
onOpenChild(child) 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![](/api/attachments.redirect?id=\(created.attachment.id))\n\n"
)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
}
}
} }
/// Every toggle-backed piece of state shown as a checkmark inside /// Every toggle-backed piece of state shown as a checkmark inside
@@ -350,23 +554,44 @@ struct DocumentReaderView: View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init( configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle, codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution, textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion, textCompletion: editorTextCompletion,
writingTools: editorWritingTools, writingTools: editorWritingTools,
heightBehavior: .fitsContent heightBehavior: .fitsContent,
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
), ),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable, isEditable: viewModel.isEffectivelyEditable,
onCodeBlockSelectionChange: { readerCodeBlocks = $0 } onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
) )
if showCodeBlockLineNumbers { if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
} }
} }
if viewModel.isEffectivelyEditable {
ForEach(readerCodeBlocks) { selection in
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
pendingCodeBlockLanguageChange = request
}
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
} }
} }
} }
@@ -394,11 +619,13 @@ struct DocumentReaderView: View {
} }
} }
/// Left is a plain, unrendered raw-text editor (deliberately not /// Left is the literal Markdown source in `rawSourceMode` (no syntax
/// `NativeTextViewWrapper` just the literal Markdown source); right /// hiding/styling, but still the real engine needed so selection
/// is the same rich rendering used everywhere else in the app, /// tracking and caret-position insertion, e.g. from the Image Playground
/// read-only, bound to the same `viewModel.text` so it updates live as /// button, work here the same as everywhere else); right is the same
/// the left side is typed into. /// 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 /// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s /// only way to do that would be reaching into `NativeTextViewWrapper`'s
@@ -408,30 +635,47 @@ struct DocumentReaderView: View {
/// follow-up, not attempted here. /// follow-up, not attempted here.
private var splitEditorView: some View { private var splitEditorView: some View {
HSplitView { HSplitView {
TextEditor(text: $viewModel.text) NativeTextViewWrapper(
.font(.system(.body, design: .monospaced)) text: $viewModel.text,
.scrollContentBackground(.hidden) pendingTextInsertion: $pendingTextInsertion,
.padding(8) configuration: .init(rawSourceMode: true),
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView { ScrollView {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init( configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared), services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle, codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent heightBehavior: .fitsContent
), ),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: false, isEditable: false,
onCodeBlockSelectionChange: { previewCodeBlocks = $0 } onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
) )
if showCodeBlockLineNumbers { if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth) CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
} }
} }
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
} }
.padding(8) .padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
@@ -476,10 +720,17 @@ struct DocumentReaderView: View {
Task { await duplicate() } Task { await duplicate() }
} }
.disabled(!isEffectivelyOnline) .disabled(!isEffectivelyOnline)
Button("Unpublish") { if viewModel.publishedAt == nil {
isShowingUnpublishConfirmation = true Button("Publish…") {
isShowingPublishSheet = true
}
.disabled(!isEffectivelyOnline)
} else {
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
} }
.disabled(!isEffectivelyOnline)
Button("Archive…") { Button("Archive…") {
isShowingArchiveConfirmation = true isShowingArchiveConfirmation = true
} }
@@ -697,6 +948,110 @@ struct DocumentReaderView: View {
/// char-wraps code blocks, no way to opt out without forking the package) /// 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 /// throws this off every row below it reads one line low. Documented in
/// TODO.local.md alongside the same package's other gaps. /// 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.
/// Top-left pill overlay on each code block opposite corner from the
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
/// list (not the full ~190-language highlight.js catalog same "small
/// curated set, not the whole thing" call as the emoji reaction picker);
/// picking one rewrites just the fence line via
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
private struct CodeBlockLanguagePicker: View {
let selection: CodeBlockSelection
let documentId: String
let onRequest: (TextRangeReplacementRequest) -> Void
private static let languages: [(label: String, tag: String?)] = [
("Plain Text", nil),
("Swift", "swift"),
("Python", "python"),
("JavaScript", "javascript"),
("TypeScript", "typescript"),
("Bash", "bash"),
("JSON", "json"),
("YAML", "yaml"),
("HTML", "html"),
("CSS", "css"),
("SQL", "sql"),
("Java", "java"),
("Kotlin", "kotlin"),
("C", "c"),
("C++", "cpp"),
("C#", "csharp"),
("Go", "go"),
("Rust", "rust"),
("Ruby", "ruby"),
("PHP", "php"),
("Markdown", "markdown"),
]
private var currentLabel: String {
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
}
var body: some View {
Color.clear
.frame(width: selection.rect.width, height: selection.rect.height)
.overlay(alignment: .topLeading) {
Menu {
ForEach(Self.languages, id: \.label) { entry in
Button(entry.label) {
onRequest(TextRangeReplacementRequest(
documentId: documentId,
range: selection.fenceRange,
replacement: "```\(entry.tag ?? "")\n"
))
}
}
} label: {
Text(currentLabel.uppercased())
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
}
.menuStyle(.borderlessButton)
.fixedSize()
.padding(.top, 6)
.padding(.leading, 8)
}
.position(
x: selection.rect.midX,
y: selection.rect.midY
)
}
}
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 { private struct CodeBlockLineNumberGutter: View {
let selection: CodeBlockSelection let selection: CodeBlockSelection
let gutterWidth: CGFloat let gutterWidth: CGFloat
@@ -731,4 +1086,44 @@ private struct CodeBlockLineNumberGutter: View {
.allowsHitTesting(false) .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 #endif
@@ -9,6 +9,9 @@ final class DocumentReaderViewModel {
var emoji: String? var emoji: String?
var text: String var text: String
var collectionId: String? var collectionId: String?
var parentDocumentId: String?
/// `nil` = draft (not published/visible to other workspace members).
var publishedAt: Date?
var isFullWidth = false var isFullWidth = false
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
@@ -76,6 +79,8 @@ final class DocumentReaderViewModel {
self.emoji = document.emoji self.emoji = document.emoji
self.text = document.text self.text = document.text
self.collectionId = document.collectionId self.collectionId = document.collectionId
self.parentDocumentId = document.parentDocumentId
self.publishedAt = document.publishedAt
self.isFullWidth = document.fullWidth ?? false self.isFullWidth = document.fullWidth ?? false
self.separateEditingEnabled = separateEditingEnabled self.separateEditingEnabled = separateEditingEnabled
self.lastSyncedText = document.text self.lastSyncedText = document.text
@@ -95,6 +100,8 @@ final class DocumentReaderViewModel {
emoji = full.emoji emoji = full.emoji
text = full.text text = full.text
collectionId = full.collectionId collectionId = full.collectionId
parentDocumentId = full.parentDocumentId
publishedAt = full.publishedAt
isFullWidth = full.fullWidth ?? false isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text lastSyncedText = full.text
lastSyncedTitle = full.title lastSyncedTitle = full.title
@@ -104,14 +111,18 @@ final class DocumentReaderViewModel {
} }
func loadViewers() async { func loadViewers() async {
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return } // A single blip here used to just leave `viewers` empty forever with
// no sign anything went wrong retry-with-backoff absorbs that;
// `try?` still covers the "still failing after retries" case, same
// silent-but-harmless fallback as before (an empty viewers list).
guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return }
viewers = views.filter { $0.lastViewedAt != nil } viewers = views.filter { $0.lastViewedAt != nil }
} }
func loadPinAndSubscriptionState() async { func loadPinAndSubscriptionState() async {
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server. // Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }),
let match = pins.first(where: { $0.documentId == documentId }) { let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true isPinned = true
pinId = match.id pinId = match.id
@@ -120,7 +131,7 @@ final class DocumentReaderViewModel {
pinId = nil pinId = nil
} }
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)), if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }),
let match = subscriptions.first { let match = subscriptions.first {
isSubscribed = true isSubscribed = true
subscriptionId = match.id subscriptionId = match.id
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
@State private var currentMatchIndex = 0 @State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool @FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] { /// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
guard !query.isEmpty else { return [] } /// instead of being a computed property this used to re-scan the whole
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } /// document on every access, and it's read multiple times per row
/// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a
/// large document turned into an O(n²) case-insensitive scan per frame.
@State private var matchingLineIndices: [Int] = []
/// O(1) membership for the per-row highlight check below `matchingLineIndices`
/// stays an ordered array (needed for `currentMatchIndex`/stepping), this is
/// just a parallel lookup so a common search term with many matches doesn't
/// make every row's highlight check an O(k) linear scan.
@State private var matchingLineIndexSet: Set<Int> = []
private func recomputeMatches() {
guard !query.isEmpty else {
matchingLineIndices = []
matchingLineIndexSet = []
return
}
matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
matchingLineIndexSet = Set(matchingLineIndices)
} }
var body: some View { var body: some View {
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query) TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.focused($isSearchFieldFocused) .focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 } .onChange(of: query) {
currentMatchIndex = 0
recomputeMatches()
}
if !matchingLineIndices.isEmpty { if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)") Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
.padding(.horizontal, 4) .padding(.horizontal, 4)
.background( .background(
isCurrentMatch(index) ? Color.yellow.opacity(0.4) isCurrentMatch(index) ? Color.yellow.opacity(0.4)
: matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15) : matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15)
: Color.clear : Color.clear
) )
.id(index) .id(index)
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
do { do {
let text = try await apiClient.documentInfo(id: document.id).text let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n") lines = text.components(separatedBy: "\n")
recomputeMatches()
} catch { } catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
} }
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
private func loadMembers() async { private func loadMembers() async {
isLoadingMembers = true isLoadingMembers = true
defer { isLoadingMembers = false } defer { isLoadingMembers = false }
members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? [] members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? []
} }
private func searchUsers(_ query: String) async { private func searchUsers(_ query: String) async {
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
} }
isSearchingUsers = true isSearchingUsers = true
defer { isSearchingUsers = false } defer { isSearchingUsers = false }
userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? [] userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? []
} }
private func addUser(_ user: OutlineUser) async { private func addUser(_ user: OutlineUser) async {
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
ProgressView().frame(maxWidth: .infinity) ProgressView().frame(maxWidth: .infinity)
} else { } else {
Picker("Collection", selection: $selectedCollectionID) { Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none) Text("Draft (not published)").tag(String?.none)
ForEach(collections) { collection in ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id)) Text(collection.name).tag(Optional(collection.id))
} }
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
} }
.labelsHidden() .labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments) .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 { if let errorMessage {
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
HStack { HStack {
Spacer() Spacer()
Button("Cancel", role: .cancel) { dismiss() } Button("Cancel", role: .cancel) { dismiss() }
Button("Create") { Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
Task { await create() } Task { await create() }
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating) .disabled(isCreating)
} }
} }
.padding(20) .padding(20)
.frame(width: 380) .frame(width: 380)
.task { .task {
await loadCollections() 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 selectedParentID = initialParentDocument?.id
isTitleFocused = true isTitleFocused = true
} }
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
} }
private func create() async { private func create() async {
guard let selectedCollectionID else { return }
isCreating = true isCreating = true
defer { isCreating = false } defer { isCreating = false }
do { do {
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
title: title.isEmpty ? "Untitled" : title, title: title.isEmpty ? "Untitled" : title,
text: "", text: "",
collectionId: selectedCollectionID, 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) onCreated(document)
@@ -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
+1
View File
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
case popular = "Popular" case popular = "Popular"
case recentlyUpdated = "Recently Updated" case recentlyUpdated = "Recently Updated"
case createdByMe = "Created by Me" case createdByMe = "Created by Me"
case drafts = "Drafts"
var id: String { rawValue } var id: String { rawValue }
} }
+1
View File
@@ -101,6 +101,7 @@ struct HomeView: View {
PinnedDocumentCard(document: document) PinnedDocumentCard(document: document)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
+22 -8
View File
@@ -10,6 +10,7 @@ final class HomeViewModel {
private(set) var popular: [OutlineDocument] = [] private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = [] private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = [] private(set) var createdByMe: [OutlineDocument] = []
private(set) var drafts: [OutlineDocument] = []
var isLoadingPinned = false var isLoadingPinned = false
var isLoadingTab = false var isLoadingTab = false
@@ -32,6 +33,7 @@ final class HomeViewModel {
case .popular: popular case .popular: popular
case .recentlyUpdated: recentlyUpdated case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe case .createdByMe: createdByMe
case .drafts: drafts
} }
} }
@@ -81,16 +83,25 @@ final class HomeViewModel {
/// `pins.list` only returns pin records, not the documents themselves /// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated /// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable /// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar. /// where it wouldn't be in the sidebar but they're fetched concurrently
/// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin
/// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s
/// own cached-read/retry path either way.
private func fetchPinnedThrowing() async throws -> [OutlineDocument] { private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
var documents: [OutlineDocument] = [] let client = apiClient
for pin in pins { let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
if let document = try? await apiClient.documentInfo(id: pin.documentId) { for pin in pins {
documents.append(document) group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) }
} }
var result: [String: OutlineDocument] = [:]
for await (id, document) in group {
if let document { result[id] = document }
}
return result
} }
return documents // Preserve pins.list's own order rather than task-completion order.
return pins.compactMap { documentsByID[$0.documentId] }
} }
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] { private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
@@ -115,6 +126,8 @@ final class HomeViewModel {
return try await apiClient.documentsList( return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25) DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
) )
case .drafts:
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
} }
} }
@@ -124,12 +137,13 @@ final class HomeViewModel {
case .popular: popular = documents case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
} }
} }
private func resolveCurrentUserID() async throws -> String { private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID } if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser() let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
currentUserID = user.id currentUserID = user.id
return user.id return user.id
} }
+40 -28
View File
@@ -2,45 +2,57 @@
import SwiftUI import SwiftUI
import OutlineKit import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for /// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
/// a quick scan of a small curated set, not browsing, so this is a dense /// pinned section reads as a set of cards, not a row of tab-like chips the
/// single-line row rather than a tall card, with an explicit pin glyph so /// accent-filled pin badge is the one thing that marks these as pinned.
/// it doesn't read the same as the tab grids below it.
struct PinnedDocumentCard: View { struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
let document: OutlineDocument let document: OutlineDocument
var body: some View { var body: some View {
HStack(spacing: 10) { VStack(alignment: .leading, spacing: 8) {
if let emoji = document.emoji { HStack(spacing: 6) {
Text(emoji) if let emoji = document.emoji {
.font(.title3) Text(emoji)
} else { .font(.title2)
Image(systemName: "doc.text") } else {
.font(.body) Image(systemName: "doc.text")
.foregroundStyle(.secondary) .font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(5)
.background(Color.accentColor, in: Circle())
} }
Text(document.title.isEmpty ? "Untitled" : document.title) Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.callout.weight(.medium)) .font(.headline)
.lineLimit(1) .lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0) Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.horizontal, 12) .padding(14)
.padding(.vertical, 9) .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) .overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
} }
} }
#endif #endif
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.pointerCursorOnHover()
} }
} }
} }
+25 -8
View File
@@ -34,6 +34,7 @@ struct OutpostApp: App {
.onAppear { applyMacAppearance() } .onAppear { applyMacAppearance() }
.onChange(of: appearance) { _, _ in applyMacAppearance() } .onChange(of: appearance) { _, _ in applyMacAppearance() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session) .logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.background(TransparentTitlebarWindowAccessor())
#endif #endif
} }
#if os(macOS) #if os(macOS)
@@ -74,14 +75,6 @@ struct OutpostApp: App {
} }
} }
#endif #endif
#if os(macOS)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
#endif
} }
#if os(macOS) #if os(macOS)
@@ -101,3 +94,27 @@ struct OutpostApp: App {
} }
#endif #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
+67
View File
@@ -0,0 +1,67 @@
import Foundation
import Observation
import OutlineKit
/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner
/// the user can actually see and act on, instead of a silently-swallowed
/// `try?` see the pins bug this whole mechanism exists to catch a repeat
/// of. `RootView` polls the client periodically and feeds results in via
/// `update(with:)`; nothing here talks to the network directly.
@MainActor
@Observable
final class APIFailureCenter {
/// The single most-relevant category to show right now, or nil if
/// nothing's currently past the threshold (or everything past it has
/// been dismissed and is still in its cooldown).
private(set) var activeBanner: RepeatedFailure?
/// Categories the user's already dismissed, and when suppressed from
/// reappearing until `dismissCooldown` passes, so a still-flaky
/// operation doesn't pop the same banner right back up a few seconds
/// after being told to go away.
private var dismissedAt: [String: Date] = [:]
private let dismissCooldown: TimeInterval = 900
/// Called from `RootView`'s poll loop with the latest snapshot from
/// `CachingOutlineAPIClient`. Picks the worst-offending category
/// (highest failure count) that isn't in cooldown; clears the banner
/// entirely once nothing qualifies (e.g. the user went back online and
/// everything recovered).
func update(with summaries: [RepeatedFailure]) {
let now = Date()
dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown }
let eligible = summaries
.filter { dismissedAt[$0.category] == nil }
.sorted { $0.count > $1.count }
activeBanner = eligible.first
}
/// Dismiss without reporting starts that category's cooldown so it
/// won't immediately reappear on the next poll if it's still failing.
func dismiss() {
guard let category = activeBanner?.category else { return }
dismissedAt[category] = Date()
activeBanner = nil
}
/// Everything folded into the report is safe to paste into a public bug
/// tracker as-is: a category name, a generic error description, and
/// version numbers no document content, no server URL, no token.
func reportURL(appVersion: String, osVersion: String) -> URL? {
guard let banner = activeBanner else { return nil }
var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new")
let body = """
Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes).
Error: \(banner.message)
App version: \(appVersion)
macOS: \(osVersion)
<!-- Anything else you can add about what you were doing when this started would help. -->
"""
components?.queryItems = [URLQueryItem(name: "body", value: body)]
return components?.url
}
}
+5 -3
View File
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
/// explicitly built yet. Content lands section by section. /// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable { enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours) // General (ours)
case appearance, editor, offlineSync, advanced, about case appearance, editor, navigation, offlineSync, advanced, about
// Account // Account
case profile, preferences, notifications, passkeys, apiAccess case profile, preferences, notifications, passkeys, apiAccess
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
var category: SettingsCategory { var category: SettingsCategory {
switch self { switch self {
case .appearance, .editor, .offlineSync, .advanced, .about: case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess: case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account return .account
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self { switch self {
case .appearance: return "Appearance" case .appearance: return "Appearance"
case .editor: return "Editor" case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync" case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced" case .advanced: return "Advanced"
case .about: return "About" case .about: return "About"
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self { switch self {
case .appearance: return "paintbrush" case .appearance: return "paintbrush"
case .editor: return "square.split.2x1" case .editor: return "square.split.2x1"
case .navigation: return "command"
case .offlineSync: return "arrow.triangle.2.circlepath" case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver" case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle" case .about: return "info.circle"
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built. /// specified and built.
var isImplemented: Bool { var isImplemented: Bool {
switch self { switch self {
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true return true
default: default:
return false return false
+38
View File
@@ -0,0 +1,38 @@
#if os(macOS)
import SwiftUI
/// Shown when the same category of API call has failed repeatedly within a
/// few minutes (see `APIFailureCenter`) the self-diagnosing replacement
/// for a `try?` that used to fail silently. No manual "Retry" button: the
/// retries already happened automatically before this ever appears, so all
/// that's left worth offering is reporting it and moving on.
struct RepeatedFailureBanner: View {
let message: String
let onReport: () -> Void
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(message)
.font(.callout)
.lineLimit(2)
Spacer(minLength: 8)
Button("Report", action: onReport)
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+60
View File
@@ -3,8 +3,10 @@ import OutlineKit
struct RootView: View { struct RootView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@State private var failureCenter = APIFailureCenter()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -34,10 +36,39 @@ struct RootView: View {
} }
} }
.environment(starStore) .environment(starStore)
.environment(failureCenter)
#if os(macOS)
.overlay(alignment: .top) {
if let banner = failureCenter.activeBanner {
RepeatedFailureBanner(
message: bannerMessage(for: banner),
onReport: { reportActiveFailure() },
onDismiss: { failureCenter.dismiss() }
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner)
#endif
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
.task { .task {
await session.refreshTeamInfoIfNeeded() await session.refreshTeamInfoIfNeeded()
} }
// Repeated (non-transient) API failures already get an automatic
// retry-with-backoff inside CachingOutlineAPIClient itself this
// just surfaces the ones that kept failing anyway, on a cheap poll
// (the client's own state, no network call of its own) rather than
// a push, since the client is a plain actor with no UI dependency.
.task(id: session.isSignedIn) {
guard session.isSignedIn else { return }
while !Task.isCancelled {
if let cachingClient = session.cachingClient {
let summaries = await cachingClient.repeatedFailureSummaries()
failureCenter.update(with: summaries)
}
try? await Task.sleep(for: .seconds(30))
}
}
.task(id: session.isSignedIn) { .task(id: session.isSignedIn) {
if session.isSignedIn, let apiClient = session.apiClient { if session.isSignedIn, let apiClient = session.apiClient {
await starStore.load(apiClient: apiClient) await starStore.load(apiClient: apiClient)
@@ -89,6 +120,35 @@ struct RootView: View {
} }
} }
#if os(macOS)
/// Category names are internal plumbing (`documents-write`, `pins`,
/// `collections-write`, ...) this is the one place they turn into
/// something a user reads, so a new category added later just needs a
/// case here, not a rewrite of the tracking/polling underneath it.
private func bannerMessage(for failure: RepeatedFailure) -> String {
switch failure.category {
case "documents-write": return "Outpost is having trouble saving your document edits."
case "collections-write": return "Outpost is having trouble saving collection changes."
case "pins": return "Outpost is having trouble updating pins."
case "subscriptions": return "Outpost is having trouble updating subscriptions."
case "stars": return "Outpost is having trouble updating stars."
case "document", "documents": return "Outpost is having trouble loading documents."
case "collections": return "Outpost is having trouble loading collections."
case "drafts": return "Outpost is having trouble loading drafts."
default: return "Outpost is having trouble talking to the server (\(failure.category))."
}
}
private func reportActiveFailure() {
guard let url = failureCenter.reportURL(
appVersion: OutpostVersion.displayString,
osVersion: ProcessInfo.processInfo.operatingSystemVersionString
) else { return }
openURL(url)
failureCenter.dismiss()
}
#endif
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
welcomeName = result.user.name welcomeName = result.user.name
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team) session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
+33 -6
View File
@@ -15,6 +15,7 @@ final class SessionStore {
private static let userPreferencesDefaultsKey = "outline.userPreferences" private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults private let defaults: UserDefaults
var isSignedIn: Bool var isSignedIn: Bool
@@ -43,8 +44,13 @@ final class SessionStore {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
} }
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { init(
tokenStore: TokenStoring = KeychainTokenStore(),
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
defaults: UserDefaults = .standard
) {
self.tokenStore = tokenStore self.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults self.defaults = defaults
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
@@ -53,7 +59,12 @@ final class SessionStore {
if hasToken, let storedServerURL { if hasToken, let storedServerURL {
isSignedIn = true isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(
serverURL: storedServerURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
userPreferences = Self.loadCachedPreferences(defaults: defaults) userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else { } else {
// Keychain and the sandboxed UserDefaults container don't // Keychain and the sandboxed UserDefaults container don't
@@ -73,7 +84,12 @@ final class SessionStore {
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(
serverURL: serverURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
apply(user: user, team: team, serverURL: serverURL) apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true isSignedIn = true
} }
@@ -99,6 +115,7 @@ final class SessionStore {
private static func makeAPIClient( private static func makeAPIClient(
serverURL: URL, serverURL: URL,
tokenStore: TokenStoring, tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore? cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) { ) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient( let live = LiveOutlineAPIClient(
@@ -106,12 +123,22 @@ final class SessionStore {
tokenStore: tokenStore tokenStore: tokenStore
) )
guard let cache else { return (live, nil) } guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache) let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
return (caching, caching) return (caching, caching)
} }
func signOut() { /// Clears everything scoped to this sign-in: the API token, the offline
/// cache's encryption key, and the cache/pending-write storage itself
/// (in that order wiping storage before the key would leave it
/// readable a moment longer than necessary, and wiping the key without
/// the storage would leave permanently-undecryptable rows sitting
/// around instead of actually freeing anything). Whoever signs in next
/// on this machine gets a clean slate, not a previous account's
/// leftover cached content.
func signOut() async {
try? tokenStore.clear() try? tokenStore.clear()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey) defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false isSignedIn = false
userId = nil userId = nil
@@ -136,7 +163,7 @@ final class SessionStore {
/// in-memory state didn't. /// in-memory state didn't.
func refreshTeamInfoIfNeeded() async { func refreshTeamInfoIfNeeded() async {
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return } guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
guard let auth = try? await apiClient.authInfo() else { return } guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return }
apply(user: auth.user, team: auth.team, serverURL: serverURL) apply(user: auth.user, team: auth.team, serverURL: serverURL)
} }
+25
View File
@@ -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,84 @@
#if os(macOS)
import AppKit
import MarkdownEngine
import OutlineKit
/// Resolves `![alt](url)` 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
+8 -6
View File
@@ -7,24 +7,26 @@ import Foundation
enum OutpostVersion { enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out /// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is /// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. /// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`.
static let releaseStage = "ALPHA" /// Empty since the App Store release (no more alpha/beta suffix)
/// `displayString`/`fullVersionString` just show the plain version now.
static let releaseStage = ""
static var shortVersion: String { static var shortVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
} }
static var buildNumber: String { static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "3"
} }
/// e.g. `"0.0.3-ALPHA"` for compact display (sidebar footer). /// e.g. `"0.1.0"` for compact display (sidebar footer).
static var displayString: String { static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)" return "\(shortVersion)\(stageSuffix)"
} }
/// e.g. `"Version 0.0.3-ALPHA (1)"` for the About page. /// e.g. `"Version 0.1.0 (3)"` for the About page.
static var fullVersionString: String { static var fullVersionString: String {
"Version \(displayString) (\(buildNumber))" "Version \(displayString) (\(buildNumber))"
} }
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
#if os(macOS)
import AppKit
/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row,
/// gated by the "Pointer Cursor" setting (Settings Editor Pointer Cursor).
/// `didPush` tracks whether this instance actually pushed a cursor, so a
/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop
/// stack unbalanced pop only fires for a push this same hover made.
private struct PointerCursorOnHover: ViewModifier {
@AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true
@State private var didPush = false
func body(content: Content) -> some View {
content.onHover { hovering in
if hovering {
guard isEnabled else { return }
NSCursor.pointingHand.push()
didPush = true
} else if didPush {
NSCursor.pop()
didPush = false
}
}
}
}
extension View {
func pointerCursorOnHover() -> some View {
modifier(PointerCursorOnHover())
}
}
#else
extension View {
func pointerCursorOnHover() -> some View { self }
}
#endif
+1 -1
View File
@@ -22,7 +22,7 @@ final class StarStore {
} }
func load(apiClient: OutlineAPIClient) async { func load(apiClient: OutlineAPIClient) async {
guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return } guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return }
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
star.documentId.map { ($0, star) } star.documentId.map { ($0, star) }
}) })
+71
View File
@@ -0,0 +1,71 @@
import StoreKit
import Observation
/// Backs the tip jar in Settings About. Consumables only a tip doesn't
/// unlock anything, so there's no entitlement to persist or restore, and
/// finishing the transaction immediately (rather than checking
/// `Transaction.currentEntitlements` on launch, the way a real purchase
/// would need to) is correct here.
@MainActor
@Observable
final class TipJarStore {
enum PurchaseState: Equatable {
case idle
case purchasing(String)
case thankYou(String)
case failed(String)
}
/// Must match the consumable In-App Purchase products created in App
/// Store Connect for this app exactly, including the bundle id prefix.
static let productIDs = [
"com.psmattas.OutpostApp.tip.small",
"com.psmattas.OutpostApp.tip.medium",
"com.psmattas.OutpostApp.tip.large",
"com.psmattas.OutpostApp.tip.generous"
]
private(set) var products: [Product] = []
private(set) var isLoading = false
var purchaseState: PurchaseState = .idle
/// Tip options don't change during a session no reason to refetch
/// every time the About screen appears.
func loadProductsIfNeeded() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: Self.productIDs)
// Keep the order defined above (small -> generous), not
// whatever order the App Store happens to return them in.
products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } }
if products.isEmpty {
purchaseState = .failed("Tip options aren't available right now.")
}
} catch {
purchaseState = .failed("Couldn't load tip options. Check your connection and try again.")
}
}
func purchase(_ product: Product) async {
purchaseState = .purchasing(product.id)
do {
switch try await product.purchase() {
case .success(let verification):
guard case .verified(let transaction) = verification else {
purchaseState = .failed("Couldn't verify this purchase.")
return
}
await transaction.finish()
purchaseState = .thankYou(product.id)
case .userCancelled, .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .failed("Something went wrong completing the purchase.")
}
}
}
@@ -8,7 +8,7 @@ extension View {
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button("Log Out", role: .destructive) { Button("Log Out", role: .destructive) {
session.signOut() Task { await session.signOut() }
} }
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
} message: { } message: {
-38
View File
@@ -1,38 +0,0 @@
#if os(macOS)
import SwiftUI
import AppKit
/// Grabs the hosting `NSWindow` once it's attached to a screen, for the
/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for.
private struct WindowConfigurator: NSViewRepresentable {
let configure: (NSWindow) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
configure(window)
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
extension View {
/// `Window` scenes default to a full standard titlebar, fullscreen
/// (green) button included not appropriate for fixed-size reference
/// panels like About or Keyboard Shortcuts, which have no reason to
/// support fullscreen at all.
func disablesFullScreen() -> some View {
background(
WindowConfigurator { window in
window.collectionBehavior.remove(.fullScreenPrimary)
window.collectionBehavior.insert(.fullScreenNone)
window.standardWindowButton(.zoomButton)?.isHidden = true
}
)
}
}
#endif
+4 -4
View File
@@ -5,14 +5,14 @@
<h1 align="center">Outpost</h1> <h1 align="center">Outpost</h1>
<p align="center"> <p align="center">
<a href="https://testflight.apple.com/join/y1mYcYAM"> <a href="https://apps.apple.com/us/app/outpost-for-outline/id6802736230">
<img src="https://img.shields.io/badge/Download-TestFlight-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on TestFlight"> <img src="docs/assets/mac-app-store-badge.svg" height="40" alt="Download on the Mac App Store">
</a> </a>
</p> </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. 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. > **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 ## Why
@@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing
## Requirements ## Requirements
- Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion) - 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 - macOS 14+ to run the app. iOS/iPadOS support is planned but not in the current build (see the note above)
- A self-hosted (or hosted) Outline instance with API access - A self-hosted (or hosted) Outline instance with API access
## Setup ## Setup
+3 -3
View File
@@ -18,9 +18,9 @@ within 14 days depending on severity.
## Supported Versions ## Supported Versions
Outpost is in early alpha (`0.0.x`) — there's no stable release line Outpost is early (`0.1.x`) — there's no stable release line yet. Only
yet. Only the most recent tagged release receives fixes; please make the most recent tagged release receives fixes; please make sure
sure you're on the latest alpha before reporting. you're on the latest release before reporting.
| Version | Supported | | Version | Supported |
| :--- | :---: | | :--- | :---: |
@@ -94,6 +94,14 @@ public struct MarkdownEditorConfiguration: Sendable {
/// so this stays the embedder's explicit decision rather than something the /// so this stays the embedder's explicit decision rather than something the
/// engine infers from a color it happens to see. /// engine infers from a color it happens to see.
public var cursorFollowsSpanInk: Bool public var cursorFollowsSpanInk: Bool
/// Show a pointing-hand cursor over links while editing (not just in
/// read-only mode, where it always shows regardless of this flag).
///
/// On by default. The embedder's "pointer cursor" preference maps
/// straight to this off just means the I-beam stays over links like
/// any other text, since a link's edge zone still repositions the caret
/// for editing rather than navigating (see `clickedOnLink`).
public var pointerCursorOverLinksWhileEditing: Bool
public init( public init(
theme: MarkdownEditorTheme = .default, theme: MarkdownEditorTheme = .default,
@@ -123,7 +131,8 @@ public struct MarkdownEditorConfiguration: Sendable {
heightBehavior: HeightBehavior = .scrolls, heightBehavior: HeightBehavior = .scrolls,
rawSourceMode: Bool = false, rawSourceMode: Bool = false,
extensions: [any MarkdownExtension] = [], extensions: [any MarkdownExtension] = [],
cursorFollowsSpanInk: Bool = false cursorFollowsSpanInk: Bool = false,
pointerCursorOverLinksWhileEditing: Bool = true
) { ) {
self.theme = theme self.theme = theme
self.services = services self.services = services
@@ -153,6 +162,7 @@ public struct MarkdownEditorConfiguration: Sendable {
self.rawSourceMode = rawSourceMode self.rawSourceMode = rawSourceMode
self.extensions = extensions self.extensions = extensions
self.cursorFollowsSpanInk = cursorFollowsSpanInk self.cursorFollowsSpanInk = cursorFollowsSpanInk
self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing
} }
public static let `default` = MarkdownEditorConfiguration() public static let `default` = MarkdownEditorConfiguration()
@@ -9,7 +9,9 @@
// which costs grow with file size instead of staying constant. The whole point: // 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. // 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. // Toggle: set the env var MD_PERF=1 in the run scheme to enable.
// Off by default even in Debug opt-in, not opt-out, so a normal
// debug run stays quiet.
// Debug-only the whole thing compiles out in Release. // Debug-only the whole thing compiles out in Release.
// Remove before shipping (this file + the `PerfTrace.` call sites). // Remove before shipping (this file + the `PerfTrace.` call sites).
// //
@@ -18,7 +20,7 @@ import Foundation
enum PerfTrace { enum PerfTrace {
#if DEBUG #if DEBUG
static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] == "1"
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
/// backtick census, parse buffer). They run 3× O(doc) work synchronously /// backtick census, parse buffer). They run 3× O(doc) work synchronously
/// on every 64th keystroke periodic spikes that pollute the PERF /// on every 64th keystroke periodic spikes that pollute the PERF
@@ -75,6 +75,7 @@ extension MarkdownStyler {
paragraphSpacing: imageEmbedConfig.paragraphSpacing, paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left, alignment: .left,
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap), mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
restyleOnWidthChange: true,
ctx: ctx, ctx: ctx,
attrs: &attrs attrs: &attrs
) )
@@ -88,6 +89,7 @@ extension MarkdownStyler {
paragraphSpacing: imageEmbedConfig.paragraphSpacing, paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left, alignment: .left,
mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]), mode: .collapsedSource(markerTexts: ["![", "]", "(", ")"]),
restyleOnWidthChange: true,
ctx: ctx, ctx: ctx,
attrs: &attrs attrs: &attrs
) )
@@ -166,6 +168,7 @@ extension MarkdownStyler {
paragraphSpacing: imageEmbedConfig.paragraphSpacing, paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left, alignment: .left,
mode: .visibleSource(imageGap: imageEmbedConfig.imageGap), mode: .visibleSource(imageGap: imageEmbedConfig.imageGap),
restyleOnWidthChange: true,
ctx: ctx, ctx: ctx,
attrs: &attrs attrs: &attrs
) )
@@ -179,6 +182,7 @@ extension MarkdownStyler {
paragraphSpacing: imageEmbedConfig.paragraphSpacing, paragraphSpacing: imageEmbedConfig.paragraphSpacing,
alignment: .left, alignment: .left,
mode: .collapsedSource(markerTexts: ["![[", "]]"]), mode: .collapsedSource(markerTexts: ["![[", "]]"]),
restyleOnWidthChange: true,
ctx: ctx, ctx: ctx,
attrs: &attrs attrs: &attrs
) )
@@ -24,12 +24,19 @@ public struct CodeBlockSelection: Identifiable, Sendable {
/// Plain text content of the block, suitable for putting on the /// Plain text content of the block, suitable for putting on the
/// pasteboard. /// pasteboard.
public let code: String public let code: String
/// Range of the opening fence line (`` ```lang\n `` or `` ```\n ``) in
/// the text view's DISPLAY-form string `token.markerRanges[0]` from
/// the parser. Lets an embedder safely rewrite just the language tag
/// (e.g. a language-picker menu) without touching the block's content,
/// which `code`/`rect` alone don't give enough to do.
public let fenceRange: NSRange
public init(id: Int, rect: CGRect, language: String?, code: String) { public init(id: Int, rect: CGRect, language: String?, code: String, fenceRange: NSRange) {
self.id = id self.id = id
self.rect = rect self.rect = rect
self.language = language self.language = language
self.code = code self.code = code
self.fenceRange = fenceRange
} }
} }
@@ -0,0 +1,44 @@
//
// CommentAnchor.swift
// MarkdownEngine
//
// Lets an embedder ask "where on screen is this text?" for arbitrary
// plain-text needles (e.g. an anchored comment's `anchorText`) that the
// engine has no concept of on its own it doesn't know what a comment is,
// it just resolves a search term to a rect the same way it already
// resolves code-block token ranges.
//
import SwiftUI
/// A plain-text search term the embedder wants a screen rect for, tagged
/// with an opaque id so results can be matched back to whatever the
/// embedder cares about (e.g. a comment id). Engine does a first-occurrence
/// substring search against the current display text no fuzzier
/// disambiguation than that, since the engine has no positional data to
/// work from beyond the text itself.
public struct CommentAnchorQuery: Sendable, Equatable {
public let id: String
public let anchorText: String
public init(id: String, anchorText: String) {
self.id = id
self.anchorText = anchorText
}
}
/// Resolved screen rect for one ``CommentAnchorQuery``. Delivered through
/// ``NativeTextViewWrapper/onCommentAnchorRectsChange`` queries whose
/// `anchorText` isn't found in the current text (deleted, edited past
/// recognition) simply don't appear in the result.
public struct CommentAnchorRect: Identifiable, Sendable {
public let id: String
/// Frame of the matched text in the text view's coordinate space
/// same coordinate system as ``CodeBlockSelection/rect``.
public let rect: CGRect
public init(id: String, rect: CGRect) {
self.id = id
self.rect = rect
}
}
@@ -15,7 +15,7 @@ import AppKit
extension NativeTextViewCoordinator { extension NativeTextViewCoordinator {
func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) {
guard let textContainer = textView.textContainer else { guard let textContainer = textView.textContainer else {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -24,7 +24,7 @@ extension NativeTextViewCoordinator {
// no per-call full-token filter. // no per-call full-token filter.
cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices
} else if cachedCodeBlockTokens.isEmpty { } else if cachedCodeBlockTokens.isEmpty {
onCodeBlockSelectionChange?([]) fireCodeBlockSelectionChange([])
return return
} }
@@ -77,6 +77,7 @@ extension NativeTextViewCoordinator {
guard !activeTokenIndices.contains(originalIndex) else { return nil } guard !activeTokenIndices.contains(originalIndex) else { return nil }
if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil } if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil }
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil } guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
guard let fenceRange = token.markerRanges.first else { return nil }
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
boundingRect.size.width = textContainer.containerSize.width boundingRect.size.width = textContainer.containerSize.width
@@ -85,10 +86,11 @@ extension NativeTextViewCoordinator {
id: originalIndex, id: originalIndex,
rect: boundingRect, rect: boundingRect,
language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string), language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string),
code: nsText.substring(with: token.contentRange) code: nsText.substring(with: token.contentRange),
fenceRange: fenceRange
) )
} }
onCodeBlockSelectionChange?(selections) fireCodeBlockSelectionChange(selections)
} }
} }
@@ -0,0 +1,33 @@
//
// NativeTextViewCoordinator+CommentAnchors.swift
// MarkdownEngine
//
// Resolves embedder-supplied CommentAnchorQuery search terms to on-screen
// rects, the same viewRect(forCharacterRange:using:) utility the code-block
// copy-button overlay already uses. Deliberately no per-keystroke caching
// like updateCodeBlockSelection has queries only change when the
// embedder's comment list changes (rare), not every keystroke, so the
// first-occurrence substring search + a handful of viewRect calls is cheap
// enough to just always redo.
//
import AppKit
extension NativeTextViewCoordinator {
func updateCommentAnchorRects(textView: NSTextView) {
guard !commentAnchorQueries.isEmpty else {
fireCommentAnchorRectsChange([])
return
}
let nsText = textView.string as NSString
var results: [CommentAnchorRect] = []
for query in commentAnchorQueries {
guard !query.anchorText.isEmpty else { continue }
let found = nsText.range(of: query.anchorText)
guard found.location != NSNotFound,
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
results.append(CommentAnchorRect(id: query.id, rect: rect))
}
fireCommentAnchorRectsChange(results)
}
}
@@ -200,6 +200,31 @@ extension NativeTextViewCoordinator {
nativeTextView?.updateWideTableOverlays() nativeTextView?.updateWideTableOverlays()
} }
} }
// Standalone images (`![alt](url)`/`![[embed]]`) size themselves off
// the text container's width AT THIS MOMENT (see styleImageLinks/
// styleImageEmbeds' maxWidth) every keystroke rebuilds this whole
// pane when it's a read-only mirror of another editable view driving
// the same `text` binding (e.g. Split View's preview pane), and mid-
// typing the container can be reflowing (HSplitView divider, a
// `.fitsContent` pane still resizing) and read too small/zero for a
// moment. That undersized measurement then just sticks nothing
// else re-triggers a restyle once typing stops and the fingerprint-
// based image-load path (see NativeTextViewWrapper's `imageChanged`
// handling) doesn't fire for an already-cached image. Re-measure
// once more, one tick later, only when there's actually an image in
// the document same fix shape as the wide-table reconciliation
// above, just for image sizing instead of overlay frames.
let hasImages = (parsedForReplay?.classified.imageLink.isEmpty == false)
|| (parsedForReplay?.classified.imageEmbed.isEmpty == false)
if hasImages {
DispatchQueue.main.async { [weak self, weak textView] in
guard let self, let textView else { return }
let range = NSRange(location: 0, length: (textView.string as NSString).length)
guard range.length > 0 else { return }
self.restyleParagraphs([range], in: textView)
}
}
} }
func restyleTextView( func restyleTextView(
@@ -417,6 +442,61 @@ extension NativeTextViewCoordinator {
classified: parsed.classified, blocks: parsed.blocks) classified: parsed.classified, blocks: parsed.blocks)
} }
/// Inserts `request.text` at the current caret (replacing the selection,
/// if any) as a normal undoable edit. Simpler than
/// ``applyInlineReplacement(_:to:)`` no inline-token range, no
/// wiki-link ID side-channel, just a plain insert.
func applyTextInsertion(_ request: TextInsertionRequest, to textView: NSTextView) {
lastAppliedTextInsertionID = request.id
let range = textView.selectedRange()
textView.breakUndoCoalescing()
isProgrammaticEdit = true
defer { isProgrammaticEdit = false }
guard textView.shouldChangeText(in: range, replacementString: request.text) else {
return
}
textView.textStorage?.replaceCharacters(in: range, with: request.text)
textView.didChangeText()
textView.undoManager?.setActionName("Insert Image")
textView.breakUndoCoalescing()
let documentLength = (textView.string as NSString).length
let caretLocation = min(range.location + (request.text as NSString).length, documentLength)
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
}
/// Replaces `request.range` verbatim unlike `applyTextInsertion`, does
/// NOT touch the current caret/selection at all, since the range being
/// replaced (e.g. a code block's fence line) usually isn't wherever the
/// caret happens to be. No-ops if `range` no longer fits inside the
/// current document (the doc changed shape since the request was built
/// safer to drop the edit than risk corrupting an unrelated range).
func applyTextRangeReplacement(_ request: TextRangeReplacementRequest, to textView: NSTextView) {
lastAppliedTextRangeReplacementID = request.id
let documentLength = (textView.string as NSString).length
guard request.range.location != NSNotFound,
NSMaxRange(request.range) <= documentLength else { return }
textView.breakUndoCoalescing()
isProgrammaticEdit = true
defer { isProgrammaticEdit = false }
guard textView.shouldChangeText(in: request.range, replacementString: request.replacement) else {
return
}
textView.textStorage?.replaceCharacters(in: request.range, with: request.replacement)
textView.didChangeText()
textView.undoManager?.setActionName("Change Code Block Language")
textView.breakUndoCoalescing()
}
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) { func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
lastAppliedInlineReplacementID = request.id lastAppliedInlineReplacementID = request.id
@@ -337,6 +337,7 @@ extension NativeTextViewCoordinator {
PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) } PerfTrace.measure("restyle") { restyleTextView(tv, paragraphCandidates: effectiveParagraphCandidates, tokens: tokens, classified: parsed.classified, blocks: parsed.blocks) }
PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) } PerfTrace.measure("codeSel") { updateCodeBlockSelection(textView: tv, parsed: parsed) }
updateCommentAnchorRects(textView: tv)
if wtActive { if wtActive {
previousActiveTokenIndices = activeTokenIndices previousActiveTokenIndices = activeTokenIndices
PerfTrace.end() PerfTrace.end()
@@ -355,6 +356,14 @@ extension NativeTextViewCoordinator {
public func textViewDidChangeSelection(_ notification: Notification) { public func textViewDidChangeSelection(_ notification: Notification) {
guard let tv = notification.object as? NSTextView else { return } guard let tv = notification.object as? NSTextView else { return }
// Cheap and mode-independent fire before the raw-mode/rebuild
// early-returns below, which would otherwise mean a `rawSourceMode`
// editor (e.g. Split View's raw-source pane) never reports a
// selection at all.
if !isRebuildingDocument {
let selRange = tv.selectedRange()
fireSelectedTextChange(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil)
}
// Raw mode: plain source no reveal, snap-back, or inline previews. // Raw mode: plain source no reveal, snap-back, or inline previews.
if configuration.rawSourceMode { return } if configuration.rawSourceMode { return }
if isWritingToolsActive { return } if isWritingToolsActive { return }
@@ -692,6 +701,7 @@ extension NativeTextViewCoordinator {
// Skip during a pending edit viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling. // Skip during a pending edit viewRect is stale until textDidChange's restyle runs; otherwise the overlay flashes to the old Y before settling.
if !shouldSkipSelectionRestyle { if !shouldSkipSelectionRestyle {
updateCodeBlockSelection(textView: tv, parsed: parsed) updateCodeBlockSelection(textView: tv, parsed: parsed)
updateCommentAnchorRects(textView: tv)
} }
} }
@@ -84,6 +84,29 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)? var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
var onSelectedTextChange: ((String?) -> Void)?
var commentAnchorQueries: [CommentAnchorQuery] = []
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
/// These three callbacks can fire from inside `NativeTextViewWrapper.updateNSView`
/// itself (a programmatic edit re-enters `textViewDidChangeSelection`/
/// `textDidChange` synchronously see `isRebuildingDocument` above), which is a
/// SwiftUI view update already in progress on the call stack. Calling straight into
/// an embedder's `@State` setter there trips "Modifying state during view update"
/// deferring one runloop tick is enough to land outside it, same as how
/// `NativeTextViewWrapper` already clears its `pending*` bindings.
func fireCodeBlockSelectionChange(_ selections: [CodeBlockSelection]) {
DispatchQueue.main.async { [onCodeBlockSelectionChange] in onCodeBlockSelectionChange?(selections) }
}
func fireSelectedTextChange(_ text: String?) {
DispatchQueue.main.async { [onSelectedTextChange] in onSelectedTextChange?(text) }
}
func fireCommentAnchorRectsChange(_ rects: [CommentAnchorRect]) {
DispatchQueue.main.async { [onCommentAnchorRectsChange] in onCommentAnchorRectsChange?(rects) }
}
var didInitialFormatting: Bool = false var didInitialFormatting: Bool = false
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
var didEnsureLayoutForCurrentDocument: Bool = false var didEnsureLayoutForCurrentDocument: Bool = false
@@ -106,6 +129,8 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var wtUndoneDuringSession: Bool = false var wtUndoneDuringSession: Bool = false
var wtPostUndoSnapshot: String? var wtPostUndoSnapshot: String?
var lastAppliedInlineReplacementID: UUID? var lastAppliedInlineReplacementID: UUID?
var lastAppliedTextInsertionID: UUID?
var lastAppliedTextRangeReplacementID: UUID?
var activeTokenIndices: Set<Int> = [] var activeTokenIndices: Set<Int> = []
var previousActiveTokenIndices: Set<Int> = [] var previousActiveTokenIndices: Set<Int> = []
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
@@ -75,11 +75,17 @@ extension NativeTextView {
} }
/// In read-only mode, override NSTextView's I-beam: pointing hand over a /// In read-only mode, override NSTextView's I-beam: pointing hand over a
/// `.link` range, arrow everywhere else. /// `.link` range, arrow everywhere else. In edit mode, only the pointing
/// hand part applies (gated on the embedder's preference) everywhere
/// else keeps the I-beam, which `super` already set.
private func applyReadOnlyCursor(for event: NSEvent) { private func applyReadOnlyCursor(for event: NSEvent) {
guard isSelectable, !isEditable else { return } // edit mode: keep I-beam guard isSelectable else { return }
let viewPoint = convert(event.locationInWindow, from: nil) let viewPoint = convert(event.locationInWindow, from: nil)
if isOverLink(at: viewPoint) { if isEditable {
guard configuration.pointerCursorOverLinksWhileEditing,
isOverLink(at: viewPoint) else { return } // keep I-beam
NSCursor.pointingHand.set()
} else if isOverLink(at: viewPoint) {
NSCursor.pointingHand.set() NSCursor.pointingHand.set()
} else { } else {
NSCursor.arrow.set() NSCursor.arrow.set()
@@ -269,21 +269,31 @@ extension NativeTextView {
recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize") recalcOverscroll(for: scrollView, targetWidth: newSize.width, debugTag: "setFrameSize")
// Width change only rendered table paragraphs need restyling. Their image // Width change only paragraphs tagged `.scrollableBlockFullRange`
// width can change, and an initially narrow table can become scrollable. // (rendered tables and standalone images see appendRenderedStandaloneBlock's
// `restyleOnWidthChange`) need restyling. Their display size is measured
// off the container width, and an initially narrow table can become
// scrollable.
if widthChanged { if widthChanged {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.configuration.readingWidth == nil { if self.configuration.readingWidth == nil {
self.restyleTableParagraphsForWidthChange() self.restyleWidthDependentParagraphsForWidthChange()
} }
self.updateWideTableOverlays() self.updateWideTableOverlays()
// Comment anchor bars are just as width-dependent as the
// table/image restyle above wrapped lines reflow, shifting
// every anchor's y-position.
if let coordinator = self.delegate as? NativeTextViewCoordinator {
coordinator.updateCommentAnchorRects(textView: self)
}
} }
} }
} }
/// Restyle only table paragraphs via stamped anchor ranges; avoids re-tokenizing the doc. /// Restyle only width-dependent paragraphs (tables, standalone images) via
private func restyleTableParagraphsForWidthChange() { /// stamped anchor ranges; avoids re-tokenizing the doc.
private func restyleWidthDependentParagraphsForWidthChange() {
guard let storage = textStorage, guard let storage = textStorage,
let coord = delegate as? NativeTextViewCoordinator else { return } let coord = delegate as? NativeTextViewCoordinator else { return }
var ranges: [NSRange] = [] var ranges: [NSRange] = []
@@ -93,3 +93,63 @@ public struct InlineReplacementRequest: Sendable {
self.isImageEmbedMode = isImageEmbedMode self.isImageEmbedMode = isImageEmbedMode
} }
} }
/// Request to insert literal text at the current caret (replacing the
/// current selection, if any) e.g. a Markdown image reference from an
/// embedder-side image picker or generator.
///
/// Embedders push one of these into
/// ``NativeTextViewWrapper/pendingTextInsertion`` to commit it. The engine
/// inserts it as a normal (undoable) edit, moves the caret past it, and
/// clears the binding. Unlike ``InlineReplacementRequest``, this doesn't
/// target an existing inline token it just inserts at wherever the caret
/// currently is.
public struct TextInsertionRequest: Sendable {
/// Stable identifier so the engine can detect already-applied requests
/// across SwiftUI re-renders.
public let id: UUID
/// Document the insertion targets. Ignored if it doesn't match the
/// editor's current `documentId` (prevents cross-document writes).
public let documentId: String
/// Storage-form text to insert at the caret.
public let text: String
public init(id: UUID = UUID(), documentId: String, text: String) {
self.id = id
self.documentId = documentId
self.text = text
}
}
/// Request to replace a SPECIFIC, already-known range e.g. a code block's
/// fence line (``CodeBlockSelection/fenceRange``) when switching its
/// language regardless of where the caret currently is. Unlike
/// ``TextInsertionRequest``, this doesn't touch the current selection at
/// all.
///
/// Embedders push one of these into
/// ``NativeTextViewWrapper/pendingTextRangeReplacement`` to commit it. The
/// range is in DISPLAY-form coordinates (the same space `CodeBlockSelection`
/// and every other rect/range the engine hands back use) stale by the
/// time it's applied only if the document changed shape in between, in
/// which case the engine no-ops rather than risk corrupting an unrelated
/// range.
public struct TextRangeReplacementRequest: Sendable {
/// Stable identifier so the engine can detect already-applied requests
/// across SwiftUI re-renders.
public let id: UUID
/// Document the replacement targets. Ignored if it doesn't match the
/// editor's current `documentId` (prevents cross-document writes).
public let documentId: String
/// Range to replace, in the display-form string's coordinates.
public let range: NSRange
/// Text to put in `range`'s place.
public let replacement: String
public init(id: UUID = UUID(), documentId: String, range: NSRange, replacement: String) {
self.id = id
self.documentId = documentId
self.range = range
self.replacement = replacement
}
}
@@ -55,6 +55,15 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// Push a replacement into the editor by setting this to a non-nil value; /// Push a replacement into the editor by setting this to a non-nil value;
/// the engine applies it on the next update and then clears the binding. /// the engine applies it on the next update and then clears the binding.
@Binding public var pendingInlineReplacement: InlineReplacementRequest? @Binding public var pendingInlineReplacement: InlineReplacementRequest?
/// Push a plain-text insertion at the caret by setting this to a non-nil
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextInsertionRequest``.
@Binding public var pendingTextInsertion: TextInsertionRequest?
/// Push a specific-range replacement (e.g. rewriting a code block's
/// fence line to change its language) by setting this to a non-nil
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextRangeReplacementRequest``.
@Binding public var pendingTextRangeReplacement: TextRangeReplacementRequest?
/// The full editor configuration (theme + services + style toggles). Engine /// The full editor configuration (theme + services + style toggles). Engine
/// embedders construct this themselves and pass it in; the wrapper does /// embedders construct this themselves and pass it in; the wrapper does
/// not read UserDefaults or know about app-specific colors/services. /// not read UserDefaults or know about app-specific colors/services.
@@ -95,6 +104,19 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// Fires when the set of visible code blocks changes, so embedders can /// Fires when the set of visible code blocks changes, so embedders can
/// overlay copy buttons (see ``CodeBlockButton``). /// overlay copy buttons (see ``CodeBlockButton``).
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
/// Fires whenever the text selection changes. `nil` for an empty (caret-only)
/// selection, otherwise the plain-text substring currently selected useful
/// for embedder features that act on "whatever's selected" (e.g. seeding a
/// generator's prompt).
public var onSelectedTextChange: ((String?) -> Void)?
/// Plain-text search terms the embedder wants a screen rect for e.g.
/// anchored comments' `anchorText`, to draw an inline marker next to
/// them. See ``CommentAnchorQuery``/``CommentAnchorRect``.
public var commentAnchorQueries: [CommentAnchorQuery] = []
/// Fires whenever `commentAnchorQueries`' resolved positions change
/// (queries updated, or the document reflows). A query whose text isn't
/// found in the current document simply doesn't appear in the array.
public var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
/// Fires after the user toggles any of the three spell/grammar/auto-correction /// Fires after the user toggles any of the three spell/grammar/auto-correction
/// menu items. Embedders persist the policy and pass it back via /// menu items. Embedders persist the policy and pass it back via
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch. /// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
@@ -141,6 +163,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
text: Binding<String>, text: Binding<String>,
isWikiLinkActive: Binding<Bool> = .constant(false), isWikiLinkActive: Binding<Bool> = .constant(false),
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil), pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
pendingTextRangeReplacement: Binding<TextRangeReplacementRequest?> = .constant(nil),
configuration: MarkdownEditorConfiguration = .default, configuration: MarkdownEditorConfiguration = .default,
fontName: String = "SF Pro", fontName: String = "SF Pro",
fontSize: CGFloat = 16, fontSize: CGFloat = 16,
@@ -153,6 +177,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil, onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil, onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil, onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
onSelectedTextChange: ((String?) -> Void)? = nil,
commentAnchorQueries: [CommentAnchorQuery] = [],
onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? = nil,
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil, onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
placeholder: NSAttributedString? = nil, placeholder: NSAttributedString? = nil,
header: AnyView? = nil, header: AnyView? = nil,
@@ -166,6 +193,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self._text = text self._text = text
self._isWikiLinkActive = isWikiLinkActive self._isWikiLinkActive = isWikiLinkActive
self._pendingInlineReplacement = pendingInlineReplacement self._pendingInlineReplacement = pendingInlineReplacement
self._pendingTextInsertion = pendingTextInsertion
self._pendingTextRangeReplacement = pendingTextRangeReplacement
self.configuration = configuration self.configuration = configuration
self.fontName = fontName self.fontName = fontName
self.fontSize = fontSize self.fontSize = fontSize
@@ -178,6 +207,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self.onInlineSelectionChange = onInlineSelectionChange self.onInlineSelectionChange = onInlineSelectionChange
self.onInlinePreviewKey = onInlinePreviewKey self.onInlinePreviewKey = onInlinePreviewKey
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
self.onSelectedTextChange = onSelectedTextChange
self.commentAnchorQueries = commentAnchorQueries
self.onCommentAnchorRectsChange = onCommentAnchorRectsChange
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
self.placeholder = placeholder self.placeholder = placeholder
self.header = header self.header = header
@@ -328,6 +360,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlineSelectionChange = onInlineSelectionChange
context.coordinator.onInlinePreviewKey = onInlinePreviewKey context.coordinator.onInlinePreviewKey = onInlinePreviewKey
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
context.coordinator.onSelectedTextChange = onSelectedTextChange
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
context.coordinator.commentAnchorQueries = commentAnchorQueries
textView.recalcOverscroll(for: scrollView) textView.recalcOverscroll(for: scrollView)
textView.setPlaceholder(placeholder) textView.setPlaceholder(placeholder)
@@ -541,6 +576,40 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
if fullRange.length > 0 { if fullRange.length > 0 {
context.coordinator.restyleParagraphs([fullRange], in: textView) context.coordinator.restyleParagraphs([fullRange], in: textView)
} }
// An image's display width is measured off the text container's
// CURRENT width (see styleImageLinks/styleImageEmbeds' maxWidth),
// which may not have settled to its real value yet in this same
// pass e.g. right after a text change that also grows/shrinks
// the pane (Split View's HSplitView reflow, `.fitsContent`
// resizing). A too-small width here falls back to a small
// default and, with nothing else in this document changing
// afterward, stays wrong until something else forces a restyle
// (reopening the document). Re-measure one runloop tick later,
// once layout has actually settled mirrors the WideTableOverlay
// reconciliation below.
if imageChanged {
let coordinator = context.coordinator
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
let range = NSRange(location: 0, length: (textView.string as NSString).length)
guard range.length > 0 else { return }
coordinator.restyleParagraphs([range], in: textView)
}
}
}
// Comment queries can change (comments finished loading, resolved,
// etc.) with the document's own text completely unchanged the
// "nothing to do" early-return just below would otherwise skip
// recomputing their positions forever. Handled the same way as
// imageChanged/wikiChanged just above: detect and act on it before
// that gate, independent of whether a real rebuild is happening.
if context.coordinator.commentAnchorQueries != commentAnchorQueries {
context.coordinator.commentAnchorQueries = commentAnchorQueries
let coordinator = context.coordinator
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
coordinator.updateCommentAnchorRects(textView: textView)
}
} }
textView.isEditable = isEditable textView.isEditable = isEditable
textView.isSelectable = true textView.isSelectable = true
@@ -562,6 +631,30 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
} }
return return
} }
if let pendingTextInsertion {
if pendingTextInsertion.documentId == documentId,
context.coordinator.lastAppliedTextInsertionID != pendingTextInsertion.id {
context.coordinator.applyTextInsertion(pendingTextInsertion, to: textView)
}
DispatchQueue.main.async {
if self.pendingTextInsertion?.id == pendingTextInsertion.id {
self.pendingTextInsertion = nil
}
}
return
}
if let pendingTextRangeReplacement {
if pendingTextRangeReplacement.documentId == documentId,
context.coordinator.lastAppliedTextRangeReplacementID != pendingTextRangeReplacement.id {
context.coordinator.applyTextRangeReplacement(pendingTextRangeReplacement, to: textView)
}
DispatchQueue.main.async {
if self.pendingTextRangeReplacement?.id == pendingTextRangeReplacement.id {
self.pendingTextRangeReplacement = nil
}
}
return
}
if context.coordinator.didInitialFormatting if context.coordinator.didInitialFormatting
&& context.coordinator.lastSyncedText == text && context.coordinator.lastSyncedText == text
&& !fontChanged { && !fontChanged {
@@ -660,8 +753,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
} }
// Document rebuilds bypass textDidChange re-derive emptiness here. // Document rebuilds bypass textDidChange re-derive emptiness here.
textView.refreshPlaceholderVisibility() textView.refreshPlaceholderVisibility()
context.coordinator.commentAnchorQueries = commentAnchorQueries
DispatchQueue.main.async { DispatchQueue.main.async {
context.coordinator.updateCodeBlockSelection(textView: textView) context.coordinator.updateCodeBlockSelection(textView: textView)
context.coordinator.updateCommentAnchorRects(textView: textView)
} }
context.coordinator.onCaretRectChange = onCaretRectChange context.coordinator.onCaretRectChange = onCaretRectChange
@@ -669,6 +764,8 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
context.coordinator.onInlineSelectionChange = onInlineSelectionChange context.coordinator.onInlineSelectionChange = onInlineSelectionChange
context.coordinator.onInlinePreviewKey = onInlinePreviewKey context.coordinator.onInlinePreviewKey = onInlinePreviewKey
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
context.coordinator.onSelectedTextChange = onSelectedTextChange
context.coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
context.coordinator.didInitialFormatting = true context.coordinator.didInitialFormatting = true
} }
@@ -691,6 +788,9 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
coordinator.lastImageFingerprint = configuration.services.images.fingerprint() coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint() coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
coordinator.onSelectedTextChange = onSelectedTextChange
coordinator.onCommentAnchorRectsChange = onCommentAnchorRectsChange
coordinator.commentAnchorQueries = commentAnchorQueries
coordinator.onInlinePreviewKey = onInlinePreviewKey coordinator.onInlinePreviewKey = onInlinePreviewKey
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
+51
View File
@@ -0,0 +1,51 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="156.10054" height="40" viewBox="0 0 156.10054 40">
<title>Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917</title>
<g>
<g>
<g>
<path d="M146.57123,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.4378,6.4378,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01514.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27446,6.27446,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H146.57123c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.60205-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72184,5.72184,0,0,1-.543-1.6572,12.41339,12.41339,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37032,12.37032,0,0,1,.16553-1.87207,5.75552,5.75552,0,0,1,.54346-1.6621A5.3735,5.3735,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875h139.205l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73127,12.73127,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<g id="_Group_4" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M46.14895,30.49609V21.35645H46.0884l-3.74316,9.04492H40.91652l-3.75293-9.04492H37.104v9.13965H35.34816v-12.418h2.22949l4.01855,9.80176h.06836l4.01074-9.80176h2.2373v12.418Z" style="fill: #fff"/>
<path d="M49.396,27.92285c0-1.583,1.21289-2.53906,3.36523-2.668l2.47852-.1377v-.68848c0-1.00684-.66309-1.5752-1.791-1.5752a1.73035,1.73035,0,0,0-1.90137,1.27441H49.8091c.05176-1.63574,1.5752-2.79687,3.69141-2.79687,2.16016,0,3.58887,1.17871,3.58887,2.96v6.20508H55.30813V29.00684h-.043a3.23683,3.23683,0,0,1-2.85742,1.64453A2.74447,2.74447,0,0,1,49.396,27.92285Zm5.84375-.81738V26.4082l-2.22949.1377c-1.11035.06934-1.73828.55078-1.73828,1.3252,0,.792.6543,1.30859,1.65234,1.30859A2.17046,2.17046,0,0,0,55.23977,27.10547Z" style="fill: #fff"/>
<path d="M64.89309,24.55762a1.99909,1.99909,0,0,0-2.13379-1.66895c-1.42871,0-2.375,1.19629-2.375,3.08105,0,1.92773.95508,3.08887,2.3916,3.08887a1.94829,1.94829,0,0,0,2.11719-1.626h1.79A3.61835,3.61835,0,0,1,62.7593,30.6084c-2.582,0-4.26855-1.76465-4.26855-4.63867,0-2.81445,1.68652-4.63867,4.251-4.63867a3.63931,3.63931,0,0,1,3.9248,3.22656Z" style="fill: #fff"/>
<path d="M78.7593,27.13965H74.0259l-1.13672,3.35645H70.8843l4.4834-12.418h2.083l4.4834,12.418H79.895Zm-4.24316-1.54883h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M91.61672,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438H83.0884V21.44238h1.79883v1.50586h.03418a3.21161,3.21161,0,0,1,2.88281-1.60059C90.10207,21.34766,91.61672,23.16406,91.61672,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C88.7593,29.01563,89.70656,27.81934,89.70656,25.96973Z" style="fill: #fff"/>
<path d="M101.58156,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238h1.79883v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C100.06691,21.34766,101.58156,23.16406,101.58156,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C98.72414,29.01563,99.67141,27.81934,99.67141,25.96973Z" style="fill: #fff"/>
<path d="M108.1675,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52149.75684-2.52149,1.8584c0,.87793.65431,1.39453,2.25489,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M119.80324,19.2998v2.14258h1.72168v1.47168h-1.72168v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406h-1.31641V21.44238h1.31641V19.2998Z" style="fill: #fff"/>
<path d="M122.521,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C124.18215,30.6084,122.521,28.82617,122.521,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40137,1.16211-2.40137,3.10742c0,1.96191.89551,3.10645,2.40137,3.10645S129.21633,27.93164,129.21633,25.96973Z" style="fill: #fff"/>
<path d="M132.64309,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.598,2.598,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93651,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M145.84035,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,139.55813,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
</g>
<g id="_Group_5" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57521,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.40041,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.40041,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29494,13.03955Z" style="fill: #fff"/>
<path d="M71.34768,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.07131,14.772,71.34768,13.87061,71.34768,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72072,10.91846,72.26564,11.49707,72.26564,12.44434Z" style="fill: #fff"/>
<path d="M79.22951,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.124,13.99463,82.56252,13.42432,82.56252,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB