132 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
Puranjay Savar Mattas 69837b3471 chore: visionOS support removed 2026-08-20 14:24:16 +01:00
Puranjay Savar Mattas b60e7ec94e feat: wire smart text replacements, autocomplete, Writing Tools toggles
Adds TextSubstitutionPolicy/TextCompletionPolicy/WritingToolsPolicy to
MarkdownEditorConfiguration (previously hardcoded AppKit calls with no
config surface). Outline's existing synced smartText preference now
actually drives smart quotes/dashes. Autocomplete and Writing Tools
get new local-only Settings -> Editor toggles (writingToolsBehavior
was already on unconditionally with no way to turn it off).
2026-08-20 13:53:53 +01:00
Puranjay Savar Mattas 3c3a5865bd fix: seed code-block cache on programmatic document load
updateCodeBlockSelection was only ever called from the two AppKit
text-delegate callbacks (textDidChange / selection-changed), never
from the programmatic rebuildTextStorageAndStyle load path. Line
number gutters (and the built-in copy-code button) stayed empty on a
cold document open until the user's first click or keystroke.
Threads the rebuild's own parsed document into the same call instead
of leaving cachedCodeBlockTokens empty until the next real edit.
2026-08-20 13:49:43 +01:00
Puranjay Savar Mattas 707dde14a9 docs: credit swift-markdown-engine in README 2026-08-20 13:30:20 +01:00
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00
Puranjay Savar Mattas 1552a3cd23 Merge branch 'main' into feature/document-editing 2026-08-20 00:43:27 +01:00
Puranjay Savar Mattas cc5b329d8e Merge pull request 'legal: relicense from MIT to Business Source License 1.1' (#12) from legal/bsl-1.1-license into main
Reviewed-on: #12
2026-08-20 00:42:25 +01:00
Puranjay Savar Mattas da890e88fd legal: push BSL change date out to 10 years (2036-08-20) 2026-08-20 00:31:27 +01:00
Puranjay Savar Mattas c04e4d542c legal: relicense from MIT to Business Source License 1.1
Switches from a fully permissive license to BSL 1.1 (the same family
Outline's own server uses, for the same underlying reason): personal
use, self-hosting, and non-commercial forks stay explicitly allowed,
but shipping a competing hosted/distributed product off this code, or
distributing a fork under branding that claims official/affiliated
status, now requires a separate commercial agreement. Converts
automatically to Apache License 2.0 on the change date in LICENSE.

Adds an explicit trademark notice for the "Outpost" name/logo,
independent of the code license — the specific thing this was meant to
close off (a fork getting relabeled as an official/endorsed product).

README gets a plain-English summary of what changed; CONTRIBUTING gets
a one-line note that PRs are contributed under the same terms.
2026-08-20 00:28:32 +01:00
Puranjay Savar Mattas cc27a860e5 Merge branch 'main' into feature/document-editing 2026-08-20 00:12:55 +01:00
Puranjay Savar Mattas d2e213b516 Merge pull request 'chore: vendor swift-markdown-engine as a local submodule' (#11) from chore/vendor-markdown-engine-submodule into main
Reviewed-on: #11
2026-08-20 00:09:01 +01:00
Puranjay Savar Mattas 226d6fb748 chore: switch Xcode to the local swift-markdown-engine submodule
Removed the remote XCRemoteSwiftPackageReference and pointed
MarkdownEngine/MarkdownEngineCodeBlocks/MarkdownEngineLatex at the
XCLocalSwiftPackageReference for Vendor/swift-markdown-engine instead.
Package.resolved drops the now-irrelevant remote pin (HighlighterSwift
and SwiftMath stay remote, only swift-markdown-engine itself moved
local).

Also bumps the submodule pointer to include three warning fixes made
directly in the vendored source (var->let, unused local, #selector) —
first real edits to the package now that it's locally editable.
2026-08-20 00:09:01 +01:00
Puranjay Savar Mattas 44a2aff050 chore: vendor swift-markdown-engine as a git submodule
Pinned to e5f7607 (v0.12.0), the exact commit already resolved in
Outpost.xcodeproj's Package.resolved — no version change, just gives it
a local, versioned checkout instead of only existing as an Xcode-managed
remote package cache. Brings its own ARCHITECTURE.md along at
Vendor/swift-markdown-engine/ARCHITECTURE.md.

Xcode side still needs a manual follow-up: remove the remote
"swift-markdown-engine" package reference and add
Vendor/swift-markdown-engine as a local package instead (File > Add
Package Dependencies > Add Local...). Not done here — pbxproj package
references aren't safely hand-editable without a build to verify against.
2026-08-20 00:09:00 +01:00
Puranjay Savar MattasandClaude Sonnet 5 d44e4dd4ec WIP(editor): syntax highlighting + code block line numbers
Syntax highlighting: wires the already-pinned HighlighterSwiftBridge
(MarkdownEngineCodeBlocks) into services.syntaxHighlighter on every
NativeTextViewWrapper, shared via one CodeSyntaxHighlighting.shared
instance (JSContext init is expensive, don't build one per text view).

Line numbers: app-side CodeBlockLineNumberGutter overlay positioned via
onCodeBlockSelectionChange's rect, widens codeBlock.horizontalIndent to
make room. Marked WIP: line-number overlays (and the engine's own
built-in copy-button overlay) stay empty on cold document load until
the user's first click/keystroke — a real gap in swift-markdown-engine's
rebuild path (cachedCodeBlockTokens is only ever seeded by AppKit text-
delegate callbacks, not the programmatic text-binding rebuild), not
fixable from the app side without patching the package. See
TODO.local.md for the full trace and options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:44:50 +01:00
Puranjay Savar Mattas 195dc2cc59 feat(editor): Remember previous location + Command Palette (⌘K)
Remember previous location (Preferences): persists collection +
document chain (or Home) to UserDefaults on every navigation change,
gated on the preference. Restored once per launch by resolving the
stored IDs back through the API, stopping at the first failure
(deleted doc, offline, etc.) rather than aborting the whole restore —
a partial chain beats falling all the way back to Home. Cleared on
sign-out so switching accounts can't restore a stale location.

Command Palette (new Settings → Editor section, not synced to
Outline): ⌘K opens a floating overlay, arrow-key/click navigation,
Enter or click to select. Always searches locally, never a
per-keystroke network request:
- Lightweight (default): live listCollections + listViewedDocuments
  fetch once on open.
- Full Workspace (opt-in, requires Full Local Sync on): reads
  CachingOutlineAPIClient's local cache directly — zero network calls,
  includes nested sub-documents now that Full Local Sync actually
  caches them (see the paired OutlineKit commit).

Fixed through live testing, in order found:
- Focus: the search field wasn't reliably first responder the instant
  ⌘K opened it (also the likely source of several AppKit
  CA-transaction console warnings) — added a short delay before
  focusing.
- Full Workspace "no results": was sequential one-collection-at-a-time
  fetching before the cache-read redesign: withTaskGroup made it
  concurrent, and reading from the cache instead of the network made
  it moot.
- Arrow keys not moving selection: .onKeyPress was on the outer card,
  but the focused TextField swallowed the events before they could
  bubble up. Moved the handlers directly onto the TextField.
- Search ranking: exact-phrase-only matching meant a title like "Test
  Plan Document" never matched a "test document" query at all
  (filtered out, not just ranked low), making it look like collections
  always won. Added a fallback tier: every word of a multi-word query
  present anywhere in the title still matches, ranked below
  exact/prefix/substring hits.
- foregroundStyle(.secondary vs .orange) ternary: HierarchicalShapeStyle
  vs Color type mismatch, fixed with AnyShapeStyle on both branches.
2026-08-19 17:14:49 +01:00
Puranjay Savar Mattas bef194d493 fix(outlinekit): Full Local Sync now recurses into nested documents
performFullSync only ever cached each collection's root-level documents
— a document's own sub-documents were never cached at all, so nested
content stayed unreachable offline even with a full sync (and even
after implicitly re-fetching individual documents, since the walk that
seeds the cache never visited them). Now recurses depth-first into
every document's children via cacheDocumentTree, terminating naturally
once a branch runs out of sub-documents.

Also caches each collection individually under "collection:<id>" (was
only ever cached as part of a paginated list blob), and adds
OfflineCacheStore.loadAll(keyPrefix:) plus
cachedDocumentsIndex()/cachedCollectionsIndex() on
CachingOutlineAPIClient to read everything back as a flat local index
— no per-id lookup needed, no network involved.

Found and fixed a real infinite-recursion bug in the process: an
existing test's stub ignored parentDocumentId and kept returning the
same root documents at every recursion depth, hanging swift test
indefinitely once the real code started recursing into children. Fixed
the stub, added a dedicated regression test for the recursive behavior
itself. 78/78 tests passing.
2026-08-19 17:14:28 +01:00
Puranjay Savar Mattas bdde8642f7 feat(editor): Split View (raw Markdown / live preview), remove Sub-documents
New Outpost-local Settings → Editor section (not synced to Outline,
same as Appearance) with a Split View toggle: raw Markdown source on
the left (plain TextEditor, not the rendering engine), the same rich
rendering used everywhere else in the app on the right, read-only,
live-updating off the same text binding.

Fixed a real layout bug before shipping it: the split view was nested
inside the page-level ScrollView, which proposes unbounded height to
its content, so a minHeight just resolved to exactly that minimum
instead of filling the window. Restructured so Split View bypasses the
outer scroll entirely (title fixed at top, HSplitView taking every
remaining pixel below it) — each pane already scrolls itself, so
nesting it inside another unbounded scroll container was fighting
itself for height. Normal single-pane reading/editing untouched.

Known follow-up, not attempted: scroll position between the two panes
isn't synchronized — the editor package exposes no scroll hook, so
doing this for real means introspecting its private view hierarchy.

Also removed the "Sub-documents" section from the reader per explicit
request — the childrenSection view, and the now-unnecessary
listDocuments(parentDocumentId:) fetch backing it in the view model.
2026-08-19 15:42:35 +01:00
Puranjay Savar Mattas 1229678c00 feat(editor): wire up Separate Editing preference
Preferences → Separate Editing now actually drives document reader
behavior, not just a saved-but-inert server setting:

- On (default, today's existing behavior): unchanged — explicit
  Edit/Done toggle, save on Done.
- Off: no Edit/Done affordance — document is always editable directly
  (no per-document permission field exists server-side to pre-check
  against, so an unauthorized edit just fails to save rather than
  being blocked client-side). Edits autosave 1.5s after typing pauses,
  through the same offline-queue-aware updateDocument path the
  explicit save already used. Guarded against firing a pointless save
  right after opening a document, and against clobbering newer
  keystrokes typed while a debounced save is still in flight.

Prerequisite: SessionStore now caches OutlineUserPreferences to
UserDefaults, loaded on launch before any network call — this needed
to stay valid on a cold offline launch, not just live in memory from
the last successful fetch. Still read-only while offline, unchanged.
2026-08-19 15:29:38 +01:00
Puranjay Savar Mattas 0da26e0fed Merge pull request 'fix(about): About Outpost opens Settings' About section, no popup window' (#10) from fix/about-menu-no-popup into main
Reviewed-on: #10
2026-08-19 14:22:40 +01:00
Puranjay Savar Mattas 6ffba3be02 fix(about): About Outpost opens Settings' About section, no popup window
"About Outpost" in the app menu used to open a separate standalone
window (Window(id: "about") wrapping AboutView). Now just opens Settings
and jumps straight to the About section instead — same content
(AboutInfoView, unchanged), one less window/code path to maintain.

Also removed the "Check for Updates…" button — deferred per explicit
instruction, not deleted for a real reason beyond "not now." A simple
button can come back later; no update-checking logic was ever built; ,
nothing else to clean up alongside it.
2026-08-19 14:08:43 +01:00
Puranjay Savar Mattas 187f2eaa25 Merge pull request 'docs: fix wiki links to include trailing .-' (#9) from docs/wiki-link-fix into main
Reviewed-on: #9
2026-08-19 02:43:10 +01:00
Puranjay Savar Mattas 3fadd008f4 Merge branch 'main' into docs/wiki-link-fix 2026-08-19 02:42:56 +01:00
Puranjay Savar Mattas 410049d161 docs: fix wiki links, they need a trailing .- to resolve correctly
Confirmed live against the actual Gitea wiki — hyperlinks to
hyphenated wiki page names 404 without a trailing .-, e.g.
.../wiki/Privacy-Policy needs to be .../wiki/Privacy-Policy.-
2026-08-19 02:40:43 +01:00
Puranjay Savar Mattas 5301187413 Merge pull request 'docs: README - logo, TestFlight link, drop Status, expand Privacy' (#8) from docs/readme-testflight-privacy into main
Reviewed-on: #8
2026-08-19 02:38:12 +01:00
Puranjay Savar Mattas fe275ac17c docs: README — logo, real TestFlight link, drop Status, expand Privacy
- Logo and a TestFlight download badge at the top.
- Removed the Status/phase checklist — CLAUDE.md already tracks this,
  duplicating it in the README just goes stale.
- Requirements corrected to what the project actually targets right
  now (Xcode 27+, macOS 27+) instead of stale Xcode 16/iOS 17 numbers.
- Privacy section expanded with a real inline summary, linking out to
  the wiki for the full Privacy Policy, Terms of Service, and Data
  Processing Statement rather than duplicating them as repo files.
2026-08-19 02:26:36 +01:00
Puranjay Savar Mattas 710a6617f5 Merge pull request 'Settings parity: Profile, Preferences, Notifications, Passkeys, API & Access' (#7) from feature/settings-parity into main
Reviewed-on: #7
2026-08-18 17:13:52 +01:00
Puranjay Savar Mattas 55c23afaff chore: bump version to 0.0.4 2026-08-18 17:01:17 +01:00
Puranjay Savar Mattas f07c5055fa fix(settings): style Coming Soon as a capsule badge instead of plain text 2026-08-18 16:59:06 +01:00
Puranjay Savar Mattas 8af860cd7e feat(settings): mark Workspace category Coming Soon in the sidebar
Shows a small tertiary-styled 'Coming Soon' next to the category header
whenever every section under it is still !isImplemented — not hardcoded
to Workspace specifically, so it stops on its own once real Workspace
sections start landing instead of needing a manual follow-up removal.
2026-08-18 16:58:14 +01:00
Puranjay Savar Mattas 78e3566a8e fix(settings): show offline hint on Passkeys, clearer API key web-only message
Passkeys was the only implemented section missing the standard offline
banner. Reworded the API key create/delete popup to state plainly it's
only supported on Outline's web version, matching Passkeys' own phrasing
style instead of the more roundabout original wording.
2026-08-18 16:54:45 +01:00
Puranjay Savar Mattas 64e970fc92 fix(settings): API key create/delete need Outline's web session, not this app
Same limitation as Passkeys — apiKeys.create/apiKeys.delete need
Outline's cookie+CSRF web session, not this app's Bearer-token auth.
"New API Key…" and the per-row trash button now show an explanatory
popup instead of attempting a request that doesn't work.

The real create sheet, reveal-once flow, delete confirmation, and their
backing functions are untouched and still fully built/tested at the
OutlineKit layer — only the two trigger points were redirected, each
marked with a TODO pointing back to how to re-enable them (swap the
button action back) once there's a supported native auth path or
Outline adds Bearer support for these two endpoints.
2026-08-18 16:53:46 +01:00
Puranjay Savar Mattas 1e541979e9 fix(settings): sidebar version footer — alpha tag, offline handling, auto-refresh
Outpost's version was missing the -ALPHA suffix About already shows —
extracted OutpostVersion (Support/) as the one shared source for both,
so a third divergent copy can't happen the way AboutInfoView's own doc
comment already warns against for its two call sites.

Outline's version now accounts for the manual Offline Mode toggle too,
not just real connectivity, shows "Outline — offline" instead of just
disappearing when nothing's been fetched yet, and re-fetches
automatically via .task(id: isEffectivelyOnline) whenever connectivity
changes — previously a one-shot fetch on first sidebar mount only.
2026-08-18 16:51:13 +01:00
Puranjay Savar Mattas 26b0d7b118 fix(settings): grey out API & Access while offline
New API Key/delete were already gated on isEffectivelyOnline, but
nothing visually signaled offline state the way Preferences/Profile do
— added the same offline hint banner plus dimmed+disabled the key list
itself, and gated the sheet's own Create button too (in case Offline
Mode gets toggled on while the sheet is already open).
2026-08-18 16:49:45 +01:00
Puranjay Savar Mattas 45c950d474 feat(settings): API key create, reveal-once, and delete
Create: name + expiration picker (No expiration/1/3/6 months/1 year,
computed client-side and sent as expiresAt — omitted entirely for no
expiration, confirmed live that's what produces a non-expiring key).

Reveal: plaintext value only ever shown in a dedicated one-time sheet,
separate from the persisted apiKeys list (which is refreshed from the
server right after creating, so it never carries the value at all).
Requires clicking Copy before the confirm button unlocks, copies to the
system pasteboard, and clears the value from @State the moment the sheet
closes however it closes (explicit confirm, Escape, or otherwise) via
onDisappear — not just hidden behind dismissed UI.

Delete: confirmation dialog naming the key, per-row spinner while in
flight.

Both New API Key and per-row delete disable while offline, consistent
with every other server-synced action in Settings.
2026-08-18 16:48:12 +01:00
Puranjay Savar Mattas 044a706bd4 feat(outlinekit): add apiKeys.create/delete
value is only ever present in the create response (confirmed live —
apiKeys.list never includes it), so the model and call sites treat it
as a one-time reveal, not persisted state. expiresAt omitted (not null)
for a non-expiring key, matches synthesized Encodable's default
encodeIfPresent behavior.
2026-08-18 16:45:18 +01:00
Puranjay Savar Mattas fa22ac616b feat(settings): move Outline version to sidebar footer, drop Installation section
Removed the Integrations & Installation category and its lone
Installation section entirely — Outline's server version now shows in
the settings sidebar footer instead, alongside Outpost's own version
(installation.info, fetched once when the sidebar appears). About stays
where it was, unaffected.
2026-08-18 16:39:19 +01:00
Puranjay Savar Mattas adfa477ecf feat(settings): Installation section
Server version + up-to-date/behind indicator via installation.info.
2026-08-18 14:20:26 +01:00
Puranjay Savar Mattas dc780e420f feat(settings): API & Access section (personal keys, read-only)
Lists existing personal API keys via apiKeys.list (name, masked last4,
created/last-used dates) with a link to Outline's developer docs.
Creation/revocation deferred per instruction — read-only for this pass.
2026-08-18 14:19:50 +01:00
Puranjay Savar Mattas 6eb780d1bd feat(settings): Passkeys section
Informational only, per Outline itself — passkey/WebAuthn registration
needs a browser context, so this stays web-only rather than a
placeholder for missing native functionality.
2026-08-18 14:18:26 +01:00
Puranjay Savar Mattas 78a0e31897 feat(settings): mark Passkeys, API & Access, Installation implemented 2026-08-18 14:17:55 +01:00
Puranjay Savar Mattas e3ad8650c6 feat(outlinekit): add apiKeys.list and installation.info plumbing
Read-only for now, per instruction to defer key creation/revocation.
Both confirmed against live network captures.
2026-08-18 14:17:41 +01:00
Puranjay Savar Mattas 4faa23f79e chore(xcode): sync project signing config after bundle ID rename
Picks up Xcode's own automatic-signing additions from setting up the
Development provisioning profile in the IDE (CODE_SIGN_IDENTITY,
PROVISIONING_PROFILE_SPECIFIER, ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS,
Info.plist display name/category keys), alongside the earlier
com.psmattas.OutpostApp bundle ID and CW6GQT9SK5 team fixes.
2026-08-18 13:52:32 +01:00
Puranjay Savar Mattas 09e1ab8f65 fix(auth): properly clean the login logo edge, previous pass left a fringe
Plain -fuzz -transparent white color-keyed every pixel in the whole
image close enough to white by color distance — including an
antialiased blend inside the artwork itself (where the light beam meets
the window frame), which punched a stray transparent hole there, and
still left a faint opaque fringe ring around the outer edge since the
binary cutoff didn't fully consume the antialiasing ramp.

Redone with a flood fill seeded from a corner pixel instead: only
transforms the background region actually connected to that seed (the
four corners + thin white border, confirmed contiguous), leaving
disconnected internal antialiased pixels untouched. Then eroded the
alpha channel by ~2px to consume the remaining boundary ramp cleanly.
Verified against both dark and light composited backgrounds, and
confirmed the beam/window-frame area is no longer punctured.
2026-08-18 12:39:42 +01:00
Puranjay Savar Mattas bec8135c74 fix(auth): remove white corners around the login logo
The App Store marketing icon source (outpost-ios-1024.png) is a flat
opaque square with the squircle baked in and pure-white corners outside
it — meant to be masked by the OS everywhere it's normally shown as an
app icon, but nothing masks it when used as a plain in-app image.
Color-keyed white to transparent (fuzz 8%, safe here since nothing in
the actual artwork is white/near-white) so the corners disappear instead
of showing a white border.
2026-08-18 12:30:05 +01:00
Puranjay Savar Mattas fcb72ba9d6 fix(auth): login logo was blank, AppIcon app-icon set isn't Image()-loadable
Image("AppIcon") resolved to nothing at runtime (confirmed live) — App
Icon-type asset catalog entries aren't reliably retrievable through
Image(_:)/UIImage(named:) the way a normal Image Set is. Added AppLogo,
a plain Image Set duplicating the same outpost-ios-1024.png artwork, and
pointed AuthHeaderView at that instead.
2026-08-18 12:22:16 +01:00
Puranjay Savar Mattas 2b0924be04 fix(auth): use the real app icon on the login screen, not a placeholder
AuthHeaderView was still a generic gradient-circle + SF Symbol book icon
from before AppIcon.appiconset had real branded artwork — never swapped
out once the actual icon was added. Now renders Image("AppIcon") directly
so the login screen can't drift from whatever the Dock/Home Screen icon
actually is.
2026-08-18 12:09:52 +01:00
Puranjay Savar Mattas 931338c9d3 fix(session): don't show signed-in UI when only half the session survived
SessionStore.init treated a Keychain token alone as "signed in," without
checking the paired serverURL in UserDefaults also existed. UserDefaults
is scoped to the app's sandboxed container (keyed by bundle ID), while
Keychain items can survive a reinstall or bundle ID change independently
of it — hit this live after renaming the bundle ID: Keychain still had
the old token, the new container had no serverURL, so isSignedIn came
back true with apiClient nil. App landed on Home with nothing able to
load instead of the login screen.

Now requires both to be present to consider the session valid, and clears
whichever half survived otherwise so a fresh sign-in rewrites both
consistently.
2026-08-18 12:07:02 +01:00
Puranjay Savar Mattas 2740eaeaaf feat(settings): Notifications document-access-requested toggle
Closes out the Notifications page — all 13 event toggles + the All
notifications master toggle now wired.
2026-08-17 21:53:06 +01:00
Puranjay Savar Mattas 6986601b8b feat(settings): Notifications invited-to-collection/export-completed toggles 2026-08-17 21:52:58 +01:00
Puranjay Savar Mattas ddd6701446 feat(settings): Notifications invite-accepted/invited-to-document toggles 2026-08-17 21:52:50 +01:00
Puranjay Savar Mattas aee09cb60a feat(settings): Notifications reaction-added/collection-created toggles 2026-08-17 21:52:42 +01:00
Puranjay Savar Mattas 944d0bf373 feat(settings): Notifications group-mentions/resolved toggles 2026-08-17 21:52:33 +01:00
Puranjay Savar Mattas a1df1b3d3d feat(settings): Notifications comment-posted/mentioned toggles
"Mentioned" groups comments.mentioned + documents.mentioned under one
visible toggle, per Outline's own settings copy.
2026-08-17 21:52:25 +01:00
Puranjay Savar Mattas 8faa94f3cc feat(settings): Notifications document-published/document-updated toggles 2026-08-17 21:52:15 +01:00
Puranjay Savar Mattas bf1e28c9ac feat(settings): Notifications section skeleton + All-notifications toggle
Marks .notifications implemented, adds the shared setNotifications save
path (per-event subscribe/unsubscribe, sequential calls for toggles that
group more than one wire event type) and the master "All notifications"
row. Individual event toggles land in follow-up commits.
2026-08-17 21:52:07 +01:00
Puranjay Savar Mattas c05b787bb7 feat(session): track user notification settings
Same pattern as language/preferences — Notifications settings page reads
current subscribed state from here.
2026-08-17 21:51:24 +01:00
Puranjay Savar Mattas 960919227b feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification
Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications
(users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed
against the "All notifications" master toggle live too).
2026-08-17 21:51:07 +01:00
Puranjay Savar Mattas a11be29ba9 feat(settings): grey out server-synced settings while offline
Profile's name/avatar and every Preferences toggle (except Appearance,
which is local-only) now disable + show a hint when isEffectivelyOnline
is false, instead of letting a save silently fail. Matches how Share/
Permissions/Search already behave — these are a "needs a real connection"
category, not queued through the offline write queue (infrequent writes,
not worth a second offline-sync path for).
2026-08-17 21:49:11 +01:00
Puranjay Savar Mattas 564cce0637 fix(outlinekit): correct preferences wire keys to the real server shape
All preference toggles showed as off regardless of server state, and
saving notificationBadge 400'd with "notificationBadge: Invalid Input" —
the guessed wire keys/values from the earlier speculative commit were
wrong. Fixed against a live network capture of Outline's own web app
toggling every one of these settings:

- separateEditing -> seamlessEdit, and inverted (seamlessEdit is
  separate editing's negation, confirmed by toggling it live)
- showCommentMarker -> commentsInGutter
- smartText -> enableSmartText
- notificationBadge values -> "disabled"/"indicator"/"count", not the
  guessed "none"/"unread"/"all"
- rememberLastPath/useCursorPointer/codeBlockLineNumbers were already
  correct

Also preserves fullWidthDocuments (a real preference this app has no UI
for) on round-trip, since the client always sends the whole preferences
object back on save — dropping an unrecognized key during decode would
otherwise silently clear it the next time any toggle here gets saved.
2026-08-17 21:35:17 +01:00
Puranjay Savar Mattas 7e34d58a81 fix(settings): language picker breaks on a server locale not in our list
Live warning: "en_GB" invalid tag, undefined Picker display — the curated
OutlineLocale.all didn't include it. Added en_GB explicitly, and made the
Picker's item list always include whatever code the server actually
reports (falling back to the code itself as the label) so any future
unlisted locale degrades gracefully instead of breaking the control.
2026-08-17 21:26:12 +01:00
Puranjay Savar Mattas 384dfe3cc1 feat(settings): Preferences delete-account action
Danger subsection, confirmation dialog before calling users.delete
(deleteAccount()), signs out locally on success. Closes out the
Preferences page.
2026-08-15 20:31:50 +01:00
Puranjay Savar Mattas cac11f0a22 feat(settings): Preferences notification-badge picker
Closes out the Behavior subsection. Uses NotificationBadgeStyle
(None/Unread Indicator/Unread Count) — wire values are a guess same as
the other preference keys, flagged in OutlineUserPreferences.
2026-08-15 20:31:24 +01:00
Puranjay Savar Mattas 78e1c158aa feat(settings): Preferences smart-text-replacements toggle 2026-08-15 20:31:11 +01:00
Puranjay Savar Mattas eeb6dc2564 feat(settings): Preferences remember-previous-location toggle 2026-08-15 20:31:05 +01:00
Puranjay Savar Mattas 286fe80e58 feat(settings): Preferences separate-editing toggle
Starts the Behavior subsection.
2026-08-15 20:30:58 +01:00
Puranjay Savar Mattas 32600bf081 feat(settings): Preferences show-comment-marker toggle
Closes out the Display subsection.
2026-08-15 20:30:51 +01:00
Puranjay Savar Mattas bdb1b4977f feat(settings): Preferences show-line-numbers toggle 2026-08-15 20:30:44 +01:00
Puranjay Savar Mattas f5cfb53f96 feat(settings): Preferences use-pointer-cursor toggle 2026-08-15 20:30:38 +01:00
Puranjay Savar Mattas 73602d1ade feat(settings): shared save path for preference toggles
savePreference(_:) reads the current OutlineUserPreferences, flips one
field, sends the whole object via updateUserPreferences. Every Behavior/
Display toggle added next reuses this instead of its own copy of the
same read-modify-write.
2026-08-15 20:30:31 +01:00
Puranjay Savar Mattas a3651a9364 feat(settings): Preferences appearance setting
Reuses the existing local Appearance color-scheme picker instead of a
second divergent implementation — this has always been a device-side
preference in this app, not a server-synced one.
2026-08-15 20:30:08 +01:00
Puranjay Savar Mattas cfdae2f9ba feat(settings): Preferences section skeleton + Language setting
Marks .preferences implemented, adds the section header/description and
a Display subsection. Language is real and wired end-to-end (users.update
language field, high confidence per Outline's public API docs) — the rest
of the toggles land in follow-up commits, one setting at a time.
2026-08-15 20:29:54 +01:00
Puranjay Savar Mattas fdb4ba5c06 feat(session): track user language + preferences
Mirrors the existing name/avatar/id fields — Settings' Preferences page
needs somewhere to read current values from and applyUpdatedProfile
already refreshes everything else on save.
2026-08-15 20:29:06 +01:00
Puranjay Savar Mattas eb4e1472b9 feat(outlinekit): add language/preferences plumbing for Preferences settings
OutlineUser gains language + preferences fields, new updateUserLanguage/
updateUserPreferences/deleteAccount client methods. Preference wire keys
are best-effort against Outline's own naming, same speculative treatment
as OutlinePin/OutlineDocumentMember — expect a correction round once
tested against a live server.
2026-08-15 20:28:38 +01:00
Puranjay Savar Mattas cd6277f5aa fix(avatar): attach the Bearer token — the real bug, not propagation delay
Your network capture of the working web-app request showed session
cookies (accessToken, authelia_session) on the attachments.redirect
call. This app authenticates every other request with an Authorization:
Bearer header instead — AvatarBadge's fetch never attached one, sending
a bare unauthenticated GET. Almost certainly a 401 the whole time, for
every avatar image, not just a freshly-uploaded one — a failed fetch
and "no avatar set" render identically here (placeholder icon, no
visible error), so there was nothing on screen to reveal it before now.

Uses KeychainTokenStore() directly, same keychain entry SessionStore
already reads, rather than threading a token through every AvatarBadge
call site. Kept the retry loop from the previous attempt too — genuinely
useful insurance against upload-consistency timing, just not the actual
cause here.
2026-08-15 16:44:45 +01:00
Puranjay Savar Mattas bde6be91ca fix(avatar): retry the redirect fetch instead of giving up on one failure
You confirmed the whole upload chain works — the new avatar shows
instantly on Outline's web app — but this app kept showing the old/
placeholder picture in both Settings and the sidebar, which read the
same underlying session.userAvatarURL. Since that state is provably
correct (the same URL that works on web), the bug has to be in how
AvatarBadge loads it, not in the update itself.

Leading theory: a freshly-uploaded attachment's redirect URL can fail
on the very first request right after upload — self-hosted storage
behind a reverse proxy isn't necessarily instantly consistent —  and
AvatarBadge's .task(id:) only ever fires once per URL with no retry,
so a single transient failure right after uploading would leave the
placeholder showing forever even though the exact same URL works fine
moments later (which lines up with it looking fine on a fresh web
load). Now retries twice with a short delay and explicitly checks the
HTTP status before treating the body as image data, instead of
silently accepting whatever came back. Also drops any URLCache
involvement (.reloadIgnoringLocalCacheData) as a second, independent
possible cause, cheap to rule out at the same time.

Couldn't confirm this is the actual root cause without being able to
run the app — worth retesting.
2026-08-15 16:37:44 +01:00
Puranjay Savar Mattas cb8bbd53c4 fix(profile): refresh from the server every time the page opens
SessionStore only ever populated from auth.info once per app launch
— a name (or avatar) changed elsewhere, like Outline's web app, never
reached it until a full quit-and-relaunch. Profile now refetches via
currentUser() every time it's opened and applies the result, same as
any live edit made from this app already does. Fails silently offline,
same as everything else that needs a live read.
2026-08-15 16:31:40 +01:00
Puranjay Savar Mattas 90df5f056d fix: missing UniformTypeIdentifiers import for NSOpenPanel content types 2026-08-15 16:27:39 +01:00
Puranjay Savar Mattas 077042295b feat(profile): avatar upload/remove with crop editor, name editing
Profile now has a real avatar row (AvatarBadge + Upload Photo…/Remove),
wired through last commit's presigned-upload backend: pick a file via
NSOpenPanel, crop/rotate/zoom in a new AvatarCropperView, upload,
point users.update at the result, then best-effort delete whatever
attachment the previous avatar pointed to so replacing/removing a
photo doesn't leak an orphaned blob in Outline's storage every time.

AvatarCropperView builds its on-screen preview and its final exported
image from the exact same SwiftUI view composition (just instantiated
twice — once for display, once through ImageRenderer) rather than a
separately hand-derived set of crop math for a higher resolution —
that's deliberate: there's no way to visually verify a second
independent set of transform math agrees with what the user actually
saw and confirmed without running the app, so making the export
WYSIWYG by construction was the safer choice here.

Name is now editable too (TextField + Save, users.update name-only).
Email is read-only with a note pointing to Outline's web app instead —
per instruction, changing it here isn't supported since email is
tied to sign-in.

SessionStore gained userId (never stored before — needed for every
users.update call) and applyUpdatedProfile(_:), so a successful
change reflects immediately in the account footer and everywhere
else without waiting for the next auth.info refresh.
2026-08-15 16:27:01 +01:00
Puranjay Savar Mattas 052db93d69 feat(profile): name update + attachment cleanup backend
UpdateUserNameRequest (users.update, name only) and deleteAttachment
(attachments.delete, id-only — same shape as every other simple
delete in this API, not yet confirmed live) so the app can clean up
the previous avatar attachment when replacing or removing it instead
of leaking an orphaned blob in Outline's storage every time. 2 new
tests, 65/65 passing.
2026-08-15 16:23:43 +01:00
Puranjay Savar Mattas 373c681c10 feat(profile): avatar upload/remove backend (attachments + users.update)
Adds the presigned-upload plumbing Outline's own web client uses for
any file upload, not just avatars: attachments.create requests an
upload target (server-assigned key, ACL, a short-lived signed form),
then a direct multipart POST to that target (uploadUrl/form) actually
uploads the bytes — confirmed against a live server's own request/
response shapes pulled from network capture. MultipartFormDataBuilder
is a pure, fully-tested function (no network) building that S3-style
presigned-POST body.

uploadAttachmentFile deliberately sends no Bearer/CSRF header — the
presigned form's `sig` field is what authorizes that specific request,
same as an S3 presigned POST; flagged as best-effort pending live
confirmation, cheap to add if the server turns out to also want one.

UpdateUserAvatarRequest (users.update, avatar only) needed a custom
encode(to:) — Swift's synthesized Encodable omits nil Optional keys
via encodeIfPresent, but removing an avatar needs a literal
"avatarUrl": null in the body, not the key missing. Confirmed the
request shape (id + avatarUrl) from the captured request's
Content-Length matching that shape and no shorter alternative.

6 new tests (multipart body structure/field ordering, explicit-null
encoding, create/upload/update round trip) — 63/63 passing.
2026-08-15 16:19:33 +01:00
Puranjay Savar Mattas 2e6de4baf4 fix(settings): label our own settings group "Outpost"
The general category (Appearance, Offline & Sync, Advanced, About)
sat unlabeled at the top of the sidebar, ambiguous next to the named
Outline categories below it. Now has its own "Outpost" header.
2026-08-15 16:06:05 +01:00
Puranjay Savar Mattas 67bef76716 feat(settings): grouped sidebar matching Outline's own settings categories
SettingsSection now carries a SettingsCategory (general/account/
workspace/integrationsInstallation) and SettingsSidebarList renders it
as a grouped, headered list — general (ours: Appearance, Offline &
Sync, Advanced, About) unlabeled at the top, then Account (Profile,
Preferences, Notifications, Passkeys, API & Access), Workspace
(Details, Authentication, Security, AI, Members, Groups, Templates,
Emojis, Applications, Shared, Links, Webhooks, Import, Export), and
Integrations & Installation (Installation).

Nav skeleton only, per instruction — everything except the ones
already built (renamed the old flat "Account" page to "Profile", its
natural new home; content unchanged) shows a "Coming Soon" placeholder
until each section's real content is specified and built one at a
time.
2026-08-15 16:04:03 +01:00
Puranjay Savar Mattas 861f596f5f Merge pull request 'Offline-first support: read cache, write queue, settings redesign' (#6) from feature/offline-cache into main
Reviewed-on: #6
2026-08-15 15:47:48 +01:00
Puranjay Savar Mattas badb206f8a chore: bump version to 0.0.3 2026-08-15 15:45:08 +01:00
Puranjay Savar Mattas bf9eec6a9b fix(offline): retry pending sync with a cooloff instead of one shot
The auto-flush that runs when reconnecting (or turning Offline Mode
back off) only ever tried once — any operation that still failed on
that attempt sat stuck until the user manually hit Retry or
connectivity changed again. Now retries every 5 minutes for as long as
anything's still pending and the signal stays on, stopping on its own
once the queue is empty. Full Local Sync's existing 20-minute loop is
unaffected/separate.
2026-08-15 15:43:00 +01:00
Puranjay Savar Mattas 21e81feed7 fix(settings): stop non-About sections from vertically centering
.leading as an Alignment means horizontally-leading but vertically
centered, not top-left — the minHeight fix for centering About ended
up vertically centering every other section too inside that same tall
box. .topLeading is what was actually meant; About still centers
correctly since its own .frame(maxHeight: .infinity, alignment: .center)
fills and self-centers within whatever space it's given regardless.
2026-08-15 01:49:05 +01:00
Puranjay Savar Mattas 8b4c5852ed feat(offline): support creating documents offline
createDocument now queues and syncs like the other offline-capable
writes, closing the gap deliberately left open earlier this session.
Synthesizes a pending-<uuid> document (same placeholder scheme as
pin/star/subscribe), caches it so it's immediately readable/openable,
and queues the real create. On sync, the server's real document
replaces the placeholder in the cache — no dead orphan entries.

Editing that same still-unsynced document folds the edit into the
pending create's payload instead of queuing a separate update, which
would otherwise target the placeholder id and 404 forever once
flushed. CreateDocumentRequest widened Encodable -> Codable for the
queue round-trip.

Known limitation: a reader still open on that exact document at the
moment it syncs in the background keeps holding the stale placeholder
id until navigated away and back. Narrow, not solved generally here.

3 new OutlineKit tests (57/57). Removed the now-unneeded offline
restriction on the reader's New Document button/menu item.
2026-08-15 01:46:32 +01:00
Puranjay Savar Mattas 284ad07251 fix(offline): false "New changes available" banner, sync stuck on toggle-off
Home's checkForRemoteChanges compared fresh pinned docs against what
was displayed, but the pinned fetch (listPins isn't a cached endpoint)
silently collapsed any failure to []  — every poll while offline
compared "[]" against the real non-empty pinned list, which always
looked like a change and popped the banner every ~45s. Split fetchPinned
into a throwing variant checkForRemoteChanges can bail on (matching the
already-correct pattern in CollectionsViewModel/DocumentsViewModel,
which don't have this bug), keeping the non-throwing version for the
initial load where collapsing to [] is the right behavior.

RootView's auto-flush was keyed only to session.networkMonitor.isOnline
— turning the manual Offline Mode toggle back off while the real
network had been up the whole time never changes that value, so queued
operations sat stuck until the next real network blip. Keyed the flush
(and the Full Local Sync loop's online check) to a combined
isEffectivelyOnline instead, so either signal clearing resumes sync
immediately.
2026-08-15 01:40:07 +01:00
Puranjay Savar Mattas 8d8c8fead8 style(settings): center the About section on the page
AboutInfoView (icon/name/version/links) now centers both
horizontally and vertically within the detail pane instead of sitting
pinned to the top-left like the other sections. Needed a GeometryReader
+ minHeight on the shared ScrollView wrapper — a ScrollView proposes
unbounded height to its content, so maxHeight: .infinity alone doesn't
give short content anything to center within; minHeight pinned to the
real viewport height does. Harmless for the other sections, which stay
top/leading-aligned as before.
2026-08-15 01:35:24 +01:00
Puranjay Savar Mattas e2e4b746c1 fix(reader): pass a real per-document id to NativeTextViewWrapper
Every document was using the library's literal "default" documentId
— never our own viewModel.documentId — meaning undo stacks, content-
divergence snapshots, scroll-offset memory, and pending inline
replacements were all keyed to the same slot across every document
instead of being scoped per-document. That's a real, unambiguous bug
regardless of the click/cursor issue: opening a second document could
replay or discard the wrong document's undo history.

Investigated the click-does-nothing-in-edit-mode report by reading
through the library's (vendored, external — swift-markdown-engine)
own isEditable/isSelectable wiring in both makeNSView and updateNSView
directly; both looked correctly applied on every pass, and a clean-
relaunch test ruled out the settings/NavigationSplitView bug from
earlier in this session as the cause. Couldn't confirm this documentId
fix resolves the click issue without being able to run the app here —
worth retesting either way.
2026-08-15 01:33:54 +01:00
Puranjay Savar Mattas 0d9f7d752d fix(reader): grey out share/permissions and other live-only actions offline
Share, Permissions, Templatize, Duplicate, Unpublish, Archive, Move,
New Document, History, Insights (sheet + Viewer Insights toggle), and
Download all hit the server directly with no offline path — disabled
(with a tooltip on the two toolbar buttons) whenever the app isn't
effectively online, instead of failing confusingly on tap.

Left enabled: Edit, Pin/Unpin, Star/Unstar, Subscribed, Full Width
(all queue via CachingOutlineAPIClient and sync later), Present and
Search in Document (both read documents.info, which is cached), and
Copy/Print (read already-loaded text directly, no network at all).
2026-08-15 01:22:47 +01:00
Puranjay Savar Mattas a9db3e1412 fix(settings): make Settings its own top-level branch, not swapped content
The .id() fix on the detail pane wasn't enough — still reported as
the document staying visible with only the sidebar switching.
NavigationSplitView bridges to NSSplitViewController on macOS, and
apparently doesn't reliably replace already-mounted detail content
(a NavigationStack with real push history) for something unrelated
inside one persisting split view instance, identity hints or not.

Restructured so `navigation.isShowingSettings` picks between two
entirely separate NavigationSplitView instances (settingsContent /
mainContent) at the top of body, instead of branching on content
inside a single one. A different top-level view hierarchy is a
guaranteed full teardown of whatever AppKit was holding onto — no
split-view instance persists across the switch for it to get
confused about reusing.

Also dropped the now-redundant `isShowingSettings` guards scattered
through mainContent's toolbar/leadingToolbarContent — moot once
mainContent only ever renders while Settings isn't showing.
2026-08-15 01:13:28 +01:00
Puranjay Savar Mattas 82ff3b49ff fix(settings): force detail pane teardown when opening/closing Settings
Opening Settings while a document was pushed left the document
visible in the detail pane even though the sidebar correctly switched
to the settings list — NavigationSplitView on macOS doesn't reliably
replace the detail pane's content on an implicit branch change alone
between very different subtrees (a NavigationStack with a real push
history vs. a plain view). An explicit .id() keyed to
isShowingSettings forces a real teardown/remount; documentPath itself
is untouched, so returning via Done still lands back on the same
document.
2026-08-15 01:10:29 +01:00
Puranjay Savar Mattas 924f84525c fix(offline): manual mode never skipped live reads, storage/banner cleanup
The main fix: CachingOutlineAPIClient.cachedFetch (documentInfo,
listCollections, listDocuments, etc.) never actually checked manual
Offline Mode — only the write path did. With a real connection still
up, turning Offline Mode on did nothing for reads: the sidebar kept
fetching live, showing collections/documents beyond what was actually
cached. Reads now skip `live` entirely under manual offline mode, same
as writes already did — the sidebar and document lists are now
genuinely limited to whatever's cached, and only those documents are
openable, once the toggle is on. 1 new regression test (54/54).

Settings: removed the cache-size readout from Offline & Sync (storage
management is Advanced-only now, per the "one place that can touch the
cache" design) and added it next to Advanced's Clear All Cache instead,
where it was missing.

Sidebar: replaced the old passive "Offline — showing cached content"
banner for a real dropped connection with an actionable prompt
(OfflineConnectionPromptBanner, styled like the existing
RemoteChangesBanner) offering to turn Offline Mode on — doesn't change
any behavior on its own, same as RemoteChangesBanner never auto-
refreshes. The informational banner still shows once Offline Mode is
actually on (manually, or via this prompt).
2026-08-15 01:08:03 +01:00
Puranjay Savar Mattas 1ce2aced90 fix(settings): red Clear Cache button, confirm when it's done
role: .destructive alone wasn't rendering red on its own — explicit
.buttonStyle(.borderedProminent).tint(.red), matching how other
destructive actions in the app (DocumentShareSheet's Revoke) had to
be styled explicitly too. Also shows a transient "Cleared" checkmark
for 2s after finishing, same timed-reset pattern as the share sheet's
copy-link feedback, since the storage count updating alone was easy
to miss.
2026-08-15 01:01:19 +01:00
Puranjay Savar Mattas d4d81bb848 feat(settings): Advanced section with safeguards, fully-automatic sync
Full Local Sync and Clear Cache both need a real connection or a real
cache to be safe against — clearing while offline, or letting sync
think it should run with nothing to talk to, can leave the app with
no local copy and no way to rebuild one. Both are now disabled
(Toggle/button, with a tooltip explaining why) whenever the app isn't
effectively online (real network down OR the manual Offline Mode
toggle).

Clear Cache moves out of Offline & Sync entirely into a new Advanced
section (sidebar, above About) — the one deliberate escape hatch that
works even offline, gated behind an off-by-default "Enable Advanced
Options" master toggle that only turns on after a confirmation dialog
warning about data loss. A few not-yet-implemented settings sit under
it as permanently-dimmed "Coming Soon" rows. Nothing destructive is
reachable outside this one screen, so there's no path to breaking
Full Local Sync's cache by accident.

Full Local Sync itself is now fully automatic: RootView's background
task syncs immediately whenever it (re)starts — covers both "just
turned on" and "was already on at a fresh launch" — then every 20
minutes, and again immediately on reconnect. No more needing to press
Sync Now by hand.
2026-08-15 00:58:24 +01:00
Puranjay Savar Mattas ce235d656d fix(offline): pagination-limit bug in full sync, missing manual-mode badge, capture error
- performFullSync was requesting listCollections with limit: 250 —
  Outline caps pagination at 100 and rejects anything over that
  outright, which was the actual "Synced with 1 error" (now visible
  in the UI as of the last fix: "Pagination limit is too large (max
  100)"). Paginate collections in increments of 100 the same way
  documents already were, continuing until a short page signals the
  end — the protocol doesn't expose a total count to ask for up front,
  so this is the only way to know when to stop. 2 new regression tests.
- OfflineBanner only ever reflected NetworkMonitor (a real dropped
  connection) — turning on the manual "Offline Mode" toggle did
  nothing to it, since that's a separate AppStorage flag the banner
  never read. ContentView_macOS's sidebar now shows the banner for
  either condition, with distinct copy for "you turned this on" vs
  "the network's actually down".
- NetworkMonitor: the previous fix (weak self only on the inner Task)
  traded one Swift 6 error for another ("'weak' ownership of capture
  'self' differs from implicitly-captured strong reference in outer
  scope") since the inner closure's capture forced the outer one to
  implicitly capture self too. Standard fix: weak capture on the outer
  closure, guard-let into a strong local immediately, let the inner
  Task closure capture that plain local instead.
2026-08-15 00:45:09 +01:00
Puranjay Savar Mattas ae6cb7dc7d fix: real Xcode compile errors from Swift 6 strict concurrency
- SettingsView: ternary between .secondary (HierarchicalShapeStyle)
  and .red (Color) doesn't unify — both sides now explicit Color.
- CollectionRowView: passing OutlineIconMapping.sfSymbolName as a bare
  function reference to flatMap loses its (inferred default) MainActor
  isolation; wrapping it in a closure keeps the call inside body's
  already-MainActor context.
- NetworkMonitor: [weak self] was captured on NWPathMonitor's
  non-isolated pathUpdateHandler closure, which must cross into the
  inner @MainActor Task — moved the weak capture onto the Task closure
  itself instead, which is where it's actually used.
2026-08-15 00:38:15 +01:00
Puranjay Savar Mattas d1ed52b825 redesign(settings): reuse the real sidebar instead of a mini one
Settings now swaps the actual app sidebar's content (search field,
collections tree, account footer) for a section list, instead of
SettingsView drawing its own nested sidebar inside the detail pane.
Done, wherever it's triggered from, restores the collections tree and
detail content exactly as they were.

AppNavigation gains selectedSettingsSection (and the SettingsSection
enum moves there, shared by the new list and the sidebar host) so the
sidebar's list and the detail pane agree on which section is showing.

Also deferred the profile menu's "Settings…" action by one runloop
tick — setting isShowingSettings synchronously in the same call that
dismisses the popover was very likely the source of the AppKit "CA
commit" transaction warnings in the console.
2026-08-15 00:36:28 +01:00
Puranjay Savar Mattas 45a72f8d10 fix(settings): surface the actual full-sync error, not just a count
"Synced with 1 error" gave no way to tell what failed. Now lists each
FullSyncSummary.errors entry underneath the summary line.
2026-08-15 00:30:55 +01:00
Puranjay Savar Mattas 19eb1bae69 redesign(settings): section list + single-section detail
Replaces the stacked/gridded cards with the same shape as macOS System
Settings: a section list on the left, one section's content in the
detail pane on the right. Sections never render next to each other
anymore, so there's no card-height mismatch to look weird, and the
layout holds up at any window size or aspect ratio without needing a
grid to reflow.
2026-08-15 00:29:51 +01:00
Puranjay Savar Mattas e991f4ed4c style(settings): grid the short cards, keep the tall ones stacked
Appearance and Account are both short — a LazyVGrid lets them sit
side-by-side when the window's wide enough instead of each wasting a
full-width row. Offline & Sync and About stay full-width: pairing
those with anything in the same grid row looked worse (very uneven
row heights) than just stacking them. Also widened the content column
(640 -> 900) so the grid actually has room to go two-up.
2026-08-15 00:23:37 +01:00
Puranjay Savar Mattas 4f423ef971 fix(settings): keep the sidebar visible instead of a full-window overlay
Settings now swaps into the same NavigationSplitView detail pane as
Home/collections (gated on AppNavigation.isShowingSettings) instead of
covering the whole root window — the sidebar, and the ability to just
click something else in it to leave Settings, stays available. Any
sidebar navigation (Home, a collection, a document) exits Settings.
2026-08-15 00:17:51 +01:00
Puranjay Savar Mattas 4769645489 feat(offline): write queue with sync-on-reconnect + settings redesign
Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.

Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.

Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.

8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
2026-08-15 00:12:05 +01:00
Puranjay Savar Mattas eee00197bb feat(offline): read-through cache for browse endpoints + offline badge
CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the
existing protocol boundary: always tries live, falls back to a SwiftData
key/value cache only on failure, and overwrites the cache on every live
success. Applies to the read/browse path only (collections, document
lists, document content) — search, writes, and sharing pass straight
through uncached. Wired in once at SessionStore.makeAPIClient, so no
view model needed to change. 5 new tests via a stub OutlineAPIClient
(43/43 passing).

NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the
cache's own fallback logic — it only drives a new sidebar OfflineBanner
so the user knows when they might be looking at stale content.
2026-08-14 23:41:00 +01:00
249 changed files with 35626 additions and 492 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
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
id: summary
attributes:
+1 -1
View File
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
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
id: summary
attributes:
+7 -1
View File
@@ -2,7 +2,7 @@
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.
---
@@ -69,3 +69,9 @@ Issues and PRs use two label prefixes:
## Questions
Open an issue — this is a single-repo project, there's no separate issue tracker to route to.
---
## License
Outpost is licensed under the [Business Source License 1.1](./LICENSE), not a traditional OSI open-source license — see the [README's License section](./README.md#license) for what that means in practice. By submitting a PR, you agree your contribution is licensed under the same terms as the rest of the project.
+118 -17
View File
@@ -1,22 +1,123 @@
MIT License
Business Source License 1.1
Copyright (c) 2026 Puranjay Savar Mattas
Parameters
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Licensor: Puranjay Savar Mattas
Licensed Work: Outpost
The Licensed Work is (c) 2026 Puranjay Savar Mattas
Additional Use Grant: You may use, copy, modify, and self-host the Licensed
Work, and build and distribute your own modified or
unmodified copies of it, for personal, educational, or
internal non-commercial purposes.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
You may not, without a separate commercial agreement
with the Licensor:
(a) offer the Licensed Work, or any modified or
unmodified version of it, as a hosted or
distributed product or service to third parties,
whether for a fee or free of charge; or
(b) distribute the Licensed Work, or any modified or
unmodified version of it, under a name, logo, or
branding that states or implies it is an official,
endorsed, or affiliated product of Outline, Inc.
or of any other third party, or that removes or
obscures its origin as an independent,
unaffiliated project.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Change Date: 2036-08-20
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed
Work, contact the Licensor.
Notice
The Business Source License (this document, or the "License") is not an
Open Source license. However, the Licensed Work will eventually be made
available under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first
publicly available distribution of a specific version of the Licensed Work
under this License, whichever comes first, the Licensor hereby grants you
rights under the terms of the Change License, and the rights granted in the
paragraph above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may
vary for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified
copy of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in
this License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo
of Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this License's text to license
your works, and to refer to it using the trademark "Business Source
License", as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this License's text and the "Business
Source License" name and trademark, Licensor covenants to MariaDB, and to
all other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later
version, or a license that is compatible with GPL Version 2.0 or a later
version, where "compatible" means that software provided under the
Change License can be included in a program with software provided
under GPL Version 2.0 or a later version. Licensor may specify
additional Change Licenses without limitation.
2. To either: (a) specify an additional grant of rights to use that does
not impose any additional restriction on the right granted in this
License, as the Additional Use Grant; or (b) insert the text "None" to
specify a Change License.
3. To specify a Change Date.
4. Not to modify this License in any other way.
-----------------------------------------------------------------------------
Trademark Notice
"Outpost" and any associated logo are trademarks of the Licensor. This
License does not grant permission to use them to identify or market any
product, service, or distribution of the Licensed Work — modified or
unmodified — that is not published by the Licensor, including forks. See
the Additional Use Grant above.
@@ -0,0 +1,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)
}
}
@@ -0,0 +1,18 @@
import Foundation
import SwiftData
/// Generic key/value row backing `OfflineCacheStore` one table for every
/// cached response shape instead of a `@Model` per Outline type, so adding a
/// new cached endpoint never needs a schema migration.
@Model
public final class CachedPayload {
@Attribute(.unique) public var key: String
public var payload: Data
public var cachedAt: Date
public init(key: String, payload: Data, cachedAt: Date) {
self.key = key
self.payload = payload
self.cachedAt = cachedAt
}
}
@@ -0,0 +1,960 @@
import Foundation
import CryptoKit
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
/// the network exists:
///
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
/// cache. Always tries live first never pre-checks reachability, since
/// "did this specific request just fail" is a more honest signal than a
/// reachability monitor, and the two can disagree (captive portals,
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
/// always overwrites the cache, so staleness is bounded by "last time this
/// endpoint actually worked."
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
/// pin/star/subscribe create+delete): applied optimistically against the
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
/// failure, then replayed in order by `flushPendingOperations()` once back
/// online. Deliberately doesn't include anything that creates new tree
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) those need
/// real server-assigned ids to stay consistent with the rest of the tree,
/// and reconciling a locally-invented id with the one the server hands
/// back on sync is a much bigger problem than this pass takes on. Sharing,
/// permissions, search, and export also stay live-only read the request,
/// they need someone else's server session, not just a network.
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
/// attempting `live` entirely same fallback/queue paths as a real
/// failure, just chosen on purpose instead of discovered.
public actor CachingOutlineAPIClient: OutlineAPIClient {
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
private let live: OutlineAPIClient
private let cache: OfflineCacheStore
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
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?
/// 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.cache = cache
self.defaults = defaults
self.encryptionKeyStore = encryptionKeyStore
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
self.encoder = encoder
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let keyEncoder = JSONEncoder()
keyEncoder.outputFormatting = .sortedKeys
self.keyEncoder = keyEncoder
}
private var isManualOfflineModeEnabled: Bool {
defaults.bool(forKey: Self.offlineModeDefaultsKey)
}
// MARK: - Cached reads
public func documentInfo(id: String) async throws -> OutlineDocument {
try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) }
}
public func listDocuments(
collectionId: String?,
parentDocumentId: String?,
offset: Int,
limit: Int
) async throws -> [OutlineDocument] {
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
return try await cachedFetch(key: key, category: "documents") {
try await self.live.listDocuments(
collectionId: collectionId,
parentDocumentId: parentDocumentId,
offset: offset,
limit: limit
)
}
}
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) }
}
public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] {
try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") {
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] {
try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") {
try await self.live.listCollections(offset: offset, limit: limit)
}
}
public func collectionInfo(id: String) async throws -> OutlineCollection {
try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) }
}
// MARK: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result)
return result
} catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) }
recordSuccess(category: "documents-write")
await cacheDocument(result)
return result
} catch {
recordWriteFailureIfStructural(category: "documents-write", error)
return try await queueDocumentUpdate(request, dueTo: error)
}
}
return try await queueDocumentUpdate(request, dueTo: nil)
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) }
recordSuccess(category: "collections-write")
await cacheCollection(result)
return result
} catch {
recordWriteFailureIfStructural(category: "collections-write", error)
return try await queueCollectionUpdate(request, dueTo: error)
}
}
return try await queueCollectionUpdate(request, dueTo: nil)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do {
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)
}
public func deletePin(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
recordSuccess(category: "pins")
return
} catch {
recordWriteFailureIfStructural(category: "pins", error)
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
}
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do {
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)
}
public func deleteSubscription(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
recordSuccess(category: "subscriptions")
return
} catch {
recordWriteFailureIfStructural(category: "subscriptions", error)
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
}
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do {
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)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
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)
}
public func deleteStar(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
recordSuccess(category: "stars")
return
} catch {
recordWriteFailureIfStructural(category: "stars", error)
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
}
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
}
// MARK: - Pass-through
public func authInfo() async throws -> OutlineAuthInfo {
try await live.authInfo()
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await live.searchDocuments(request)
}
public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] {
try await live.searchDocumentTitles(request)
}
public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate {
try await live.templatizeDocument(request)
}
public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] {
try await live.duplicateDocument(request)
}
public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument {
try await live.unpublishDocument(request)
}
public func archiveDocument(id: String) async throws -> OutlineDocument {
try await live.archiveDocument(id: id)
}
public func moveDocument(_ request: MoveDocumentRequest) async throws {
try await live.moveDocument(request)
}
public func deleteDocument(_ request: DeleteDocumentRequest) async throws {
try await live.deleteDocument(request)
}
public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] {
try await live.documentInsights(request)
}
public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] {
try await live.listRevisions(request)
}
public func exportDocument(id: String) async throws -> String {
try await live.exportDocument(id: id)
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await live.createShare(request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
try await live.shareInfo(documentId: documentId)
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await live.updateShare(request)
}
public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] {
try await live.listShares(request)
}
public func revokeShare(id: String) async throws {
try await live.revokeShare(id: id)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await live.listPins(request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await live.listSubscriptions(request)
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await live.listViews(request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await live.listComments(request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await live.commentInfo(id: id)
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await live.createComment(request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await live.resolveComment(id: id)
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await live.unresolveComment(id: id)
}
public func addReaction(commentId: String, emoji: String) async throws {
try await live.addReaction(commentId: commentId, emoji: emoji)
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await live.removeReaction(commentId: commentId, emoji: emoji)
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await live.addDocumentUser(request)
}
public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws {
try await live.removeDocumentUser(request)
}
public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] {
try await live.documentUsers(request)
}
public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] {
try await live.listUsers(request)
}
public func deleteCollection(id: String) async throws {
try await live.deleteCollection(id: id)
}
public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation {
try await live.exportCollection(request)
}
public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
try await live.listStars(request)
}
public func currentUser() async throws -> OutlineUser {
try await live.currentUser()
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await live.createAttachment(request)
}
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
try await live.fetchAuthenticatedFile(path: path)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await live.updateUserAvatar(request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await live.updateUserName(request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await live.updateUserLanguage(request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await live.updateUserPreferences(request)
}
public func deleteAccount() async throws {
try await live.deleteAccount()
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.subscribeToNotifications(eventType: eventType)
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await live.unsubscribeFromNotifications(eventType: eventType)
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await live.listApiKeys(request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await live.createApiKey(request)
}
public func deleteApiKey(id: String) async throws {
try await live.deleteApiKey(id: id)
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await live.installationInfo()
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
await cache.pendingOperations().map {
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
}
}
public func cacheStorageSummary() async -> CacheStorageSummary {
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
}
public func clearCache() async {
await cache.clearAll()
}
/// 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
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
let operations = await cache.pendingOperations()
var succeeded = 0
var failed = 0
for operation in operations {
do {
try await replay(operation)
await cache.removeOperation(id: operation.id)
succeeded += 1
} catch {
await cache.recordFailure(id: operation.id, error: errorDescription(error))
failed += 1
}
}
return SyncFlushSummary(succeeded: succeeded, failed: failed)
}
/// "Full Local Sync": eagerly walks every collection and caches every
/// document's full content (not just whatever's been opened), so
/// browsing offline works for the whole workspace, not only what was
/// already viewed. Goes through `self`, not `live`, directly the
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
///
/// Recurses into every document's children, not just collections' own
/// root-level documents a document with sub-documents used to leave
/// them uncached entirely (only reachable if something else happened to
/// open them individually first). Also caches each collection under its
/// own `"collection:<id>"` key (previously only cached as part of the
/// paginated list blob), so both are individually enumerable afterward
/// via `OfflineCacheStore.loadAll(keyPrefix:)` see
/// `cachedDocumentsIndex()`/`cachedCollectionsIndex()`.
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
var collections: [OutlineCollection] = []
var collectionsOffset = 0
let collectionsLimit = 100
// Outline rejects any `limit` over 100 outright page in increments
// of that instead of guessing a total up front (the protocol doesn't
// expose `pagination`'s total count, only the page itself); a page
// shorter than the limit is what signals "that was the last one".
while true {
let page: [OutlineCollection]
do {
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
} catch {
errors.append(errorDescription(error))
break
}
collections.append(contentsOf: page)
guard page.count == collectionsLimit else { break }
collectionsOffset += collectionsLimit
}
for collection in collections {
await cacheCollection(collection)
let result = await cacheDocumentTree(collectionId: collection.id, parentDocumentId: nil, collectionName: collection.name)
documentsCount += result.count
errors.append(contentsOf: result.errors)
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
/// Caches every document under `parentDocumentId` (`nil` = a
/// collection's root level) and recurses into each one's own children,
/// depth-first, until a branch runs out of sub-documents. Returns a
/// plain `(count, errors)` pair rather than mutating shared state across
/// `await` boundaries, since this calls itself recursively.
private func cacheDocumentTree(
collectionId: String,
parentDocumentId: String?,
collectionName: String
) async -> (count: Int, errors: [String]) {
var count = 0
var errors: [String] = []
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collectionId, parentDocumentId: parentDocumentId, offset: offset, limit: limit)
} catch {
errors.append("\(collectionName): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
count += 1
let childResult = await cacheDocumentTree(collectionId: collectionId, parentDocumentId: document.id, collectionName: collectionName)
count += childResult.count
errors.append(contentsOf: childResult.errors)
}
guard documents.count == limit else { break }
offset += limit
}
return (count, errors)
}
/// Every individually cached document from the last Full Local Sync
/// empty if a sync has never run (or found nothing). Purely a local
/// SwiftData read, no network involved.
public func cachedDocumentsIndex() async -> [OutlineDocument] {
let payloads = await cache.loadAll(keyPrefix: "document:")
return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) }
}
/// Every individually cached collection from the last Full Local Sync.
public func cachedCollectionsIndex() async -> [OutlineCollection] {
let payloads = await cache.loadAll(keyPrefix: "collection:")
return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) }
}
// MARK: - Helpers
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
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
do {
let result = try await RetryPolicy.withRetry { try await fetch() }
recordSuccess(category: category)
if let data = encryptedEncode(result) {
await cache.save(data, forKey: key)
}
return result
} catch {
// 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
}
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 {
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
return prefix
}
return "\(prefix):\(json)"
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = encryptedEncode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = encryptedEncode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = encryptedEncode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
/// A delete targeting a `pending-*` id can only mean "cancel the create
/// still sitting in the queue" the server has never heard of that id,
/// so queuing the delete would just fail once synced. Returns whether it
/// found (and removed) a matching create, meaning the caller is done.
private func cancelIfNeverSynced(id: String) async -> Bool {
guard id.hasPrefix("pending-") else { return false }
return await cache.removeOperation(id: id)
}
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = decryptedDecode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
let merged = OutlineDocument(
id: base.id,
title: request.title ?? base.title,
text: mergedText,
emoji: base.emoji,
collectionId: base.collectionId,
parentDocumentId: base.parentDocumentId,
url: base.url,
revision: base.revision,
fullWidth: request.fullWidth ?? base.fullWidth,
createdAt: base.createdAt,
updatedAt: Date(),
publishedAt: base.publishedAt,
archivedAt: base.archivedAt,
deletedAt: base.deletedAt
)
await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe.
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
return merged
}
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = decryptedDecode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
id: base.id,
name: request.name ?? base.name,
description: request.description ?? base.description,
color: base.color,
icon: base.icon,
createdAt: base.createdAt,
updatedAt: Date()
)
await cacheCollection(merged)
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
return merged
}
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createPin, payload: request, id: pendingId)
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
}
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createSubscription, payload: request, id: pendingId)
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
}
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starDocument, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
}
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starCollection, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
}
private func replay(_ operation: PendingOperation) async throws {
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
))
}
switch kind {
case .createDocument:
let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload)
_ = 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 {
if let apiError = error as? OutlineAPIError {
switch apiError {
case .unauthorized: return "Sign-in expired."
case .notFound: return "Not found on the server."
case .server(let status, let message): return message ?? "Server error (\(status))."
case .decoding: return "Unexpected response shape."
case .transport: return "Couldn't reach the server."
case .tokenUnavailable: return "Couldn't access the saved sign-in."
}
}
return String(describing: error)
}
}
@@ -0,0 +1,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
]
}
}
@@ -0,0 +1,129 @@
import Foundation
import SwiftData
/// Read-through cache backing `CachingOutlineAPIClient` stores whatever it
/// last fetched successfully, keyed by request shape, so browsing keeps
/// working (stale) once the network stops. Also owns the offline write
/// queue (`PendingOperation`) same store, same actor, since both need
/// synchronized access to the same on-disk SwiftData container.
@ModelActor
public actor OfflineCacheStore {
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
}
// MARK: - Read-through cache
public func save(_ data: Data, forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
if let existing = try? modelContext.fetch(descriptor).first {
existing.payload = data
existing.cachedAt = Date()
} else {
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
}
try? modelContext.save()
}
public func load(forKey key: String) -> Data? {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
return try? modelContext.fetch(descriptor).first?.payload
}
/// Everything cached under a key prefix e.g. every individually
/// cached document (`"document:<id>"`) or collection
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
/// search index without a per-item exact-key lookup.
public func loadAll(keyPrefix: String) -> [Data] {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
}
public func totalBytes() -> Int {
let descriptor = FetchDescriptor<CachedPayload>()
let rows = (try? modelContext.fetch(descriptor)) ?? []
return rows.reduce(0) { $0 + $1.payload.count }
}
public func clearAll() {
let descriptor = FetchDescriptor<CachedPayload>()
guard let rows = try? modelContext.fetch(descriptor) else { return }
rows.forEach { modelContext.delete($0) }
try? modelContext.save()
}
/// 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
/// Upserts by `id` a second call with the same id (an edit coalescing
/// onto a still-unsynced edit, or a create being cancelled by its own
/// synthesized id) replaces the row in place rather than piling up.
public func enqueueOperation(id: String, kind: String, payload: Data) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
if let existing = try? modelContext.fetch(descriptor).first {
existing.kind = kind
existing.payload = payload
existing.lastError = nil
existing.attemptCount = 0
} else {
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
}
try? modelContext.save()
}
public func pendingOperations() -> [PendingOperation] {
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Returns whether a matching operation was actually found and removed
/// callers use this to detect "this targeted something that never made
/// it to the server in the first place" and skip queuing a delete.
@discardableResult
public func removeOperation(id: String) -> Bool {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
modelContext.delete(existing)
try? modelContext.save()
return true
}
public func recordFailure(id: String, error: String) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
existing.lastAttemptAt = Date()
existing.lastError = error
existing.attemptCount += 1
try? modelContext.save()
}
}
@@ -0,0 +1,40 @@
import Foundation
import SwiftData
/// A queued mutation made while offline, waiting to replay against the live
/// server. `id` is deliberately overloaded: for actions that create a new
/// server-side record (pin, star, subscription), it's also the synthesized
/// placeholder id handed back to the caller immediately so a matching
/// delete queued before that create ever syncs can cancel both out by id
/// instead of hitting a server that's never heard of the placeholder. For
/// actions that edit an existing record (document/collection updates), it's
/// deterministic per target id, so a second edit before the first syncs
/// coalesces into one queued operation instead of piling up.
@Model
public final class PendingOperation {
@Attribute(.unique) public var id: String
public var kind: String
public var payload: Data
public var createdAt: Date
public var lastAttemptAt: Date?
public var lastError: String?
public var attemptCount: Int
public init(
id: String,
kind: String,
payload: Data,
createdAt: Date,
lastAttemptAt: Date? = nil,
lastError: String? = nil,
attemptCount: Int = 0
) {
self.id = id
self.kind = kind
self.payload = payload
self.createdAt = createdAt
self.lastAttemptAt = lastAttemptAt
self.lastError = lastError
self.attemptCount = attemptCount
}
}
@@ -0,0 +1,50 @@
import Foundation
/// UI-facing snapshot of a queued offline mutation deliberately not the
/// `@Model` type itself, so Settings can display it without holding a
/// reference into the SwiftData store.
public struct PendingOperationSummary: Identifiable, Sendable {
public let id: String
public let kind: String
public let createdAt: Date
public let attemptCount: Int
public let lastError: String?
}
public struct CacheStorageSummary: Sendable {
public let itemCount: Int
public let totalBytes: Int
}
public struct SyncFlushSummary: Sendable {
public let succeeded: Int
public let failed: Int
}
public struct FullSyncSummary: Sendable {
public let collectionsCount: Int
public let documentsCount: Int
public let errors: [String]
public let finishedAt: Date
}
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
/// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument
case updateCollection
case createPin
case deletePin
case createSubscription
case deleteSubscription
case starDocument
case deleteStar
case starCollection
}
/// Shared payload shape for the delete-by-id queueable operations.
struct IDPayload: Codable, Sendable {
let id: String
}
@@ -13,6 +13,9 @@ public protocol OutlineAPIClient: Sendable {
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument]
/// Documents the current user has recently viewed. Backed by `documents.viewed`.
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument]
/// Draft (unpublished) documents belonging to the current user. Backed
/// by `documents.drafts`.
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument]
/// Full-text search with snippets/ranking. Backed by `documents.search`.
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult]
/// Title-only search faster, no snippets. Backed by `documents.search_titles`.
@@ -48,6 +51,20 @@ public protocol OutlineAPIClient: Sendable {
/// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
/// See `OutlineComment`. `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are confirmed real against Outline's own server
/// source but aren't in the vendored spec.
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment]
func commentInfo(id: String) async throws -> OutlineComment
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment
func resolveComment(id: String) async throws -> OutlineComment
func unresolveComment(id: String) async throws -> OutlineComment
/// Reaction endpoints return `{success: true}`, not the updated
/// comment callers refetch via `commentInfo` for the fresh
/// `reactions` array.
func addReaction(commentId: String, emoji: String) async throws
func removeReaction(commentId: String, emoji: String) async throws
/// See `OutlineMembership`/`OutlineDocumentMember` `add` is confirmed
/// from Outline's official docs, the rest are best-effort.
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership
@@ -69,4 +86,48 @@ public protocol OutlineAPIClient: Sendable {
func deleteStar(id: String) async throws
func currentUser() async throws -> OutlineUser
/// Two-step presigned upload: this requests where/how to upload,
/// `uploadAttachmentFile` performs the actual multipart POST to that
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Fetches raw bytes from an authenticated, server-relative GET path
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
/// own editor embeds for uploaded images in document Markdown. Unlike
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
/// actual (often presigned, cross-host) storage URL; `path` is resolved
/// against the client's base URL, same as `uploadAttachmentFile`'s
/// `uploadUrl` handling.
func fetchAuthenticatedFile(path: String) async throws -> Data
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
func deleteAttachment(id: String) async throws
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
/// `users.update`, name only. See `UpdateUserNameRequest`.
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser
/// `users.update`, language only. See `UpdateUserLanguageRequest`.
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser
/// `users.update`, preferences only. See `UpdateUserPreferencesRequest`.
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser
/// Backed by `users.delete` self-service account deletion, no
/// confirmation code param confirmed live, matches every other simple
/// no-body delete in this API.
func deleteAccount() async throws
/// `nil` targets every notification event. See `NotificationEventType`.
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
/// Settings API & Access.
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey]
/// The returned `OutlineAPIKey.value` is the only time the full
/// plaintext key is ever available the caller is responsible for
/// displaying it once and then discarding it.
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey
func deleteApiKey(id: String) async throws
/// Settings Installation. Self-hosted server version info.
func installationInfo() async throws -> OutlineInstallationInfo
}
@@ -59,6 +59,10 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.viewed", body: PaginationParams(offset: offset, limit: limit))
}
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
try await post("documents.drafts", body: request)
}
public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] {
try await post("documents.search", body: request)
}
@@ -175,6 +179,34 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("views.list", body: request)
}
public func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] {
try await post("comments.list", body: request)
}
public func commentInfo(id: String) async throws -> OutlineComment {
try await post("comments.info", body: StarIDParams(id: id))
}
public func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment {
try await post("comments.create", body: request)
}
public func resolveComment(id: String) async throws -> OutlineComment {
try await post("comments.resolve", body: StarIDParams(id: id))
}
public func unresolveComment(id: String) async throws -> OutlineComment {
try await post("comments.unresolve", body: StarIDParams(id: id))
}
public func addReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.add_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func removeReaction(commentId: String, emoji: String) async throws {
try await postForSuccess("comments.remove_reaction", body: CommentReactionParams(id: commentId, emoji: emoji))
}
public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership {
try await post("documents.add_user", body: request)
}
@@ -229,6 +261,142 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.info", body: EmptyParams())
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await post("attachments.create", body: request)
}
/// No Bearer/CSRF header attached here, deliberately the presigned
/// `form.sig` field (short-lived, scoped to this exact upload key) is
/// what authorizes this specific request, the same way an S3 presigned
/// POST works. Best-effort against a live server: if it turns out the
/// self-hosted local-storage backend also wants a bearer token here,
/// that's a one-line addition once confirmed, not a design change.
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
let (body, contentType) = MultipartFormDataBuilder.build(
fields: result.form,
fileFieldName: "file",
fileName: result.attachment.name ?? "avatar",
fileData: fileData,
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
)
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
// a self-hosted local-storage backend, not an absolute S3 URL
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
// replaces the whole path for a leading-slash relative string per
// RFC 3986, same resolution already used for user/team avatar URLs.
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: url)
// URLSession's default redirect handling drops Authorization on a
// cross-host redirect (same as a browser dropping cookies on one)
// exactly what's wanted here: authorize the request to Outline's own
// `attachments.redirect`, not the presigned storage URL it 302s to.
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
}
return data
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
public func deleteAccount() async throws {
try await postForSuccess("users.delete", body: EmptyParams())
}
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
}
public func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] {
try await post("apiKeys.list", body: request)
}
public func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey {
try await post("apiKeys.create", body: request)
}
public func deleteApiKey(id: String) async throws {
try await postForSuccess("apiKeys.delete", body: StarIDParams(id: id))
}
public func installationInfo() async throws -> OutlineInstallationInfo {
try await post("installation.info", body: EmptyParams())
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -388,6 +556,11 @@ private struct StarIDParams: Encodable {
let id: String
}
private struct CommentReactionParams: Encodable {
let id: String
let emoji: String
}
private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]?
let collections: [OutlineCollection]?
@@ -0,0 +1,69 @@
import Foundation
/// Minimal recursive JSON tree for fields the API returns as genuinely
/// arbitrary/untyped JSON (`type: object` with no fixed schema in the
/// OpenAPI spec) currently just `Comment.data`, a ProseMirror document.
/// Decode-only: nothing in this codebase writes comment bodies back yet.
public enum JSONValue: Decodable, Sendable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
}
}
}
extension JSONValue {
/// Best-effort plain-text extraction from a ProseMirror-shaped document
/// walks `content` arrays, concatenates `text` node strings, and adds a
/// trailing newline after known block-level node types so paragraphs
/// don't run together. Good enough for a read-only comment list; not a
/// real ProseMirror renderer (no marks, no lists/tables structure).
public func plainText() -> String {
switch self {
case .object(let fields):
if case .string(let text)? = fields["text"] {
return text
}
let childText: String
if case .array(let items)? = fields["content"] {
childText = items.map { $0.plainText() }.joined()
} else {
childText = ""
}
if case .string(let type)? = fields["type"], Self.blockTypes.contains(type) {
return childText + "\n"
}
return childText
case .array(let items):
return items.map { $0.plainText() }.joined()
case .string(let value):
return value
case .number, .bool, .null:
return ""
}
}
private static let blockTypes: Set<String> = [
"paragraph", "heading", "listItem", "blockquote", "codeBlock", "list_item", "code_block"
]
}
@@ -0,0 +1,28 @@
import Foundation
/// The subset of Outline's notification event types this app exposes a
/// toggle for. Wire values confirmed live (captured `users.update`
/// responses showing the full `notificationSettings` dictionary after
/// toggling each one in Outline's own web app). The server tracks more
/// event types than this app has UI for (`revisions.create`,
/// `emails.onboarding`, `emails.features` were also seen live) those are
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
/// per-event, not a whole-object replace like `preferences`, so there's no
/// clobbering risk in only covering a subset.
public enum NotificationEventType: String, CaseIterable, Sendable {
case documentPublish = "documents.publish"
case documentUpdate = "documents.update"
case commentCreate = "comments.create"
case commentMentioned = "comments.mentioned"
case documentMentioned = "documents.mentioned"
case commentGroupMentioned = "comments.group_mentioned"
case documentGroupMentioned = "documents.group_mentioned"
case commentResolve = "comments.resolve"
case reactionCreate = "reactions.create"
case collectionCreate = "collections.create"
case emailsInviteAccepted = "emails.invite_accepted"
case documentAddUser = "documents.add_user"
case collectionAddUser = "collections.add_user"
case emailsExportCompleted = "emails.export_completed"
case accessRequestCreate = "access_requests.create"
}
@@ -0,0 +1,40 @@
import Foundation
/// A personal API key (Settings API & Access). Only the last 4 characters
/// of the actual token are ever returned by the server there's no way to
/// see a full key again after creation, matching every other API-key UI
/// convention.
public struct OutlineAPIKey: Codable, Identifiable, Hashable, Sendable {
public let id: String
public let name: String
public let last4: String?
public let scope: [String]?
public let createdAt: Date
public let expiresAt: Date?
public let lastActiveAt: Date?
/// The full plaintext key present *only* in `apiKeys.create`'s
/// response, confirmed live: `apiKeys.list` never includes it, matching
/// "shown once at creation" being enforced server-side, not just a
/// client-side UI convention this app has to uphold on its own.
public let value: String?
public init(
id: String,
name: String,
last4: String? = nil,
scope: [String]? = nil,
createdAt: Date,
expiresAt: Date? = nil,
lastActiveAt: Date? = nil,
value: String? = nil
) {
self.id = id
self.name = name
self.last4 = last4
self.scope = scope
self.createdAt = createdAt
self.expiresAt = expiresAt
self.lastActiveAt = lastActiveAt
self.value = value
}
}
@@ -0,0 +1,26 @@
import Foundation
/// One uploaded file created via a two-step presigned upload
/// (`attachments.create` for the upload target, then a direct POST to
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
/// local-storage backend; `size` comes back as a string there, not a
/// number same "don't trust the vendored spec's implied types" lesson as
/// everywhere else this codebase has hit it.
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let contentType: String?
public let name: String?
public let url: String?
public let size: String?
}
/// `attachments.create`'s response everything needed to perform the
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, )
/// must all be included as their own multipart parts, in the same request
/// as the file itself, POSTed to `uploadUrl`.
public struct CreateAttachmentResult: Decodable, Sendable {
public let attachment: OutlineAttachment
public let uploadUrl: String
public let form: [String: String]
}
@@ -0,0 +1,46 @@
import Foundation
/// A comment (or reply, via `parentCommentId`) on a document. Backed by
/// `comments.*` `create`/`info`/`update`/`delete`/`list` are in the
/// vendored OpenAPI spec; `resolve`/`unresolve`/`add_reaction`/
/// `remove_reaction` are not (confirmed against Outline's own server
/// source instead same "spec mirror is incomplete" lesson as
/// `OutlinePin`). `data` is the comment body as a ProseMirror document,
/// not plain text see `JSONValue.plainText()`.
public struct OutlineComment: Decodable, Identifiable, Sendable {
public let id: String
public let data: JSONValue
public let documentId: String
public let parentCommentId: String?
public let createdAt: Date
public let createdBy: OutlineUser?
public let updatedAt: Date?
public let resolvedAt: Date?
public let resolvedBy: OutlineUser?
public let reactions: [ReactionSummary]
/// The document text this comment is anchored to only populated when
/// the request set `includeAnchorText: true`; `nil` for a document-level
/// (non-anchored) comment either way. No prefix/suffix comes back on
/// read (only accepted as create-time disambiguation input), so
/// re-finding this text in the rendered document is inherently
/// best-effort first occurrence wins, same as Outline's own create
/// behavior when nothing else disambiguates.
public let anchorText: String?
public var isResolved: Bool { resolvedAt != nil }
public var bodyText: String {
data.plainText().trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// One emoji's worth of reactions on a comment grouped by emoji server-side
/// (`ReactionSummary` in Outline's own source), not one object per reaction.
public struct ReactionSummary: Codable, Sendable, Equatable {
public let emoji: String
public let userIds: [String]
public init(emoji: String, userIds: [String]) {
self.emoji = emoji
self.userIds = userIds
}
}
@@ -0,0 +1,11 @@
import Foundation
/// `installation.info` the self-hosted server's own version, confirmed
/// live. `policies` (a separate top-level array alongside `data` in the raw
/// response) isn't modeled here not used by this app's Installation
/// settings page.
public struct OutlineInstallationInfo: Decodable, Sendable {
public let version: String
public let latestVersion: String
public let versionsBehind: Int
}
@@ -6,18 +6,30 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
public let email: String?
public let avatarUrl: String?
public let role: String?
public let language: String?
public let preferences: OutlineUserPreferences?
/// Keyed by `NotificationEventType`'s raw values, plus event types this
/// app has no UI for kept as a flexible dictionary rather than a
/// fixed struct for that reason. `true` means subscribed.
public let notificationSettings: [String: Bool]?
public init(
id: String,
name: String,
email: String? = nil,
avatarUrl: String? = nil,
role: String? = nil
role: String? = nil,
language: String? = nil,
preferences: OutlineUserPreferences? = nil,
notificationSettings: [String: Bool]? = nil
) {
self.id = id
self.name = name
self.email = email
self.avatarUrl = avatarUrl
self.role = role
self.language = language
self.preferences = preferences
self.notificationSettings = notificationSettings
}
}
@@ -0,0 +1,109 @@
import Foundation
/// `User.preferences` a free-form JSON blob on Outline's own `User` row,
/// not a fixed-shape API resource. Wire key names below are confirmed
/// against a live server's own web app traffic (captured toggling every
/// Preferences setting one at a time), not guessed see the `CodingKeys`
/// mapping for the two that don't match this struct's own property names.
///
/// The server also validates `preferences` against a known key allowlist
/// and rejects the whole `users.update` call (not just the bad field) if
/// any key it doesn't recognize is present confirmed live via a
/// `"notificationBadge: Invalid Input"` error when an earlier, wrong value
/// was sent. That's also why `fullWidthDocuments` is kept here even though
/// this app has no UI for it yet: this app always sends the *whole*
/// preferences object back on every save (see
/// `UpdateUserPreferencesRequest`), so silently dropping an unknown key
/// during decode would permanently clear it the next time any other
/// preference here gets saved.
public struct OutlineUserPreferences: Codable, Hashable, Sendable {
public var rememberLastPath: Bool?
/// App-facing polarity: `true` means "separate editing mode is on"
/// the opposite of the wire's own `seamlessEdit` (seamless editing and
/// separate editing modes are each other's negation), inverted in
/// `init(from:)`/`encode(to:)` so nothing outside this file has to
/// remember that.
public var separateEditing: Bool?
public var useCursorPointer: Bool?
public var codeBlockLineNumbers: Bool?
public var showCommentMarker: Bool?
public var smartText: Bool?
/// One of `NotificationBadgeStyle`'s raw values.
public var notificationBadge: String?
/// No UI in this app yet preserved purely so saving any other
/// preference here doesn't clobber it. See the type doc comment.
public var fullWidthDocuments: Bool?
public init(
rememberLastPath: Bool? = nil,
separateEditing: Bool? = nil,
useCursorPointer: Bool? = nil,
codeBlockLineNumbers: Bool? = nil,
showCommentMarker: Bool? = nil,
smartText: Bool? = nil,
notificationBadge: String? = nil,
fullWidthDocuments: Bool? = nil
) {
self.rememberLastPath = rememberLastPath
self.separateEditing = separateEditing
self.useCursorPointer = useCursorPointer
self.codeBlockLineNumbers = codeBlockLineNumbers
self.showCommentMarker = showCommentMarker
self.smartText = smartText
self.notificationBadge = notificationBadge
self.fullWidthDocuments = fullWidthDocuments
}
private enum CodingKeys: String, CodingKey {
case rememberLastPath
case seamlessEdit
case useCursorPointer
case codeBlockLineNumbers
case commentsInGutter
case enableSmartText
case notificationBadge
case fullWidthDocuments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
rememberLastPath = try container.decodeIfPresent(Bool.self, forKey: .rememberLastPath)
separateEditing = try container.decodeIfPresent(Bool.self, forKey: .seamlessEdit).map { !$0 }
useCursorPointer = try container.decodeIfPresent(Bool.self, forKey: .useCursorPointer)
codeBlockLineNumbers = try container.decodeIfPresent(Bool.self, forKey: .codeBlockLineNumbers)
showCommentMarker = try container.decodeIfPresent(Bool.self, forKey: .commentsInGutter)
smartText = try container.decodeIfPresent(Bool.self, forKey: .enableSmartText)
notificationBadge = try container.decodeIfPresent(String.self, forKey: .notificationBadge)
fullWidthDocuments = try container.decodeIfPresent(Bool.self, forKey: .fullWidthDocuments)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(rememberLastPath, forKey: .rememberLastPath)
try container.encodeIfPresent(separateEditing.map { !$0 }, forKey: .seamlessEdit)
try container.encodeIfPresent(useCursorPointer, forKey: .useCursorPointer)
try container.encodeIfPresent(codeBlockLineNumbers, forKey: .codeBlockLineNumbers)
try container.encodeIfPresent(showCommentMarker, forKey: .commentsInGutter)
try container.encodeIfPresent(smartText, forKey: .enableSmartText)
try container.encodeIfPresent(notificationBadge, forKey: .notificationBadge)
try container.encodeIfPresent(fullWidthDocuments, forKey: .fullWidthDocuments)
}
}
/// App-icon unread indicator style. Wire values confirmed live (captured
/// setting all three from Outline's own web app).
public enum NotificationBadgeStyle: String, CaseIterable, Identifiable, Sendable {
case none = "disabled"
case unreadIndicator = "indicator"
case unreadCount = "count"
public var id: String { rawValue }
public var label: String {
switch self {
case .none: return "None"
case .unreadIndicator: return "Unread Indicator"
case .unreadCount: return "Unread Count"
}
}
}
@@ -0,0 +1,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,40 @@
import Foundation
/// Builds the multipart/form-data body for Outline's presigned-upload
/// targets (`attachments.create`'s `uploadUrl`/`form`) an S3-style
/// presigned POST: every `form` field has to ride along as its own part in
/// the same request as the file, not as query params or headers.
enum MultipartFormDataBuilder {
static func build(
fields: [String: String],
fileFieldName: String,
fileName: String,
fileData: Data,
fileContentType: String,
boundary: String = "Boundary-\(UUID().uuidString)"
) -> (body: Data, contentType: String) {
var body = Data()
for (key, value) in fields {
body.append("--\(boundary)\r\n".utf8Data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
body.append(value.utf8Data)
body.append("\r\n".utf8Data)
}
body.append("--\(boundary)\r\n".utf8Data)
body.append(
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
)
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
body.append(fileData)
body.append("\r\n".utf8Data)
body.append("--\(boundary)--\r\n".utf8Data)
return (body, "multipart/form-data; boundary=\(boundary)")
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
@@ -0,0 +1,21 @@
import Foundation
/// `apiKeys.create`. `expiresAt: nil` (the key omitted entirely, not sent as
/// literal `null`) confirmed live to mean no expiration Swift's
/// synthesized `Encodable` already omits `nil` optionals via
/// `encodeIfPresent`, so no custom `encode(to:)` is needed here the way
/// `UpdateUserAvatarRequest` needed one for the opposite case.
public struct CreateApiKeyRequest: Encodable, Sendable {
public let name: String
public let expiresAt: Date?
/// `nil`/omitted grants full access confirmed live (every key created
/// without a scope came back with unrestricted access). A specific
/// scope is a list of allowed API paths, e.g. `["/api/documents.info"]`.
public let scope: [String]?
public init(name: String, expiresAt: Date? = nil, scope: [String]? = nil) {
self.name = name
self.expiresAt = expiresAt
self.scope = scope
}
}
@@ -0,0 +1,19 @@
import Foundation
/// Requests an upload target for a new file not the upload itself, see
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
/// user-avatar upload sends (confirmed live); a real value scopes the
/// attachment to a document instead (e.g. an inline image embed).
public struct CreateAttachmentRequest: Encodable, Sendable {
public let name: String
public let contentType: String
public let size: Int
public let documentId: String?
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
self.name = name
self.contentType = contentType
self.size = size
self.documentId = documentId
}
}
@@ -0,0 +1,26 @@
import Foundation
/// `parentCommentId: nil` creates a document-level (top-level) comment;
/// non-nil creates a reply (Outline supports one level of nesting a
/// reply's own `parentCommentId` should always be a top-level comment's
/// id, never another reply's). `anchorText` creates an inline (anchored)
/// comment instead of a document-level one the first occurrence of that
/// exact substring in the document's plain text is used, same as Outline's
/// own web editor; `anchorPrefix`/`anchorSuffix` aren't sent (this app has
/// no UI for choosing between multiple identical occurrences). `text` is
/// the documented markdown convenience field for `data` (a ProseMirror
/// document) simplest path for plain-text comment bodies, no need to
/// construct ProseMirror JSON by hand.
public struct CreateCommentRequest: Encodable, Sendable {
public let documentId: String
public let parentCommentId: String?
public let text: String
public let anchorText: String?
public init(documentId: String, parentCommentId: String? = nil, text: String, anchorText: String? = nil) {
self.documentId = documentId
self.parentCommentId = parentCommentId
self.text = text
self.anchorText = anchorText
}
}
@@ -1,16 +1,18 @@
import Foundation
public struct CreateDocumentRequest: Encodable, Sendable {
public struct CreateDocumentRequest: Codable, Sendable {
public let title: String
public let text: String
public let collectionId: String
/// `nil` (with `parentDocumentId` also `nil`) creates a draft Outline
/// requires one of the two to publish at all, regardless of `publish`.
public let collectionId: String?
public let parentDocumentId: String?
public let publish: Bool
public init(
title: String,
text: String,
collectionId: String,
collectionId: String? = nil,
parentDocumentId: String? = nil,
publish: Bool = true
) {
@@ -2,7 +2,7 @@ import Foundation
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable {
public struct CreatePinRequest: Codable, Sendable {
public let documentId: String
public let collectionId: String?
@@ -1,7 +1,7 @@
import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct CreateSubscriptionRequest: Encodable, Sendable {
public struct CreateSubscriptionRequest: Codable, Sendable {
public let documentId: String
public let event: String
@@ -0,0 +1,14 @@
import Foundation
/// `apiKeys.list` matches every other paginated `.list` endpoint's flat
/// offset/limit convention (e.g. `ListSharesRequest`), not the nested
/// `pagination` object that only appears in list *responses*.
public struct ListApiKeysRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,18 @@
import Foundation
public struct ListCommentsRequest: Encodable, Sendable {
public let documentId: String
public let offset: Int
public let limit: Int
/// Include each anchored comment's `anchorText` (the document text it's
/// attached to) in the response. Off by default it's extra payload
/// only needed when actually positioning inline markers.
public let includeAnchorText: Bool
public init(documentId: String, offset: Int = 0, limit: Int = 100, includeAnchorText: Bool = false) {
self.documentId = documentId
self.offset = offset
self.limit = limit
self.includeAnchorText = includeAnchorText
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct ListDraftsRequest: Encodable, Sendable {
public let offset: Int
public let limit: Int
public init(offset: Int = 0, limit: Int = 25) {
self.offset = offset
self.limit = limit
}
}
@@ -0,0 +1,13 @@
import Foundation
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
/// `nil` `eventType` targets every notification event at once confirmed
/// live via Outline's own "All notifications" master toggle, which sends
/// no `eventType` at all.
public struct NotificationSubscriptionRequest: Encodable, Sendable {
public let eventType: String?
public init(eventType: String? = nil) {
self.eventType = eventType
}
}
@@ -1,6 +1,6 @@
import Foundation
public struct StarCollectionRequest: Encodable, Sendable {
public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String
public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct StarDocumentRequest: Encodable, Sendable {
public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String
public init(documentId: String) {
@@ -1,6 +1,6 @@
import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable {
public struct UpdateCollectionRequest: Codable, Sendable {
public let id: String
public let name: String?
public let description: String?
@@ -1,12 +1,19 @@
import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable {
public struct UpdateDocumentRequest: Codable, Sendable {
public let id: String
public let title: String?
public let text: String?
public let append: Bool?
public let fullWidth: Bool?
public let insightsEnabled: Bool?
/// Moves the document to this collection. Combined with `publish: true`,
/// this is how a draft (no `collectionId`, or one it just hasn't left
/// yet) gets published into a specific collection in one call.
public let collectionId: String?
/// Publishes a draft, making it visible to other workspace members.
/// Documented as a no-op if the document is already published.
public let publish: Bool?
public init(
id: String,
@@ -14,7 +21,9 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
text: String? = nil,
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil
insightsEnabled: Bool? = nil,
collectionId: String? = nil,
publish: Bool? = nil
) {
self.id = id
self.title = title
@@ -22,5 +31,7 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.collectionId = collectionId
self.publish = publish
}
}
@@ -0,0 +1,28 @@
import Foundation
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
/// properties, which *omits* the key entirely when the value is `nil`
/// removing the avatar needs a literal `"avatarUrl": null` in the request
/// body, not the key missing. `id` is required: confirmed live (the
/// captured request body's length only matches `{"id": "...", "avatarUrl":
/// ...}`, not a shorter shape without it).
public struct UpdateUserAvatarRequest: Encodable, Sendable {
public let id: String
public let avatarUrl: String?
public init(id: String, avatarUrl: String?) {
self.id = id
self.avatarUrl = avatarUrl
}
private enum CodingKeys: String, CodingKey {
case id, avatarUrl
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(avatarUrl, forKey: .avatarUrl)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, language only.
public struct UpdateUserLanguageRequest: Encodable, Sendable {
public let id: String
public let language: String
public init(id: String, language: String) {
self.id = id
self.language = language
}
}
@@ -0,0 +1,12 @@
import Foundation
/// `users.update`, name only.
public struct UpdateUserNameRequest: Encodable, Sendable {
public let id: String
public let name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
@@ -0,0 +1,14 @@
import Foundation
/// `users.update`, preferences only. Sends the *whole* preferences object
/// back (not a single changed key) avoids needing to know whether the
/// server deep-merges a partial `preferences` body or replaces it outright.
public struct UpdateUserPreferencesRequest: Encodable, Sendable {
public let id: String
public let preferences: OutlineUserPreferences
public init(id: String, preferences: OutlineUserPreferences) {
self.id = id
self.preferences = preferences
}
}
@@ -0,0 +1,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
}
}
@@ -0,0 +1,738 @@
import XCTest
import CryptoKit
@testable import OutlineKit
private struct NotStubbed: Error {}
/// Conforms to the full protocol (so it can stand in for `live`), but only
/// the two methods under test in this file have real behavior everything
/// else throws loudly if a test accidentally exercises it.
private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable {
var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)?
var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])?
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() }
func documentInfo(id: String) async throws -> OutlineDocument {
guard let handler = documentInfoHandler else { throw NotStubbed() }
return try await handler(id)
}
func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() }
func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() }
func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
guard let handler = createDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() }
func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() }
func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() }
func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() }
func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() }
func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() }
func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() }
func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() }
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() }
func exportDocument(id: String) async throws -> String { throw NotStubbed() }
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() }
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() }
func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() }
func revokeShare(id: String) async throws { throw NotStubbed() }
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
guard let handler = createPinHandler else { throw NotStubbed() }
return try await handler(request)
}
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
func deletePin(id: String) async throws {
guard let handler = deletePinHandler else { throw NotStubbed() }
try await handler(id)
}
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() }
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() }
func deleteSubscription(id: String) async throws { throw NotStubbed() }
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() }
func listComments(_ request: ListCommentsRequest) async throws -> [OutlineComment] { throw NotStubbed() }
func commentInfo(id: String) async throws -> OutlineComment { throw NotStubbed() }
func createComment(_ request: CreateCommentRequest) async throws -> OutlineComment { throw NotStubbed() }
func resolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func unresolveComment(id: String) async throws -> OutlineComment { throw NotStubbed() }
func addReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func removeReaction(commentId: String, emoji: String) async throws { throw NotStubbed() }
func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() }
func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() }
func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() }
func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() }
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
guard let handler = listCollectionsHandler else { throw NotStubbed() }
return try await handler(offset, limit)
}
func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() }
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() }
func deleteCollection(id: String) async throws { throw NotStubbed() }
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() }
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() }
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
func deleteAccount() async throws { throw NotStubbed() }
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
func listApiKeys(_ request: ListApiKeysRequest) async throws -> [OutlineAPIKey] { throw NotStubbed() }
func createApiKey(_ request: CreateApiKeyRequest) async throws -> OutlineAPIKey { throw NotStubbed() }
func deleteApiKey(id: String) async throws { throw NotStubbed() }
func installationInfo() async throws -> OutlineInstallationInfo { throw NotStubbed() }
}
private struct StubTransportError: Error {}
/// 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 {
private func makeCache() throws -> OfflineCacheStore {
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
}
private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument {
OutlineDocument(
id: id,
title: title,
text: "body",
url: "/doc/\(id)",
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection {
OutlineCollection(
id: id,
name: name,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0)
)
}
func testDocumentInfoWritesThroughToCacheOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
stub.documentInfoHandler = { _ in document }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
}
func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let document = makeDocument()
var callCount = 0
stub.documentInfoHandler = { _ in
callCount += 1
if callCount == 1 { return document }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// First call succeeds and populates the cache.
_ = try await sut.documentInfo(id: "doc-1")
// Second call fails live should transparently return the cached value.
let result = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(result, document)
XCTAssertEqual(callCount, 2)
}
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do {
_ = try await sut.documentInfo(id: "doc-1")
XCTFail("Expected an error")
} catch is StubTransportError {
// expected
}
}
func testListCollectionsFallsBackToCacheWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let collections = [makeCollection()]
var callCount = 0
stub.listCollectionsHandler = { _, _ in
callCount += 1
if callCount == 1 { return collections }
throw StubTransportError()
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.listCollections(offset: 0, limit: 25)
let result = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(result, collections)
}
func testDifferentDocumentIdsAreCachedSeparately() async throws {
let stub = StubOutlineAPIClient()
let docOne = makeDocument(id: "doc-1", title: "One")
let docTwo = makeDocument(id: "doc-2", title: "Two")
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.documentInfo(id: "doc-2")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedOne = try await sut.documentInfo(id: "doc-1")
let cachedTwo = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedOne, docOne)
XCTAssertEqual(cachedTwo, docTwo)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore())
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
let clearedSummary = await sut.cacheStorageSummary()
XCTAssertEqual(clearedSummary.itemCount, 0)
}
// MARK: - Full sync
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
let stub = StubOutlineAPIClient()
var requestedLimits: [Int] = []
// 105 collections across two pages (100 + 5) regression test for a
// real bug: this used to ask for `limit: 250` in one shot, which
// Outline's server rejects outright ("Pagination limit is too large
// (max 100)"), turning the whole sync into a single silent failure.
stub.listCollectionsHandler = { offset, limit in
requestedLimits.append(limit)
let remaining = max(0, 105 - offset)
let count = min(limit, remaining)
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
// Must return empty for any non-nil parentDocumentId (no children)
// performFullSync now recurses into every document's own children,
// so a stub that ignores parentDocumentId and always returns the
// same root documents regardless would recurse into itself forever.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard parentDocumentId == nil else { return [] }
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
func testPerformFullSyncRecursesIntoNestedDocuments() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
// doc-1 (root) -> doc-2 (child of doc-1) -> doc-3 (grandchild)
// regression test for the real gap this fixed: only root-level
// documents were ever cached before, so a document's own
// sub-documents were never reachable offline at all unless
// something else happened to open them individually first.
stub.listDocumentsHandler = { _, parentDocumentId, offset, _ in
guard offset == 0 else { return [] }
switch parentDocumentId {
case nil: return [self.makeDocument(id: "doc-1")]
case "doc-1": return [self.makeDocument(id: "doc-2")]
case "doc-2": return [self.makeDocument(id: "doc-3")]
default: return []
}
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 3)
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedGrandchild = try await sut.documentInfo(id: "doc-3")
XCTAssertEqual(cachedGrandchild.id, "doc-3")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
// 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")
}
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
],
"data": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Sounds great" }
]
}
]
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
XCTAssertEqual(comments.first?.id, "comment-1")
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
XCTAssertFalse(comments.first?.isResolved ?? true)
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
}
func testResolveCommentDecodesResolvedComment() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": "2026-01-02T00:00:00.000Z",
"resolvedBy": { "id": "user-2", "name": "John Roe" },
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comment = try await client.resolveComment(id: "comment-1")
XCTAssertTrue(comment.isResolved)
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
}
func testCreateCommentSendsParentCommentIdForAReply() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-2",
"documentId": "doc-1",
"parentCommentId": "comment-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let reply = try await client.createComment(
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
)
XCTAssertEqual(reply.parentCommentId, "comment-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
}
func testAddReactionPostsIdAndEmoji() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
}
func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -993,6 +1124,490 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:)
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "New Name"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
XCTAssertEqual(user.name, "New Name")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
}
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": false }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.unsubscribeFromNotifications(eventType: nil)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNil(sentBody?["eventType"])
}
func testListApiKeysDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": [
{
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
"name": "Outpost",
"scope": null,
"last4": "dl1h",
"createdAt": "2026-08-12T18:44:32.467Z",
"updatedAt": "2026-08-12T18:44:32.467Z",
"expiresAt": null,
"lastActiveAt": "2026-08-18T01:01:36.553Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let keys = try await client.listApiKeys(ListApiKeysRequest())
XCTAssertEqual(keys.count, 1)
XCTAssertEqual(keys.first?.name, "Outpost")
XCTAssertEqual(keys.first?.last4, "dl1h")
XCTAssertNil(keys.first?.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
}
func testInstallationInfoDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
"policies": []
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let info = try await client.installationInfo()
XCTAssertEqual(info.version, "1.9.2")
XCTAssertEqual(info.versionsBehind, 0)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
}
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
"name": "test",
"scope": null,
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
"last4": "0DGv",
"createdAt": "2026-08-18T15:39:29.872Z",
"expiresAt": null,
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
XCTAssertNil(key.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["name"] as? String, "test")
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
XCTAssertNil(sentBody?["scope"])
}
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
"name": "Test",
"scope": null,
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
"last4": "cLts",
"createdAt": "2026-08-18T15:38:22.928Z",
"expiresAt": "2026-11-16T23:59:59.999Z",
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNotNil(sentBody?["expiresAt"])
}
func testDeleteApiKeySendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAttachment(id: "attach-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -0,0 +1,48 @@
import XCTest
@testable import OutlineKit
final class MultipartFormDataBuilderTests: XCTestCase {
func testBuildIncludesEveryFieldAndTheFile() throws {
let fileData = Data("fake-jpeg-bytes".utf8)
let (body, contentType) = MultipartFormDataBuilder.build(
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
fileFieldName: "file",
fileName: "avatar.jpg",
fileData: fileData,
fileContentType: "image/jpeg",
boundary: "TestBoundary"
)
let bodyString = String(decoding: body, as: UTF8.self)
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
XCTAssertTrue(bodyString.contains("uploads/abc"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
XCTAssertTrue(bodyString.contains("public-read"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
}
func testFileFieldComesAfterAllFormFields() throws {
let (body, _) = MultipartFormDataBuilder.build(
fields: ["a": "1", "b": "2"],
fileFieldName: "file",
fileName: "x.jpg",
fileData: Data("bytes".utf8),
fileContentType: "image/jpeg",
boundary: "B"
)
let bodyString = String(decoding: body, as: UTF8.self)
let fieldsRange = bodyString.range(of: "name=\"a\"")
let fileRange = bodyString.range(of: "name=\"file\"")
XCTAssertNotNil(fieldsRange)
XCTAssertNotNil(fileRange)
if let fieldsRange, let fileRange {
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
}
}
}
@@ -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)
}
}
}
+77 -61
View File
@@ -3,14 +3,15 @@
archiveVersion = 1;
classes = {
};
objectVersion = 110;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B130366A1D0000167E /* MarkdownEngine */; };
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */; };
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FA7596B530366A1D0000167E /* MarkdownEngineLatex */; };
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FADBE086303734FE001E69F0 /* ImagePlayground.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99C43302CF1BD00C9949F /* OutlineKit */; };
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CAE302D120500C9949F /* MarkdownEngine */; };
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */; };
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */ = {isa = PBXBuildFile; productRef = FAF99CB2302D120500C9949F /* MarkdownEngineLatex */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -31,6 +32,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
FADBE086303734FE001E69F0 /* ImagePlayground.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImagePlayground.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk/System/Library/Frameworks/ImagePlayground.framework; sourceTree = DEVELOPER_DIR; };
FAF99C18302CE96100C9949F /* Outpost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Outpost.app; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C27302CE96200C9949F /* OutpostTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
FAF99C31302CE96200C9949F /* OutpostUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OutpostUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -59,9 +61,10 @@
isa = PBXFrameworksBuildPhase;
files = (
FAF99C44302CF1BD00C9949F /* OutlineKit in Frameworks */,
FAF99CB1302D120500C9949F /* MarkdownEngineCodeBlocks in Frameworks */,
FAF99CB3302D120500C9949F /* MarkdownEngineLatex in Frameworks */,
FAF99CAF302D120500C9949F /* MarkdownEngine in Frameworks */,
FA7596B430366A1D0000167E /* MarkdownEngineCodeBlocks in Frameworks */,
FA7596B630366A1D0000167E /* MarkdownEngineLatex in Frameworks */,
FADBE087303734FE001E69F0 /* ImagePlayground.framework in Frameworks */,
FA7596B230366A1D0000167E /* MarkdownEngine in Frameworks */,
);
};
FAF99C24302CE96200C9949F /* Frameworks */ = {
@@ -77,12 +80,21 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
FADBE085303734FE001E69F0 /* Frameworks */ = {
isa = PBXGroup;
children = (
FADBE086303734FE001E69F0 /* ImagePlayground.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
FAF99C0F302CE96100C9949F = {
isa = PBXGroup;
children = (
FAF99C1A302CE96100C9949F /* Outpost */,
FAF99C2A302CE96200C9949F /* OutpostTests */,
FAF99C34302CE96200C9949F /* OutpostUITests */,
FADBE085303734FE001E69F0 /* Frameworks */,
FAF99C19302CE96100C9949F /* Products */,
);
sourceTree = "<group>";
@@ -116,9 +128,9 @@
name = Outpost;
packageProductDependencies = (
FAF99C43302CF1BD00C9949F /* OutlineKit */,
FAF99CAE302D120500C9949F /* MarkdownEngine */,
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */,
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */,
FA7596B130366A1D0000167E /* MarkdownEngine */,
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */,
FA7596B530366A1D0000167E /* MarkdownEngineLatex */,
);
productName = Outpost;
productReference = FAF99C18302CE96100C9949F /* Outpost.app */;
@@ -200,7 +212,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */,
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */,
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = FAF99C19302CE96100C9949F /* Products */;
@@ -299,7 +311,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -361,7 +373,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -385,15 +397,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -407,20 +424,22 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Debug;
@@ -430,15 +449,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Outpost/Outpost.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = CW6GQT9SK5;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Outpost;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -452,20 +476,22 @@
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,7";
TARGETED_DEVICE_FAMILY = "1,2";
XROS_DEPLOYMENT_TARGET = 27.0;
};
name = Release;
@@ -476,10 +502,10 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -502,10 +528,10 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostTests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -527,10 +553,10 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -552,10 +578,10 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = B95H74ZDY6;
DEVELOPMENT_TEAM = CW6GQT9SK5;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 27.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -610,43 +636,33 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
FA7596B030366A1D0000167E /* XCLocalSwiftPackageReference "Vendor/swift-markdown-engine" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "Vendor/swift-markdown-engine";
};
FAF99C42302CF1BD00C9949F /* XCLocalSwiftPackageReference "OutlineKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = OutlineKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/nodes-app/swift-markdown-engine";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.12.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
FA7596B130366A1D0000167E /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngine;
};
FA7596B330366A1D0000167E /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineCodeBlocks;
};
FA7596B530366A1D0000167E /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownEngineLatex;
};
FAF99C43302CF1BD00C9949F /* OutlineKit */ = {
isa = XCSwiftPackageProductDependency;
productName = OutlineKit;
};
FAF99CAE302D120500C9949F /* MarkdownEngine */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngine;
};
FAF99CB0302D120500C9949F /* MarkdownEngineCodeBlocks */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineCodeBlocks;
};
FAF99CB2302D120500C9949F /* MarkdownEngineLatex */ = {
isa = XCSwiftPackageProductDependency;
package = FAF99CAD302D120500C9949F /* XCRemoteSwiftPackageReference "swift-markdown-engine" */;
productName = MarkdownEngineLatex;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = FAF99C10302CE96100C9949F /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "f233fa96f0c6bdcdbf87f726af38f25704f6d46a156a27dfac47541baa63bf97",
"originHash" : "4127e8224149bef00a33500e8db49748a735e1c07626f2255faea52554afef5e",
"pins" : [
{
"identity" : "highlighterswift",
@@ -10,15 +10,6 @@
"version" : "3.1.0"
}
},
{
"identity" : "swift-markdown-engine",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nodes-app/swift-markdown-engine",
"state" : {
"revision" : "e5f7607fc4021181056ef7a09dbb7573dc0237d9",
"version" : "0.12.0"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
@@ -0,0 +1,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>
+21
View File
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "outpost-ios-1024.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+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
}
}
+14 -31
View File
@@ -2,25 +2,17 @@
import AppKit
import SwiftUI
struct AboutView: View {
/// Bare content (icon, name, version, links) with no window chrome the
/// macOS "About Outpost" app-menu command now opens Settings' own About
/// section directly (no separate popup window), so this is its only caller.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
private var appName: String {
var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
}
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA"
private var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "Version \(shortVersion)\(stageSuffix) (\(buildNumber))"
}
var versionString: String { OutpostVersion.fullVersionString }
private var copyrightYear: String {
String(Calendar.current.component(.year, from: Date()))
@@ -51,29 +43,20 @@ struct AboutView: View {
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity)
VStack(spacing: 10) {
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Button("Check for Updates…") {
checkForUpdates()
}
Link(destination: repositoryURL) {
Label("View Source on Git", systemImage: "link")
}
.font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(32)
.frame(width: 320)
}
// No Sparkle-style in-app updater yet this just opens the releases page
// on the self-hosted Gitea instance so the user can check/download manually.
private func checkForUpdates() {
NSWorkspace.shared.open(releasesURL)
}
}
#endif
+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
+27 -25
View File
@@ -1,5 +1,7 @@
#if os(macOS)
import SwiftUI
import StoreKit
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after
@@ -7,21 +9,14 @@ import SwiftUI
/// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View {
@Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@Environment(AppNavigation.self) private var navigation
@Environment(\.requestReview) private var requestReview
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false
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 {
Button {
isMenuPresented = true
@@ -61,20 +56,10 @@ struct AccountFooter: View {
private var menuContent: some View {
VStack(alignment: .leading, spacing: 2) {
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
Divider()
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) }
// Apple's own review/feedback prompt there's no separate
// native channel for "bug" vs. "feedback", so one button covers
// both.
menuItem("Leave Us Feedback") { requestReview() }
Divider()
@@ -93,8 +78,25 @@ struct AccountFooter: View {
.padding(.horizontal, 6)
.padding(.vertical, 4)
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() }
// Deferred a tick: setting this synchronously in the same call
// that dismisses this popover collides two AppKit window/layer
// transactions in the same runloop turn (visible in the console
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
// 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()
@@ -0,0 +1,127 @@
#if os(macOS)
import AppKit
import SwiftUI
/// Crop/rotate/zoom editor shown after picking a photo, before it's
/// uploaded pan (drag), zoom (pinch or the slider), and 90°-increment
/// rotate, all inside a circular mask matching how the avatar actually
/// renders everywhere else in the app.
///
/// The on-screen preview and the final exported image are built from the
/// exact same view composition (`avatarContent`), just instantiated once
/// for display and once inside an `ImageRenderer` that's deliberate:
/// hand-deriving a separate set of crop-math for a higher-resolution
/// render would risk it silently disagreeing with what the user actually
/// saw and confirmed, and there's no way to visually verify that
/// agreement without running the app. Reusing the identical view tree
/// makes the export WYSIWYG by construction instead of by careful math.
struct AvatarCropperView: View {
let sourceImage: NSImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@State private var rotationDegrees: Double = 0
@GestureState private var dragTranslation: CGSize = .zero
/// Used for both the live preview and the exported image see the
/// type-level doc comment for why that's the same size, not two.
private let diameter: CGFloat = 320
var body: some View {
VStack(spacing: 20) {
Text("Edit Photo")
.font(.headline)
ZStack {
avatarContent
.clipShape(Circle())
Circle()
.strokeBorder(Color.primary.opacity(0.15), lineWidth: 1)
}
.frame(width: diameter, height: diameter)
.contentShape(Circle())
.gesture(
DragGesture()
.updating($dragTranslation) { value, state, _ in state = value.translation }
.onEnded { value in
offset.width += value.translation.width
offset.height += value.translation.height
}
)
HStack(spacing: 16) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees -= 90 }
} label: {
Image(systemName: "rotate.left")
}
.help("Rotate left")
Slider(value: $scale, in: 1...4)
.frame(width: 140)
Button {
withAnimation(.easeInOut(duration: 0.2)) { rotationDegrees += 90 }
} label: {
Image(systemName: "rotate.right")
}
.help("Rotate right")
}
HStack {
Button("Cancel", role: .cancel, action: onCancel)
Spacer()
Button("Use Photo") {
Task {
if let data = await renderFinalImage() {
onConfirm(data)
}
}
}
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 360)
}
/// Aspect-fills `sourceImage` into a `diameter`×`diameter` square, then
/// applies the user's pan/zoom/rotation on top identical between the
/// live preview and the final render (see the type-level doc comment).
private var avatarContent: some View {
Image(nsImage: sourceImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: diameter, height: diameter)
.scaleEffect(scale)
.rotationEffect(.degrees(rotationDegrees))
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
.frame(width: diameter, height: diameter)
.clipped()
}
/// `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
private func renderFinalImage() async -> Data? {
let content = avatarContent
.clipShape(Circle())
.frame(width: diameter, height: diameter)
let renderer = ImageRenderer(content: content)
renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays
guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil }
return await Task.detached(priority: .userInitiated) {
NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9])
}.value
}
}
#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
@@ -1,40 +0,0 @@
#if os(macOS)
import SwiftUI
struct PreferencesView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isShowingLogoutConfirmation = false
var body: some View {
Form {
Section("Appearance") {
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section("Account") {
LabeledContent("Signed in as", value: session.userName ?? "")
if let email = session.userEmail {
LabeledContent("Email", value: email)
}
if let teamName = session.teamName {
LabeledContent("Workspace", value: teamName)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
.formStyle(.grouped)
.frame(width: 380, height: 300)
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
}
#endif
@@ -0,0 +1,131 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different
/// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace groups, matching
/// the settings page structure of the Outline web app. Outline's own server
/// version has no dedicated section (there used to be an Integrations &
/// Installation category for just that) it's cheap enough to show
/// unconditionally in the footer here instead, alongside Outpost's own
/// version.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
@Environment(SessionStore.self) private var session
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var outlineVersion: String?
/// Mirrors `SettingsView`'s own check a real dropped connection or
/// the manual Offline Mode toggle both mean there's no server to ask.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Settings")
.font(.headline)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
List(selection: $selection) {
// 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 }
Section {
ForEach(sections) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
} header: {
if let title = category.title {
HStack(spacing: 6) {
Text(title)
// Not hardcoded to `.workspace` specifically
// stays correct on its own as sections get
// built, only shows while every section in
// the category is still `!isImplemented`.
if sections.allSatisfy({ !$0.isImplemented }) {
Text("Coming Soon")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.15), in: Capsule())
}
}
}
}
}
}
.listStyle(.sidebar)
Divider()
versionFooter
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
// Keyed to connectivity, not a one-shot `.task {}` reconnecting
// (or turning the manual Offline Mode toggle back off) re-fires
// this automatically instead of leaving the footer stuck on
// whatever it last knew, or blank, until Settings is reopened.
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
}
/// 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 {
VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(OutpostVersion.displayString)")
if let outlineVersion {
Text("Outline \(outlineVersion)")
} else if !isEffectivelyOnline {
Text("Outline — offline")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func refreshOutlineVersion() async {
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version })
}
}
#endif
File diff suppressed because it is too large Load Diff
+11 -15
View File
@@ -3,21 +3,17 @@ import SwiftUI
struct AuthHeaderView: View {
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 64, height: 64)
Image(systemName: "text.book.closed.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
// A plain Image Set, not the AppIcon *app icon* asset App Icon
// sets aren't reliably resolvable through Image(_:)/UIImage
// (named:) at runtime (confirmed live: showed nothing). This is
// the same source artwork (outpost-ios-1024.png) duplicated
// into a normal image set so SwiftUI can actually load it.
Image("AppLogo")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 72 * 0.2237, style: .continuous))
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
VStack(spacing: 4) {
Text("Welcome to Outpost")
@@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View {
/// every document in the tree.
@State private var pinsByDocumentID: [String: OutlinePin] = [:]
private var tree: [DocumentNode] {
buildDocumentTree(from: viewModel.documents, sortedBy: sortOption)
/// Recomputed only when `viewModel.documents`/`sortOption` actually change
/// (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(
@@ -86,11 +93,14 @@ struct CollectionDocumentsOutline: View {
.task(id: "\(refreshToken)-\(externalRefreshToken)") {
await viewModel.load()
await loadPins()
rebuildTree()
}
.onChange(of: viewModel.documents) { rebuildTree() }
.onChange(of: sortOption) { rebuildTree() }
}
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) })
}
}
@@ -157,6 +167,7 @@ private struct DocumentNodeRow: View {
.frame(width: 12)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Button {
@@ -186,6 +197,7 @@ private struct DocumentNodeRow: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
CollectionRowView(collection: collection)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Read-only for now document/overview editing isn't wired up yet. Uses
@@ -9,21 +10,34 @@ import OutlineKit
/// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View {
@State private var markdown: String
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick`.
@State private var imageReloadTick = 0
init(collection: OutlineCollection) {
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
_markdown = State(initialValue: collection.description ?? "")
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
ScrollView {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
}
}
#endif
@@ -3,6 +3,7 @@ import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
@@ -32,8 +34,15 @@ struct CollectionOverviewView: View {
self.onOpenDocument = onOpenDocument
}
private var sortedDocuments: [OutlineDocument] {
selectedTab.sorted(viewModel.documents)
/// Recomputed only when `viewModel.documents`/`selectedTab` actually
/// 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 {
@@ -59,7 +68,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
@@ -92,6 +101,8 @@ struct CollectionOverviewView: View {
await viewModel.checkForRemoteChanges()
}
}
.onChange(of: viewModel.documents) { resortDocuments() }
.onChange(of: selectedTab) { resortDocuments() }
}
// Spans the full window width, centered, directly under the toolbar
@@ -114,6 +125,7 @@ struct CollectionOverviewView: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
Spacer(minLength: 0)
}
@@ -150,6 +162,7 @@ struct CollectionOverviewView: View {
DocumentRowView(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -182,6 +195,7 @@ struct CollectionOverviewView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
} icon: {
if let emoji = collection.emojiIcon {
Text(emoji)
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) {
} else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
Image(systemName: symbolName)
.foregroundStyle(tintColor)
} else {
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
.animation(.easeInOut(duration: 0.15), value: isExpanded)
.contextMenu { contextMenuContent }
@@ -0,0 +1,262 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// K. Settings Editor Command Palette. Always searches locally, never a
/// per-keystroke network request. Two data-source modes:
///
/// - Lightweight (default): a live `listCollections` + `listViewedDocuments`
/// fetch once when the palette opens two small requests, near-instant,
/// works with no setup.
/// - Full Workspace: reads `CachingOutlineAPIClient`'s local SwiftData cache
/// directly (`cachedDocumentsIndex()`/`cachedCollectionsIndex()`) zero
/// network calls at all, and includes every nested sub-document, not just
/// collection roots. Requires Full Local Sync to actually have populated
/// that cache first (gated in Settings the toggle here is disabled
/// without it); this view doesn't trigger a sync itself.
struct CommandPaletteView: View {
let apiClient: OutlineAPIClient
let cachingClient: CachingOutlineAPIClient?
let fullWorkspaceSearch: Bool
let onSelectDocument: (OutlineDocument) -> Void
let onSelectCollection: (OutlineCollection) -> Void
let onDismiss: () -> Void
@State private var query = ""
@State private var collections: [OutlineCollection] = []
@State private var documents: [OutlineDocument] = []
@State private var isLoading = true
@State private var selectedIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private enum Result: Identifiable {
case collection(OutlineCollection)
case document(OutlineDocument)
var id: String {
switch self {
case .collection(let collection): return "collection-\(collection.id)"
case .document(let document): return "document-\(document.id)"
}
}
}
/// 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)
guard !trimmed.isEmpty else {
// No query yet: surface collections first, then the most
// recent/full-workspace documents as-is, capped so the panel
// doesn't dump the entire workspace with nothing typed.
results = (collections.map(Result.collection) + documents.map(Result.document))
.prefix(20)
.map { $0 }
return
}
let scored: [(Result, Int)] = collections.compactMap { collection in
matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) }
} + documents.compactMap { document in
matchScore(document.title, query: trimmed).map { (Result.document(document), $0) }
}
results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0)
}
/// Lower is better exact match, then prefix match, then earliest
/// contiguous-substring position, then (for multi-word queries) every
/// word present somewhere in the title in any order. That last tier is
/// what makes "test document" find a title like "Test Plan Document"
/// requiring the exact phrase contiguously (the previous behavior)
/// meant a title with anything between the words never matched at all,
/// which looked like "documents never show up, only collections" any
/// time the real title didn't happen to contain the typed phrase
/// verbatim. `nil` means no match at all. Still deliberately not a full
/// fuzzy/Levenshtein algorithm good enough for document/collection
/// titles without the unpredictability that brings.
private func matchScore(_ title: String, query: String) -> Int? {
let haystack = title.lowercased()
let needle = query.lowercased()
if haystack == needle { return 0 }
if haystack.hasPrefix(needle) { return 1 }
if let range = haystack.range(of: needle) {
return 2 + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
let words = needle.split(separator: " ").map(String.init)
guard words.count > 1, words.allSatisfy({ haystack.contains($0) }) else { return nil }
let totalPosition = words.reduce(0) { partial, word in
guard let range = haystack.range(of: word) else { return partial }
return partial + haystack.distance(from: haystack.startIndex, to: range.lowerBound)
}
return 100 + totalPosition
}
var body: some View {
ZStack {
Color.black.opacity(0.001) // catches clicks outside the card to dismiss
.onTapGesture { onDismiss() }
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search documents and collections…", text: $query)
.textFieldStyle(.plain)
.font(.title3)
.focused($isSearchFieldFocused)
.onChange(of: query) {
selectedIndex = 0
recomputeResults()
}
.onSubmit { selectCurrent() }
// Attached directly on the field itself, not an
// ancestor confirmed live that .onKeyPress on the
// outer card never saw arrow-key events at all while
// this TextField actually held focus, the up/down
// presses just went nowhere. Escape still needs its
// own handler below since this one only covers
// whichever view is actually focused.
.onKeyPress(.downArrow) { moveSelection(by: 1); return .handled }
.onKeyPress(.upArrow) { moveSelection(by: -1); return .handled }
.onKeyPress(.escape) { onDismiss(); return .handled }
if isLoading {
ProgressView().controlSize(.small)
}
}
.padding(14)
Divider()
if results.isEmpty {
ContentUnavailableView(
isLoading ? "Loading…" : "No Results",
systemImage: isLoading ? "ellipsis" : "magnifyingglass"
)
.frame(height: 160)
} else {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(results.enumerated()), id: \.element.id) { index, result in
resultRow(result, isSelected: index == selectedIndex)
.id(index)
.contentShape(Rectangle())
.pointerCursorOnHover()
.onTapGesture {
selectedIndex = index
selectCurrent()
}
}
}
.padding(6)
}
.frame(maxHeight: 360)
.onChange(of: selectedIndex) { _, newValue in
scrollProxy.scrollTo(newValue, anchor: .center)
}
}
}
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).strokeBorder(.separator))
.frame(width: 560)
.shadow(color: .black.opacity(0.3), radius: 24, y: 12)
}
.task {
// The window/responder chain isn't always ready to accept a
// first-responder change in the same instant this view is
// inserted confirmed live: setting this synchronously on
// appear left the field unfocused until manually clicked
// (also the likely source of several "entangle context after
// pre-commit" / CA-transaction warnings in the console, which
// are exactly what fighting AppKit for first-responder status
// mid-commit looks like). A one-frame-ish delay is enough for
// the overlay's insertion to settle first.
try? await Task.sleep(for: .milliseconds(50))
isSearchFieldFocused = true
await loadResults()
}
.onChange(of: collections) { recomputeResults() }
.onChange(of: documents) { recomputeResults() }
}
private func resultRow(_ result: Result, isSelected: Bool) -> some View {
HStack(spacing: 10) {
switch result {
case .collection(let collection):
// Reuses the sidebar's own icon logic (emoji vs Outline's
// icon-key-to-SF-Symbol mapping vs fallback) instead of
// guessing `collection.icon` isn't a raw SF Symbol name.
CollectionRowView(collection: collection)
.labelStyle(.iconOnly)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(collection.name)
.lineLimit(1)
Text("Collection")
.font(.caption2)
.foregroundStyle(.secondary)
}
case .document(let document):
if let emoji = document.emoji {
Text(emoji).frame(width: 20)
} else {
Image(systemName: "doc.text")
.foregroundStyle(.secondary)
.frame(width: 20)
}
VStack(alignment: .leading, spacing: 1) {
Text(document.title.isEmpty ? "Untitled" : document.title)
.lineLimit(1)
Text("Document")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func moveSelection(by delta: Int) {
guard !results.isEmpty else { return }
selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
}
private func selectCurrent() {
guard results.indices.contains(selectedIndex) else { return }
switch results[selectedIndex] {
case .collection(let collection): onSelectCollection(collection)
case .document(let document): onSelectDocument(document)
}
onDismiss()
}
private func loadResults() async {
isLoading = true
defer { isLoading = false }
if fullWorkspaceSearch {
// Purely local SwiftData reads no network at all, and (since
// Full Local Sync now recurses into every document's children)
// this includes nested sub-documents the live per-collection
// fetch never could. Empty if a sync has never actually run.
collections = await cachingClient?.cachedCollectionsIndex() ?? []
documents = await cachingClient?.cachedDocumentsIndex() ?? []
return
}
async let fetchedCollections = (try? apiClient.listCollections(offset: 0, limit: 250)) ?? []
async let fetchedRecent = (try? apiClient.listViewedDocuments(offset: 0, limit: 30)) ?? []
collections = await fetchedCollections
documents = await fetchedRecent
}
}
#endif
@@ -4,6 +4,9 @@ import OutlineKit
struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
/// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work.
@@ -21,16 +24,83 @@ struct ContentView_macOS: View {
/// Home's "New Document" buttons) every expanded sidebar row reloads
/// itself in response. See `CollectionDocumentsOutline.externalRefreshToken`.
@State private var documentsChangedToken = 0
/// Guards the restore-on-launch attempt to exactly once per app launch
/// without this, `mainContent`'s `.task` would re-run (and
/// re-navigate out from under the user) every time it reappears, e.g.
/// after a trip through Settings.
@State private var hasAttemptedLocationRestore = false
private var trimmedGlobalQuery: String {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
/// own (that's `@Bindable`'s job, and introducing one in `body` would
/// mean restructuring it away from a single implicit-return expression)
/// a manually-built `Binding` over the same reference is simpler here.
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
Binding(
get: { navigation.selectedSettingsSection },
set: { navigation.selectedSettingsSection = $0 }
)
}
var body: some View {
// A totally separate top-level branch, not content swapped inside
// one persistent `NavigationSplitView` that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList(
selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false }
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView {
VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery)
Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar
AccountFooter()
}
@@ -83,6 +153,38 @@ struct ContentView_macOS: View {
if newValue != nil {
isShowingHome = false
}
persistLastLocationIfEnabled()
}
.onChange(of: documentPath) { _, _ in
persistLastLocationIfEnabled()
}
.onChange(of: isShowingHome) { _, _ in
persistLastLocationIfEnabled()
}
// Once per launch, before the user has a chance to navigate
// manually restores whatever `restoreLastLocationIfEnabled`
// finds, or leaves today's Home default alone if there's nothing
// to restore (preference off, nothing stored yet, or resolution
// fails e.g. a deleted document/collection or being offline).
.task {
guard !hasAttemptedLocationRestore else { return }
hasAttemptedLocationRestore = true
await restoreLastLocationIfEnabled()
}
.overlay {
if navigation.isShowingCommandPalette, let apiClient = session.apiClient {
CommandPaletteView(
apiClient: apiClient,
cachingClient: session.cachingClient,
fullWorkspaceSearch: isCommandPaletteFullWorkspaceSearch,
onSelectDocument: openDocument,
onSelectCollection: { collection in
selectedCollection = collection
replaceDocumentPath(with: [])
},
onDismiss: { navigation.isShowingCommandPalette = false }
)
}
}
}
@@ -92,9 +194,67 @@ struct ContentView_macOS: View {
isContextualSearchExpanded = false
selectedCollection = nil
isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: [])
}
// MARK: - Remember previous location (Preferences Remember previous location)
private static let lastLocationDefaultsKey = "outline.lastLocation"
/// What gets persisted `isHome` disambiguates "was on Home" from "no
/// collection selected yet" (the latter only otherwise happens on the
/// brief `ContentUnavailableView` placeholder state), since both would
/// otherwise look identical (`collectionId == nil`).
private struct LastLocation: Codable {
var isHome: Bool
var collectionId: String?
var documentIds: [String]
}
/// Called from every navigation-changing `.onChange` cheap to persist
/// on every change rather than debouncing, this is just a small JSON
/// blob in `UserDefaults`, not a network call.
private func persistLastLocationIfEnabled() {
guard session.userPreferences?.rememberLastPath == true else { return }
let location = LastLocation(isHome: isShowingHome, collectionId: selectedCollection?.id, documentIds: documentPath.map(\.id))
guard let data = try? JSONEncoder().encode(location) else { return }
UserDefaults.standard.set(data, forKey: Self.lastLocationDefaultsKey)
}
/// Resolves IDs back into real `OutlineCollection`/`OutlineDocument`
/// objects via the API stored IDs alone aren't enough to populate
/// `selectedCollection`/`documentPath` directly. Resolves the document
/// chain in order and stops at the first failure (deleted document,
/// offline, etc.) rather than aborting the whole restore whatever
/// prefix of the chain resolved successfully is still a better landing
/// spot than falling all the way back to Home.
private func restoreLastLocationIfEnabled() async {
guard session.userPreferences?.rememberLastPath == true,
let apiClient = session.apiClient,
let data = UserDefaults.standard.data(forKey: Self.lastLocationDefaultsKey),
let location = try? JSONDecoder().decode(LastLocation.self, from: data)
else { return }
// A pure "was on Home, nothing pushed" location needs no action
// Home is already the default state before this ever runs.
guard location.collectionId != nil || !location.documentIds.isEmpty else { return }
if let collectionId = location.collectionId {
guard let collection = try? await apiClient.collectionInfo(id: collectionId) else { return }
selectedCollection = collection
isShowingHome = false
}
var resolvedChain: [OutlineDocument] = []
for documentId in location.documentIds {
guard let document = try? await apiClient.documentInfo(id: documentId) else { break }
resolvedChain.append(document)
}
if !resolvedChain.isEmpty {
replaceDocumentPath(with: resolvedChain)
}
}
@ViewBuilder
private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
@@ -285,6 +445,13 @@ struct ContentView_macOS: View {
if !documentPath.isEmpty {
documentPath.removeLast()
}
// Delete/Archive/Unpublish/Move all change what
// should show in the sidebar tree this only
// popped the reader before, leaving the sidebar
// showing the document until some unrelated
// trigger (the 45s poll, navigating away and
// back) happened to refresh it.
documentsChangedToken += 1
},
onDocumentCreated: { documentsChangedToken += 1 }
)
@@ -0,0 +1,425 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Document-level and anchored comments + single-level replies + emoji
/// reactions + resolve/unresolve. Composing new comments/replies is still
/// plain text only (no bold/italic/lists/etc.) sent through the `text`
/// (markdown) convenience field, not a hand-built ProseMirror `data`
/// document.
@MainActor
struct DocumentCommentsSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
/// Set when opened by tapping an inline anchor marker scrolls to and
/// briefly highlights that comment/reply. `nil` opens unfocused (the
/// plain toolbar marker).
var focusedCommentId: String? = nil
/// Set when opened via "Comment on Selection" from the editor's
/// right-click menu the selected text to anchor a NEW comment to.
/// The first occurrence of this exact substring in the document is
/// what the server (and later, this app's own inline marker) anchors
/// to; no prefix/suffix disambiguation UI for multiple identical
/// occurrences.
var pendingAnchorText: String? = nil
/// Called after any successful create/reply/resolve/reaction lets the
/// reader refresh its own lightweight copy (inline anchor markers, the
/// toolbar badge count) without waiting for the document to be reopened.
var onCommentsChanged: (() -> Void)? = nil
/// A small curated set rather than the full system emoji picker quick
/// taps for the common reactions, matching how most chat apps default.
private static let quickReactions = ["👍", "❤️", "😂", "🎉", "😮", "😢"]
@State private var comments: [OutlineComment] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var actionErrorMessage: String?
@State private var currentUserId: String?
@State private var newCommentText = ""
@State private var isPostingNewComment = false
/// Mutable mirror of `pendingAnchorText` lets the user clear it (fall
/// back to a plain document-level comment) without touching the init
/// param itself.
@State private var composingAnchorText: String?
@State private var replyingToThreadId: String?
@State private var replyText = ""
@State private var isPostingReply = false
@State private var resolvingCommentIds: Set<String> = []
@State private var reactingCommentIds: Set<String> = []
@State private var reactionPickerCommentId: String?
private struct CommentThread: Identifiable {
let top: OutlineComment
let replies: [OutlineComment]
var id: String { top.id }
}
private var threads: [CommentThread] {
let topLevel = comments.filter { $0.parentCommentId == nil }.sorted { $0.createdAt < $1.createdAt }
return topLevel.map { top in
let replies = comments
.filter { $0.parentCommentId == top.id }
.sorted { $0.createdAt < $1.createdAt }
return CommentThread(top: top, replies: replies)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Comments")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
.padding()
Divider()
Group {
if isLoading && comments.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Comments", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") { Task { await load() } }
}
} else if threads.isEmpty {
ContentUnavailableView {
Label("No Comments", systemImage: "bubble.left.and.bubble.right")
} description: {
Text("This document has no comments yet.")
}
} else {
ScrollViewReader { proxy in
List(threads) { thread in
threadSection(thread)
}
.task {
guard let focusedCommentId else { return }
// The list needs a beat to lay out before a
// scrollTo lands correctly on first appear.
try? await Task.sleep(for: .milliseconds(50))
withAnimation {
proxy.scrollTo(focusedCommentId, anchor: .center)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
newCommentComposer
}
.frame(width: 480, height: 560)
.task { await load() }
.task { currentUserId = try? await 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
@@ -1,6 +1,7 @@
#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Distraction-free reading view no toolbar/sidebar chrome, larger type.
@@ -16,11 +17,17 @@ struct DocumentPresentSheet: View {
@State private var text: String
@State private var isLoading = false
@State private var errorMessage: String?
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick` same "force an
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
/// mechanism, needed here too since this sheet renders its own images.
@State private var imageReloadTick = 0
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.document = document
_text = State(initialValue: document.text)
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
@@ -44,7 +51,10 @@ struct DocumentPresentSheet: View {
.padding(.bottom, 8)
NativeTextViewWrapper(
text: $text,
configuration: .init(heightBehavior: .fitsContent),
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.font(.system(size: 18))
@@ -67,6 +77,12 @@ struct DocumentPresentSheet: View {
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() }
}
@@ -3,7 +3,11 @@ import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread see the identical note on
@@ -12,6 +16,56 @@ import OutlineKit
struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Local-only Outpost setting (Settings Editor), not synced to
/// Outline see `SettingsView.editorDetail`.
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@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
/// `session.userPreferences`, not `@AppStorage` this one's the
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
/// problem here since this is read directly in the view, not the
/// view model.
private var showCodeBlockLineNumbers: Bool {
session.userPreferences?.codeBlockLineNumbers ?? false
}
/// Widened left indent reserved for the number gutter when line numbers
/// are on (default is 12pt, just enough margin, no room for digits).
private static let lineNumberGutterWidth: CGFloat = 32
private var editorCodeBlockStyle: CodeBlockStyle {
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
}
/// Outline's own "Smart text replacements" preference (synced) smart
/// quotes/dashes while typing. Only meaningful on the editable pane.
private var editorTextSubstitution: TextSubstitutionPolicy {
let enabled = session.userPreferences?.smartText ?? false
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
}
/// Local-only Outpost settings (Settings Editor) not synced to
/// Outline, same as Split View above.
private var editorTextCompletion: TextCompletionPolicy {
.init(isEnabled: isAutocompleteEnabled)
}
private var editorWritingTools: WritingToolsPolicy {
.init(isEnabled: isWritingToolsEnabled)
}
@State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient
@@ -30,16 +84,73 @@ struct DocumentReaderView: View {
let onDocumentCreated: () -> Void
@State private var isShowingUnpublishConfirmation = false
@State private var isShowingPublishSheet = false
@State private var isShowingArchiveConfirmation = false
@State private var isShowingDeleteConfirmation = false
@State private var isShowingMoveSheet = false
@State private var isShowingHistorySheet = false
@State private var isShowingInsightsSheet = false
@State private var isShowingCommentsSheet = false
/// Fetched once on load, purely to drive the toolbar marker/badge and
/// the inline anchor bars below the sheet itself fetches its own copy
/// independently (see `DocumentCommentsSheet`), same as every other
/// self-contained sheet in this file.
@State private var loadedComments: [OutlineComment] = []
/// Comment id to scroll/focus to when the sheet opens set when an
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
@State private var focusedCommentId: String?
/// Resolved on-screen positions for each anchored comment's text, from
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
@State private var commentAnchorRects: [CommentAnchorRect] = []
/// Set from "Comment on Selection" in the editor's right-click menu
/// the sheet opens straight into composing a new anchored comment.
@State private var pendingCommentAnchorText: String?
private var commentCount: Int? {
loadedComments.isEmpty ? nil : loadedComments.count
}
private var commentAnchorQueries: [CommentAnchorQuery] {
loadedComments.compactMap { comment in
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
}
}
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String?
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// 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`
/// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
/// widened, which is gated on the same flag).
@State private var readerCodeBlocks: [CodeBlockSelection] = []
@State private var previewCodeBlocks: [CodeBlockSelection] = []
init(
apiClient: OutlineAPIClient,
@@ -50,49 +161,47 @@ struct DocumentReaderView: View {
) {
self.apiClient = apiClient
self.document = document
// `separateEditingEnabled` can't be read from `@Environment` here
// environment values aren't populated yet inside a view's `init`,
// only from `body` onward. Defaults to `true` (today's only
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
}
/// New Document, editing, pin/star/subscribe, and Full Width all queue
/// and sync later (see `CachingOutlineAPIClient`) everything else here
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
/// history, insights, export) hits the server directly with no offline
/// path, so it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
/// Split View needs the full window height (each pane scrolls itself),
/// which an unbounded page-level `ScrollView` can't give it a
/// `minHeight` inside one just resolves to exactly that minimum, not
/// "fill available space", since there's no bounded space to fill.
/// Only switches over once there's real content to show; loading/error
/// states still go through the normal scrolling layout.
private var canShowSplitView: Bool {
isSplitViewEnabled
&& viewModel.isEffectivelyEditable
&& viewModel.errorMessage == nil
&& !(viewModel.isLoading && viewModel.text.isEmpty)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEditing {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
isEditable: viewModel.isEditing
)
if !viewModel.children.isEmpty {
childrenSection
}
}
Group {
if canShowSplitView {
splitViewContent
} else {
scrollingReaderContent
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
.overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty {
@@ -110,21 +219,68 @@ struct DocumentReaderView: View {
} label: {
Image(systemName: "square.and.arrow.up")
}
.help("Share")
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
// 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)
}
}
.disabled(viewModel.isSaving)
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
Button {
imagePlaygroundSeedText = currentSelectedText
isShowingImagePlayground = true
} label: {
Image(systemName: "sparkles")
}
.help("Create Image with Image Playground")
.disabled(!viewModel.isEffectivelyEditable)
}
if viewModel.separateEditingEnabled {
Button {
Task { await viewModel.toggleEditing() }
} label: {
if viewModel.isSaving {
ProgressView().controlSize(.small)
} else {
Text(viewModel.isEditing ? "Done" : "Edit")
}
}
.disabled(viewModel.isSaving)
} else if viewModel.isSaving {
// No Edit/Done affordance when documents are always
// editable this is the only feedback that an autosave
// is actually happening.
ProgressView().controlSize(.small)
.help("Saving…")
}
Button {
isShowingNewDocumentSheet = true
@@ -147,13 +303,37 @@ struct DocumentReaderView: View {
.id(menuIdentity)
}
}
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
.onChange(of: viewModel.text) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.onChange(of: viewModel.title) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.task {
await viewModel.loadPinAndSubscriptionState()
}
.task {
await viewModel.loadInsightsEnabledState()
}
.task {
loadedComments = (try? await RetryPolicy.withRetry({
try await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)
})) ?? []
}
.task {
while !Task.isCancelled {
await viewModel.loadViewers()
@@ -201,6 +381,38 @@ struct DocumentReaderView: View {
} message: {
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) {
MoveDocumentSheet(apiClient: apiClient, document: document) {
onDeleted()
@@ -224,6 +436,59 @@ struct DocumentReaderView: View {
onOpenChild(child)
}
}
.modifier(ImagePlaygroundPresenter(
isPresented: $isShowingImagePlayground,
seedText: imagePlaygroundSeedText,
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
onCompletion: { url in handleGeneratedImage(url) }
))
}
/// Adds "Comment on Selection" to the right-click menu when there's an
/// actual (non-empty) selection to anchor to `currentSelectedText` is
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
/// top since it's the primary reason to right-click selected text here,
/// matching Outline's own web editor surfacing comment as the first
/// selection action.
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
let item = ClosureMenuItem(title: "Comment on Selection…") {
pendingCommentAnchorText = selection
focusedCommentId = nil
isShowingCommentsSheet = true
}
menu.insertItem(item, at: 0)
menu.insertItem(.separator(), at: 1)
return menu
}
/// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the
/// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then
/// inserts the hosted image's Markdown reference at the caret.
private func handleGeneratedImage(_ localURL: URL) {
Task {
do {
let data = try Data(contentsOf: localURL)
let created = try await apiClient.createAttachment(.init(
name: localURL.lastPathComponent,
contentType: "image/png",
size: data.count,
documentId: viewModel.documentId
))
try await apiClient.uploadAttachmentFile(created, fileData: data)
// Outline's own editor never embeds the raw (presigned/storage)
// upload URL in document Markdown it writes this stable
// redirect-by-id reference instead, which keeps resolving
// correctly even if the underlying storage URL rotates/expires.
pendingTextInsertion = TextInsertionRequest(
documentId: viewModel.documentId,
text: "\n\n![](/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
@@ -235,7 +500,8 @@ struct DocumentReaderView: View {
viewModel.isPinned,
viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth,
viewModel.isEditing
viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-")
}
@@ -259,31 +525,166 @@ struct DocumentReaderView: View {
}
}
private var childrenSection: some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 4)
Text("Sub-documents")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
ForEach(viewModel.children) { child in
Button {
onOpenChild(child)
} label: {
DocumentRowView(document: child)
/// Today's single-pane layout page-level `ScrollView` wrapping title +
/// content, used for the normal reading/editing view, and for every
/// loading/error state regardless of Split View.
private var scrollingReaderContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
.buttonStyle(.plain)
.padding(.vertical, 4)
if child.id != viewModel.children.last?.id {
Divider()
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
} else if let errorMessage = viewModel.errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadFullContent() }
}
}
} else {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent,
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
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
}
}
}
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
}
/// Split View's layout title fixed at the top (not part of either
/// scrolling pane), `splitEditorView` filling every remaining pixel of
/// the window below it. No outer `ScrollView` here on purpose: each
/// pane already scrolls itself, and nesting that inside another
/// unbounded scroll container is exactly what was capping both panes
/// at a fixed height instead of spanning the window.
private var splitViewContent: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
.padding([.horizontal, .top])
splitEditorView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
/// hiding/styling, but still the real engine needed so selection
/// tracking and caret-position insertion, e.g. from the Image Playground
/// button, work here the same as everywhere else); right is the same
/// rich rendering used everywhere else in the app, read-only, bound to
/// the same `viewModel.text` so it updates live as the left side is
/// typed into.
///
/// Scroll position between the two panes is **not** synchronized the
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
/// private internal view hierarchy to find its scroll view (the package
/// exposes no scroll position/delegate hook at all), which is fragile
/// enough to break silently on a package update. Flagged as a known
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(rawSourceMode: true),
fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var menuContent: some View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
@@ -296,8 +697,10 @@ struct DocumentReaderView: View {
Divider()
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
if viewModel.separateEditingEnabled {
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() }
}
}
// Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet,
@@ -305,27 +708,40 @@ struct DocumentReaderView: View {
Button("Permissions…") {
isShowingShareSheet = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Templatize") {
Task { await templatize() }
}
.disabled(!isEffectivelyOnline)
Button("Duplicate") {
Task { await duplicate() }
}
Button("Unpublish") {
isShowingUnpublishConfirmation = true
.disabled(!isEffectivelyOnline)
if viewModel.publishedAt == nil {
Button("Publish…") {
isShowingPublishSheet = true
}
.disabled(!isEffectivelyOnline)
} else {
Button("Unpublish") {
isShowingUnpublishConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
Button("Archive…") {
isShowingArchiveConfirmation = true
}
.disabled(!isEffectivelyOnline)
Divider()
Button("Move") {
isShowingMoveSheet = true
}
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than
// half-built here.
Button("Import Document…") {}
@@ -342,9 +758,13 @@ struct DocumentReaderView: View {
Button("History") {
isShowingHistorySheet = true
}
.disabled(!isEffectivelyOnline)
Button("Insights") {
isShowingInsightsSheet = true
}
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") {
isShowingPresentSheet = true
}
@@ -354,6 +774,7 @@ struct DocumentReaderView: View {
Button("Download") {
Task { await download() }
}
.disabled(!isEffectivelyOnline)
Button("Copy") {
Task { await copyMarkdown() }
}
@@ -370,6 +791,7 @@ struct DocumentReaderView: View {
get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } }
))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the
@@ -386,6 +808,7 @@ struct DocumentReaderView: View {
Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true
}
.disabled(!isEffectivelyOnline)
}
private func star() async {
@@ -505,4 +928,202 @@ struct DocumentReaderView: View {
operation.run()
}
}
/// One code block's number gutter, positioned absolutely over a
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` same overlay
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
///
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
/// + close fence line), matching what the engine actually lays out the
/// fence lines render with invisible (`.clear`) text once the caret leaves
/// the block, but they don't collapse to zero height, so the block is
/// always exactly `content line count + 2` rows tall. `selection.code` is
/// content only, so the row height and number positions below both account
/// for that phantom top/bottom row explicitly instead of dividing by the
/// content line count alone (which would drift the numbers upward, more so
/// per line, the taller the block).
///
/// Known limitation, accepted rather than fixable app-side: a content line
/// that soft-wraps onto a second visual row (MarkdownEngine always
/// char-wraps code blocks, no way to opt out without forking the package)
/// throws this off every row below it reads one line low. Documented in
/// TODO.local.md alongside the same package's other gaps.
/// Thin vertical bar next to an anchored comment's text same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
/// 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 {
let selection: CodeBlockSelection
let gutterWidth: CGFloat
/// `selection.code` (`token.contentRange`) always ends with exactly one
/// trailing `\n` per content line the range runs right up to the
/// start of the closing fence's own line, so the newline that ends the
/// last content line is included, but there's never an unterminated
/// final line to add one more for. Counting `\n` characters directly
/// (not `.components(separatedBy:).count`, which is one too many
/// whenever the string ends in the separator) is what makes a
/// single-line block read "1", not "2".
private var contentLineCount: Int {
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
}
var body: some View {
let totalRows = CGFloat(contentLineCount + 2)
let rowHeight = selection.rect.height / totalRows
ForEach(0..<contentLineCount, id: \.self) { line in
Text("\(line + 1)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: gutterWidth - 6, alignment: .trailing)
.position(
x: selection.rect.minX + (gutterWidth - 6) / 2,
// +1.5 rows: skip the invisible open-fence row, then
// center within this content row.
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
)
}
.allowsHitTesting(false)
}
}
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
/// only once the `ImagePlayground` framework is actually linked in Xcode
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
/// valid to build before that link-up happens.
private struct ImagePlaygroundPresenter: ViewModifier {
@Binding var isPresented: Bool
/// Highlighted document text at the moment the button was pressed, if
/// any seeds Image Playground's prompt instead of opening blank.
let seedText: String?
/// Document title, used as the concept's title when `seedText` is used.
let seedTitle: String
let onCompletion: (URL) -> Void
func body(content: Content) -> some View {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
let concepts: [ImagePlaygroundConcept] = {
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
}()
content.imagePlaygroundSheet(
isPresented: $isPresented,
concepts: concepts,
onCompletion: { url in
isPresented = false
onCompletion(url)
},
onCancellation: { isPresented = false }
)
} else {
content
}
#else
content
#endif
}
}
#endif
@@ -9,8 +9,10 @@ final class DocumentReaderViewModel {
var emoji: String?
var text: String
var collectionId: String?
var parentDocumentId: String?
/// `nil` = draft (not published/visible to other workspace members).
var publishedAt: Date?
var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
@@ -18,6 +20,38 @@ final class DocumentReaderViewModel {
var isSaving = false
var saveErrorMessage: String?
/// Snapshot of the preference, set once via `.task` right after the
/// view appears (can't be read from `@Environment` inside the view's
/// own `init`) rather than a live binding to `SessionStore` matches
/// how `isFullWidth` etc. are already seeded from the document at init
/// rather than observed reactively. A change made in Settings while a
/// document is already open takes effect the next document opened, not
/// mid-session; an acceptable tradeoff for how rarely this gets
/// toggled versus the complexity of threading a live preference
/// reference through every reader instance.
var separateEditingEnabled: Bool
/// The single source of truth the view reads for both "show the title
/// field" and "is the text view editable" when separate editing is
/// off there's no Edit/Done mode at all, the document is just always
/// editable (assuming permission; there's no per-document permission
/// field to pre-check against, so an unauthorized edit simply fails to
/// save rather than being blocked client-side up front).
var isEffectivelyEditable: Bool {
separateEditingEnabled ? isEditing : true
}
private var autosaveTask: Task<Void, Never>?
/// Tracks the last known-synced-with-the-server values so
/// `scheduleAutosave()` can no-op when called just because `text`/
/// `title` were reassigned *from* a server response (initial load, or
/// a completed save) rather than actually edited without this, every
/// document open in the always-editable mode would fire one pointless
/// autosave round-trip immediately, re-sending exactly what was just
/// received.
private var lastSyncedText: String
private var lastSyncedTitle: String
/// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration
@@ -38,14 +72,19 @@ final class DocumentReaderViewModel {
let documentId: String
private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
self.apiClient = apiClient
self.documentId = document.id
self.title = document.title
self.emoji = document.emoji
self.text = document.text
self.collectionId = document.collectionId
self.parentDocumentId = document.parentDocumentId
self.publishedAt = document.publishedAt
self.isFullWidth = document.fullWidth ?? false
self.separateEditingEnabled = separateEditingEnabled
self.lastSyncedText = document.text
self.lastSyncedTitle = document.title
}
/// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -61,28 +100,29 @@ final class DocumentReaderViewModel {
emoji = full.emoji
text = full.text
collectionId = full.collectionId
parentDocumentId = full.parentDocumentId
publishedAt = full.publishedAt
isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text
lastSyncedTitle = full.title
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
children = (try? await apiClient.listDocuments(
collectionId: nil,
parentDocumentId: documentId,
offset: 0,
limit: 100
)) ?? []
}
func loadViewers() async {
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 }
}
func loadPinAndSubscriptionState() async {
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// 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 }) {
isPinned = true
pinId = match.id
@@ -91,7 +131,7 @@ final class DocumentReaderViewModel {
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 {
isSubscribed = true
subscriptionId = match.id
@@ -146,20 +186,55 @@ final class DocumentReaderViewModel {
}
}
/// Turning editing off saves; turning it on is just a mode switch.
/// Turning editing off saves; turning it on is just a mode switch. Only
/// meaningful when `separateEditingEnabled` the always-editable path
/// uses `scheduleAutosave()` instead.
func toggleEditing() async {
guard isEditing else {
isEditing = true
return
}
await save()
if saveErrorMessage == nil {
isEditing = false
}
}
/// Debounced save for the always-editable (separate editing off) path
/// cancels any pending save and starts a fresh countdown on every call,
/// so a save only actually fires once typing pauses, not on every
/// keystroke. Goes through the same `updateDocument` call the explicit
/// Done-button save uses, which is already offline-queue-aware
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
/// like any other edit instead of needing separate handling here.
func scheduleAutosave() {
guard text != lastSyncedText || title != lastSyncedTitle else { return }
autosaveTask?.cancel()
autosaveTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1.5))
guard let self, !Task.isCancelled else { return }
await self.save()
}
}
private func save() async {
isSaving = true
saveErrorMessage = nil
defer { isSaving = false }
let sentTitle = title
let sentText = text
do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
title = updated.title
text = updated.text
isEditing = false
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
// Only reconcile with the server's response if nothing changed
// locally while the request was in flight otherwise this
// would clobber keystrokes typed during a debounced autosave's
// round trip. Whatever's newer goes out on the next autosave
// cycle regardless, since `scheduleAutosave()` keeps getting
// re-triggered by continued typing.
if title == sentTitle { title = updated.title }
if text == sentText { text = updated.text }
lastSyncedTitle = sentTitle
lastSyncedText = sentText
} catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
}
@@ -22,9 +22,26 @@ struct DocumentSearchSheet: View {
@State private var currentMatchIndex = 0
@FocusState private var isSearchFieldFocused: Bool
private var matchingLineIndices: [Int] {
guard !query.isEmpty else { return [] }
return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) }
/// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`)
/// instead of being a computed property this used to re-scan the whole
/// 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 {
@@ -35,7 +52,10 @@ struct DocumentSearchSheet: View {
TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query)
.textFieldStyle(.plain)
.focused($isSearchFieldFocused)
.onChange(of: query) { currentMatchIndex = 0 }
.onChange(of: query) {
currentMatchIndex = 0
recomputeMatches()
}
if !matchingLineIndices.isEmpty {
Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)")
@@ -83,7 +103,7 @@ struct DocumentSearchSheet: View {
.padding(.horizontal, 4)
.background(
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
)
.id(index)
@@ -131,6 +151,7 @@ struct DocumentSearchSheet: View {
do {
let text = try await apiClient.documentInfo(id: document.id).text
lines = text.components(separatedBy: "\n")
recomputeMatches()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
private func loadMembers() async {
isLoadingMembers = true
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 {
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
}
isSearchingUsers = true
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 {
@@ -71,7 +71,7 @@ struct NewDocumentSheet: View {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
Text("Draft (not published)").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
@@ -86,6 +86,12 @@ struct NewDocumentSheet: View {
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
if selectedCollectionID != nil {
Label("This will publish the document immediately, making it visible to your workspace.", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let errorMessage {
@@ -97,18 +103,24 @@ struct NewDocumentSheet: View {
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Create") {
Button(selectedCollectionID == nil ? "Save as Draft" : "Create & Publish") {
Task { await create() }
}
.keyboardShortcut(.defaultAction)
.disabled(selectedCollectionID == nil || isCreating)
.disabled(isCreating)
}
}
.padding(20)
.frame(width: 380)
.task {
await loadCollections()
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId ?? collections.first?.id
// "By default" means a bare New Document (Home, reader
// toolbar) starts as a draft no `collections.first` fallback
// like there used to be. A contextual entry point (right-click
// a specific collection/document) still pre-fills that
// location, since that already carries clear placement intent;
// the publish warning above still applies to it either way.
selectedCollectionID = initialCollectionID ?? initialParentDocument?.collectionId
selectedParentID = initialParentDocument?.id
isTitleFocused = true
}
@@ -147,7 +159,6 @@ struct NewDocumentSheet: View {
}
private func create() async {
guard let selectedCollectionID else { return }
isCreating = true
defer { isCreating = false }
do {
@@ -156,7 +167,12 @@ struct NewDocumentSheet: View {
title: title.isEmpty ? "Untitled" : title,
text: "",
collectionId: selectedCollectionID,
parentDocumentId: selectedParentID
parentDocumentId: selectedParentID,
// No collection/parent selected = draft; Outline can't
// publish without one of the two regardless of this
// flag, but setting it explicitly rather than relying
// on that keeps intent obvious at the call site.
publish: selectedCollectionID != nil
)
)
onCreated(document)
@@ -0,0 +1,27 @@
#if os(macOS)
import SwiftUI
/// Shown at the top of the sidebar whenever the app is offline either for
/// real (`NetworkMonitor` reports no connection) or because the user turned
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
/// browsing working either way, but the user should know they might be
/// looking at stale data.
struct OfflineBanner: View {
/// Whether this is the user's own "Offline Mode" toggle rather than a
/// real dropped connection same banner slot, different explanation.
var isManual: Bool = false
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
.font(.callout)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,30 @@
#if os(macOS)
import SwiftUI
/// Shown in the sidebar when the network is actually down and the user
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
/// any fetch behavior on its own reads still try live and fall back to
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
/// actually taps the button here, same as `RemoteChangesBanner` never
/// auto-refreshes on their behalf either.
struct OfflineConnectionPromptBanner: View {
let onEnableOfflineMode: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text("No connection")
.font(.callout)
Spacer(minLength: 8)
Button("Turn On Offline Mode", action: onEnableOfflineMode)
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,144 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Destination picker for publishing a draft same collection-then-
/// optional-parent-document shape as `MoveDocumentSheet`, since Outline's
/// own web app treats "where does this go" identically for both actions.
/// Pre-selects the document's own `collectionId`/`parentDocumentId` when it
/// already has one (a draft can already belong to a collection without
/// being published) rather than starting blank, per explicit instruction.
///
/// Publishing itself is two calls, not one: `documents.update(publish:
/// true, collectionId:)` does the actual publish and top-level collection
/// placement in one round-trip (confirmed from a live network capture);
/// nesting under a specific parent document isn't a documented
/// `documents.update` field, so that part reuses `documents.move` the
/// same already-working mechanism `MoveDocumentSheet` uses as a second
/// step, only when a parent was actually chosen.
@MainActor
struct PublishDocumentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
let onPublished: () async -> Void
@State private var collections: [OutlineCollection] = []
@State private var selectedCollectionID: String?
@State private var rootDocuments: [OutlineDocument] = []
@State private var selectedParentID: String?
@State private var isLoadingCollections = false
@State private var isLoadingDestinationDocuments = false
@State private var isPublishing = false
@State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Publish \"\(document.title.isEmpty ? "Untitled" : document.title)\"")
.font(.headline)
Text("Choose where this document should live once published.")
.font(.callout)
.foregroundStyle(.secondary)
if isLoadingCollections {
ProgressView().frame(maxWidth: .infinity)
} else {
Picker("Collection", selection: $selectedCollectionID) {
Text("Choose a collection").tag(String?.none)
ForEach(collections) { collection in
Text(collection.name).tag(Optional(collection.id))
}
}
.labelsHidden()
Picker("Location", selection: $selectedParentID) {
Text("Collection root").tag(String?.none)
ForEach(rootDocuments.filter { $0.id != document.id }) { candidate in
Text(candidate.title.isEmpty ? "Untitled" : candidate.title).tag(Optional(candidate.id))
}
}
.labelsHidden()
.disabled(selectedCollectionID == nil || isLoadingDestinationDocuments)
}
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
Button("Publish") {
Task { await publish() }
}
.disabled(selectedCollectionID == nil || isPublishing)
}
}
.padding(20)
.frame(width: 360)
.task {
await loadCollections()
selectedCollectionID = document.collectionId
selectedParentID = document.parentDocumentId
}
.task(id: selectedCollectionID) {
await loadRootDocuments()
}
}
private func loadCollections() async {
isLoadingCollections = true
defer { isLoadingCollections = false }
do {
collections = try await apiClient.listCollections(offset: 0, limit: 100)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load collections.")
}
}
private func loadRootDocuments() async {
guard let selectedCollectionID else {
rootDocuments = []
return
}
isLoadingDestinationDocuments = true
defer { isLoadingDestinationDocuments = false }
do {
rootDocuments = try await apiClient.listDocuments(
collectionId: selectedCollectionID,
parentDocumentId: nil,
offset: 0,
limit: 100
)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load destination documents.")
}
}
private func publish() async {
guard let selectedCollectionID else { return }
isPublishing = true
defer { isPublishing = false }
do {
_ = try await apiClient.updateDocument(
UpdateDocumentRequest(id: document.id, collectionId: selectedCollectionID, publish: true)
)
// Only a documents.move call actually supports parentDocumentId
// skip it entirely when publishing straight to the collection root,
// the update above already placed it there.
if let selectedParentID {
try await apiClient.moveDocument(
MoveDocumentRequest(id: document.id, collectionId: selectedCollectionID, parentDocumentId: selectedParentID)
)
}
await onPublished()
dismiss()
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't publish this document.")
}
}
}
#endif
+1
View File
@@ -5,6 +5,7 @@ enum HomeTab: String, CaseIterable, Identifiable {
case popular = "Popular"
case recentlyUpdated = "Recently Updated"
case createdByMe = "Created by Me"
case drafts = "Drafts"
var id: String { rawValue }
}
+1
View File
@@ -101,6 +101,7 @@ struct HomeView: View {
PinnedDocumentCard(document: document)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
.padding(.horizontal, 24)
+36 -14
View File
@@ -10,6 +10,7 @@ final class HomeViewModel {
private(set) var popular: [OutlineDocument] = []
private(set) var recentlyUpdated: [OutlineDocument] = []
private(set) var createdByMe: [OutlineDocument] = []
private(set) var drafts: [OutlineDocument] = []
var isLoadingPinned = false
var isLoadingTab = false
@@ -32,6 +33,7 @@ final class HomeViewModel {
case .popular: popular
case .recentlyUpdated: recentlyUpdated
case .createdByMe: createdByMe
case .drafts: drafts
}
}
@@ -57,32 +59,49 @@ final class HomeViewModel {
/// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup shouldn't pop the refresh banner.
/// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned()
async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned
guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true
}
}
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated
/// set (unlike a full collection tree), so the N+1 here is acceptable
/// where it wouldn't be in the sidebar.
private func fetchPinned() async -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
return []
}
var documents: [OutlineDocument] = []
for pin in pins {
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
documents.append(document)
/// 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] {
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
let client = apiClient
let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in
for pin in pins {
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] {
@@ -107,6 +126,8 @@ final class HomeViewModel {
return try await apiClient.documentsList(
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
)
case .drafts:
return try await apiClient.listDrafts(ListDraftsRequest(limit: 25))
}
}
@@ -116,12 +137,13 @@ final class HomeViewModel {
case .popular: popular = documents
case .recentlyUpdated: recentlyUpdated = documents
case .createdByMe: createdByMe = documents
case .drafts: drafts = documents
}
}
private func resolveCurrentUserID() async throws -> String {
if let currentUserID { return currentUserID }
let user = try await apiClient.currentUser()
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
currentUserID = user.id
return user.id
}
+40 -28
View File
@@ -2,45 +2,57 @@
import SwiftUI
import OutlineKit
/// Deliberately distinct from `DocumentCardView` the pinned section is for
/// a quick scan of a small curated set, not browsing, so this is a dense
/// single-line row rather than a tall card, with an explicit pin glyph so
/// it doesn't read the same as the tab grids below it.
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
/// pinned section reads as a set of cards, not a row of tab-like chips the
/// accent-filled pin badge is the one thing that marks these as pinned.
struct PinnedDocumentCard: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument
var body: some View {
HStack(spacing: 10) {
if let emoji = document.emoji {
Text(emoji)
.font(.title3)
} else {
Image(systemName: "doc.text")
.font(.body)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
if let emoji = document.emoji {
Text(emoji)
.font(.title2)
} else {
Image(systemName: "doc.text")
.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)
.font(.callout.weight(.medium))
.lineLimit(1)
.font(.headline)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Image(systemName: "pin.fill")
.font(.caption2)
Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
.padding(14)
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
}
}
#endif
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.pointerCursorOnHover()
}
}
}
+51 -21
View File
@@ -14,17 +14,19 @@ import AppKit
@main
struct OutpostApp: App {
@State private var session = SessionStore()
@State private var navigation = AppNavigation()
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS)
@Environment(\.openWindow) private var openWindow
@State private var isShowingLogoutConfirmation = false
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
#endif
var body: some Scene {
WindowGroup {
RootView()
.environment(session)
.environment(navigation)
#if os(iOS)
.preferredColorScheme(appearance.colorScheme)
#endif
@@ -32,15 +34,27 @@ struct OutpostApp: App {
.onAppear { applyMacAppearance() }
.onChange(of: appearance) { _, _ in applyMacAppearance() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
.background(TransparentTitlebarWindowAccessor())
#endif
}
#if os(macOS)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About Outpost") {
openWindow(id: "about")
navigation.selectedSettingsSection = .about
navigation.isShowingSettings = true
}
}
// No `Settings {}` scene anymore Settings renders inside the
// root window (see `AppNavigation`), not a separate popup, so
// , has to be wired up by hand instead of coming for free.
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
navigation.isShowingSettings = true
}
.keyboardShortcut(",")
.disabled(!session.isSignedIn)
}
CommandGroup(after: .appSettings) {
Divider()
Button("Log Out…") {
@@ -48,25 +62,17 @@ struct OutpostApp: App {
}
.disabled(!session.isSignedIn)
}
}
#endif
#if os(macOS)
Window("About Outpost", id: "about") {
AboutView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
KeyboardShortcutsView()
.disablesFullScreen()
}
.windowResizability(.contentSize)
Settings {
PreferencesView()
.environment(session)
// Settings Editor Command Palette gates this disabled
// (not just a no-op) when the user's turned it off, matching
// how Settings/Log Out already disable rather than silently
// do nothing.
CommandGroup(after: .newItem) {
Button("Command Palette…") {
navigation.isShowingCommandPalette = true
}
.keyboardShortcut("k")
.disabled(!session.isSignedIn || !isCommandPaletteEnabled)
}
}
#endif
}
@@ -88,3 +94,27 @@ struct OutpostApp: App {
}
#endif
}
#if os(macOS)
/// Makes the titlebar/toolbar strip blend into the sidebar's own background
/// instead of reading as a separate bar same look as Mail/Notes/Finder.
/// SwiftUI's `WindowGroup` exposes no direct hook for this, so this reaches
/// into the underlying `NSWindow` the way `applyMacAppearance()` above
/// reaches into `NSApp` for the same reason (no SwiftUI-level API exists).
/// A `View` (not the window itself) is what actually needs to extend under
/// the now-transparent titlebar `.fullSizeContentView` just makes room;
/// the sidebar's `.background` already does the rest with no other change.
private struct TransparentTitlebarWindowAccessor: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
guard let window = view.window else { return }
window.titlebarAppearsTransparent = true
window.styleMask.insert(.fullSizeContentView)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
#endif
+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
}
}
+143
View File
@@ -0,0 +1,143 @@
import Observation
/// Top-level groupings shown as section headers in `SettingsSidebarList`.
/// `general` holds everything that's ours, not Outline's own settings
/// categories labeled "Outpost" so it reads as clearly distinct from the
/// Outline-sourced groups below it.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
var id: String { rawValue }
var title: String? {
switch self {
case .general: return "Outpost"
case .account: return "Account"
case .workspace: return "Workspace"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace) so this app's settings read as a native
/// counterpart to the web app's, plus a `general` group for things that are
/// ours and don't map onto Outline's structure (offline/sync, advanced,
/// about, appearance). Outline's own version info moved to the sidebar
/// footer (`SettingsSidebarList`) instead of a standalone
/// Integrations & Installation section.
///
/// Most of the Account/Workspace cases are navigation-only for now
/// `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, editor, navigation, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
var id: String { rawValue }
var category: SettingsCategory {
switch self {
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
}
}
var title: String {
switch self {
case .appearance: return "Appearance"
case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
case .profile: return "Profile"
case .preferences: return "Preferences"
case .notifications: return "Notifications"
case .passkeys: return "Passkeys"
case .apiAccess: return "API & Access"
case .details: return "Details"
case .authentication: return "Authentication"
case .security: return "Security"
case .ai: return "AI"
case .members: return "Members"
case .groups: return "Groups"
case .templates: return "Templates"
case .emojis: return "Emojis"
case .applications: return "Applications"
case .shared: return "Shared"
case .links: return "Links"
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .editor: return "square.split.2x1"
case .navigation: return "command"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
case .profile: return "person.crop.circle"
case .preferences: return "gearshape"
case .notifications: return "bell"
case .passkeys: return "key"
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
case .details: return "building.2"
case .authentication: return "lock"
case .security: return "shield"
case .ai: return "sparkles"
case .members: return "person.2"
case .groups: return "person.3"
case .templates: return "doc.on.doc"
case .emojis: return "face.smiling"
case .applications: return "app.badge"
case .shared: return "square.and.arrow.up.on.square"
case .links: return "link"
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace renders a "Coming Soon" placeholder until its content is
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
}
}
}
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
/// `OutpostApp` (so both `RootView`'s content and its `,` command can reach
/// it) and is read wherever something needs to open Settings (the profile
/// menu) or render it as a swap of the *existing* sidebar/detail panes in
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
/// the sidebar.
@Observable
@MainActor
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
/// K, see `OutpostApp`'s `CommandGroup` and `CommandPaletteView`.
var isShowingCommandPalette = false
}
+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
+115
View File
@@ -3,8 +3,23 @@ import OutlineKit
struct RootView: View {
@Environment(SessionStore.self) private var session
@Environment(\.openURL) private var openURL
@State private var welcomeName: String?
@State private var starStore = StarStore()
@State private var failureCenter = APIFailureCenter()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
ZStack {
@@ -21,10 +36,39 @@ struct RootView: View {
}
}
.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)
.task {
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) {
if session.isSignedIn, let apiClient = session.apiClient {
await starStore.load(apiClient: apiClient)
@@ -32,8 +76,79 @@ struct RootView: View {
starStore.reset()
}
}
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
//
// The flush itself gets a retry loop with a cooloff, not just one
// attempt: a single transient failure (one bad operation, a blip
// mid-flush) used to leave everything else stuck until the user
// manually hit Retry or connectivity changed again. Keeps retrying
// every 5 minutes for as long as *anything* is still pending and
// this signal stays on stops on its own once the queue is empty,
// and `.task(id:)` cancels/restarts it automatically if
// isEffectivelyOnline flips again in the meantime.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
while !Task.isCancelled {
_ = await cachingClient.flushPendingOperations()
let remaining = await cachingClient.pendingOperations()
guard !remaining.isEmpty else { break }
try? await Task.sleep(for: .seconds(300))
}
}
// Fully automatic this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch"
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
}
#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) {
welcomeName = result.user.name
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
+132 -11
View File
@@ -6,72 +6,193 @@ import OutlineKit
@Observable
final class SessionStore {
private static let serverURLDefaultsKey = "outline.serverURL"
/// Preferences now drive real editor behavior (separate editing, etc.),
/// not just a settings screen they need to survive a cold launch with
/// no network, not just live in memory from the last successful fetch.
/// Still read-only while offline (Settings already gates every toggle
/// on `isEffectivelyOnline`) this only makes the *last known* values
/// available, never lets them be changed without a server round-trip.
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
private let defaults: UserDefaults
var isSignedIn: Bool
private(set) var userId: String?
var userName: String?
var userEmail: String?
var userAvatarURL: URL?
var userLanguage: String?
var userPreferences: OutlineUserPreferences?
var userNotificationSettings: [String: Bool]?
var teamName: String?
var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient?
/// Same object as `apiClient` when the offline cache is available kept
/// as a separately-typed reference so Settings can reach cache/sync-queue
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
/// without downcasting the protocol-typed `apiClient` everywhere.
private(set) var cachingClient: CachingOutlineAPIClient?
let networkMonitor = NetworkMonitor()
/// `nil` only if the on-disk SwiftData store failed to open (e.g. disk
/// full) in that case `apiClient` falls back to talking to the server
/// directly with no offline cache, rather than failing sign-in outright.
private let cacheStore: OfflineCacheStore?
var serverURL: URL? {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
}
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
init(
tokenStore: TokenStoring = KeychainTokenStore(),
cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(),
defaults: UserDefaults = .standard
) {
self.tokenStore = tokenStore
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL {
apiClient = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
let hasToken = (try? tokenStore.token()) != nil
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
if hasToken, let storedServerURL {
isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: storedServerURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else {
// Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an
// older-signed build can outlive a reinstall that wipes the
// container (or vice versa), leaving a token with no server or
// a server with no token. Clear whichever half survived rather
// than showing a broken "signed in" UI with no working
// apiClient a fresh sign-in rewrites both consistently.
if hasToken {
try? tokenStore.clear()
}
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
}
}
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
apiClient = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
(apiClient, cachingClient) = Self.makeAPIClient(
serverURL: serverURL,
tokenStore: tokenStore,
cacheEncryptionKeyStore: cacheEncryptionKeyStore,
cache: cacheStore
)
apply(user: user, team: team, serverURL: serverURL)
isSignedIn = true
}
func signOut() {
/// `static` (not an instance method) so `init` can call it before every
/// stored property has a value same reason `makeAPIClient` is static.
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
}
/// `nil` clears the cache instead of writing a `null` happens whenever
/// a fresh fetch legitimately comes back with no preferences set, so a
/// stale cached value from a previous account/state can't linger.
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
return
}
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
}
private static func makeAPIClient(
serverURL: URL,
tokenStore: TokenStoring,
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
cache: OfflineCacheStore?
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
guard let cache else { return (live, nil) }
let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore)
return (caching, caching)
}
/// 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()
await cachingClient?.clearEverythingForSignOut()
try? cacheEncryptionKeyStore.clear()
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
isSignedIn = false
userId = nil
userName = nil
userEmail = nil
userAvatarURL = nil
userLanguage = nil
userPreferences = nil
userNotificationSettings = nil
teamName = nil
teamAvatarURL = nil
apiClient = nil
cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
// Same key `ContentView_macOS` persists "Remember previous
// location" under cleared here too so switching accounts/servers
// can't restore a stale location that belongs to a different sign-in.
defaults.removeObject(forKey: "outline.lastLocation")
}
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
/// in-memory state didn't.
func refreshTeamInfoIfNeeded() async {
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)
}
/// Settings calls this after a successful name/avatar change so the
/// sidebar's account footer and everywhere else reading these reflect
/// it immediately, without waiting for the next `auth.info` refresh.
func applyUpdatedProfile(_ user: OutlineUser) {
guard let serverURL else { return }
userId = user.id
userName = user.name
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
}
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
userId = user.id
userName = user.name
userEmail = user.email
// Outline can return either an absolute URL or a server-relative path
// (e.g. `/api/files.get?key=...`) for avatarUrl resolve against the
// configured server so relative paths don't fail as "unsupported URL".
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language
userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings
teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
}
+38 -2
View File
@@ -1,4 +1,5 @@
import SwiftUI
import OutlineKit
#if os(macOS)
import AppKit
@@ -41,11 +42,46 @@ struct AvatarBadge: View {
.task(id: avatarURL) {
loadedImage = nil
guard let avatarURL else { return }
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return }
loadedImage = PlatformImage(data: data)
loadedImage = await Self.loadImage(from: avatarURL)
}
}
/// The real fix, confirmed against a live network capture: every other
/// request this app makes attaches `Authorization: Bearer <token>`
/// this one never did, sending a bare unauthenticated GET. Outline's
/// browser session authenticates `attachments.redirect` via cookies
/// instead, which a native app doesn't have; the API-token equivalent
/// is the same Bearer header every RPC call already uses. Almost
/// certainly means no avatar image (not just a freshly-uploaded one)
/// has ever actually loaded in this app a 401 and a "no avatar set"
/// look identical here, both just fall back to the placeholder icon
/// with nothing on screen to flag it as an error.
///
/// The retry loop is a secondary, independent hardening cheap
/// insurance against a self-hosted reverse-proxied storage backend not
/// being instantly consistent right after an upload kept alongside
/// the auth fix rather than instead of it.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
if let token = try? KeychainTokenStore().token() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
+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,11 @@
#if os(macOS)
import MarkdownEngineCodeBlocks
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
/// context (expensive to spin up) plus its own highlight cache, so every
/// `NativeTextViewWrapper` should share this instance rather than each
/// constructing its own.
enum CodeSyntaxHighlighting {
static let shared = HighlighterSwiftBridge()
}
#endif
+14
View File
@@ -0,0 +1,14 @@
#if os(macOS)
import AppKit
extension NSImage {
/// `NSImage` has no built-in JPEG encoder (unlike `UIImage`) routes
/// through a bitmap representation to get one.
func jpegData(compressionQuality: CGFloat) -> Data? {
guard let tiffData = tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData) else {
return nil
}
return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality])
}
}
#endif
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import Network
import Observation
/// Backs the sidebar's offline badge purely a UI signal. Unrelated to
/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual
/// request failures rather than pre-checking reachability.
@Observable
@MainActor
final class NetworkMonitor {
private(set) var isOnline = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.outpost.network-monitor")
init() {
// Weak on the outer closure (it's held by `monitor` for the object's
// whole lifetime, so a strong capture here would be a retain cycle),
// then unwrapped once into a plain strong local the inner `Task`
// closure capturing that local `self` is what a nested closure needs
// to be consistent about capture semantics with its enclosing one.
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let online = path.status == .satisfied
Task { @MainActor in
self.isOnline = online
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,84 @@
#if os(macOS)
import AppKit
import MarkdownEngine
import OutlineKit
/// Resolves `![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
+35
View File
@@ -0,0 +1,35 @@
import Foundation
/// Outline's interface-language options. Not exhaustive Outline accepts
/// community translations via its own translation portal, so the real list
/// on any given server can be longer than this; this covers the common
/// cases and falls back to showing whatever code the server already has
/// set even if it isn't in this list.
struct OutlineLocale: Identifiable, Hashable {
let code: String
let label: String
var id: String { code }
static let all: [OutlineLocale] = [
OutlineLocale(code: "en_US", label: "English (US)"),
OutlineLocale(code: "en_GB", label: "English (UK)"),
OutlineLocale(code: "de_DE", label: "Deutsch"),
OutlineLocale(code: "fr_FR", label: "Français"),
OutlineLocale(code: "es_ES", label: "Español"),
OutlineLocale(code: "pt_PT", label: "Português"),
OutlineLocale(code: "pt_BR", label: "Português (Brasil)"),
OutlineLocale(code: "it_IT", label: "Italiano"),
OutlineLocale(code: "nl_NL", label: "Nederlands"),
OutlineLocale(code: "pl_PL", label: "Polski"),
OutlineLocale(code: "ru_RU", label: "Русский"),
OutlineLocale(code: "ja_JP", label: "日本語"),
OutlineLocale(code: "ko_KR", label: "한국어"),
OutlineLocale(code: "zh_CN", label: "中文 (简体)"),
OutlineLocale(code: "zh_TW", label: "中文 (繁體)"),
]
static func label(for code: String) -> String {
all.first(where: { $0.code == code })?.label ?? code
}
}
+33
View File
@@ -0,0 +1,33 @@
import Foundation
/// Single source of truth for how Outpost's own version is formatted
/// used by both the About page and the Settings sidebar footer, so they
/// can't drift out of sync the way `AboutInfoView` was already written to
/// avoid for its own two call sites.
enum OutpostVersion {
/// Bumped alongside `MARKETING_VERSION` in the Xcode project kept out
/// of the bundle version itself since `CFBundleShortVersionString` is
/// expected to stay a plain dotted-numeric string, not `0.1.0-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 {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
}
static var buildNumber: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "3"
}
/// e.g. `"0.1.0"` for compact display (sidebar footer).
static var displayString: String {
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
return "\(shortVersion)\(stageSuffix)"
}
/// e.g. `"Version 0.1.0 (3)"` for the About page.
static var fullVersionString: String {
"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

Some files were not shown because too many files have changed in this diff Show More